Back to Mcpproxy Go

One-click auto-updater for macOS + mcpproxy update CLI

docs/research/auto-updater-issue-957-2026-08-07.html

0.54.016.6 KB
Original Source

MCPProxy · Engineering decision report

One-click auto-updater for macOS + mcpproxy update CLI

Issue #957 — old version app still running after upgrade · 2026-08-07 · 21-agent research workflow, 14 load-bearing claims adversarially verified against the code

Recommendation

Option A — finish the Sparkle 2 integration that is already half-shipped , with the stale-process fix (Option D) folded in as Phase 0.

Sparkle 2.9.3 is already declared in Package.swift, dynamically linked into the tray binary, and bundled + signed into Contents/Frameworks — it has just never been imported. The Info.plist already carries SUFeedURL=https://mcpproxy.app/appcast.xml (nonexistent) and a placeholder EdDSA key. Spec 037 FR-014/FR-015 already wrote the design. The remaining work is mostly deterministic CI plumbing, not novel security-critical runtime code.

Critically, Sparkle alone does not fix #957 — its relaunch callbacks don't cover install-on-quit or manual DMG drag-installs (Sparkle Discussion #2572). The actual bug fix is a version-mismatch supersede check in the tray, which ships first and works for every upgrade path.

01What exists today (verified)

Three parallel update-awareness paths, zero install automation:

  • Core: internal/updatecheck (Spec 079) polls GitHub daily, detects the install channel (dmg / homebrew / deb / rpm / docker / go-install / windows-installer / tarball) and surfaces guidance via /api/v1/info, status, doctor, Web UI, and tray. The DMG channel deliberately gets no command — only “Download the latest DMG from…”. internal/updatecheck/channel.go:129-217 · guidance.go:12-25, 58-59 — CONFIRMED
  • Tray: Sparkle 2.9.3 is declared, linked (@rpath/Sparkle.framework per otool), bundled and signed by both build scripts — but no Swift file ever import Sparkles. checkWithSparkle() is a stub that falls through to a raw GitHub API check. The menu already has the exact slots: “Check for Updates” and a conditional “Update available: vX” that just opens the browser. Package.swift:8 · UpdateService.swift:49,132-139 · MCPProxyApp.swift:1112-1115, 1132-1136 — CONFIRMED
  • Sparkle infra is half-plumbed but dead: Info.plist has SUFeedURL=https://mcpproxy.app/appcast.xml, SUPublicEDKey=SPARKLE_PUBLIC_KEY_PLACEHOLDER, SUEnableAutomaticChecks=true. No appcast.xml exists in the repo, the release workflow, or the website repo. No EdDSA keys were ever generated. native/macos/MCPProxy/MCPProxy/Info.plist:35-42 — CONFIRMED
  • Release pipeline already publishes trust artifacts: checksums.txt + keyless cosign bundle + SLSA v1 provenance. Gotcha: the bare per-arch DMG is signed but not notarized; only the PKG-wrapping -installer.dmg is notarized + stapled. A Sparkle enclosure needs a notarized, stapled, symlink-preserving .app zip — a new CI asset. .github/workflows/release.yml — CONFIRMED
  • No mcpproxy update subcommand exists. The legacy Go tray (tarball-only now) has a real binary self-updater (inconshreveable/go-update) that refuses to run inside .app bundles. cmd/mcpproxy/ · internal/tray/tray.go — CONFIRMED

02Root cause of #957

Nothing in any upgrade path stops or restarts the running processes. The user drags the new MCPProxy.app over the old one (or runs the PKG); macOS replaces the bundle on disk, but the old tray and its old core keep running from deleted inodes. The PKG postinstall launches with open -a — self-documented as “activates rather than duplicating” — so it foregrounds the stale old-version instance. There is zero bundle-replacement or version-mismatch detection in the Swift tray.

The tray’s lifecycle actively works against upgrades: on start it attaches to any core answering ~/.mcpproxy/mcpproxy.sock with no version comparison, and the core enforces single-instance via the bbolt flock on config.db (loser exits code 3) and TCP port conflict (exit 2) — so a new-version core can’t even start while the old one runs. postinstall.sh:27,62-75 · CoreProcessManager.swift:248-319,462-475 — CONFIRMED (one nuance: a busy Unix socket alone doesn’t stop the core; the DB flock and port do)

