Skip to content

fix(tray): install the tray binary, not the core, on self-update - #1349

Open
Dumbris wants to merge 6 commits into
fix/verify-update-artifact-checksumfrom
fix/tray-self-update-extracts-core-binary
Open

Dumbris wants to merge 6 commits into
fix/verify-update-artifact-checksumfrom
fix/tray-self-update-extracts-core-binary

Conversation

@Dumbris

@Dumbris Dumbris commented Sep 22, 2026

Copy link
Copy Markdown
Member

Problem

The tray's self-update installed the core binary over the tray binary.

internal/tray is imported only by cmd/mcpproxy-tray, and its self-update passes update.Options{TargetPath: os.Executable()} — so it replaces itself. But release archives ship both binaries at the root, core first (release.yml FILES_TO_ARCHIVE = mcpproxy + mcpproxy-tray[.exe]), and the member selection picked the wrong one:

  • applyTarGzUpdate matched strings.HasSuffix(header.Name, "mcpproxy"), which hits the core member.
  • applyZipUpdate applied the first non-directory entry regardless of name. Compress-Archive lists mcpproxy.exe first, so this was the same live bug on Windows, not just latent fragility.

A "successful" update therefore left the user with an mcpproxy-tray that is really a headless core. Reachable on tarball/zip installs; DMG app-bundles and Homebrew are gated off by isAppBundle() / isHomebrewInstallation().

Found while adding checksum verification in #1343 and deliberately left out of scope there.

Fix

Both formats now extract the member matched on its exact base name and fail closed when it is absent.

The matching logic already existed and was correct in cmd/mcpproxy (extractBinary / extractFromZip / extractFromTarGz). It moves to internal/updatecheck as ExtractBinary so both self-update paths share one implementation — the same treatment ParseChecksums / VerifyFileSHA256 got in #1343. cmd/mcpproxy keeps thin delegating wrappers, so its call sites and tests are unchanged. The tray also picks up the 512 MB per-member cap for free.

Release-pipeline fixes this exposed

Making extraction fail closed turned three latent packaging gaps into live ones:

prerelease.yml archived only ${CLEAN_BINARY} RC builds track the rc channel by construction, so every prerelease tray would have hit archive does not contain mcpproxy-tray.
prerelease.yml built the tray for darwin only The Windows RC tray ships via the Inno installer (built after archiving) and self-updates out of that zip, so its archive branch was dead code.
The archived Windows tray lacked -H windowsgui build-windows-installer.ps1 has always passed it. Without it, a self-update swaps the installer's GUI binary for a console one that pops a console window. Fixed in release.yml too — stable had the same latent defect.

Both workflows now also fail the build if the tray binary is missing, rather than silently shipping a tray-less archive.

Tests

internal/tray/update_extract_test.go (build-tagged !nogui && !headless && !linux, run on darwin) covers: the tray member winning over a core member listed first, in both formats; refusal when no tray member exists; base-name matching against a nested member and a mcpproxy-mcpproxy-tray decoy; and trayBinaryName() per GOOS.

Each archive member carries a payload naming it, and the expectation is an independent literal rather than being derived from trayBinaryName() — deriving it from the function under test made the assertion move with any bug in it.

Verified the tests bite by mutating the implementation: forcing trayBinaryName() to "mcpproxy" reproduces the original bug (installed "BINARY:mcpproxy", want "BINARY:mcpproxy-tray"), and forcing it to ignore runtime.GOOS also fails.

Verification

  • go test -race ./internal/... — 66 packages, exit 0
  • go test -race ./internal/tray/... ./internal/updatecheck/... ./cmd/mcpproxy/... — green
  • Server edition builds; -tags server CI-parity race tests green
  • Both golangci-lint passes (bare + --build-tags server) clean on every changed file
  • Cross-model review: 3 rounds via codex gpt-5.6-sol (3 findings, all verified and fixed) plus zcode (no correctness defects; 3 test-rigour items, all fixed)

Note on the base branch

