Skip to content

feat(update): one-click macOS auto-updater + channel-aware mcpproxy update CLI (spec 092) - #958

Merged
Dumbris merged 37 commits into
mainfrom
092-auto-updater
Aug 8, 2026
Merged

feat(update): one-click macOS auto-updater + channel-aware mcpproxy update CLI (spec 092)#958
Dumbris merged 37 commits into
mainfrom
092-auto-updater

Conversation

@Dumbris

@Dumbris Dumbris commented Aug 7, 2026

Copy link
Copy Markdown
Member

Summary

Spec for the auto-updater decided in the #957 investigation (Option A of the decision report, included under docs/research/):

  • Phase 0 — fixes [Bug]: old version App still after upgrade #957 directly: stale-version supersede in the Swift tray (version-mismatch detection for both the running core and the on-disk bundle) + postinstall.sh quit-before-launch. Ships independently.
  • Phase 1 — one-click updater: finish the already-linked-but-never-imported Sparkle 2 integration: menu shows "Update X.Y.Z — ready to restart?", one click → download, verify (feed signature + codesign), bundle swap, relaunch. CI gains EdDSA keys, appcast generation, and a notarized+stapled symlink-preserving .app zip enclosure.
  • Phase 2 — mcpproxy update CLI: channel-aware (uv/deno pattern) — package-manager one-liners for brew/deb/rpm, guidance for docker/windows, tray delegation for DMG, cosign-verified atomic self-update for tarball installs only.

Related #957

Review notes

Spec only — no code. Key research findings it rests on (adversarially verified): Sparkle 2.9.3 is already SPM-declared, linked, bundled and signed into MCPProxy.app but never imported; Info.plist already points at a nonexistent appcast URL with a placeholder EdDSA key; the bare per-arch DMG is signed but not notarized; #957's root cause is that no upgrade path stops the old processes.

Open maintainer decisions are listed in the report (appcast hosting URL, externally-attached core policy, per-arch vs universal enclosure, CLI verification dependency).

🤖 Generated with Claude Code

…date CLI

Related #957

