Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/native-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ jobs:
timeout-minutes: 5
outputs:
native: ${{ steps.filter.outputs.native }}
gotray: ${{ steps.filter.outputs.gotray }}
steps:
- uses: actions/checkout@v7.0.1
- uses: dorny/paths-filter@v4
Expand All @@ -62,6 +63,11 @@ jobs:
- 'frontend/src/components/settings/**'
- 'scripts/check-settings-parity.py'
- '.github/workflows/native-tests.yml'
gotray:
- 'internal/tray/**'
- 'cmd/mcpproxy-tray/**'
- 'internal/updatecheck/**'
- '.github/workflows/native-tests.yml'

swift-test:
name: swift-test
Expand All @@ -82,6 +88,28 @@ jobs:
# code path that looks green.
run: swift test

go-tray-test:
name: go-tray-test
needs: changes
if: needs.changes.outputs.gotray == 'true'
runs-on: macos-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7.0.1
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: "1.26"
cache: true
# internal/tray is behind `!nogui && !headless && !linux`, so NOTHING in
# the PR gate compiles it: pr-build.yml tests with `-tags nogui`, and
# unit-tests.yml narrows its pull_request matrix to ubuntu-latest. Its
# tests — including the self-update archive-member regression tests —
# used to run only on the push-to-main macOS/Windows legs, i.e. after
# merge. This job is the PR-time gate for that package. No `-tags nogui`,
# and macOS so the `!linux` half of the tag is satisfied too.
- name: Test the tray package
run: go test -race ./internal/tray/... ./internal/updatecheck/...

settings-parity:
name: settings-parity
needs: changes
Expand Down
44 changes: 40 additions & 4 deletions .github/workflows/prerelease.yml
Original file line number Diff line number Diff line change
Expand Up @@ -235,10 +235,24 @@ jobs:
# it is not made here.
go build -ldflags "${LDFLAGS}" -o ${CLEAN_BINARY} ./cmd/mcpproxy

# Build tray binary for macOS
# Build tray binary for the platforms with GUI support, matching
# release.yml. Windows matters here even though the RC tray reaches
# users through the Inno installer (built further below, AFTER the
# archives): that installed tray self-updates by downloading this
# job's .zip and extracting the mcpproxy-tray.exe member by name, so
# a zip built without it leaves every Windows RC tray unable to
# update. As in release.yml, the archived tray is unsigned — SignPath
# signs installers, not archive members.
if [ "${{ matrix.goos }}" = "darwin" ]; then
echo "Building mcpproxy-tray for macOS..."
go build -ldflags "${LDFLAGS}" -o mcpproxy-tray ./cmd/mcpproxy-tray
elif [ "${{ matrix.goos }}" = "windows" ]; then
echo "Building mcpproxy-tray.exe for Windows..."
# -H windowsgui matches scripts/build-windows-installer.ps1: the
# tray must link as a GUI executable or it opens a console window
# on launch, and a self-update installs this copy over the
# installer's.
go build -ldflags "${LDFLAGS} -H windowsgui" -o mcpproxy-tray.exe ./cmd/mcpproxy-tray
fi

# Build Swift tray app (macOS only — replaces Go tray in .app bundle for DMG/PKG)
Expand Down Expand Up @@ -424,17 +438,39 @@ jobs:
# Create archive with version info - DO NOT create "latest" archives for prereleases
ARCHIVE_BASE="mcpproxy-${VERSION#v}-${{ matrix.goos }}-${{ matrix.goarch }}"

# The tray binary ships alongside the core, exactly as release.yml
# does it. The tray's self-update extracts the mcpproxy-tray member
# from this archive by name and fails closed when it is absent, so an
# RC archive without it leaves every prerelease tray unable to update
# (RC builds track the rc channel by construction — see
# App.includePrereleases in internal/tray/tray.go).
FILES_TO_ARCHIVE="${CLEAN_BINARY}"
if [ "${{ matrix.goos }}" = "windows" ] && [ -f "mcpproxy-tray.exe" ]; then
FILES_TO_ARCHIVE="${FILES_TO_ARCHIVE} mcpproxy-tray.exe"
echo "Including mcpproxy-tray.exe in archive"
elif [ "${{ matrix.goos }}" = "darwin" ] && [ -f "mcpproxy-tray" ]; then
FILES_TO_ARCHIVE="${FILES_TO_ARCHIVE} mcpproxy-tray"
echo "Including mcpproxy-tray in archive"
elif [ "${{ matrix.goos }}" = "darwin" ] || [ "${{ matrix.goos }}" = "windows" ]; then
# Same rule as release.yml: fail rather than ship a core-only
# archive that would strand every tray self-update. Linux has no
# tray and falls through untouched.
echo "::error::no tray binary to archive for ${{ matrix.goos }}; a core-only archive would strand every tray self-update"
exit 1
fi