This is stacked on fix/verify-update-artifact-checksum (#1343), not main: the applyUpdateFn seam and the buildTarGz / buildZip test helpers this builds on exist only there, and both PRs rewrite the same two functions. Merge #1343 first, or say the word and I'll rebase onto main.

The tray's self-update hands update.Apply a TargetPath of os.Executable().
cmd/mcpproxy-tray is the only importer of internal/tray, so that path is
always mcpproxy-tray — but the extraction picked the wrong member out of the
archive:

- applyTarGzUpdate selected on strings.HasSuffix(name, "mcpproxy"), and a
  release .tar.gz ships mcpproxy AND mcpproxy-tray side by side
  (.github/workflows/release.yml stages both, core first). A "successful"
  update therefore wrote the headless core over the tray executable, leaving
  the user with an mcpproxy-tray that is really a core.
- applyZipUpdate applied the FIRST non-directory entry regardless of name.
  Windows archives list mcpproxy.exe first, so this was the same bug, and any
  future layout change would silently install some other file.

Both now extract the member matched on its exact base name via a new
trayBinaryName(), and fail closed when the archive does not carry it.

The matching logic is the one cmd/mcpproxy already had; it moves to
internal/updatecheck (ExtractBinary, MaxArchiveMemberBytes) so both
self-update paths share one implementation, the way ParseChecksums and
VerifyFileSHA256 were shared. cmd/mcpproxy keeps thin delegating wrappers so
its own tests and call sites are unchanged. The tray also gains the 512 MB
per-member cap for free.
Round 1 of cross-model review: the tray self-update now extracts the
mcpproxy-tray member by name and fails closed when it is absent, but
prerelease.yml archived only ${CLEAN_BINARY}. RC builds track the rc channel
by construction (App.includePrereleases), so every prerelease tray would have
hit 'archive does not contain mcpproxy-tray'. Mirrors what release.yml
already does for stable archives.
Round 2 of cross-model review. prerelease.yml built mcpproxy-tray for darwin
only, so the windows branch of the archive step could never fire. The Windows
RC tray does reach users — build-windows-installer.ps1 builds it, but that
runs AFTER the archives — and it self-updates out of this job's .zip, so the
zip must carry the mcpproxy-tray.exe member. Matches release.yml, including
the archived tray being unsigned (SignPath signs installers, not archive
members).
Round 3 of cross-model review. scripts/build-windows-installer.ps1 has always
passed -H windowsgui, but the tray copy placed in the release/prerelease
archives did not, so it links as a console executable and pops a console
window on launch.

This was latent until now: the tray self-update used to install the CORE
binary over the tray, so the archived tray was never actually installed. Now
that it selects the mcpproxy-tray member correctly, a Windows self-update
would have swapped the installer's GUI binary for a console one.
…hives

Round 3 of cross-model review (zcode), test-rigour findings:

- TestApp_SelfUpdate_InstallsTheTrayBinaryNotTheCore gave both tray members
  the same payload, so a selection that ignored runtime.GOOS still passed.
  Members now carry payloads naming them, and the expectation is an
  independent literal rather than payloadFor(trayBinaryName()) — deriving it
  from the function under test made the assertion move with any bug in it.
- The nested base-name case covered tar.gz only; zip is a separate code path
  and now has its own subtest.
- The workflows' [ -f mcpproxy-tray ] gate degraded silently to shipping a
  tray-less archive. Since the self-update fails closed without that member,
  a missing binary now fails the build in both release.yml and prerelease.yml.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 22, 2026

Copy link
Copy Markdown

Deploying mcpproxy-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: 5a4a00c
Status: ✅  Deploy successful!
Preview URL: https://005c1e94.mcpproxy-docs.pages.dev
Branch Preview URL: https://fix-tray-self-update-extract.mcpproxy-docs.pages.dev

View logs

Round 4 of cross-model review (zcode), three findings:

- internal/tray is behind '!nogui && !headless && !linux', and 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. The self-update regression
  tests therefore only ran on the push-to-main macOS/Windows legs — after
  merge. native-tests.yml gains a path-gated macOS go-tray-test job, following
  the workflow's existing required-safe pattern (a skipped job reports green,
  so non-tray PRs spend no macOS runner).

- TestTrayBinaryName's base-name assertion tested the test's own literal, so
  filepath.Base(want) == want always held and the branch could never fire. It
  now checks the return value; verified by making trayBinaryName() return
  'bin/mcpproxy-tray', which the fixed assertion catches.

- The build-step guard added in the previous commit sat ~250 lines from the
  conditional that actually degrades silently, and only caught 'go build
  exited 0 but wrote no file'. It moves to the archive-assembly gate itself,
  as an else branch, so a future refactor splitting build from archive cannot
  reopen the original bug. Linux and the server edition fall through
  untouched - neither ships a tray.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant