docs/research/auto-updater-issue-957-2026-08-07.html
MCPProxy · Engineering decision report
mcpproxy update CLIIssue #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.
Three parallel update-awareness paths, zero install automation:
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@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 — CONFIRMEDSUFeedURL=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-installer.dmg is notarized + stapled. A Sparkle enclosure needs a notarized, stapled, symlink-preserving .app zip — a new CI asset. .github/workflows/release.yml — CONFIRMEDmcpproxy 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 — CONFIRMEDNothing 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)
| Option | Delivers one-click UX | Effort | Risk |
|---|---|---|---|
| A · Finish Sparkle 2 | Yes — full download → verify → swap → relaunch | L | Medium |
| B · Custom Swift updater | Yes | XL | High |
| C · Go-core-driven self-update | Yes | XL | Highest |
| D · Fix #957 only, no downloader | No — browser download stays | S/M | Low |
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.
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.
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.
Stale-process supersede in the tray + postinstall.sh fix + tarball-only CLI self-update. The “Update available” menu item keeps opening the browser.
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.open -a foregrounding the stale one.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).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.checkForUpdates(). Menu-repaint plumbing already exists.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.auto_updates true so brew doesn’t fight Sparkle.mcpproxy update CLI — the uv/deno pattern--force; never auto-sudo; never touch anything inside MCPProxy.app.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.--prerelease and the Sparkle beta channel both track next/v*-rc.*, with docs/prerelease-builds.md staying the single source of truth?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 unstamped | CONFIRMED |
| Checker cadence 24h, backoff 8×, env kill-switch wins over config | CONFIRMED |
| DMG channel has no programmatic update path in the core | CONFIRMED |
| Sparkle declared + linked + bundled + signed, never imported | CONFIRMED |
UpdateService’s checkWithSparkle() is a stub falling through to GitHub API | CONFIRMED |
| Info.plist SUFeedURL/SUPublicEDKey placeholders; no appcast anywhere | CONFIRMED |
| Menu already has “Check for Updates” + “Update available: vX” slots wired to repaint | CONFIRMED |
| Bundle layout: tray at Contents/MacOS, core at Contents/Resources/bin, Sparkle at Contents/Frameworks, hardened runtime | CONFIRMED |
| 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 tray | CONFIRMED |
| Attach-first lifecycle with no version comparison — stale cores survive upgrades | CONFIRMED |
| Single-instance enforcement is socket+flock+port | PARTIAL |
#957 root cause: nothing stops old processes; postinstall open -a foregrounds the stale instance | CONFIRMED |
| Tray quit kills managed core only; external cores just disconnect and keep running | CONFIRMED |
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.