03Options

OptionDelivers one-click UXEffortRisk
A · Finish Sparkle 2Yes — full download → verify → swap → relaunchLMedium
B · Custom Swift updaterYesXLHigh
C · Go-core-driven self-updateYesXLHighest
D · Fix #957 only, no downloaderNo — browser download staysS/MLow

Wire SPUStandardUpdaterController programmatically in UpdateService.swift with the “gentle reminders” pattern for menu-bar apps: the menu item “Update 0.54.1 — ready to restart?” triggers one click → download, EdDSA + Apple-signature verify, bundle swap, relaunch. Add EdDSA keys, CI-generated appcast, and a notarized + stapled .app zip enclosure. Fix #957 belt-and-suspenders: delegate hooks stop the Go core before the swap, and an unconditional startup version-mismatch check supersedes any stale core. Separately: mcpproxy update that self-replaces only on tarball/unknown channels and prints the package-manager command elsewhere.

Pros

  • Sparkle handles the genuinely hard macOS parts free: quarantine release (Ventura 13.1+ Gatekeeper regression that kills naive custom updaters), App Translocation detection, privileged copy, atomic swap, EdDSA pinning, delta updates.
  • Least new code: framework already linked/bundled/signed; menu slot, update state, and core lifecycle ownership all exist.
  • Spec 037 FR-014/FR-015 already specifies exactly this flow — design work is done.
  • Ecosystem-standard for menu-bar apps (iTerm2 et al.); maintained through 2026; no sandbox/XPC work needed.
  • The startup version-mismatch check fixes #957 for all upgrade paths, not just the one-click path.
  • CLI reuses existing checksums.txt + cosign bundle + SLSA provenance — zero new CI for CLI verification.

Cons

  • The bulk of the cost is CI/release plumbing: EdDSA key secret, appcast generation and stable hosting, a new stapled .app zip asset.
  • Appcast URL becomes forever-infrastructure (Info.plist already points at the nonexistent mcpproxy.app/appcast.xml).
  • Sparkle silently no-ops when the app runs translocated/read-only — must surface via delegate.
  • Two update brains (Sparkle feed + internal/updatecheck) must not double-nudge; CI=true suppression must carry over.
  • import Sparkle under plain swift build (no Xcode) needs a quick prototype — it already links, but the import path is unproven.

Download the notarized artifact, verify sha256 against cosign-verified checksums.txt + codesign --verify --deep + spctl -a, strip quarantine, atomic-rename-swap, relaunch via a detached helper. One signing system (cosign) instead of two.

Pros

  • No appcast, EdDSA key, or hosted infra — GitHub Releases + cosign is the whole supply chain.
  • Could drop the 5 MB dead-weight Sparkle.framework from the bundle.
  • Uniform verification across tray and CLI; full control over core-shutdown choreography.

Cons

  • Re-implements what Sparkle hardened over 20 years: quarantine release, translocation, privileged copy, per-inode signature caching (Killed: 9), rollback.
  • The relaunch helper (survive deletion of the bundle it was spawned from) is subtle and macOS-version-sensitive.
  • Most novel security-critical Swift code to maintain; a bug bricks installs. Still needs the same notarized-artifact CI work as A.

All update logic in Go (go-selfupdate + minio/selfupdate, cosign-verified); the tray menu calls a new POST /api/v1/update/apply; the core swaps /Applications/MCPProxy.app and asks the tray to relaunch.

Pros

  • Single implementation for CLI, tray, and later Windows/Linux; better testing story in Go.
  • Mature Go libraries for the binary-replace primitive; no Sparkle/appcast/EdDSA.

Cons

  • Architecturally inverted: the core replaces its own parent bundle while both processes run from it — the exact deleted-inode tangle behind #957, with more moving parts.
  • Go selfupdate libraries are single-binary oriented; whole-bundle swap with nested signatures is entirely custom (bundle seal breaks → Killed: 9 per Apple DTS).
  • Reverses the working tray-owns-core lifecycle; a failed swap can leave no surviving process to show an error.