Spec for Option A from the decision research: Phase 0 stale-version
supersede (the actual #957 bug fix), Phase 1 finish the half-plumbed
Sparkle 2 integration, Phase 2 channel-aware CLI self-update.
Includes the multi-agent decision report under docs/research/.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 7, 2026

Copy link
Copy Markdown

Deploying mcpproxy-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: 663d52c
Status: ✅  Deploy successful!
Preview URL: https://f83e92eb.mcpproxy-docs.pages.dev
Branch Preview URL: https://092-auto-updater.mcpproxy-docs.pages.dev

View logs

@codecov-commenter

codecov-commenter commented Aug 7, 2026

Copy link
Copy Markdown

Dumbris added 2 commits August 7, 2026 20:53
…idance-only, enclosure-signature wording, RC pipeline parity

Related #957

- FR-001a: launch provenance must survive tray restarts (today every pre-existing core reads as external)
- FR-002: external-core restart = explicit user consent or instructions
- FR-020: self-update only on positively identified tarball channel; unknown stays guidance-only (detector never returns tarball today — positive marker is a prerequisite)
- FR-011: enclosure-level signature + bundle-identity codesign policy
- FR-014: prerelease pipeline must gain feed/manifest parity for RC channel
- FR-015: explicit hot-reloadable update policy contract governing all tray-side checks
- FR-017: single-source-of-truth menu state machine (feed owns one-click; legacy check is browser-guidance only)
- FR-021/022: defined confirmation point, backup/rollback, symlink/permission semantics; downgrade needs explicit version + --force
- FR-006: SemVer 2.0 precedence (rc.10 > rc.2)
- SC-001 scoped by ownership/acceptance; FR-030 premise corrected
…(unknown guidance-only; downgrade needs explicit version + --force)

Related #957
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

📦 Build Artifacts

Workflow Run: View Run
Branch: 092-auto-updater

Available Artifacts

  • archive-darwin-amd64 (28 MB)
  • archive-darwin-arm64 (26 MB)
  • archive-linux-amd64 (17 MB)
  • archive-linux-arm64 (15 MB)
  • archive-windows-amd64 (28 MB)
  • archive-windows-arm64 (25 MB)
  • frontend-dist-pr (0 MB)
  • installer-dmg-darwin-amd64 (22 MB)
  • installer-dmg-darwin-arm64 (20 MB)

How to Download

Option 1: GitHub Web UI (easiest)

  1. Go to the workflow run page linked above
  2. Scroll to the bottom "Artifacts" section
  3. Click on the artifact you want to download

Option 2: GitHub CLI

gh run download 31258349320 --repo smart-mcp-proxy/mcpproxy-go

Note: Artifacts expire in 14 days.

Dumbris added 20 commits August 8, 2026 05:51
Related #957

The tray already stamps MCPPROXY_LAUNCHED_BY=tray on every core it spawns
(CoreProcessManager.swift, cmd/mcpproxy-tray), and the macOS PKG postinstall
stamps installer — but the core never reported it back, so ownership lived
only in the launching tray's memory. A newer tray attaching to a core started
by an older tray classified it as external and could not supersede it: exactly
the #957 upgrade scenario (FR-001a).

## Changes
- internal/launch: capture MCPPROXY_LAUNCHED_BY once at process start and
  normalize it to "tray" / "installer" / "" (strict — an unrecognized marker
  never authorizes killing a process)
- GET /api/v1/info always carries launched_by (contracts.InfoResponse, oas,
  generated frontend contracts, docs/api/rest-api.md)
- mcpproxy status renders "Launched by:" when the core asserted a marker and
  stays quiet otherwise; JSON keeps it omitempty

## Testing
- go test -race ./internal/launch/... ./internal/httpapi/... ./cmd/mcpproxy/...
- golangci-lint v2 clean on the touched packages
…able

Related #957

ChannelTarball existed since Spec 079 but detect() could never return it: the
release matrix stamped no build marker and no heuristic produces "tarball". A
positively identified tarball install is the precondition for `mcpproxy update`
to ever self-replace a binary (FR-020), so without it the CLI self-update path
is unreachable by construction.

## Changes
- release.yml builds a SEPARATE core binary for the .tar.gz/.zip archive,
  stamped with updatecheck.buildChannel=tarball, signed alongside the others on
  macOS, and verified post-build via `go version -m`. The shared matrix binary
  stays unstamped so the DMG/PKG bundle and the .deb/.rpm packages keep
  detecting their own channel.
- The tarball marker is deliberately WEAK in detect(): Homebrew installs the
  very same .tar.gz into its Cellar, so positive heuristics (brew prefix,
  container, package-manager path, .app bundle) override it and it only applies
  where detection would otherwise answer "unknown". Every other marker keeps
  absolute precedence.
- UpdateCommand(tarball) / PrereleaseUpdateCommand(tarball) => "mcpproxy update"
  (FR-024 for the tarball channel only; DMG guidance is unchanged until the
  tray updater ships).
- prerelease.yml documents why RC archives stay unstamped for now: self-update
  needs checksums.txt + cosign bundle, which that pipeline does not publish yet
  (FR-014).
- docs/features/version-updates.md: weak-marker rule and the updated command
  matrix.

## Testing
- go test -race ./internal/updatecheck/... (incl. a precedence table proving a
  stamped binary under a brew prefix / in a container / in an .app bundle /
  under dpkg ownership never resolves to tarball)
- golangci-lint v2 clean; both workflows re-parsed as YAML
Related #957

`mcpproxy update` branches on the already-detected install channel (Spec 092
US3/FR-020..FR-023): package-manager installs get the exact upgrade command,
Docker/Windows-installer get guidance, a macOS app bundle is delegated to the
tray, and only a positively identified tarball install (or `unknown` plus an
explicit `--self` assertion) is ever self-replaced.

## Changes
- cmd/mcpproxy/update_cmd.go: the command, its decision table and the
  -o json/yaml report. --check reports current/latest/channel with no side
  effects and is also the output when nothing is newer. A development build is
  reported as such instead of "already up to date".
- cmd/mcpproxy/update_apply.go: checksum manifest parsing, sha256 verification,
  archive extraction, and the atomic swap — stage inside the target directory,
  preserve mode, rename target -> .old, rename new -> target, run
  `<new> --version` and only then drop the backup; restore on any failure.
  Symlinked launchers have their destination replaced, never the symlink.
- Refusals: app-bundle and staged-copy paths (checked independently of GOOS),
  non-writable targets (error names path + owner, never suggests sudo), and a
  downgrade without both --version and --force.
- internal/updatecheck: GetReleaseByTag + an overridable API base so an exact
  tag can be requested and tests can run against httptest.

## Signature verification — deliberate compromise (flagged)
FR-021 asks for offline signature verification. The sha256 check against the
release's checksums.txt is unconditional, and the cosign bundle over
checksums.txt is verified with the identity pinned to this repo's release
workflow — but via a locally installed cosign binary, not in-process.
sigstore-go v1.3.0 would add 72 linked modules (+16 MB to a hello-world
binary) and force a go.mod Go-directive bump; the decision report lists that
dependency as an open maintainer decision. Missing cosign therefore ABORTS the
update by default; --allow-unverified-signature is the explicit, loudly warned
opt-out.

## Testing
- go test -race ./cmd/mcpproxy/... ./internal/updatecheck/... — 130 test cases
  across the channel decision table, the downgrade/force matrix, bundle-path
  refusal, non-writable targets, and a real download->verify->swap against an
  httptest release server (happy path, tampered artifact, verify-failure
  restore, missing/failing signature, unlisted artifact)
- golangci-lint v2 (.github/.golangci.yml) on ./... — 0 issues
- go build -tags server ./cmd/mcpproxy + go test -tags server
  ./internal/serveredition/... -race
Related #957

The step runs under `bash -eo pipefail`, where `go version -m … | grep -q …`
can report the pipeline as failed when grep exits on the first match and go
takes SIGPIPE — turning the guard into a flaky release blocker. Capture the
build settings into a variable and match with `case` instead.
Related #957

FR-002 needs a stop mechanism for a core the tray only ATTACHED to: it
holds no Process handle for one, and the core exposes no shutdown
endpoint, so without a pid the consent action could only ever print
instructions. Paired with the launched_by provenance added in e186f57
this is what lets a newer tray actually supersede a core an older tray
started.

## Changes
- contracts.InfoResponse.PID + the `pid` key in the handler payload,
  indirected through `var pidFn = os.Getpid` as the test seam
- regenerated frontend/src/types/contracts.ts (generator edited, not the
  output), oas/swagger.yaml + oas/docs.go via `make swagger`
- docs/api/rest-api.md field table and example

## Testing
- go test -race ./internal/httpapi/... ./internal/contracts/... — ok
- golangci-lint v2 (.github/.golangci.yml) — 0 issues
Related #957

FR-006. The tray's only version comparison sorted prerelease identifiers
as whole strings, so 1.0.0-rc.10 ranked BELOW 1.0.0-rc.2. On the update
path that offers an RC user a downgrade as an "update"; the supersede
logic Phase 0 builds on top of it would have killed a newer core to
start an older one. Extracted into a shared type so both callers get the
same ordering and it has tests of its own.

## Changes
- new `SemanticVersion`: SemVer 2.0 parse + §11 precedence (numeric
  identifiers numerically, numeric < alphanumeric, prerelease < release,
  larger identifier set wins), tolerating a leading "v" and a two-part
  core, discarding build metadata per §10
- malformed input returns nil — "no decision" — instead of 0; only the
  update-nudge adapter (`UpdateService.compareSemver`) collapses that to
  0, where "not greater" already means "no update"

## Testing
- swift test --filter SemanticVersionTests: 13 tests, 0 failures
Related #957

FR-001/FR-001a/FR-002/FR-005. After an upgrade the new tray finds the
OLD core still running: the previous tray spawned it, that tray is gone,
and nothing ever stopped it. The tray attached and served the old
version indefinitely — the reported bug.

Ownership was in-memory in the launching tray, so every pre-existing
core classified as external and could never be superseded. The core now
reports durable provenance (launched_by) and its pid, which is what
makes the decision possible at all.

## Changes
- `CoreSupersede.decide`: pure verdict — restartManaged (we hold the
  Process), stopAndRespawn (tray provenance + usable pid),
  askForConsent (user-launched, or tray-launched with no pid), or none
  with a logged reason. `installer` counts as tray provenance because
  launchCore deliberately preserves that marker on the core it spawns.
- `CoreProcessIdentity`: re-checks that a pid is an mcpproxy process
  immediately before signalling it — pids are recycled, and the core may
  have died between the info read and the signal.
- `BundledCore.respawnVersion`: asks the bundled binary (`version -o
  json`, a pure print — no config, no BBolt lock), falling back to
  CFBundleShortVersionString. Nil for dev builds and for a
  MCPPROXY_CORE_PATH override, where a restart would re-launch the same
  binary in a circle.
- `CoreProcessManager` evaluates after every connect (attach, launch,
  reconnect) — the only points a version report arrives — and publishes
  `AppState.staleCorePrompt` for the consent case. One attempt per
  session; shutdown retracts the offer.
- InfoResponse gains optional launched_by/pid (optional because a pre-092
  core omits them, and an old core is the whole subject).

## Testing
- swift test --filter CoreSupersede…|CoreProcessIdentity…|BundledCore…:
  21 tests, 0 failures
- swift test --filter CoreSupersedeAttachTests: 8 tests, 0 failures
  (real attach path against a Unix-socket stub core)
- swift test --filter ModelsTests: 97 tests, 0 failures
Related #957

FR-003 and the menu half of FR-002. A drag-install replaces
/Applications/MCPProxy.app underneath the running process: macOS neither
notifies nor restarts it, so the old code — and the old core it manages
— keeps running until the user works out that they have to quit it.

## Changes
- `BundleUpdateWatcher` reads CFBundleShortVersionString from the
  Info.plist ON DISK on every check. Not `Bundle(path:)`: Foundation
  caches bundles by path and would keep answering with the pre-upgrade
  dictionary, which is exactly the state being detected. Offers only on
  a strictly newer, SemVer-comparable version — equal, older and
  unparseable all stay silent.
- Checked at launch, on every `applicationDidBecomeActive`, and on a
  5-minute timer.
- Menu: "MCPProxy was updated to vY — Relaunch" (stops the core, `open
  -n` the new bundle, then terminates — `-n` because plain `open`
  activates this stale process, the reported symptom) and "Old core vX
  running — Restart into vY" (FR-002 consent; with no pid to act on it
  presents instructions instead of failing silently).

## Testing
- swift test --filter BundleUpdateWatcherTests: 7 tests, 0 failures
  (incl. rewriting a fixture bundle's plist mid-test)
- swift test --filter SupersedeMenuTests: 6 tests, 0 failures (real
  controller + real rebuildMenu through the existing menu-host seam)
…e new one

Related #957

FR-004. `open -a` ACTIVATES a running instance instead of starting one,
so the postinstall step handed the user the OLD, just-overwritten app —
still serving from the old core — and called the upgrade done. Quitting
first is what makes the launch a launch.

## Changes
- politeness ladder: `osascript … to quit` by bundle id, 5s wait,
  SIGTERM (3s), SIGKILL. The app routes SIGTERM through its normal quit
  path, so even the fallback stops the managed core.
- the quit runs through the same `launchctl asuser … env -i` wrapper as
  the launch (factored into `run_as_user`) — an AppleScript sent from
  root's context cannot reach the user's app.
- pkill matches `MCPProxy.app/Contents/MacOS/MCPProxy`, not the bare
  name: a looser pattern also matches the core (whose orderly shutdown
  belongs to the tray) and this script's own command line.
- a survivor never fails the install; the new tray's stale-core
  supersede handles it.

## Testing
- bash -n and shellcheck: clean
- verified the pgrep/pkill pattern against a fixture process living at
  .../MCPProxy.app/Contents/MacOS/MCPProxy — matched, and cleared
Related #957

FR-030, with the analysis it asks for recorded in the file header.

Findings: nothing in THIS tray resolves the staged copy at
~/Library/Application Support/mcpproxy/bin/mcpproxy ahead of the bundled
core — resolveBinary() checks the bundle first, so that branch is
reachable only for a build with no bundled core, where there is nothing
to shadow. The legacy Go tray does prefer it, but re-stages it from its
own bundle, so it tracks. The /usr/local/bin symlink points at the
bundled binary. A user's PATH is theirs.

The residual hazard is the file itself: a stale executable anything can
still run. So it is REFRESHED, never removed, and only when provable —
regular file (a symlink is deliberate wiring), answers `version -o json`
so it really is an mcpproxy binary, and that version is strictly older
by SemVer. Anything unprovable is a logged no-op.

## Changes
- `StagedCoreBinary.decide` (pure) + `refreshIfStale` (copy to a sibling
  temp, preserve the existing mode, rename over the target — a legacy
  tray may be executing that image, and a rename leaves it on its own
  inode)
- called detached at startup next to the symlink setup
- FR-030 note on resolveBinary()'s step 3 explaining the ordering

## Testing
- swift test --filter StagedCoreBinary: 13 tests, 0 failures — decision
  table (8) plus real file-system cases (5): stale copy swapped with its
  mode preserved and no temp left behind, current copy untouched,
  unidentifiable file neither refreshed nor deleted, absent path not
  created, symlink left pointing where it pointed
Related #957

FR-002. The automatic branch tore the connection down and only then
discovered it could not identify the pid — leaving the user with an
error and no core, where a moment earlier they had a working (if old)
one. The unidentifiable-pid case is not a failure, it is the
"no safe stop mechanism" case the requirement answers with instructions.

## Changes
- check the pid's identity BEFORE dropping the connection; on refusal
  downgrade the offer to the pid-less instructions prompt and leave the
  connection alone. `stopCore` keeps its own re-check immediately before
  the signal (pids are recycled).

## Testing
- swift test --filter CoreSupersede: 21 tests, 0 failures (the refusal
  test now asserts .connected + a pid-less prompt)
Related #957

FR-015 asks for the update policy to be an *explicit* contract the tray can
read, "not inferred from missing data". Today the only signal is the presence
of the `update` object — and `Checker.GetVersionInfo()` returns nil BOTH when
checking is disabled and when no check has produced a result yet. A tray that
must decide "may I run a Sparkle feed check at all?" cannot tell those apart,
so it either nudges after the operator disabled updates or stays silent when
it should not.

## Changes
- `internal/updatecheck/policy.go`: `Policy{enabled, channel, nudges_suppressed}`
  plus `Checker.Policy()` (computed live, so a `SetConfig` hot-reload and both
  environment overrides are visible on the very next read) and
  `UnavailablePolicy()` for runtimes constructed without a checker.
- `update_policy` in `GET /api/v1/info`, always present with all three fields
  (no omitempty — an all-zero policy is exactly the disabled case that must not
  vanish). Wired `Runtime.UpdatePolicy` → `Server.UpdatePolicy` →
  `httpapi.ServerController`.
- `internal/contracts`, `cmd/generate-types` (+ regenerated
  `frontend/src/types/contracts.ts`), `make swagger`, `docs/api/rest-api.md`.

`enabled` governs AUTOMATIC checks only; a user-initiated "Check for Updates"
stays available, which is what FR-015 requires.

## Testing
- `internal/updatecheck/policy_test.go`: config/env precedence matrix, CI nudge
  suppression, unavailable-checker policy (CI pinned via t.Setenv).
- `internal/httpapi/info_update_policy_test.go`: field always present for four
  policies, including the all-zero one.
- go test -race ./internal/updatecheck/... ./internal/httpapi/... ./internal/contracts/... → ok
- golangci-lint v2 → 0 issues; server edition build + tests ok.
Related #957

Sparkle 2.9.3 has been declared in Package.swift, dynamically linked, bundled
into Contents/Frameworks and code-signed by both build scripts since Spec 037 —
and never imported by a single Swift file. `UpdateService.checkWithSparkle()`
was a `NSClassFromString` stub that fell straight through to a raw GitHub API
call.

FIRST, the one unproven assumption in the decision report: `import Sparkle`
DOES resolve under plain `swift build` with no Xcode project. Verified by
compiling `#if !canImport(Sparkle) #error(...) #endif` against the package —
the error does not fire, so the whole framework path below is really compiled,
not silently `#if`-ed away.

## Changes

- `Services/FeedUpdater.swift` — `FeedUpdating` / `FeedUpdaterObserver`
  protocols plus the real `SparkleFeedUpdater`:
  * programmatic `SPUStandardUpdaterController(startingUpdater: false, …)` and
    an explicit `try updater.start()`. Deliberately NOT `startingUpdater:
    true`: that variant logs a misconfiguration and puts a "contact the
    developer" alert in front of the user a few seconds later — which is
    exactly what every build shipped before the CI appcast job would do, since
    Info.plist still carries `SPARKLE_PUBLIC_KEY_PLACEHOLDER`. A start failure
    now degrades to the browser path with a reason (FR-016/FR-017);
  * gentle reminders (FR-010): `supportsGentleScheduledUpdateReminders = true`,
    `standardUserDriverShouldHandleShowingScheduledUpdate` → false so a
    scheduled check never throws a window at a menu-bar user, and
    `standardUserDriverWillHandleShowingUpdate` feeds the published state;
  * `allowedChannelsForUpdater:` from the policy — empty set (Sparkle's
    "default channel only") for stable, `["beta"]` for RC (FR-014);
  * FR-012: the core is stopped in `updater(_:willInstallUpdate:)` — "called
    immediately before installing", i.e. the last moment the OLD bundle is
    still on disk. `shouldPostponeRelaunchForUpdate:` and
    `updaterWillRelaunchApplication:` both run AFTER the swap; the latter is
    kept as an idempotent second stop, the former is not used.
- `Services/ManagedCoreStop.swift` — the synchronous SIGTERM → bounded wait →
  SIGKILL ladder. Sparkle's pre-install hooks are synchronous main-thread
  callbacks, and `CoreProcessManager.shutdown()` hops to the main actor, so
  blocking on it would deadlock rather than stop anything. Phase 0's
  `CoreProcessIdentity` re-check still gates the signal.
- `Services/UpdatePolicy.swift` — FR-015 resolution: tray
  `MCPPROXY_DISABLE_AUTO_UPDATE`, CI suppression, and the core's new
  `update_policy`. A user-initiated "Check for Updates" bypasses all of it.
- `Services/UpdateInstallability.swift` — FR-016: translocation, read-only
  volume and unwritable parent, each with an explanation AND a fallback.
- `Services/UpdateMenuState.swift` — FR-017: one owner. Feed offer wins for the
  same or lower legacy version; equal versions dedupe; a legacy version the
  feed does not carry renders as browser guidance, never as a one-click action;
  a blocked app never gets a one-click item at all.
- `UpdateService.swift` rewritten around those pieces; `MCPProxyApp.swift`
  renders `updateService.menuEntries`, installs the pre-update core stop, and
  routes the launch/hourly checks through the policy-gated entry point (they
  used to call the user-initiated one).
- `API/Models.swift`, `Core/CoreProcessManager.swift`, `State/AppState.swift` —
  carry `update_policy` from `/api/v1/info` to the service on every connect.
- `scripts/build-swift-app.sh` — stamp `SUPublicEDKey` / `SUFeedURL` from
  `SPARKLE_PUBLIC_ED_KEY` / `SPARKLE_FEED_URL` when present; without them the
  bundle keeps the placeholder and one-click updates stay off, which is the
  correct failure direction.

## Testing
- `swift build` → Build complete (only the two pre-existing warnings).
- `cd native/macos/MCPProxy && swift test` → 911 tests, 1 failure — the known
  environmental `AppLifecycleTests.testTheSharedJournalNeverWritesToTheRealInstanceRootUnderTests`
  (this machine's live tray owns ~/.mcpproxy/tray-lifecycle.jsonl).
- New: UpdatePolicyTests 13 · UpdateMenuStateTests 14 · UpdateInstallabilityTests 6
  · ManagedCoreStopTests 8 · UpdateServiceFeedTests 16 — 0 failures. The last
  suite drives the REAL `AppController.rebuildMenu()`, so a renamed menu slot
  fails there.
- Delegate spelling verified against the Sparkle headers AND by the compiler:
  deliberately misspelling one method produces "nearly matches optional
  requirement"; the committed code produces zero such warnings.

## NOTES
- FR-018 (Homebrew cask `auto_updates true`) lives in the tap repo
  (smart-mcp-proxy/homebrew-mcpproxy) and is out of scope for this repository.
  It must be set before the first Sparkle-capable release, or `brew upgrade`
  will fight the in-app updater.
- No live upgrade QA: a real one-click run needs a notarized older build and a
  published appcast, neither of which exists yet.
Related #957

A Sparkle appcast has no architecture selector, and the release pipeline builds
one app bundle per architecture — each carrying a per-arch core in
Contents/Resources/bin — both reporting the same CFBundleShortVersionString. A
single merged feed would therefore hand an Intel user the Apple-Silicon build,
and the failure would arrive as a crash after a "successful" update.

Until a universal enclosure exists (open decision #4 in the decision report),
the pipeline publishes one feed per architecture and the tray asks for its own.

## Changes
- `Services/SparkleFeedURL.swift` — pure rewrite of the configured `SUFeedURL`:
  only the exact default file name `appcast.xml` becomes `appcast-<arch>.xml`.
  Any other name is used verbatim, so an operator already serving a merged or
  universal feed is not second-guessed.
- `SparkleFeedUpdater.feedURLString(for:)` applies it, returning nil (= "use
  Info.plist") when the rewrite is a no-op.
- The file names are half of a contract with `.github/workflows/release.yml`,
  which generates exactly those names; the test file says so.

## Testing
- `SparkleFeedURLTests` — 5 tests, 0 failures (default rewrite per arch, nested
  paths, operator-supplied names left alone, query strings preserved).
- `swift build` → Build complete, no "nearly matches optional requirement"
  warning, i.e. the delegate method really binds.
Related #957

FR-013/FR-014: the one-click updater needs two things this pipeline never
produced — a notarized, stapled, symlink-preserving archive of the .app, and a
signed update feed pointing at it. (The existing macOS assets do not qualify:
the bare DMG is signed but NOT notarized, and only the PKG-wrapping
`-installer.dmg` is notarized + stapled. Neither is an .app archive.)

## release.yml (stable)
- **Build Sparkle enclosure (macOS)**, inserted AFTER the existing notarization
  step and before keychain cleanup. The signing step order is untouched — the
  bundle was already signed nested-first; this step only archives, notarizes,
  staples and verifies it.
  * `ditto -c -k --sequesterRsrc --keepParent` — the only archiver Apple
    documents for this. `zip` drops symlinks and xattrs, which breaks the
    bundle's signature seal and surfaces on the user's machine as `Killed: 9`.
  * The .app is notarized and stapled SEPARATELY from the PKG: a stapled ticket
    lives inside the bundle it was stapled to, and the copy extracted from this
    zip is not the copy inside the PKG. Unstapled, the first launch after every
    update would wait on Apple — or fail offline.
  * Post-condition proven, not assumed: the archive is extracted again and run
    through `codesign --verify --deep --strict`, `stapler validate` and `spctl`.
  * Named `*.app.zip`, so the release job's existing "copy archives" glob picks
    it up and it lands in `checksums.txt` with everything else.
- **sparkle-appcast** job, `needs: [release]` — deliberately after publication,
  because `generate_appcast` bakes absolute download URLs into the feed and the
  first client to read it would otherwise 404. Runs Sparkle 2.9.3's
  `generate_appcast` (version pinned to Package.resolved) with the private key
  on **stdin** (`--ed-key-file -`), so it never touches disk or `ps`.
  Fails the job if a generated feed lacks `sparkle:edSignature`.
- ONE FEED PER ARCHITECTURE (`appcast-arm64.xml` / `appcast-amd64.xml`): an
  appcast has no arch selector and both bundles carry the same version, so a
  merged feed would offer Intel users the arm64 build. The tray-side half of
  this contract is `Services/SparkleFeedURL.swift`.
- `SPARKLE_PUBLIC_ED_KEY` / `SPARKLE_FEED_URL` passed to the Swift app build so
  `scripts/build-swift-app.sh` can stamp `SUPublicEDKey` / `SUFeedURL`.

## prerelease.yml (RC)
Same enclosure step and the same appcast job with `--channel beta`, gated on a
TAG (this workflow also runs on `next` pushes, which have no release to attach
a feed to). The job asserts the `<sparkle:channel>beta</sparkle:channel>` tag is
present — that tag is the only thing keeping stable users from being offered an
RC, since Sparkle offers a tagged item only to clients that request the channel.

## Fork / no-key behaviour
Every new step no-ops with a `::notice::` when `SPARKLE_ED_PRIVATE_KEY` is
absent, and both artifact uploads use `if-no-files-found: ignore`. A build
without `SPARKLE_ED_PUBLIC_KEY` keeps `SPARKLE_PUBLIC_KEY_PLACEHOLDER`, which
makes the updater refuse to start — no key, no one-click, never an unverified
install.

## Testing
- `python3 -c "import yaml; yaml.safe_load(...)"` on both files → parse.
- Every new `run:` block extracted and run through `bash -n` and
  `shellcheck -S warning` → clean.
- `actionlint` on both files reports nothing new (the pre-existing
  `matrix.edition` notices come from the commented-out server matrix rows).
- The workflows themselves cannot be executed here; the first real tag is the
  first true test. Riskiest untested lines: the `notarytool` submission of the
  .app zip and the `generate_appcast` invocation.

## NOTES
- FR-018 (`auto_updates true` on the Homebrew cask) lives in the tap repo,
  smart-mcp-proxy/homebrew-mcpproxy — out of scope here, and required before
  the first Sparkle-capable release.
- TODO (maintainer decision #1): the shipped Info.plist points at
  https://mcpproxy.app/appcast.xml, served by the WEBSITE repo, which this
  pipeline cannot publish to. Feeds are attached to the release and exported as
  the `sparkle-appcast` / `sparkle-appcast-beta` artifacts for it to consume.
- STILL MISSING from FR-014: the RC pipeline publishes no checksums.txt and no
  cosign bundle, so RC tarballs stay guidance-only for `mcpproxy update`.
Related #957

New `docs/features/auto-update.md`: the per-channel behaviour matrix (CLI and
tray side by side), the one-click flow and its two independent verifications,
why the core is stopped before the swap rather than after, what happens when an
in-place update is impossible, the FR-017 "one item, one owner" rule, every kill
switch, the `update_policy` contract, how the RC/beta channel keeps stable users
away from prereleases, and the release infrastructure (enclosure, per-arch
feeds, the three signing key/variable names, and the fork/no-key behaviour).

Linked from `docs/configuration.md`'s env-var table — which also gains the `CI`
row that was previously only documented for the core — and from
`docs/features/version-updates.md`, which covers *noticing* an update rather
than applying one.

The unresolved feed-hosting decision is called out in an admonition rather than
buried: the shipped Info.plist points at https://mcpproxy.app/appcast.xml, which
only the website repo can serve, and the interim GitHub
`releases/latest/download/...` URL works for stable but structurally cannot
serve the beta channel.
Related #957

Two follow-ups to the auto-update doc:

- `website/sidebars.js` is an EXPLICIT sidebar, not filesystem-generated, so a
  new page under docs/features/ is built and routed but invisible in navigation
  until it is listed. Added next to Version Updates.
- `docs/configuration/environment-variables.md` is a second env-var surface
  (alongside the table in `docs/configuration.md`); its Auto-Update section
  never mentioned the CI rule and had no pointer to the updater itself. Both
  surfaces now say the same thing and link to the same page.
Related #957

Both new `sparkle-appcast` jobs deliberately skip `actions/checkout` — they
only need the enclosure artifacts. But `gh` resolves the target repository
from the current directory's git remote, and it does NOT read
`GITHUB_REPOSITORY`; the only env var it consults is `GH_REPO`. So the final
`gh release upload "${GITHUB_REF_NAME}" appcast-out/*.xml` would have failed
with "could not determine what repo to use" on the first real tag — after
generating and signing the feeds, so the failure would land at the very last
step of a release.

Every other `gh release upload` in both workflows lives in a job that ran
`actions/checkout`, which is why this is the only pair that needed it.

## Changes
- `GH_REPO: ${{ github.repository }}` in the appcast step's env, in
  release.yml and prerelease.yml, with a note explaining why a checkout is
  not the fix.

## Testing
- Both workflows re-parse with `yaml.safe_load`; asserted the resolved env
  block of `jobs.sparkle-appcast` now carries GH_REPO in each file.
Related #957

FR-020 allows `--self` to turn the guidance-only `unknown` channel into a
self-update, but attaches "with a clear warning" to that override — and the
override shipped without one. The requirement is not cosmetic: `unknown` is
precisely the set of installs mcpproxy could NOT identify, which includes
AUR, MacPorts and Nix-like layouts that are writable and still owned by a
package manager whose bookkeeping the swap silently invalidates. A user who
types --self after reading the guidance line deserves to be told what they
just claimed.

A positively identified tarball install stays silent — there is nothing
uncertain about it to warn over — and the warning goes to stderr so `-o json`
stays machine-parseable.

## Changes
- `updateRunner.run`: emit the warning on the self-update branch only when
  the effective channel is `unknown`.

## Testing
- New `TestUpdateCommand_SelfOverrideWarnsOnUnknownChannel`: 3 cases
  (unknown+--self warns, tarball silent, tarball+--self silent), each also
  asserting the self-update branch really ran and that stdout's JSON report
  never contains the warning.
- `go test -race ./cmd/mcpproxy/... ./internal/updatecheck/... ./internal/httpapi/...` → 3/3 packages ok.
… plist

Related #957

Two leftovers from the tarball channel becoming command-carrying (FR-020).

`UpdateBanner.vue` still listed `tarball` among the channels with no safe
update command, in both template comments and the guidance switch. The
rendered output was already correct — every branch keys off the presence of
`update_command`, never the channel name — so this is comment-only and
changes no markup, no class, and no computed result.

`build-macos-tray.sh`'s from-scratch Info.plist (the branch that runs only
when the source Info.plist is missing) emitted `SUFeedURL` with no
`SUPublicEDKey`. A feed URL with no pinned key is the one bundle shape
FR-011 must never produce; the checked-in plist and `build-swift-app.sh`
both carry the placeholder, and this branch should not be the one that
diverges.

## Changes
- `frontend/src/components/UpdateBanner.vue`: comments only.
- `native/macos/MCPProxy/scripts/build-macos-tray.sh`: add the
  `SUPublicEDKey` placeholder to the fallback plist.

## Testing
- `bash -n` + `shellcheck -S warning` on build-macos-tray.sh: clean apart
  from the pre-existing SC2155 at line 25.
- Scanned every HTML comment in UpdateBanner.vue for an embedded `--`
  (which would break the comment) — none.
@Dumbris

Dumbris commented Aug 8, 2026

Copy link
Copy Markdown
Member Author

Implementation pushed — Phases 0–2 implemented

20 commits on top of the spec.

Phase 0 — the #957 fix (ships regardless of Sparkle keys)

  • SemVer 2.0 comparison shared util (fixes rc.10 < rc.2 lexicographic bug) with XCTests.
  • Core reports durable launch provenance (launched_by) + pid in /api/v1/info; tray supersedes stale tray-launched cores (terminate + respawn bundled), consent-gated restart for user-launched cores, keeps the connection when a stale core can't be stopped.
  • Bundle-replacement detection → "MCPProxy was updated to vY — Relaunch" menu item (stops core, open -n new bundle).
  • postinstall.sh now quits the running app (osascript → bounded wait → pkill fallback) before launching the new one — no more stale-instance foregrounding.
  • Legacy staged-core copy refreshed only when provably stale.

Phase 1 — Sparkle one-click

  • import Sparkle works under plain swift build; programmatic SPUStandardUpdaterController with gentle-reminders driver → menu item "Update X.Y.Z — ready to restart?"; updater delegate stops the managed core before relaunch; translocation/read-only failures surfaced; single-source menu state machine (Sparkle owns one-click; GitHub check falls back to browser guidance); kill switches honored incl. new update_policy in /api/v1/info.
  • CI: notarized+stapled symlink-preserving .app zip enclosure + generate_appcast jobs in release.yml and prerelease.yml (channel-tagged beta for RCs), all no-op gracefully until SPARKLE_ED_PRIVATE_KEY/SPARKLE_ED_PUBLIC_KEY secrets exist.

Phase 2 — mcpproxy update CLI

  • Channel-aware (uv/deno pattern): brew/deb/rpm/go-install → prints upgrade command; docker/windows → guidance; dmg → tray pointer; positively-detected tarball (new build-stamp, Homebrew-safe) or --self → verified self-update: sha256 vs checksums.txt always + cosign bundle verification (aborts if cosign missing), atomic write-temp+rename with .old rollback, post-swap --version confirmation, downgrade requires --version + --force, never sudo, refuses app-bundle paths.

Local verification

  • Go: build + vet + go test -race (cmd, updatecheck, httpapi) all pass · lint 0 issues · swagger clean
  • Swift: swift build + swift test — 916 tests, 1 known-environmental failure (live tray owns tray-lifecycle.jsonl); all 092 suites pass
  • ./scripts/build-swift-app.sh → 12 MB MCPProxy.app with Sparkle.framework bundled
  • ./scripts/test-api-e2e.sh65/65 pass

Blocked on maintainer (documented in spec/report): generate Sparkle EdDSA keys (secrets), appcast hosting decision (feed URL), Homebrew cask auto_updates true (tap repo), RC checksums/cosign parity, FR-024 guidance-text switch after the first Sparkle-capable release.

Related #957

🤖 Generated with Claude Code

Dumbris added 3 commits August 8, 2026 08:13
… check exact

Related #957

Two defects in the mechanical half of `mcpproxy update --self` (FR-021).

The pair of renames that swaps the binary is not atomic, and the retry path
made an interrupted swap unrecoverable: it removed a leftover `<target>.old`
before ever looking at the target, so a crash in the window where the target
path is empty and `.old` holds the only copy of the binary was turned into a
destroyed install by the next attempt.

The post-swap probe matched the reported version as a substring, so a binary
announcing 0.54.10 satisfied a request for 0.54.1 — the wrong release would
have passed verification and had its predecessor deleted.

## Changes
- `restoreInterruptedSwap` runs first: target missing + backup present moves
  the backup back; target missing with no backup is a refusal, not a silent
  install over nothing. A leftover backup is only removed once the target is
  known to be present.
- `reportsVersion` compares whole whitespace-delimited tokens (tolerating a
  `v` prefix and surrounding punctuation) instead of a substring.

## Testing
- go test ./cmd/mcpproxy/ — interrupted-swap recovery, interrupted swap with
  no staged binary, missing target and backup, 0.54.10 vs 0.54.1
Related #957

Two gaps in the comparison FR-006 makes every supersede and update decision
depend on.

Leading zeros in a numeric prerelease identifier were accepted, so "rc.01" and
"rc.1" were two spellings of one version and one of the two orderings they
produced had to be wrong. §9 forbids them; FR-006 says a malformed version is
a no-decision, not a guess.

Numeric identifiers were coerced through `Int`, which returns nil past Int64.
Two identifiers that both overflowed fell through to the alphanumeric branch
and were compared as ASCII, ordering "10000000000000000000000" below
"9999999999999999999999" — a downgrade offered as an update.

## Changes
- Prerelease identifiers are validated with `numericMayHaveLeadingZeros:
  false`; build metadata keeps the §10 exemption
- `compareNumericIdentifiers` compares digit strings by length then
  lexicographically, with no upper bound

## Testing
- swift test — leading-zero rejection, a lone "0" still valid, build metadata
  unaffected, 22- and 23-digit identifiers both directions
Related #957

FR-017 allows one update item at a time. When the feed offered a version and
the legacy GitHub check advertised a NEWER one, the resolver rendered both: a
one-click item and a browser item, side by side, asking the user to choose
between an install the tray can perform and a download it cannot verify.

The feed now owns the slot whenever it has an offer, whatever the legacy check
found. What the feed offers is real and installable in one click, and the next
scheduled check picks up the newer version. The legacy result is only rendered
when the feed has nothing.

## Changes
- Collapse the two-source switch to "feed if present, else legacy guidance"
- Drop the now-unreachable comparison branches

## Testing
- swift test — the full feed × legacy × blocked matrix asserts at most one
  offer and that the feed owns it
Dumbris added 4 commits August 8, 2026 08:22
…confirmed

Related #957

Five interlocking defects in the tray half of the updater. They share one
shape: something was assumed — a policy, a feed name, a dead process — where
nothing had been established.

**The launch check ran before the policy arrived.** `/api/v1/info` carries the
core version and the update policy, and Combine delivers `@Published`
subscribers from `willSet` — so assigning `version` first ran the launch check
under the previous (permissive-by-default) policy, checking for updates for a
user who had switched them off. The default is now restrictive and the two
assignments are ordered. A core that predates 092 is a different case and stays
permissive: the tray stamps `CoreUpdatePolicy.legacyDefault` at the point it has
actually talked to one, which is what keeps "an old core said nothing" apart
from "we have not asked yet" (FR-015).

**RC clients read the stable feed.** `prerelease.yml` publishes
`appcast-beta-<arch>.xml`; the rewrite only ever produced `appcast-<arch>.xml`.
An RC user was pointed at a feed that by FR-014's design never carries an RC,
so the beta channel could not offer anything at all.

**SIGKILL was reported as success.** `ManagedCoreStop` returned `.killed` the
instant it sent the signal. Sparkle used that answer to decide the bundle could
be replaced, so a core that outlived SIGKILL kept serving from a deleted inode
— issue #957 exactly. The stop now confirms the exit, and a stop that ends in a
question mark POSTPONES the installation through
`shouldPostponeRelaunchForUpdate:untilInvokingBlock:` (verified against Sparkle
2.9.3: it runs at the top of `installWithToolAndRelaunch:`, before the
installer is contacted) rather than being logged and ignored.

**SIGKILL trusted a five-second-old identity check.** Both kill ladders proved
the pid was an mcpproxy process, waited out the SIGTERM grace period, then
killed whatever held the pid by then. The identity is re-proven immediately
before the signal.

**One click was two clicks with a download in between.** `automatically-
DownloadsUpdates` now follows the same switch as scheduled checks, so the bytes
are verified and on disk before the menu item is touched. The residual Sparkle
confirmation cannot be removed through the public API — `SPUUpdater` has no
"install what you are holding" method — so the honest count is one click on our
item plus one on Sparkle's install prompt, and that is written down where the
next reader will look.

## Changes
- `EffectiveUpdatePolicy.awaitingCore`; `CoreUpdatePolicy.legacyDefault`
- `coreUpdatePolicy` assigned before `version` in `connectToCore`
- `SparkleFeedURL.archSpecific(_:arch:channel:)` + `fileName(channel:arch:)`
- `ManagedCoreStopOutcome.failed` and `.coreIsDown`; bounded post-SIGKILL wait
- `feedUpdaterWillInstallUpdate()` returns whether the core is down; Sparkle
  postpones when it is not
- Identity re-check before SIGKILL in `ManagedCoreStop` and `stopCore`
- `automaticallyDownloadsUpdates`, `updateIsReadyToInstall`

## Testing
- swift test — 87 tests over the policy precedence, both channels × both
  arches, the post-SIGKILL confirmation, pid recycling across the grace
  period, and the install veto
Related #957

postinstall runs as root, where `pgrep -f` and `pkill -f` match every user's
processes. Installing an upgrade for one account therefore quit — and, two
rungs down the ladder, SIGKILLed — the MCPProxy tray of every logged-in user
on the machine, including sessions nobody was upgrading.

## Changes
- Resolve `-U <console uid>` once (the same uid `run_as_user` already hops to)
  and pass it to every pgrep/pkill; empty when there is no real user, which is
  the CI-imaging case where there is nobody else's session to protect
- A plain string rather than an array: macOS bash 3.2 errors on an empty array
  expansion under `set -u`

## Testing
- bash -n packaging/macos/postinstall.sh
Related #957

FR-014 requires RC builds to be covered by a signed checksum manifest, and the
prerelease pipeline published neither checksums.txt nor a cosign bundle. That
made every RC uninstallable by `mcpproxy update`: the command refuses an
artifact it cannot verify, so a user who opted into the prerelease channel had
no update path at all — the CLI would find the RC and then decline it.

## Changes
- `Generate checksums` in the prerelease `release` job, mirroring release.yml
  including the self-reference guard
- `Sign checksums (keyless)` + bundle upload, guarded on an OIDC token being
  available so a fork run publishes unsigned rather than failing
- `id-token: write` on the job (release.yml grants it workflow-wide)
- `cosignIdentityRegexp` accepts prerelease.yml as well as release.yml; both
  are pinned to `@refs/tags/v`, so this widens the trusted set by nothing a tag
  push could not already do
- Corrected the two comments that described the missing artifacts as a
  standing gap

## Testing
- python3 -c 'yaml.safe_load(...)' on both workflow files
- go build ./... ; go test ./cmd/mcpproxy/...
Related #957

The feed URL baked into every bundle (https://mcpproxy.app/appcast.xml) is
served by nobody: the release pipeline attaches the feeds to the GitHub release
and exports them as workflow artifacts, and the website repository would have
to publish them. That is a maintainer decision, not something this branch can
make — but it was recorded only as a warning box halfway down the page, which
is not enough for someone trying to work out why nobody gets one-click updates.

An explicit activation checklist replaces it, naming the exact files the
website repo must serve, at the exact URLs the tray derives, and what the
serving side must not do to them.

The graceful side was already correct and is now pinned by tests: a feed 404
reads as "the feed has nothing" and hands the menu to the browser-download
path, rather than surfacing as an error that eats the offer.

## Changes
- "Activation checklist" section: five steps, the artifact→URL table, the
  three serving constraints, and the interim stable-only GitHub URL
- Banner at the top pointing at it
- Brought the page in line with this round's behaviour changes: honest
  two-click count, the postpone-on-failed-stop rule, one item always, the
  per-channel feed table, and the restrictive pre-policy default
- Troubleshooting entries for the feed 404 and the postponed install

## Testing
- swift test — feed failure falls back to browser guidance, and a failure
  after a withdrawal leaves no stranded one-click item
@Dumbris Dumbris changed the title spec(092): one-click auto-updater (macOS) + channel-aware mcpproxy update CLI feat(update): one-click macOS auto-updater + channel-aware mcpproxy update CLI (spec 092) Aug 8, 2026
Dumbris added 4 commits August 8, 2026 09:07
Related #957

The recovery added in 20caffa closed one crash window and left the other
open. A crash AFTER the staged->target rename but BEFORE verification leaves an
unverified binary at the target and the last known-good one at .old — and on
disk that is indistinguishable from a completed update. Recovery saw a target
and returned; the swap then deleted .old, and if that attempt's verification
failed, the rollback restored the unverified binary. The known-good one was
already gone.

Nothing about the file contents can settle it, so the swap now records that it
is in progress. A sentinel written next to the target before the first rename
and removed only after verification passes makes the two states tell themselves
apart, and no path removes the backup while it exists.

With the sentinel present, target and backup between them say how far the
interrupted swap got:

  neither         -> nothing to recover from; refuse
  backup only     -> crashed between the renames; restore it
  target only     -> crashed before the first rename; the target is original
  target + backup -> crashed after the second; the target was never verified

Only the last needs a judgement, and it is made by running the caller's
verification against what is at the target. It proves itself or the known-good
binary goes back. With no verifier available, the known-good binary wins.

## Changes
- `swapSentinelSuffix` + `writeSwapSentinel` (fsynced content; the directory
  entry is not, which is the same guarantee the renames already had)
- `recoverInterruptedSwap` replaces `restoreInterruptedSwap`, covering all four
  states plus the no-sentinel case left by a build that predates it
- `restoreKnownGood` — os.Rename over the target is what discards the
  unverified binary
- The sentinel is removed on every path the function returns by, so only a
  crash leaves one

## Testing
- go test -race ./cmd/mcpproxy/ — verified interrupted swap cleans up, an
  unverifiable one is replaced, a retry that cannot install ends on the
  known-good binary, no verifier prefers the known-good, crash-before-first-
  rename is a no-op, and the sentinel is present while the new binary is
  unverified
Related #957

Enabling automaticallyDownloadsUpdates for the pre-download in 6914bb6 also
switched Sparkle from SPUScheduledUpdateDriver to SPUAutomaticUpdateDriver —
the driver is chosen on that flag alone (SPUUpdater.m:621) — and that driver
arms a silent install-on-quit. It hands the update to the external installer
tool, which replaces the bundle once this process exits, on a path that never
calls installWithToolAndRelaunch: and therefore never reaches the postpone hook
that refuses an unconfirmed core stop. The fallback the on-quit path does reach
is `void`; it called the stop and dropped the answer.

`willInstallUpdateOnQuit:` is NOT a veto, so intercepting it does not fix this.
Its own documentation: "In either case Sparkle will always attempt to install
the update when the app terminates." Nor does the terminate path make it safe —
applicationWillTerminate sends the core a SIGTERM and does not wait, so the
installer can still run against a live core.

What is provably safe is not arming it: with automaticallyDownloadsUpdates
false, SPUAutomaticUpdateDriver is never constructed and every install goes
through the postpone hook, which can refuse. The cost is that FR-010's click
downloads before it installs — a slower click, in exchange for an install that
can always be called off.

## Changes
- `EffectiveUpdatePolicy.automaticDownloadsAllowed` — always false, given a
  name and a test because SparkleFeedUpdater cannot be instantiated outside a
  real .app bundle. Assigned unconditionally: the flag is backed by a user
  default Sparkle's own permission prompt can write, so leaving it alone is not
  the same as it being off
- `willInstallUpdateOnQuit:` implemented as a tripwire — it cannot refuse, so
  it logs and surfaces the situation instead of pretending to have handled it
- The two `void` hooks report a failed stop instead of discarding it
- Docs: honest click count and why the pre-download is refused

## Testing
- swift test — every policy (permissive, awaiting-core, legacy, rc, disabled,
  CI, kill switch) refuses pre-downloading
…he website

Related #957

The docs site's docusaurus include-whitelist serves only configuration/**,
development/**, etc.; root-level docs/prerelease-builds.md has no site route,
so /prerelease-builds broke the site build (onBrokenLinks: throw).
…lock

Related #957

Two concurrent 'mcpproxy update' runs could read each other's mid-swap
sentinel state, misclassify it, and delete the only known-good backup.
The whole recover-and-swap sequence now runs under an exclusive
non-blocking flock (LockFileEx on Windows) on <target>.update-lock; the
second invocation fails fast with 'another update is already in
progress'. The lock file is never unlinked (unlink+relock races two
holders onto different inodes); the kernel drops the lock with the
process, so a crash cannot wedge future updates.
@Dumbris

Dumbris commented Aug 8, 2026

Copy link
Copy Markdown
Member Author

Cross-model review status: 5/5 rounds used — 1 finding outstanding

Codex reviewed this PR through 5 fix→re-review rounds (2 on the spec, 3 on the implementation; 15 code findings fixed and re-verified, including crash-atomic swap recovery, kill-switch init race, PID-reuse guards, and install-on-quit hardening). The final round left one narrow finding, documented per the review-cap policy:

  1. P1 (Linux only) — lock key aliasing via /proc/self/exe: the self-update lock path derives from the resolved executable path. On Linux, os.Executable() follows the running inode, so a second mcpproxy update that resolves its path after the first process renamed mcpproxy → mcpproxy.old targets mcpproxy.old and takes mcpproxy.old.update-lock, bypassing the first process's lock (cmd/mcpproxy/update_lock.go:27). Suggested fix: refuse to self-update when the resolved executable basename carries the .old/swap suffix, and key the lock on the canonical target path.

macOS/Windows are unaffected (no /proc/self/exe rename-following). Awaiting maintainer direction on whether to continue past the round cap.

Related #957

🤖 Generated with Claude Code

Related #957

The per-target lock did not serialize what it was supposed to. Its key was the
resolved executable path, and on Linux os.Executable() reads /proc/self/exe,
which names the running INODE rather than the path it was launched from. A
second `mcpproxy update` that resolves its own path AFTER the first has renamed
mcpproxy to mcpproxy.old therefore sees itself as mcpproxy.old: it locks
mcpproxy.old.update-lock, contends with nobody, and proceeds to swap
mcpproxy.old — moving the first process's only known-good backup aside. Two
"exclusive" holders on two different files. macOS and Windows report the launch
path and do not alias this way, so the fix is pure path arithmetic with no
/proc reads and behaves identically everywhere.

Both layers are addressed, because they answer different questions.

The LOCK KEY is canonicalized: mcpproxy.old, mcpproxy.updating,
mcpproxy.update-lock and the staged .mcpproxy.new-<pid> all resolve to
mcpproxy, so however each process spells the path they contend on one file.
Mapping an unrelated user file named foo.old onto foo's lock costs nothing
worse than two updates taking turns.

Deciding which file to REPLACE gets the opposite treatment: it refuses. Two
things produce a target called mcpproxy.old — a concurrent update that just
renamed the binary aside, where we must touch nothing, and a user who kept a
copy under that name and ran it, where rewriting mcpproxy instead would
silently update the wrong binary. Nothing on disk reliably separates them and
one of the answers is destructive, so neither is guessed at; the error names
both readings and says what to do about each.

## Changes
- `canonicalUpdateTarget` / `stripUpdateArtifactSuffix`, applied repeatedly
  (a swap of an aliased path yields names like mcpproxy.old.old) and leaving
  non-aliased paths untouched rather than silently Clean-ing them
- `acquireUpdateLock` keys on the canonical path
- `refuseAliasedUpdateTarget` gates `applyNewBinary`, and `selfUpdate` too so a
  concurrent update is caught before ~90 MB is downloaded on its behalf
- `backupSuffix` named, so the suffix list cannot drift from the swap

## Testing
- go test -race ./cmd/mcpproxy/ — canonicalization table (incl. directories,
  double suffixes, staged names, near-misses), x and x.old contending on one
  lock with no aliased lock file created, and an aliased swap refused with the
  other update's backup left untouched
- go build ./... ; GOOS=linux and GOOS=windows builds of ./cmd/mcpproxy
@Dumbris

Dumbris commented Aug 8, 2026

Copy link
Copy Markdown
Member Author

Cross-model review complete — CLEAN. The Linux lock-aliasing finding was fixed in 6d615e301 with maintainer authorization for a final round: the lock key is canonicalized (both processes contend on one lock file regardless of how /proc/self/exe resolved), and a target named like an update artifact (.old/.updating/staged) is refused rather than guessed at — before any download. Codex verified: no new defects.

Related #957

🤖 Generated with Claude Code

Dumbris added 2 commits August 8, 2026 15:51
Related #957

After uploading the signed appcasts as release assets, release.yml and
prerelease.yml dispatch publish-appcast at the website repo (same
MARKETING_SITE_DISPATCH_TOKEN pattern as the marketing version bump);
its receiving workflow downloads the feeds from the public release,
verifies signatures, and commits them into public/ for Cloudflare
Pages. Non-blocking and skipped when no appcast was generated; manual
backfill stays possible via the site workflow's workflow_dispatch.
Website side: smart-mcp-proxy/mcpproxy.app-website#4.
…sal, N+1 canary

Related #957

Detection failures degrade to the legacy browser nudge (structurally
never 'stuck forever'); install failures are the class to guard. First
real test is an rc.1->rc.2 one-click on the beta channel with the full
production pipeline before any stable ships; the concurrency release
follows within days as the first OTA payload to minimize the exposure
window if release N's updater is broken.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

QA gate: 2x Codex-clean cross-review, full local gates, test-api-e2e 65/65, Stage 0 live updater rehearsal passed end-to-end.

@Dumbris
Dumbris merged commit f6628f7 into main Aug 8, 2026
51 of 52 checks passed
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.

[Bug]: old version App still after upgrade

2 participants