if [ "${{ matrix.archive_format }}" = "zip" ]; then
# Create only versioned archive (no latest for prereleases)
if [ "${{ matrix.goos }}" = "windows" ]; then
# Use PowerShell Compress-Archive on Windows since zip command isn't available
powershell -Command "Compress-Archive -Path '${CLEAN_BINARY}' -DestinationPath '${ARCHIVE_BASE}.zip'"
PS_FILES=$(echo ${FILES_TO_ARCHIVE} | sed 's/ /,/g')
powershell -Command "Compress-Archive -Path ${PS_FILES} -DestinationPath '${ARCHIVE_BASE}.zip'"
else
zip "${ARCHIVE_BASE}.zip" ${CLEAN_BINARY}
zip "${ARCHIVE_BASE}.zip" ${FILES_TO_ARCHIVE}
fi
else
# Create only versioned archive (no latest for prereleases)
tar -czf "${ARCHIVE_BASE}.tar.gz" ${CLEAN_BINARY}
tar -czf "${ARCHIVE_BASE}.tar.gz" ${FILES_TO_ARCHIVE}
fi

- name: Build Linux .deb and .rpm packages
Expand Down
20 changes: 18 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -449,14 +449,21 @@ jobs:
if [ "$EDITION" != "server" ] && { [ "${{ matrix.goos }}" = "darwin" ] || [ "${{ matrix.goos }}" = "windows" ]; }; then
echo "Building mcpproxy-tray for ${{ matrix.goos }}..."

# Determine tray binary name
# Determine tray binary name, and on Windows link it as a GUI
# (not console) executable. Without -H windowsgui the tray pops a
# console window on every launch; scripts/build-windows-installer.ps1
# has always passed it, and the archived copy must match, because a
# tray self-update extracts THIS binary and installs it over the
# installer's copy (internal/tray applyArchiveUpdate).
TRAY_LDFLAGS="${LDFLAGS}"
if [ "${{ matrix.goos }}" = "windows" ]; then
TRAY_BINARY="mcpproxy-tray.exe"
TRAY_LDFLAGS="${LDFLAGS} -H windowsgui"
else
TRAY_BINARY="mcpproxy-tray"
fi

go build -ldflags "${LDFLAGS}" -o ${TRAY_BINARY} ./cmd/mcpproxy-tray
go build -ldflags "${TRAY_LDFLAGS}" -o ${TRAY_BINARY} ./cmd/mcpproxy-tray
fi

# Build Swift tray app (macOS only — replaces Go tray in .app bundle for DMG/PKG)
Expand Down Expand Up @@ -708,6 +715,15 @@ jobs:
cp mcpproxy-tray "${TARBALL_STAGE}/"
FILES_TO_ARCHIVE="${FILES_TO_ARCHIVE} mcpproxy-tray"
echo "Including mcpproxy-tray in archive"
elif [ "$EDITION" != "server" ] && { [ "${{ matrix.goos }}" = "darwin" ] || [ "${{ matrix.goos }}" = "windows" ]; }; then
# The tray self-update extracts the mcpproxy-tray member by exact
# name and fails closed without it, so a tray-less archive strands
# every tray on this platform. This branch is the one that used to
# degrade silently: the conditions above simply did not fire and
# the archive shipped core-only. Linux and the server edition fall
# through untouched — neither ships a tray.
echo "::error::no tray binary to archive for ${{ matrix.goos }}; a core-only archive would strand every tray self-update"
exit 1
fi

ARCHIVE_OUT="$(pwd)"
Expand Down
100 changes: 9 additions & 91 deletions cmd/mcpproxy/update_apply.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
package main