Stale-process supersede in the tray + postinstall.sh fix + tarball-only CLI self-update. The “Update available” menu item keeps opening the browser.

Pros

  • Smallest, fastest change; directly closes the reported bug for every upgrade path.
  • No new CI, keys, or infrastructure; no security-critical download/swap code.
  • Every piece is a prerequisite of Option A anyway — zero throwaway work.

Cons

  • Does not deliver the requested one-click UX; users still download and drag DMGs.
  • Sparkle stays linked, bundled, signed, and dead in every release.

Fix #957 — ships independently, in the very next release

  • Stale-core supersede (CoreProcessManager.swift): compare the attached/managed core’s reported version against the bundled core’s version. Tray-managed + older → terminate (existing SIGTERM→SIGKILL) and respawn from Contents/Resources/bin. Externally-attached → don’t kill; show “Old core vX still running — restart into vY” (open decision below). Run at attach time and on every version report.
  • Stale-tray detection : on didBecomeActive + low-frequency timer, stat the on-disk bundle version vs the running version; on mismatch show “MCPProxy was updated to vY — Relaunch” (stops core, detached open -n). This closes the drag-install deleted-inode case directly.
  • packaging/macos/postinstall.sh : quit the running instance by bundle id (osascript → wait → pkill fallback) before launching the fresh bundle, instead of open -a foregrounding the stale one.
  • XCTest for the supersede state machine (fixture-driven, per 090 pattern); manual QA via mcpproxy-ui-test on a real old-DMG → new-DMG upgrade.

Sparkle one-click updater — the requested UX

  • Keys : generate_keys once → public key into Info.plist SUPublicEDKey; private key → GitHub secret SPARKLE_ED_PRIVATE_KEY. Lock the SUFeedURL now (forever URL — open decision).
  • Swift : import Sparkle; programmatic SPUStandardUpdaterController; prototype the import under plain swift build first (fallback: vendor the framework from the release tarball). Gentle-reminders user-driver delegate feeds the published update state instead of Sparkle’s window; honor CI=true and MCPPROXY_DISABLE_AUTO_UPDATE. Updater delegate stops the managed core before the swap (prototype shouldPostponeRelaunchForUpdate vs synchronous stop). Surface translocation/read-only failures. Phase 0’s startup check stays permanently as the suspenders.
  • Menu : repurpose the existing “Update available: vX” slot (MCPProxyApp.swift:1132-1136) into “Update 0.54.1 — ready to restart?” → checkForUpdates(). Menu-repaint plumbing already exists.
  • CI (release.yml): notarize + staple the .app itself; ditto -c -k --sequesterRsrc --keepParent zip (symlink-preserving or the signature breaks) as a release asset in checksums.txt; new appcast job runs generate_appcast --ed-key-file … --download-url-prefix … (delta updates free); map v*-rc.* to a Sparkle beta channel per docs/prerelease-builds.md; keep nested-first codesign order.
  • Homebrew cask : set auto_updates true so brew doesn’t fight Sparkle.

mcpproxy update CLI — the uv/deno pattern

  • New cmd/mcpproxy/update_cmd.go branching on the existing channel detection: homebrew/deb/rpm/go-install → print the existing one-liner, never self-replace. docker/windows-installer → guidance. dmg → “Use the tray: Check for Updates” (later: trigger via socket). tarball/unknown-writable → real self-update: resolve via internal/updatecheck/github.go, verify cosign bundle offline (identity pinned to the release workflow + GitHub OIDC issuer), sha256-match, apply via minio/selfupdate (write-temp + rename, never in-place — macOS per-inode kill-9). Refuse downgrades without --force; never auto-sudo; never touch anything inside MCPProxy.app.
  • Table-driven unit tests per channel branch (mock GitHub, CI="" pinning); e2e smoke against a faked release server.

