cmux NEXT: signed web UI updates between app updates (every merge on NIGHTLY)
Today a merged web change reaches a cmux NEXT user only through the Mac nightly: promote, then about 38 minutes of build, sign, notarize and publish, plus the wait for the next promote. Most GUI work is web code, which needs no Mac build. With this change a NIGHTLY app serves the web bundles of every merge to feat-cmux-next minutes after the publish, signed and checked, with no relaunch.
Design, trust rules and phase 2 (peer seeding over iroh, design only): plans/cmux-next/web-ota.md.
What changes
- CI (
cmux-next-web-bundle-publish.yml, newpointerjob): after each bundle publish (or when the key was already published), it signsfiles.cmux.com/cmux-next-web/ota/every-merge.jsonwith ed25519 and uploads it. The pointer is an envelope around exact payload bytes: key, archive, sha256, size, tree digest,abi, commit and commit time. The job runs no bun or node and never moves the pointer back. The private key is the newCMUX_WEB_OTA_SIGNING_KEYsecret in theartifactsenvironment. The public key ships in the app (CmuxNextUpdater/Resources/web-update-keys.json), and CI's verify step reads the same file.scripts/cmux-next/web-ota-pointer.pymakes, signs and checks pointers;tests/test_web_ota_pointer.shcovers it (a step of the cmux-next checks job). - App, NIGHTLY bundles only (
WebUpdaterin CmuxNextUpdater,WebUpdatesin the app): reads the pointer with a conditional GET every 10 minutes, and again when cmux comes to the front after two minutes away. It takes an update only when all of these hold: a trusted signature; anabiequal to the app's bundledpages/web-abi.json; a commit newer than the app's bundled web and than the last update taken (so a replayed old pointer can't roll the web back); an archive matching the signed size and SHA-256; and an unpacked tree matching the signed--outputsdigest. A port ofweb-bundle-key.py output_digestre-checks the tree at every launch. Debug builds keep the developer's own web; Release and RC keep the web they shipped with. - Serving (
WebBundleOverlayin CmuxNextPages): the page and agent pane scheme handlers keep their bundled roots and ask the overlay per request for the same folder inside the active update. The web-dev.json server and overlay from #22664 stay ahead of it. A ready update is held until a safe point, so an open page never mixes two builds' lazy chunks. The safe point is at once while no page shows, else after cmux has been in the background for 3 s. Then every served page and agent pane reloads in place. Composer drafts survive the reload, because the composer writes them to its origin'slocalStorageon every change. At launch, requests wait for the kept-update check, so the first page already gets it. Pages hosted in Chromium keep their bundled files for now. - Native interface:
webviews/src/web-abi.json(abi: 1) ships aspages/web-abi.json. Bump it in a change that makes a bundle need native code an older app lacks; such an update then waits for the nightly that carries the native side. - Setting
updates.webunder Settings > Updates ("Web UI Updates"):every-merge(default),nightly(readsota/nightly.json, which nothing writes yet),off(back to the bundled web at the next safe point, and from the first page at launch). Switching channel returns to the bundled web until that channel has a newer update. Downloads followupdates.meteredNetwork; a failed download is tried again at the next check. Translated for all 21 locales of the catalog. - Control:
updates.web.status,updates.web.check,updates.web.rollback(serves the previous update, or the bundled web, and never takes the rolled-back one again),updates.web.apply(swap a ready update in now).
Not in this PR: the Settings card showing the current bundle version with a one-click roll back (it reads updates.web.status / rollback), the nightly pointer, and a push hint. The release-rings lane owns updates.channel; the updates.web default will follow it.
Ownership
Agreed in writing with live-reload-loop (#22664). It owns web-dev.json, the dev server, WebDevOverride, the live overlay and WebDevOverrideWatcher. This PR owns WebBundleOverlay, WebBundleServedViews, WebUpdater/WebUpdates and updates.web. Precedence: dev server, then the live overlay, then the web update, then the bundle. After this lands, live-reload's follow-up moves its watcher onto WebBundleServedViews.reloadServed(). The release-rings lane (#22781) owns updates.channel. The "native hot reload: per-user update channel" session got this split as a proposal and has not replied: native code reloading is not in this PR, and a per-user web channel would be one more WebUpdater.Channel with its own signed pointer.
Risk
risk:high: a signing key, an updater, and code fetched from the network. The trust rules are listed above and in the doc. Nothing loads before its signature and digests check out. Leo decided the default: every merge, on NIGHTLY builds only. Switching it off is a one-line change in schemas/settings/source/updates.json. Nothing flows until this merges and the publish workflow writes the first pointer.
Signing key: generated with openssl for this lane. The private key exists only as CMUX_WEB_OTA_SIGNING_KEY in the artifacts environment (present; no copy elsewhere). The public key is CmuxNextUpdater/Resources/web-update-keys.json (id e5d7bbcacc70703d). Rotation steps are in the plan doc. A follow-up moves custody to wherever the Sparkle and notary keys are kept. Losing the key costs web updates until the next nightly, never trust.
No capture: this changes where the served web files come from, not how any screen looks. The one visible addition is the "Web UI Updates" settings row, which is generated from the schema.
Changelog
- Added: cmux NEXT NIGHTLY builds pick up web UI changes minutes after they merge, signed and verified, without waiting for the next app update (Settings > Updates > Web UI Updates).
Observed / Not run
- Fleet (
package-test-lane.sh suite, Xcode 26.6) on 895ff96a: compiled, andWebUpdatePointerTests7,WebUpdateStoreTests6,WebUpdaterTests9 andWebBundleOverlayTests4 passed. The head 47b1a90a changes only generated exports after that. bash tests/test_web_ota_pointer.shpasses in CI (sign and verify round trip; a changed payload, an unlisted key, a wrong key id and the wrong channel are refused;neweronly moves forward; exit codes).prepush-gate.pyreports no new violations.- Independent review (subagent) of 5e201e9c passed. It checked the Swift tree digest against
web-bundle-key.py --outputson the test fixture: the two agree. Earlier rounds' findings are fixed (fd47f697 and after). - Base reds on this head, not from this PR: react-apps-check timeouts (the same agent-pane tests time out on unrelated PRs), defaults-snapshot (base's own unstamped settings), check-switch-fetches (an existing webviews hit), the namespace check (
HomeThoughtHeadline), first-turn cost (its token count hit an upstream error), andCmuxNextHomeEngineTestsmissing from the target graph. The graph is only written by the base autofix (generated drift), so it gainsCmuxNextWebUpdatesafter this merges. Until then, Tier routing names that one directory. - Not run: a live NIGHTLY taking an update. That needs the first pointer, which the publish workflow writes after this merges.
Follow-ups from the review, none blocking: write the bundled web's commit time into pages/web-abi.json instead of reading its mtime; don't reinstall a tree whose key is being served; re-verify the previous tree on roll back; order the two state saves on a channel change; tests for the NIGHTLY-only start and runtime off. Next: a signed requires list checked against the bundled daemons' capabilities (#23242).
Agent session: cosmic-gray
🤖 Generated with Claude Code