import (
"archive/tar"
"archive/zip"
"compress/gzip"
"context"
"errors"
"fmt"
Expand All @@ -23,10 +20,10 @@ import (
// every rule below is unit-testable against real files in a t.TempDir().

const (
// maxArchiveMemberBytes bounds a single extracted archive member. The core
// binary is ~60-90 MB; the cap exists so a malicious archive cannot fill
// the disk before the checksum comparison would have rejected it.
maxArchiveMemberBytes = 512 << 20
// maxArchiveMemberBytes bounds a single extracted archive member. The
// implementation lives in internal/updatecheck so the tray's self-update
// path enforces exactly the same cap.
maxArchiveMemberBytes = updatecheck.MaxArchiveMemberBytes

// verifyExecTimeout bounds the post-swap `<new binary> --version` probe
// (FR-021: success means the new binary actually runs).
Expand Down Expand Up @@ -57,91 +54,12 @@ func verifyFileSHA256(path, wantHex string) error {
}

// extractBinary pulls the single archive member named memberName (matched on
// base name, so a future archive that nests files still works) into destPath,
// which is created with mode 0o700 — the caller re-applies the real mode when
// swapping it into place.
// base name) into destPath. The implementation lives in internal/updatecheck
// so the tray's self-update path selects its archive member by the same rule —
// a release archive ships both the core and the tray binary, and picking the
// wrong one installs a working binary over the wrong file.
func extractBinary(archivePath, memberName, destPath string) error {
switch {
case strings.HasSuffix(archivePath, ".zip"):
return extractFromZip(archivePath, memberName, destPath)
case strings.HasSuffix(archivePath, ".tar.gz"), strings.HasSuffix(archivePath, ".tgz"):
return extractFromTarGz(archivePath, memberName, destPath)
default:
return fmt.Errorf("unsupported archive format: %s", filepath.Base(archivePath))
}
}

func extractFromTarGz(archivePath, memberName, destPath string) error {
f, err := os.Open(archivePath) // #nosec G304 -- self-downloaded temp file
if err != nil {
return fmt.Errorf("open archive: %w", err)
}
defer f.Close()

gz, err := gzip.NewReader(f)
if err != nil {
return fmt.Errorf("open gzip stream: %w", err)
}
defer gz.Close()

tr := tar.NewReader(gz)
for {
hdr, err := tr.Next()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return fmt.Errorf("read archive: %w", err)
}
if hdr.Typeflag != tar.TypeReg || filepath.Base(hdr.Name) != memberName {
continue
}
return writeMember(tr, destPath)
}
return fmt.Errorf("archive does not contain %q", memberName)
}

func extractFromZip(archivePath, memberName, destPath string) error {
zr, err := zip.OpenReader(archivePath)
if err != nil {
return fmt.Errorf("open archive: %w", err)
}
defer zr.Close()

for _, entry := range zr.File {
if entry.FileInfo().IsDir() || filepath.Base(entry.Name) != memberName {
continue
}
rc, err := entry.Open()
if err != nil {
return fmt.Errorf("open archive member: %w", err)
}
defer rc.Close()
return writeMember(rc, destPath)
}
return fmt.Errorf("archive does not contain %q", memberName)
}

// writeMember copies at most maxArchiveMemberBytes from r into destPath.
func writeMember(r io.Reader, destPath string) error {
out, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC|os.O_EXCL, 0o700) // #nosec G304 -- destPath is our own temp path
if err != nil {
return fmt.Errorf("create staged binary: %w", err)
}
written, err := io.Copy(out, io.LimitReader(r, maxArchiveMemberBytes+1))
if err != nil {
out.Close()
return fmt.Errorf("write staged binary: %w", err)
}
if written > maxArchiveMemberBytes {
out.Close()
return fmt.Errorf("archive member exceeds the %d-byte limit", int64(maxArchiveMemberBytes))
}
if err := out.Sync(); err != nil {
out.Close()
return fmt.Errorf("flush staged binary: %w", err)
}
return out.Close()
return updatecheck.ExtractBinary(archivePath, memberName, destPath)
}

// ensureTargetWritable reports why the binary cannot be replaced, naming the
Expand Down
Loading
Loading