Cleanup & rollout

  • Have the tray refresh/remove the legacy staged core at ~/Library/Application Support/mcpproxy/bin/ (stale copies shadow for legacy-tray users).
  • docs/features/auto-update.md (channel matrix), configuration.md env vars, Spec 037 FR-014/015 marked implemented.
  • First Sparkle-capable release N publishes the appcast; one-click activates for N→N+1. Test end-to-end with a genuine notarized older build, not a dev build.

05Open decisions (maintainer input needed)

  1. Appcast hosting : mcpproxy.app/appcast.xml via the website repo (matches the URL already baked into shipped Info.plists — strong argument) vs GitHub Pages vs a mutable release asset. The URL is forever.
  2. Externally-attached cores (brew-services / CLI-started): may the tray kill/restart one during a one-click update, should the core grow a shutdown/restart socket endpoint, or is one-click limited to tray-managed cores (the sketch’s default)?
  3. Graceful drain : finish in-flight MCP requests before shutdown during update (Spec 037’s own open question), and with what timeout — or is SIGTERM→SIGKILL acceptable?
  4. Enclosure arch : per-arch zips (CI builds per-arch today) vs the universal binary build-macos-tray.sh already supports (simpler feed, larger download).
  5. CLI verification dep : sigstore-go (heavy dep tree vs the “avoid new dependencies” rule) vs adding a minisign signature in CI (tiny dep, native minio/selfupdate support) vs shelling out to user-installed cosign.
  6. Bare DMG notarization : start notarizing/stapling the drag-and-drop DMG too (Gatekeeper quality issue independent of Sparkle), or drop it for installer-DMG + app-zip only?
  7. Windows scope : defer entirely to the MSI channel, or support tarball-on-Windows via the rename-to-.old trick?
  8. Prerelease mapping : --prerelease and the Sparkle beta channel both track next/v*-rc.*, with docs/prerelease-builds.md staying the single source of truth?
  9. Sparkle XPC services : strip from the bundle (unsandboxed app, saves size) or leave stock for easier upgrades?

06Verification appendix

14 load-bearing codebase claims were adversarially re-verified by independent agents instructed to refute them: 13 CONFIRMED, 1 PARTIAL. The PARTIAL: single-instance enforcement is via the config.db flock (exit 3) and TCP port conflict (exit 2) — a busy Unix socket alone does not stop the core (it logs a warning and continues TCP-only); the net effect claimed (new core can’t run alongside old) still holds. External-research claims (Sparkle behavior, Gatekeeper regression, library maturity) rest on primary sources: Sparkle docs/discussions #2572, Apple dev-forums thread 730314, library repos.

Claim (abridged)Verdict
Channel detection: build-time -X marker then path heuristics; release matrix builds intentionally unstampedCONFIRMED
Checker cadence 24h, backoff 8×, env kill-switch wins over configCONFIRMED
DMG channel has no programmatic update path in the coreCONFIRMED
Sparkle declared + linked + bundled + signed, never importedCONFIRMED
UpdateService’s checkWithSparkle() is a stub falling through to GitHub APICONFIRMED
Info.plist SUFeedURL/SUPublicEDKey placeholders; no appcast anywhereCONFIRMED
Menu already has “Check for Updates” + “Update available: vX” slots wired to repaintCONFIRMED
Bundle layout: tray at Contents/MacOS, core at Contents/Resources/bin, Sparkle at Contents/Frameworks, hardened runtimeCONFIRMED
Core-binary resolution order; only the legacy Go tray stages to Application Support (size+mtime freshness only)CONFIRMED
Tray spawns core directly (no launchd daemon); login item relaunches trayCONFIRMED
Attach-first lifecycle with no version comparison — stale cores survive upgradesCONFIRMED
Single-instance enforcement is socket+flock+portPARTIAL
#957 root cause: nothing stops old processes; postinstall open -a foregrounds the stale instanceCONFIRMED
Tray quit kills managed core only; external cores just disconnect and keep runningCONFIRMED

Method: multi-agent workflow (2 codebase investigators, 3 external researchers, 14 adversarial verifiers, 2 synthesists · 1.2M tokens). Companion report: request queueing / concurrency limits for issue #955.