| 文件 | 最后提交记录 | 最后更新时间 |
|---|---|---|
docs: correct App Store screenshot validation paths | 2 个月前 | |
Recover timed-out Iroh lanes and stale iOS sessions (#8286) * Test physical pairing rejects untrusted routes * Fail closed on untrusted phone pairing tickets * Rotate staging relay policy verification key * Test local relay signer fallback * Recover redacted local relay signer * Test shared dev relay backend override * Share trusted dev backend across Mac and iOS * Test forty concurrent development bindings * Scale and recycle development Iroh bindings * test(ios): preserve tagged pairings on one Mac * fix(ios): keep tagged pairings on one Mac * test(ios): identify paired Mac app instances * fix(ios): identify paired Mac app instances * Test development Iroh challenge quota * Scale development Iroh challenge quotas * test(ios): reject physical reconnect to loopback * fix(ios): reject loopback reconnect on physical devices * test(ios): cover high-concurrency pairing routes * test(presence): recycle inactive iOS build scopes * fix(ios): enforce app-instance pairing identity * test(iroh): cover lane timeout recovery * test(iroh): preserve replacement after stale owner release * fix(iroh): redial timed-out application lanes * test(ios): cover stale Iroh shell redial * fix(ios): reconnect stale Iroh shell sessions * test(iroh): cover per-instance firewall partitions * fix(iroh): partition registration firewall by app identity * test(iroh): keep invalid identities account-scoped * test(iroh): cover early direct address observation * fix(iroh): replay early observed address changes * test(ios): cover team-scope reconnect restart * fix(ios): restart reconnect after team scope settles * test(ios): revoke exact secondary instance in races * test(iroh): isolate challenge quota by app instance * test(ios): cover Iroh recovery ownership * fix(iroh): scope challenge quota to app identity * fix(ios): serialize Iroh connection recovery * test(ios): cover stale connected manual reconnect * fix(ios): redial stale connected clients * test(iroh): cover expanded development binding quota * test(ios): reproduce stalled RPC write connection loss * fix(ios): recover stalled mobile RPC writes * test(iroh): cover pairing preflight release blockers * fix(iroh): close pairing deployment gates * test(iroh): cover typed attach outcomes * fix(iroh): distinguish permanent attach outcomes * test(ios): reproduce duplicate startup Iroh owner * fix(ios): serialize startup Iroh connection ownership * test(ios): require same-account Iroh discovery * feat(ios): connect same-account Macs over Iroh * test(ios): reproduce half-installed RPC connection * fix(ios): publish RPC connection state atomically * test(ios): preserve legacy session across path changes * fix(ios): preserve connection recovery invariants * test(ios): make Iroh recovery checks deterministic * test: cover Iroh discovery lifecycle races * fix: close Iroh discovery lifecycle races * test(ios): cover duplicate Iroh auth observation * test(ios): reject pairing persistence failures * test(ios): cover Iroh startup ownership races * test(ios): measure only the recovery reconnect * fix(ios): stabilize zero-touch Iroh startup * test(ios): isolate paired Mac persistence hint * test(iroh): reproduce pair-grant retry storm * fix(iroh): honor pair-grant retry authority * feat(core): expose retry-after error contract * test(ios): reproduce zero-touch retry storm * fix(ios): coalesce broker-directed reconnects * test(iroh): cover empty routes and transient backoff * fix(iroh): bound addressless reconnects * test(iroh): stabilize runtime verification * test(iroh): drive authenticated presence recovery * test(iroh): expose sign-out recovery leak * fix(iroh): cancel recovery on sign-out * test(iroh): give session fixtures a public path * test(iroh): expose sidecar-blocked host publication * test(iroh): require signed-in host activation * fix(iroh): publish host before optional sidecars * test(iroh): reproduce dev route readiness races * fix(iroh): wait for tagged endpoint publication * test(iroh): reproduce stale compatibility QR * fix(iroh): upgrade compatibility QR after publication * test(ios): reject silent unpaired reload fallback * fix(ios): fail closed when dev pairing setup fails * test(ios): reproduce cross-lane QR fallback * fix(ios): isolate tagged Iroh QR fallback * test(ios): reproduce cross-agent Iroh discovery * fix(ios): isolate zero-touch Iroh by dev tag * test(iroh): reproduce tagged broker origin drift * test(iroh): reject malformed dev broker origins * fix(iroh): share trusted broker across dev lanes * test(ios): enforce discovery build compatibility * fix(ios): apply one build policy to Iroh discovery * test(ios): require owned late transport cleanup * fix(ios): own late transport cleanup lifecycle * test(iroh): require lifecycle-owned readiness * test(ios): import lifecycle test data types * fix(iroh): signal lifecycle connection readiness * test(iroh): require relay-ready host republication * fix(iroh): republish routes after relay commit * test(iroh): reject redundant relay republication * fix(iroh): publish only changed relay routes * test(iroh): reproduce foreground refresh teardown race * fix(iroh): serialize foreground registration recovery * test(ios): reproduce stale Iroh zero-touch ambiguity * fix(ios): ignore unreachable stale Iroh bindings * test(ios): reproduce zero-touch UUID case disconnect * fix(ios): canonicalize zero-touch Mac identity checks * test(ios): cover physical dev service origins * fix(ios): use staging origins on physical dev builds * test(ios): cover foreground relay credential recovery * test(auth): require foreground validation callers to join * test(iroh): reproduce same-peer control handoff race * test(iroh): reproduce stale admission snapshot denial * fix(iroh): refresh stale admission policy before denial * fix(iroh): serialize same-peer control handoff * fix(auth): join foreground session validation * fix(ios): refresh relay credentials on foreground * test(ios): reproduce duplicate Iroh endpoint thrash * fix(ios): prevent duplicate Iroh endpoint ownership * test(iroh): reproduce idle liveness lane renegotiation * fix(iroh): keep idle liveness off optional lane setup --------- Co-authored-by: cmux reload-cloud <cmux-reload-cloud@users.noreply.github.com> Co-authored-by: cmux-lawrence <cmux-lawrence@cmux-lawrences-Mac-mini.local> Co-authored-by: austinpower1258 <austinwang115@gmail.com> | 2 个月前 | |
Harden Iroh release gate readiness | 2 个月前 | |
iOS: key all per-Mac state by pairing (device id + instance tag) so sibling builds are first-class (#8936) * feat(ios): stamp workspace and notification rows with the pairing instance tag Workspace and notification payloads carry no Mac identity; the phone attributes rows to the connection they arrived on. That attribution now includes the pairing's app-instance tag: foreground rows are stamped with the active connection's tag in setForegroundWorkspaceState, secondary rows with the subscription's proven tag, and notification feed items with the pairing behind the feed target. Aggregated rows carry macInstanceTag, per-pairing row ids include the tag so sibling builds' workspaces cannot collide, and the feed item identity includes the tag so sibling notifications never dedupe into one row. Works for every existing Mac; no wire change needed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(ios): aggregate workspaces and notifications per pairing Sibling builds of one Mac are now separate aggregation targets: the one-build-per-device coalesce is removed from secondary candidate selection, the foreground exclusion is pairing-exact so the sibling of the connected build stays a candidate, and subscriptions, per-Mac workspace state, and notification-feed maps are keyed by pairing id (legacy untagged pairings keep device keys). Promotion resolves the exact pairing and tagged switch requests can take the promotion fast path. Workspace mutations route by the row's pairing, opens and notification taps switch to the row's exact build, workspace counts and the machine filter match per build (legacy untagged rows keep matching device-wide), avatar colors stay per physical device, and hiding a pairing tears down exactly that pairing's subscription and feed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: cover sibling-build separation across aggregation, filter, and feed Aggregation ordering now iterates aggregate KEYS (pairing ids since the re-key) instead of state device ids, which returned duplicate device ids for sibling builds and dropped their rows; sibling entries order deterministically by instance tag. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ios): keep selection scope self-contained for tag comparison Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ios): address review findings on pairing-scoped feed routing Notification taps compare the exact pairing so a sibling build's notification on the foreground device still switches builds; the aggregate feed status compares owner keys instead of device ids; snapshot stamping derives the tag from the owner key itself so sibling items never dedupe even without a live subscription (covered by a new tagged-owner-key test); hiding the foreground pairing also drops its device-keyed feed snapshot when a sibling stays visible. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Restore main's ghostty submodule pin The merge-conflict resolutions staged the worktree's stale ghostty gitlink via git add -A, silently reverting main's pin bump; this branch carries no ghostty changes, so main's pin is authoritative. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ios): close autoreview findings on the pairing key-space migration Secondary refresh validation now checks the subscription under its pairing key instead of the device id, which was tearing down every healthy tagged subscription on refresh. Device-only promotion requests fail closed when sibling builds are both live instead of promoting an arbitrary one. Tagged notification items never fall back to the bare device key, so an offline pairing's mutation no-ops instead of hitting a sibling with a colliding id. Hiding the foreground pairing also removes its device-keyed workspace entry when a sibling stays visible. Workspace-create gating uses the live connection's instance tag rather than the stored isActive flag, which lags promotion. Notification feed scoping preserves the selected build (entry-aware item matching), and dismiss-outbox routing sends only through an unambiguous client for the device, deferring while sibling builds are both live. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ios): close round-two review findings on pairing identity boundaries Legacy untagged rows on the foreground device are excluded from secondary aggregation (their pairing id is the foreground's own aggregate key and would overwrite it). The picker's switch decision and the workspace-groups gate compare the live foreground pairing instead of the stored isActive flag, which lags promotion. Computers-screen status lookups query the pairing key first so tagged secondaries keep their connection dot. Notification availability matches the exact selected pairing for every signal, and the alias-selection test asserts the pairing-formed filter entries with sibling exclusion. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ios): close round-three findings on legacy identity and promotion Secondary rows are stamped with the subscription's STORED pairing identity so reconstructed owner keys always find their subscription, including upgraded-legacy pairings that adopted a tag at auth time. Device-only promotion requires the device to have a single stored pairing, not merely a single live one, so a reconnect meant for an offline sibling never promotes the other build. Exact pairing scopes exclude unknown-tag rows (they stay under device entries and All Computers). Promotion clears the promoted pairing's feed bookkeeping so the foreground refetch under the device key cannot duplicate rows, and the workspace-detail reconnect passes the row's tag. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ios): keep failure downgrades and retained-state pruning pairing-keyed An unreachable sibling's establish failure marks its own pairing entry unavailable instead of the device key (which can be the live foreground sibling), and retained pairing-keyed workspace states with no live subscription are pruned when no longer wanted so a pairing reconnected as foreground via the dial path cannot duplicate its rows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ios): dismiss routing requires a single stored sibling Counting live clients was not enough: the emitting build may be offline while a sibling is the sole live candidate, and Mac-local notification ids can collide across builds. Device-scoped dismisses now route only when the device has one stored pairing at all. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ios): reset foreground feed bookkeeping on sibling build switches The foreground feed lives under the shared device key, so switching to a sibling build left the previous build's snapshot and revision in place and rejected the new build's lower revisions as stale. Both the promotion and dial connect paths now clear the device-keyed feed state when the foreground instance tag changes on the same device. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ios): close remaining round-four findings on feed and status identity Notification-open navigation matches workspaces and surfaces by the item's exact pairing so colliding Mac-local ids on a sibling build fail closed instead of navigating to the wrong workspace. The connection status rollup never overwrites an exact pairing entry and rolls the foreground's device-keyed status only onto its own pairing representative, so an offline sibling can no longer render green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * WIP: typed MacPairingKey owner-key re-key (registry + composite core; not compiling yet) * WIP: typed owner-key re-key compiles (composite, promotion, feed, hidden, actions) * WIP: typed key test-target compiles; MacWorkspaceState.id pairing-unique * WIP: pool suites 98/111; feed reset semantics reapplied; device-level drain admission * WIP: pool+sibling suites converging; per-pairing candidate selection + drain-path replacement retirement * Restore deeplink collision test hints eaten by bulk rewrite * Fix review round 6: sibling promotion demotes previous focus by owner key, feed target ownerKey consistency, offline foreground key captured before identity clear, pairing-aware reconnect, exact-pairing retained-snapshot pruning * Fix review round 7: exact-pairing reconnect decisions, sibling-ambiguity fail-closed deeplink lookups, fail-closed tagged create gate, feed completion by owner key * Fix review round 8: openWorkspace routes by exact pairing, group/reorder gate requires exact foreground pairing, demoted-foreground feed re-keys to pairing * Fix review round 9: foreground terminal lookups scope by live pairing; known-tag row resolution in list apply and create * Fix review round 10: pairing-exact connected-refresh target, live-identity hide disconnect, tag-aware selection remap, allocation-free exact terminal lookup * Fix review round 11: fresh-dial takeover clears pairing-keyed feed source; preparse machine scope entries for row projection * Fix review round 12: foreground-scoped raw-input lookup with unowned-row fallback, exact-lookup no global fallback, ambiguous device-only switch fails closed, tagged secondary feed bootstrap by pairing id, hide authority requires proven live tag * Fix review round 13: untagged selections match only untagged live foreground; recovery flags attribute to the exact recovering pairing --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> | 2 个月前 | |
iOS: stream Mac browser panes to the phone (pixel-perfect, interactive, dialogs mirrored) (#8298) * docs: iOS browser streaming design * Add mobile browser streaming wire protocol * Add Mac mobile browser stream host * Fix stream session compile (nonisolated encoder init) and momentum end phase * Fix keyCode method shadowing in SyntheticKeyEventFactory * Add iOS browser stream surface package * Add browser stream RPC client plumbing * Wire browser streams through the mobile shell * Integrate browser streams into mobile shell UI * Beacon: detect canvas/WebGL painting via requestAnimationFrame wrap * ci: reload-build gains an ios-simulator platform Builds the unsigned simulator .app and uploads it as an artifact, for callers whose local xcodebuild is unavailable; the sim bundle installs directly via simctl. * ci: build the ios-simulator app arm64-only GhosttyKit's simulator slice is arm64-only, so the generic destination's x86_64 half fails at link; every target simulator is arm64. * Fix display link teardown for Swift 6 nonisolated deinit * Fix frame stall via store-owned decode pipeline; move chrome to bottom floating bar * Self-heal browser stream: force restart past dedupe on recovery, unanswered-input watchdog, keyboard-pinned bottom bar * Add mobile browser dialog wire model and broker * Mirror Mac browser dialogs over mobile RPC * Render mirrored browser dialogs on iOS * Wire mobile browser dialog Mac sources into Xcode project * Capture owner explicitly in basic-auth startPrompt closure * Stack browser dialog buttons vertically for 3+ or long labels * Reserve bottom bar space so chrome never occludes streamed page content * Take main's reconnect route-isolation test (recoveryTask removed by Iroh fix) * Browser bar: always-visible standard controls, drop collapse pill + confusing X/chevron; stop stream on surface exit * Add mobile browser viewport RPC DTOs * Reflow Mac browser streams to phone viewport * iOS: report phone viewport to reflow the streamed Mac browser * Fix streamed browser white-out: force repaint after viewport reflow so idle pages don't capture a blank frame * White-out fix v2: real two-frame scroll repaint nudge + settle-capture burst after reflow * Replace iOS tab switcher surface * Fix iOS switcher integration and verification * Test persistent browser render host portal ownership * Share persistent browser offscreen render hosting * Capture mobile browser streams in persistent render host * Fix switcher initial positioning and accessibility * Test switcher reopening after browser selection * Reset switcher state for each presentation * iOS browser stream: mirror phone frames in the Mac pane instead of blanking it While a browser pane streams to the phone, the live WKWebView renders in the offscreen host at phone width, so the Mac pane went fully blank. Show a read-only, letterboxed, click-through mirror of the exact frames the phone receives (fed from the same capture in MobileBrowserStreamSession at the same cadence), added to the pane's superview on stream start and removed on teardown when the full-width live web view returns. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Speed up browser stream capture on the offscreen render host Continuous JPEG frames were snapshotting with afterScreenUpdates:true, which blocks each takeSnapshot on the host window's screen-update cycle. The stream's offscreen render host lives off all screens at alpha ~0, where macOS throttles that cycle hard, so capture was capped to a few fps: the phone showed "super slow" streaming that barely moved on scroll. Snapshot continuous JPEG frames with afterScreenUpdates:false instead. That captures the currently committed render, which already reflects the new scroll offset, without waiting on the throttled cycle; the dirty loop re-captures to stay current. The rare lossless PNG settle frame keeps afterScreenUpdates:true for a pixel-perfect rest state. Add DEBUG per-capture instrumentation (capture ms, encode ms, byte size, pixel size, unacked count) so stream throughput is measurable from the debug log and capture-bound vs flow-controlled is distinguishable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Revert "Reset switcher state for each presentation" This reverts commit ed9d5f8b46021d585ac3c325b36b2e00652f9d20. * Revert "Test switcher reopening after browser selection" This reverts commit 2bfebb3346e3bab646790d3827ad99bd0cde0f31. * Revert "Fix switcher initial positioning and accessibility" This reverts commit 607ef3392418c2b05c46ceef44a1adeda9aca94c. * Revert "Fix iOS switcher integration and verification" This reverts commit 9cfb18175026c6ea499840aa470379ab0cc051b1. * Revert "Replace iOS tab switcher surface" This reverts commit 89105d342df1d6e45c64099d993ddb22cd5a25e9. * Revert "ci: build the ios-simulator app arm64-only" This reverts commit f5e9324940cbbdb11af6f330ac092b1abc6d632d. * Revert "ci: reload-build gains an ios-simulator platform" This reverts commit 42b65b2300f90cc27a7d01975b684775bd3d9892. * Scope PR to browser streaming: drop switcher residue from title menu and string catalog Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Test replayed browser input requests a stream capture * Keep the stream render host visible to WebKit: on-screen floating window, input-replay dirty, event-driven scroll beacon The persistent render host window sat at (-100000,-100000); AppKit reports a window with no on-screen portion as fully occluded, and WebKit suspends requestAnimationFrame and degrades trusted-event hit testing for occluded hosts. The rAF-throttled dirty beacon therefore never fired during a scroll gesture (one frame per gesture, captured after gesture end) and replayed taps intermittently hit a stale tree and never navigated. Host window now anchors on-screen (bottom-trailing, >=64pt visible, .floating so ordinary windows cannot occlude it) while staying imperceptible (1% alpha, click-through, non-activating). Hardening: every replayed input batch marks the session dirty directly, and the beacon posts scroll/wheel dirt from the event listener with a 16ms throttle instead of waiting for a rAF tick. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Bind the browser-stream keyboard button to real keyboard visibility The button showed the input proxy's focus intent, so a keyboard raised by the address field or a dialog's text field left it stuck on 'Show Keyboard'. The glyph now binds to MobileKeyboardVisibilityObserver (UIKit keyboard notifications); tapping while the keyboard is up resigns whichever responder raised it (shared dismissMobileKeyboard, moved to CmuxMobileSupport) and releases the proxy's focus reasons via the policy's new explicit hide, which never flips into a focus request the way toggling would. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Give dialog text fields a visible input well The dialog card is glass, so the fields' glass background vanished into it and prompt/basic-auth inputs read as labels. Fields now sit in a filled rounded well with a hairline border, the same fill language as the bottom bar's address field. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: cmux reload-cloud <cmux-reload-cloud@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> | 2 个月前 | |
iOS: stable Keychain device id + Forget computer (iroh re-key client) (#8888) * iOS: stable Keychain device id + Forget computer (iroh re-key client) Client complement to the broker binding re-key (manaflow-ai/cmux#8883), which changes the iroh binding slot from unique(app_instance_id) to unique(user_id, device_uuid, tag) and replaces the 409 binding_replacement_requires_revocation with a newest-authenticated-wins in-place UPDATE. Two changes make the phone cooperate with that slot: 1. Stable device id across reinstall. The iOS device-registry id moves from UserDefaults (erased on delete/reinstall) to a device-only Keychain item (service com.cmuxterm.deviceRegistry.iosDeviceID.v1, AfterFirstUnlockThisDeviceOnly). A returning phone now presents the same device_uuid and overwrites its own binding in place instead of stranding a fresh one. Keychain is authoritative; a pre-Keychain UserDefaults id is migrated on first read, and the generated id is mirrored back to UserDefaults for downgrade safety. This service is distinct from the iroh endpoint-identity store that sign-out/reinstall wipes, so forgetting the endpoint identity does not churn the slot key. 2. Forget a hidden computer. The per-phone Hidden Computers list gains a destructive Forget action (swipe + context menu, both gated behind a confirmation dialog, mirroring MacComputerRow's Hide) that revokes the Mac's account binding through the user-ownership-scoped broker endpoint. It resolves the binding id at action time via a fresh broker.discover() (so an offline Mac's binding is still listed and revocable), matches by canonical device id plus exact tag when known, revokes each match, then clears the local hidden marker and paired-Mac row. A still-online Mac re-registers and reappears on its next connect. Failure keeps the row and surfaces a toast. New narrow capability MobileIrohMacForgetting keeps the shell store's dependency minimal; en+ja localization added for the Forget copy. * iOS: fail closed on unreadable device id, alert on Forget failure, pin account Address the four P1 review findings on the iroh re-key iOS client branch. Finding 1 (device-id read ambiguity): DeviceIdentityStoring.read() returned an optional, collapsing "no id yet" and "Keychain locked before first unlock" into nil. A background launch before first unlock therefore looked like a fresh install and minted a NEW id, stranding the phone's existing (user, device, tag) binding. read() now returns DeviceIdentityReadResult (.found/.absent/ .unavailable). deviceID(store:defaults:) fails closed on .unavailable: it reuses the legacy UserDefaults mirror if readable, else a per-process ephemeral id that is never persisted, so the durable id is adopted once the store unlocks. A .found id is re-mirrored to UserDefaults (only when it differs) for downgrade safety; a present-but-blank/corrupt item is treated as .absent and re-minted. Finding 2 (account pinning): MobileIrohRuntimeComposition pins the expected account and ensureAccountUnchanged guards Forget so a token-source swap mid-flow can't revoke a binding under the wrong account (MobileIrohForgetError. accountChanged). Finding 3 (Forget ordering): MobileShellComposite forget removes the row before clearing the hidden marker and returns Bool so a failed broker revoke surfaces instead of silently dropping the row. Finding 4 (Forget failure visibility): DeviceTreeView shows a .alert (not a toast) on Forget failure, so the error surfaces even with the Toasts beta flag off. Keys mobile.computers.forget.failureTitle/failureMessage, mobile.common.ok localized en+ja. CmuxMobileShell host-compiles and its 21 DeviceRegistry tests pass (incl. new fail-closed + re-mirror coverage). DeviceTreeView and MobileIrohRuntimeComposition transitively need GhosttyKit, so they compile only in the fleet iOS build. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: harden iroh re-key client per review (device-id, session snapshot) Address the P1 findings from review of the iroh re-key client changes. Finding 1 (composition-half): re-resolve the durable device id at each activation via DeviceRegistryService.durableDeviceID(defaults:) instead of capturing it once at root init. A value captured while the durable identity store was unavailable (Keychain locked before first unlock, or a persistent write failure) is an ephemeral throwaway id; registering a binding under it would orphan the retained (user, device, tag) binding. When the durable id is nil, activation now defers (throws .inactive) and retries on the next reconcile once the store becomes readable. The injected resolver is @MainActor () -> String? so it can capture UserDefaults, which is not Sendable under Swift 6. Finding 2: forgetComputer now pins the revoke to one atomic AuthenticatedSessionSnapshot (session generation + account id + both tokens) captured from a single auth-session generation, and the caller passes the row's captured expectedAccountID. Reading the observed identity and the live tokens separately let a lagging observed id authorize a revoke that then ran with a different account's freshly-stored tokens. The broker token source and every mid-flight re-check now require BOTH the generation and the account id to be unchanged, so a sign-out/sign-in (even as the same user) aborts safely. Finding 4: clear the captured scope's durable row and hidden marker unconditionally after a successful revoke. removeStoredPairedMacRow targets the CAPTURED scope, so it cannot touch another account's data; skipping it on a mid-flight scope flip reported success while the row survived, so returning to the old scope showed the supposedly forgotten computer. Tests: activationDefersWhenDurableDeviceIDUnavailable proves no endpoint binds and the retained binding survives when the durable id is unavailable; forgetRemovesCapturedScopeRowEvenWhenScopeFlipsMidRevoke proves the captured account is forwarded and the row is removed on a mid-revoke scope flip; DeviceRegistryRouteSelectionTests cover the durable-id defer/mirror/adopt paths. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: failing test — forget of team-less Mac deletes wrong team on mid-revoke switch The forget-hidden-computer flow snapshots its owner scope before the async iroh revoke, then deletes the stored row. When the captured scope is team-less (no team selected) and the user switches into a team while the revoke is in flight, local cleanup goes through the team-scoping decorator's plain remove, which substitutes a nil teamID with the now-current team. It deletes that team's row and leaves the forgotten team-less computer behind, so it reappears on returning to no-team. This commit adds only the failing regression test (drives forgetHiddenComputer through a TeamScoped-wrapped store with a mid-revoke team flip); the fix follows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: forget deletes the exact captured scope, not the live team Add removeExactScope to MobilePairedMacStoring: same shape as remove but it never substitutes a nil teamID with the currently-selected team. The team-scope decorator (TeamScopedPairedMacStore) and the backup mirror (BackingUpPairedMacStore) override it to forward the captured teamID verbatim; the base SQLite store, MobileMacCompatible, and IOSBuildScoped decorators inherit the default forward (none of them substitute, so plain remove and removeExactScope are equivalent there). forgetHiddenComputer captures its owner scope before the async iroh revoke, so removeStoredPairedMacRow now deletes via removeExactScope — a mid-revoke team switch can no longer retarget a team-less forget onto the freshly-selected team. Also call clearSavedMacHintWhenNoStoredMacsRemainIfNeeded() on the forget path after reloading, matching the hide path, so forgetting the last stored Mac drops the saved-Mac hint instead of leaving a dangling reference. Makes the prior commit's regression test pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: converge device identity under races, gate snapshot during token transition Device id (FIX #3): adoptOrGenerateDeviceID now goes through Keychain createOrAdopt instead of last-writer-wins write. createOrAdopt does SecItemAdd first and, on errSecDuplicateItem, adopts the value already stored, so two launches racing to mint an id converge on one instead of overwriting each other and registering two device rows against the broker. The UserDefaults mirror is reconciled to the winning id; Keychain stays authoritative and survives app reinstalls so the broker binding is not orphaned. Session snapshot (FIX #1): authenticatedSessionSnapshot() now also requires !sessionTokenTransitionIsActive in both guards, so a snapshot taken mid token rotation cannot hand back a half-swapped session that would drive a redundant re-register. Adds convergence coverage in DeviceRegistryRouteSelectionTests (createOrAdopt adopts the concurrent winner rather than minting a second id). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: correct forget-scope regression test to genuinely catch mid-revoke team flip The committed version of this test asserted contradictory post-conditions, so it did not actually prove removeExactScope deleted the right row. Rewrite it to load the base store once and partition rows by each row's own stamped teamID (loadAll(teamID: nil) returns every team's rows, and loadAll(teamID:) also returns team-less rows, so the returned set must be filtered by teamID to prove which row was deleted). This version is red against the current visibleScope-based removeExactScope: it deletes the flipped team-b row and the team-less row survives, failing at the team-b assertion. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: forget deletes the exact captured team scope, no visibleScope re-derivation removeExactScope forwarded through visibleScope/visibleMac, which call inner.loadAll(teamID:): a nil team returns every team's rows and a set team also returns team-less rows, ordered by lastSeenAt descending, so .first could resolve a DIFFERENT team's row than the scope captured before the async revoke and delete that row instead. When the user switches into a team mid-revoke, the team-less forget then deleted the freshly-selected team's row and left the forgotten team-less computer behind. Make removeExactScope a pure pass-through to inner.removeExactScope, honoring the exact (stackUserID, teamID, instanceTag) owner key verbatim; the layers below do not substitute the team. Turns the regression test green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: break corrupt-Keychain mint deadlock; move in-memory device store to tests createOrAdopt, on errSecDuplicateItem, reads the item to converge racing callers on one id. But read() maps a present-but-undecodable item to .absent (so a fresh caller re-mints over garbage), which created a deadlock: a corrupt Keychain item made every SecItemAdd return errSecDuplicateItem while read() kept returning .absent, so the device could never mint a device-registry id and iroh activation stayed permanently disabled. On .absent after a duplicate, overwrite the corrupt item via SecItemUpdate and return desired, or nil (retry a clean add) if a concurrent delete raced it to errSecItemNotFound. .unavailable still defers so a locked-before-first-unlock item is never clobbered. Also relocate the InMemoryDeviceIdentityStore test double out of the production target into the test target; nothing in production or the app referenced it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: hidden-computer unhide spinner tracks its own task, not forget's The unhide Button's ProgressView keyed off forgetTask, so it never spun during an actual unhide and could spin during an unrelated forget. performUnhide sets actionTask; key the unhide spinner off actionTask. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: failing tests for forget deleting wrong paired-Mac scope Two regression tests, RED before the fix (commit adds tests only): - Finding 2 (release-reachable): a team-less pairing shown under a selected team (legacy visibility) is forgotten; the forget captures the LIVE display scope and deletes with it, so removeExactScope(teamID: "team-a") misses the team-less row, the hidden marker is cleared, and the row resurfaces as a normal computer on returning to no-team. - Finding 3 (dev/tagged builds): removeExactScope falls back to the protocol-default remove through MobileMacCompatiblePairedMacStore over IOSBuildScopedPairedMacStore, so an exact-scope team removal also deletes the co-located team-less build-scope fallback row. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: forget deletes each pairing's own captured scope, not the live display scope The forget flow captured the live display scope and deleted with it, so a team-less paired-Mac row shown under a selected team (fetchAllMacs legacy visibility) was missed by removeExactScope(teamID: "team-a"); the hidden marker cleared and the row resurfaced (Finding 2, release-reachable). Plumb each row's own stackUserID/teamID through MobileHiddenComputer and delete with the row's own scope. Keep exact-scope removal exact through both store decorators: add removeExactScope overrides to MobileMacCompatiblePairedMacStore and IOSBuildScopedPairedMacStore so the call no longer falls back to the protocol default remove, which over-deleted the team-less build-scope fallback via scopedTeamID(nil) on dev/tagged builds (Finding 3). The pre-existing flip regression test seeded team-less then team-b for the same device+instanceTag, but base upsert claims the team-less row into team-b (moveMacRowScope), collapsing both into one team-b row, so the old assertions passed vacuously (forget deleted a nonexistent owner_key). Reorder the seed (team row first, which a later team-less upsert never claims) so two genuinely independent rows exist, and forget the team-less one explicitly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: failing tests for forget backup-team routing, revoke pinning, broker credential pairing Three autoreview findings on the forget/revoke path, each with a failing regression test. This commit adds only the tests plus the inert API surface they reference; the behavior fixes land in the next commit so CI goes red then green. A. removeExactScope reuses the nil local team for the backup tombstone, so a team-less row forgotten under a selected team routes its backup delete to whatever team is selected at flush time (can wipe the wrong team's backup). New removeExactScope(...backupTeamID:) surface (default forwards to the 4-arg, so behavior is unchanged until BackingUp overrides it next commit). B. forgetHiddenComputer pins the revoke to the LIVE session account instead of the row's owning account, so a row left on screen after an account switch can revoke the new account's binding. Test only; the fix is a one-line arg change. C. The broker reads access and refresh tokens through two independent snapshot calls; a force refresh between them pairs a stale access token with a rotated refresh token. New CmxIrohBrokerCredentials + credentialPair surface (unused by performRequest until next commit). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: fix forget backup-team routing, revoke account pinning, broker credential pairing Behavior fixes for the three autoreview findings; the failing tests from the prior commit now pass (CI red -> green). A. BackingUpPairedMacStore.removeMirroring now takes a separate `backupTeam` scope: the local row still deletes under `team` (nil stays nil), but the backup tombstone routes to `backupTeam`. The new removeExactScope(...backupTeamID:) override supplies the captured display team, and MobileShellComposite's forget passes `displayScope.teamID`, so a team-less row forgotten under a selected team tombstones the right per-team Durable Object instead of whatever team is selected at flush time. B. forgetHiddenComputer pins the revoke to `computer.stackUserID ?? scope.userID` (the row's owning account) instead of the live session, so the runtime forget's generation/account check fails closed when a stale row is forgotten after an account switch, rather than revoking the new account's binding. C. CmxIrohTrustBrokerClient.performRequest prefers tokenSource.credentialPair (both tokens from one snapshot) over the two independent closures, and MobileIrohRuntimeComposition supplies a credentialPair closure that captures one authenticatedSessionSnapshot under the same generation/account pinning. A force refresh mid-request can no longer pair a stale access token with a rotated refresh token. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: failing test — session snapshot pairs stale access with rotated refresh authenticatedSessionSnapshot() reads the access and refresh tokens through two separate awaits (currentTokens()), so a concurrent force refresh can rotate the pair between them and hand the broker an old access token with a new refresh token. Neither snapshot guard trips on a plain token rotation. The test scripts that torn store state and asserts the snapshot returns the access minted for the captured refresh, not the stale stored access. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: session snapshot derives access from the captured refresh token authenticatedSessionSnapshot() now reads both tokens through consistentTokenPair(), which captures the refresh token once and mints the access token FOR that exact refresh via freshAccessToken(accessToken: nil, refreshToken:). The returned access always belongs to the returned refresh, so a concurrent forceRefreshAccessToken() can no longer hand the iroh broker an old access token paired with a rotated refresh token. currentTokens() is unchanged for its broader callers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: failing test — forget routes backup tombstone to display team A team-less row's backup was uploaded under the row's own (nil) team scope, but forgetting it routes the tombstone to whatever team it happened to be displayed under. The tombstone lands in the wrong per-team backup scope: the row's real backup survives (and a restore under the row's own scope can resurrect the forgotten row), while a same-device record in the displayed team's backup can be wrongly deleted. Replaces the previous test, which asserted the display-team routing as the desired behavior. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: route forget backup tombstone to the row's own team scope The forget path routed the backup delete to the team the row was displayed under. For a team-less row that team is arbitrary (legacy visibility shows it under every selected team), while upsert stamps the row and uploads its backup under one resolved team, so the row's own team_id is the only client-side value tied to where the backup lives. Display-team routing also split the pending- delete lifecycle across two scopes: the tombstone was written and flushed under the display team's outbox scope, but a restore under the row's own (team-less) scope never saw it and could resurrect the forgotten row locally. Route the tombstone to the row's own captured team, the same scope the backup was uploaded under, keeping outbox key, local apply, flush, and restore- suppression on one scope. This removes the removeExactScope(backupTeamID:) variant entirely; the 4-arg exact-scope delete already carries the row's own team. Residual: a row uploaded while no team was selected client-side had its backup scope resolved server-side, and that resolution is not echoed back or persisted, so no client-only routing can name that scope with certainty. The symmetric nil route re-resolves through the same server path as the upload. Persisting a server-echoed backup team is a cross-stack follow-up. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing test — pending-delete replay deletes a surviving sibling row A forget whose backup upload fails leaves its tombstone in the outbox; the next read replays it through the broad remove path. TeamScopedPairedMacStore's remove re-resolves the device under the scope's team, which also returns team-less legacy rows, so with the exact row already deleted locally the replay resolves a SURVIVING unrelated alias of the same device and deletes it — the exact over-deletion the exact-scope forget path exists to prevent. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: replay pending backup tombstones through the exact-scope delete A pending tombstone names one exact pairing and its outbox scope key pins the exact (account, team) it was deleted under, so the replay's only job is to finish or confirm that one deletion. Replaying through the broad remove re-resolved visibility on the way down: TeamScopedPairedMacStore looks the device up under the scope's team (which also returns team-less legacy rows) and the build-scope decorator's broad remove drops its team-less fallback alias. In the common failed-upload case the exact row is already deleted, so the broad replay resolved a surviving unrelated alias of the same device and deleted it. Replaying via removeExactScope is a no-op there and, after a crash between the tombstone write and the local delete, removes exactly the named row. Residual: a crash-interrupted BROAD remove now replays exact too, so a team-less build-fallback alias can outlive that narrow window in dev builds; it resurfaces visibly and the next hide drops it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing test — wildcard forget leaves the device's sibling rows saved A row with no instance tag cannot name its broker binding, so forgetting it revokes EVERY binding for the device. The local cleanup deleted only the exact nil-tag row, leaving the device's coexisting tagged rows saved locally while their bindings were just revoked: dead entries that resurface in the computer list until the Mac happens to re-register. A tag-known forget stays narrow on both sides (second test, passing). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: match wildcard forget's local cleanup to its revoke breadth A tag-less row cannot name its own broker binding, so forgetting it revokes every binding of the device for the pinned account. Local cleanup deleted only the exact nil-tag row, stranding the device's coexisting tagged rows as dead entries whose bindings were just revoked. After the wildcard revoke the forget now also deletes the device's tagged sibling rows visible in the captured display scope and owned by the pinned account, each through the same exact-scope removal as the primary row. Tag-known forgets stay narrow on both sides. Rows in other teams' scopes are not enumerable through the scoped store rail and self-heal when the Mac re-registers; rows owned by other accounts keep their live bindings and survive. Closes https://github.com/manaflow-ai/cmux/issues/9078. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing test — forget mints a Stack token for every broker leg The forget flow captures one coherent session snapshot up front, but the broker token source re-snapshots on every request, and each snapshot now mints a fresh access token over the network. Discovery plus every sequential revoke each add a Stack round-trip, so forgetting a computer with many bindings can stall for minutes and fail during a Stack outage even though the pinned credentials in hand are valid. The test drives a forget across four broker legs through a broker fake that fetches one credential pair per request, exactly like the real client, and expects a single mint. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: reuse the forget's pinned credential pair for every broker leg The forget captures one coherent session snapshot up front; the broker token source now returns that pinned pair after only the cheap local session check (generation + account), instead of re-capturing a snapshot per request. Each snapshot performs a network token mint, so the old path added a Stack round-trip for the discovery and for every sequential revoke: forgetting a computer with many bindings could stall for minutes and fail during a Stack outage despite holding valid credentials. The pinned pair is coherent by construction, and the access token always travels with its refresh token, so the server can re-mint server-side if it expires mid-operation. A mid-forget sign-out or account switch still fails the check and yields nil, so the revoke fails closed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing test — tombstone ignores the server-reported backup team A team-less row uploads with a nil team and the SERVER resolves which per-team Durable Object stores it; that resolution is not derivable client-side and can drift by the time the row is forgotten. The new uploadReportingResolvedTeam seam (default: echo unknown) lets a transport report the verified team an upload was stored under; the failing test shows the backing-up store discards the echo and re-resolves nil at delete time, so the tombstone can land in a different team's backup than the record it is meant to delete. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: route delete tombstones to the server-reported backup team A team-less row uploads with a nil team and the presence worker resolves which per-team Durable Object stores it. That resolution is not derivable client-side and can drift by the time the row is forgotten, so re-resolving nil at delete time could send the tombstone to a different team's backup: the forgotten Mac's record survived and restored later, and a same-device record in the wrong team could be deleted. The worker now echoes its verified resolved team in the backup POST and GET responses (from the DO, which receives the verified value). The client persists the echo per pairing in a UserDefaults-backed map owned by the backing-up store, and the tombstone flush groups pending deletes by each pairing's persisted backup team (falling back to the scope's own team when no echo was ever seen), uploading each group to the backup its records actually live in. A flushed pairing's mapping is dropped with its backup record. Legacy rows converge on their next successful upload; restores still fetch the live scope (read-path residual, benign). Closes https://github.com/manaflow-ai/cmux/issues/9076. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — restore drops the backup-team echo; wildcard forget refreshes per sibling Two gaps in the round-4 fixes. Restored rows never pass through the upload path, so the reinstall case (empty mapping store, rows arriving via restore) loses the server's statement of where their backups live: a later forget re-resolves nil and the wrong-backup deletion returns for exactly the restored rows. The snapshot now carries the worker's echoed resolved team so the restore can persist it. And the wildcard forget's cleanup refreshes the paired list per deleted sibling, re-running the backup restore fetch each time — up to the 256-binding snapshot limit of sequential round-trips for one tap; the new test pins the whole cleanup to at most one refresh. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: persist the restore snapshot's backup team; batch wildcard cleanup The restore path now records the worker's echoed resolved team for EVERY live record in the snapshot (not just locally-written ones — each record lives in that team's backup regardless of the local merge outcome), so a row restored after a reinstall and forgotten later routes its delete tombstone to the backup it actually lives in instead of re-resolving nil at delete time. The wildcard forget now deletes all of the device's rows first and runs ONE refresh (paired list + registry + reconnect hint) after the batch, instead of reloading per deleted sibling — each per-row reload also re-ran the backup restore fetch because the removal clears the restore memo, so a forget covering many bindings issued that many sequential network round-trips. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iroh: make the coherent credential pair the broker token source's only input CmxIrohBrokerTokenSource previously accepted independent access and refresh closures with the coherent pair optional. Several production constructions (iOS reconcile/quarantine paths, macOS host activation) omitted the pair, and their two closures each called auth.currentTokens() separately, so a session transition between the two reads could assemble one session's access token with another's refresh token and fail registration, discovery, or revocation. The pair closure is now the ONLY construction input, so a two-source token assembly is no longer expressible; the single-token accessors are derived from the pair. Every construction site provides a coherent capture: pinned-session pairs for the forget flow, pairs captured together up front for sign-out revokes, and a single currentTokens() call per fetch for the runtime paths. The performRequest legacy two-closure branch is gone. No new regression test: the removed hazard is inexpressible at compile time, and CmxIrohBrokerCredentialPairTests keeps asserting each request performs exactly one atomic capture. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-5 review findings A wildcard forget must delete the device's same-account rows in OTHER teams (their bindings were revoked account-wide and an offline Mac cannot re-register to self-heal); the activation broker's credentials must fail closed after an account switch instead of vending the new session's tokens against the old activation; and a legacy device-id whose Keychain migration cannot persist is NOT durable (a reinstall wipes the only copy and strands the slot). Supersedes the adopt-legacy-despite-failed-persist test and the scope-flip test's sibling-survives assertion, both of which pinned the rejected contracts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: pin activation credentials; cross-team wildcard cleanup; defer non-durable legacy id Round-5 review fixes. The activation path now captures one coherent session snapshot, verifies it belongs to the activating account, and pins the broker token source to it (same helper as the forget path): a mid-activation account switch makes every later leg fail closed instead of mutating the new account's broker state against the old activation's endpoint identity. Wildcard forget cleanup now enumerates the device through a new cross-team loadAllInstances seam on the paired-Mac store rail — the team-scoping decorator forwards it verbatim (its live-team substitution is exactly what the cleanup must see past), the build-scope decorator bounds it to its own build scope, and the backup decorator forwards without triggering a restore. Every same-account row of the device is deleted by its own exact scope, matching the account-wide revoke. DeviceRegistryService no longer reports a legacy UserDefaults id as durable when the Keychain migration write fails: the store was readable (id absent) but nothing durable holds the id, so binding activation defers and retries instead of registering a slot a reinstall would strand. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-6 review findings A valid stored access token must be reusable without a network mint (forcing a mint made the session snapshot, and with it broker activation, fail offline despite a usable stored pair); and the persisted backup-team echo must be keyed by the row's own team — the local store deliberately allows the same (account, device, tag) pairing under several teams, so a team-agnostic key let team B's upload overwrite team A's destination and route A's tombstone into B's backup. Fixture fakes gain the SDK's likely-valid reuse semantics; the forget test's mint expectation drops to zero accordingly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iroh: store-level coherent pair, per-request pinned activation source, keyed echo, forget deadline Round-6 review fixes, one architectural piece plus three scoped ones. coherentTokenPair() replaces the always-minting snapshot read: capture the refresh token, resolve a usable access token FOR it (the SDK reuses a valid stored access without the network and mints only otherwise), then re-read the refresh — an unchanged refresh proves no rotation crossed the window, a changed one retries. It runs inside the coordinator's bounded token-touching phase. The session snapshot, the iOS quarantine-recovery source, and the macOS host activation source all read through it, so no torn two-await assembly remains and an offline launch with a valid stored pair succeeds. Activation no longer freezes an activation-time pair for the runtime's lifetime (ordinary force-refresh rotation does not bump the session generation, so a frozen pair went stale and stranded relay refresh and discovery until an unrelated reconcile). The activation gate is now a cheap local identity check — no token read, so offline activation still reaches the cached relay/offline-policy recovery — and every broker request re-checks the account/generation pin and re-reads a coherent pair from the store. The backup-team echo mapping key now includes the row's own team, and the forget revoke loop gets a 60-second operation deadline (deadlineExceeded surfaces the failure; applied revokes stand and a retry re-discovers what remains) instead of up to 256 sequential broker timeouts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-7 review findings An ordinary same-account foreground revalidation must not advance the session generation (every generation-pinned broker source would starve after the first foreground), and a UserDefaults device-id mirror must never be adopted when the Keychain authoritatively reports the id absent — the mirror travels in device backups onto NEW phones while the ThisDeviceOnly Keychain item does not, so adoption would make two physical devices fight over one (user, device, tag) slot on every phone upgrade. Also pins persist-and-reuse of refreshed access tokens across repeated coherent captures (contract coverage: the ephemeral side-store defect is not expressible through the fake), and reworks the fakes to model the live store's stale-refresh-persist semantics. Supersedes the legacy-mirror-adoption migration test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iroh: round-7 identity and credential lifecycle fixes Same-account revalidation no longer bumps the session generation: the bump now happens only on a genuine transition (signed-out -> signed-in, or a different account), so generation-pinned broker sources survive ordinary foreground returns while sign-out/sign-in still fences stale flows. The device id is minted fresh when the Keychain authoritatively reports it absent, never adopted from the UserDefaults mirror (which migrates in phone backups and would collide two physical devices onto one binding slot); the mirror remains trusted only while the Keychain is temporarily unreadable. This deliberately drops the seamless pre-Keychain upgrade migration — a one-time re-pair for existing installs — to prevent a permanent cross-device identity collision on every phone upgrade. The coherent pair now resolves the access token through the LIVE store inside the refresh bracket, so a stale token is refreshed once, persisted, and deduplicated by the SDK instead of re-minted per capture through an ephemeral side store. The long-lived activation source reads a full authenticated snapshot per request (atomic identity+credential capture, transition-checked) validated against the activation pin, closing the check-then-read race. Both credential containers get redacted descriptions so reflection cannot copy live tokens into logs or crash reports. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-8 review findings An in-place upgrade (Keychain absent, mirror holding the id the live binding already uses, no witness recorded) must ADOPT the mirror — minting there changes every existing installation's identity once and strands all of their bindings. A mirror whose recorded device witness belongs to ANOTHER phone (a restored backup) must still mint fresh, and a witness matching this phone adopts. These pin the provenance mechanism that separates the two cases the last two rounds traded against each other. (The tests reference the new witness parameter, so this commit is red at compile time without the fix.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iroh: device-witness provenance for the id mirror; pin the macOS broker source The UserDefaults device-id mirror now carries a per-device witness (identifierForVendor — a value a restored phone does not inherit), written on every mirror update. On authoritative Keychain absence the mirror is adopted only when the witness proves it was recorded on THIS device or predates the mechanism (the in-place upgrade population, whose mirror holds the id their live binding already uses); a mismatched witness means a backup restored onto another phone, which mints fresh so two physical devices never share one (user, device, tag) slot. The locked-Keychain fallback applies the same test. Residual: restoring a PRE-witness backup onto a new phone is indistinguishable from an upgrade and adopts — bounded to backups taken before this ships. The macOS host runtime's broker source now mirrors the iOS one: activation verifies the live account, captures the generation, and every request reads an atomic authenticated snapshot validated against that pin, so an A-to-B account switch fails the old runtime's requests closed instead of registering B's credentials against A's endpoint state. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-9 review findings A wildcard forget's tombstones must travel in ONE request per destination (a device can carry 256 bindings, and per-row flushes each burn a request timeout); a pending tombstone must be visible to restores of its DESTINATION scope, which must both suppress the deleted record and retry the flush; an unmapped team-less tombstone must PARK instead of shipping with a guessed nil team the server would re-resolve from current account state; and a failed cross-team sibling enumeration is a cleanup failure, not silent success. Legacy tests that modeled the pre-echo worker now arm the echo; the nil-team routing test is superseded by the parked contract, and the crash-intent test becomes the mapping-recovery test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iroh: destination-keyed tombstone outbox, batched wildcard flush, propagated enumeration failure Round-9 review fixes. Pending backup tombstones are now keyed by their DESTINATION scope — the team whose Durable Object actually holds the record (the persisted echo, else the row's own concrete team) — with the row's LOCAL team encoded in each record for exact local replay. A restore of the destination therefore both suppresses the deleted record while its upload is pending and retries the flush, closing the resurrect-and-never-retry gap of local-scope keying. A team-less row with NO verified destination is parked under the nil-team scope and never uploaded with a guessed nil team; parked intents migrate to their destination and flush once a restore's echo recovers the verified mapping. Legacy single-field records decode as local==scope, preserving old outboxes. Residual, documented in code: while parked, a restore of a different team's scope cannot see the intent and may resurrect the record there; re-forgetting that row routes exactly, which is recoverable — unlike a misrouted destructive delete. removeExactScopes batches several rows: local deletes and outbox writes first, then ONE tombstone flush per destination, replacing the per-row flush that gave a wildcard forget up to one network round-trip per row. The composite deletes the primary and all wildcard siblings through one batch and clears markers only after it succeeds, and a failed sibling enumeration now fails the forget instead of silently claiming success after an account-wide revoke. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-10 review findings A TAGGED forget's revoke is also account-wide for that (device, tag) binding, so same-tag rows in other teams must be cleaned too while different-tag rows survive; and reviving one team's row must clear only THAT row's pending tombstone — the destination-keyed outbox can hold same-pairing records for different local teams, and cancelling them all lets another team's forgotten record survive in the backup and restore later. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: tag-scoped cross-team forget cleanup; revive clears only its own row's tombstone Round-10 review fixes. Cross-team sibling cleanup now runs for EVERY forget: a tagged revoke kills the (device, tag) binding account-wide, so other teams' same-tag rows are dead and get cleaned, while different-tag rows keep their own live bindings and survive; the tag-less wildcard keeps its every-tag breadth. And a revive clears only the pending tombstone whose LOCAL team matches the re-added row — same-pairing records for other local teams in the same destination stay pending, so their forgotten backup records still get deleted instead of surviving to restore later. Legacy unscoped records decode their local team from the scope they sit in and so match only in the re-added row's own scope. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-11 review findings Three confirmed defects, each with a failing test: - A wildcard forget's exact-scope cleanup silently skips rows whose instance tag is incompatible with this build, while the tombstone still flushes and the forget reports success; the revoked-binding row survives to resurface as a dead entry. - Forget clears hidden markers only in the display scope; markers are stored per (user, team), so another team's marker survives its row's deletion and keeps a re-registering Mac unexpectedly hidden there. - A whitespace-only persisted device identity classifies as .found, so the corrupt-item repair deadlocks: the mint path re-reads and adopts the same whitespace value and every launch advertises an invalid opaque device id. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: exact-scope deletes match wildcard breadth; markers and identity repair Round-11 review fixes: - The build-compatibility store no longer guards exact-scope deletes. An exact-scope delete targets a row the cleanup explicitly captured from loadAllInstances, and the broker's wildcard revoke is tag-blind, so the local cleanup must cover incompatible tags too; the guard let the tombstone flush and the forget report success while the revoked-binding row survived. Ambient verbs keep the guard. - Forget clears each deleted row's hidden marker in that row's OWN team scope in addition to the display scope. Markers are stored per (user, team); clearing only the display scope left another team's marker to keep a re-registering Mac unexpectedly hidden there. - KeychainDeviceIdentityStore classifies a whitespace-only item as corrupt (.absent), so the duplicate-item repair path overwrites it instead of endlessly re-adopting it as .found; the in-memory test double mirrors the contract, now documented on DeviceIdentityStoring. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-12 review findings - A pre-witness UserDefaults mirror is adopted on authoritative Keychain absence with no proof this is the same physical device; a backup taken before the witness shipped restores onto a new phone and clones the old phone's (user, device, tag) binding slot. - A concrete-team restore neither suppresses nor resolves a PARKED unknown-destination tombstone, so the supposedly forgotten computer is resurrected locally and its backup survives every future restore. - A partially failed batched cleanup still runs the post-forget refresh, whose rowless-marker migration clears the deleted primary's hidden marker — the retry entry disappears while the failed sibling row keeps its already-revoked binding. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: continuity-gated mirror adoption; parked tombstones suppress and resolve Round-12 review fixes: - Pre-witness mirror adoption now requires device-continuity evidence: a non-migrating artifact proving the install continues on this hardware. The probe is the iroh endpoint identity — in Release an AfterFirstUnlockThisDeviceOnly Keychain item that never travels in a backup, and one every install with a live binding necessarily has. A restored pre-witness backup on a new phone lacks it and mints fresh (no more cloned (user, device, tag) slots); an in-place upgrade with a binding has it and keeps its id; an install that never activated iroh mints harmlessly. Both production device-id callers pass the same probe so concurrent resolutions agree, and the locked-Keychain mirror branch defers instead of trusting a possibly-restored mirror. - Every restore's suppression list now includes the account's PARKED (unknown-destination) tombstones, and a verified team's snapshot echo resolves any parked intent whose pairing it contains: the mapping is recorded under the parked record's own key and the parked scope flushes, migrating the intent to its destination and deleting the backup. A forget the user was told succeeded can no longer be resurrected by the next restore. FakeBackup now honors successful delete uploads in its snapshot, mirroring the server. - The post-forget refresh runs only after COMPLETE cleanup, so a partial batch failure keeps the hidden entry as the retry owner instead of letting the rowless-marker migration clear it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing test — round-13 review finding A forget's cleanup enumerates only the LOCAL store, but backups live in per-team Durable Objects and only the selected team's backup has been restored on this phone. The same device's records in another team's backup get no tombstone even though the wildcard revoke killed their bindings account-wide; switching to that team later restores the supposedly forgotten computer as a dead entry. FakeBackup gains a per-team-bucket mode to model the server's per-team storage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: account-wide forget tombstones; device-id resolution off the UI actor Round-13 review fixes: - A forget now parks one ACCOUNT-WIDE tombstone per forgotten pairing in addition to the routed per-row intents. Backups are per-team Durable Objects and only restored teams have local rows, so the local enumeration cannot match the broker revoke's account-wide breadth; the parked intent suppresses the pairing in EVERY team's restore, each verified snapshot that proves its team holds the pairing gets a direct delete (a tag-less intent is the device-wide wildcard and matches every tag, with the snapshot supplying the concrete tags), and the intent persists until a re-pair revives the pairing. Parked intents no longer migrate to a single destination — no single team could retire an account-wide tombstone. - Durable device-id resolution moved off the MainActor for activation: a private actor captures the identifierForVendor witness with one MainActor hop and runs the Keychain reads/writes, defaults mirror, and continuity probe on its own executor, restoring the off-UI-actor guarantee the merge reconciliation had dropped. DeviceRegistryService gains a nonisolated durableDeviceID(defaults:deviceWitness:...) for such callers, and currentDeviceWitness() is public. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-14 review findings - Parked (account-wide) tombstones replay their local delete only when the nil-team scope itself is requested, so an offline launch after a crash keeps showing the supposedly forgotten computer: crash recovery must be network-independent. - The parked tombstone set retires only on revive and grows by every forget forever — unbounded persisted size and per-restore scan work; retention must be bounded. The forget-deadline scope finding (discovery and in-flight broker calls can suspend past the deadline) is fixed in the same round; it lives in the iOS-only cmuxFeature target, where no host-runnable test exists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: network-independent parked replay, bounded retention, full forget deadline Round-14 review fixes: - Both restore entry points now replay the account's PARKED tombstones locally before any backup fetch, so crash recovery (outbox written, local delete never landed) works offline instead of depending on the restore's suppression list reaching the network. - The parked account-wide tombstone set is bounded at 256 entries (matching the discovery wire cap): intents are deduped by identity, stamped with a coarse insertion time via an injected clock, and evicted oldest-first when over the cap — an evicted intent's forget has had the longest time to propagate, and losing one degrades to the pre-account-wide behavior for that single pairing. Routed records' encodings are unchanged, so exact-string outbox clearing still works. - The forget deadline now bounds the WHOLE operation: forgetComputer races credential capture, discovery, backpressure waits, and every revoke against a cancellable sleeper, cancelling in-flight broker work at the deadline instead of only checking between revokes; the per-revoke clock checks remain as a cheap early exit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: fix Swift 6 isolation and stale optional binding in cmuxFeature Round-15 review findings — both compile errors in the iOS-only targets (no host-runnable or CI compile covers them, so no regression test is practical): - deviceLocalIrohIdentityExists (and its directory helper) are nonisolated so the off-main resolver actor's synchronous continuity probe closure can call them without a MainActor hop. - The sign-out test fake still optional-bound credentialPair from before it became the token source's only, non-optional input. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: forget deadline sleeper becomes static — extensions cannot hold storage Round-16 review finding: the cancellable sleeper was declared as an instance stored property inside the extension that hosts the forget flow, which does not compile. Static storage keeps the bounded-timeout shape unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing test — round-17 review finding A completed same-account sign-in (fresh credential exchange while already authenticated) preserves the session generation, so operations pinned to the prior session — the forget flow's frozen credential pair, the activation runtime's pinned source — keep passing the session fence with the replaced session's authority. The sibling round-17 finding (the activation path creates the iroh endpoint identity before the device-id continuity probe checks for it, so a restored pre-witness backup sees its own moments-old identity as continuity evidence) is fixed in the same round; it lives in the iOS-only cmuxFeature target, where no host-runnable test exists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: sign-in always advances the session generation; probe before identity Round-17 review fixes: - applySignedInUser now takes an explicit SessionPublication reason: a completed credential exchange (.signIn) always advances the session generation, even for the same account, because the token session was replaced and prior-session pins must fail closed; only .revalidation (foreground/startup re-checks of the already-published session) preserves the generation for the same account. - The activation path resolves the durable device id BEFORE creating the iroh endpoint identity. The continuity probe treats a device-local identity as proof the install continues on this hardware; creating the identity first handed a phone restored from a pre-witness backup its own moments-old identity as evidence and adopted the migrated mirror id. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: drop @MainActor child annotation the isolation checker cannot verify The hosted iOS build fails on the forget-deadline task group: "pattern that the region-based isolation checker does not understand how to check" at the @MainActor-annotated child. The plain child hops to the MainActor implicitly at the revokeMatchingBindings call, which is exactly what the annotation expressed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-19 review findings - The upload echo is keyed by the live display team, but loadAll's legacy visibility can match a TEAM-LESS row: the forget then looks the mapping up under the row's own nil team, misses it, and parks the tombstone — undeliverable when the network is down at echo time. - A parked delete suspended in its upload can race a concurrent re-pair on the reentrant actor: the revive clears the intent and uploads the record, the older delete lands after it, and nothing repairs the wiped backup. - A partially failed batch cleanup returns before clearing ANY markers; rows deleted before the failure can never be re-enumerated on retry, so their per-team hidden markers keep a re-registering Mac hidden. FakeBackup gains an on-delete-upload hook (to interleave a mutation inside the uploader's suspension window), record-op application to its buckets, and a post-construction fetch-failure switch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: row-keyed echoes, delete/revive reentrancy fences, narrowed marker cleanup Round-19 review fixes: - The upload echo's mapping is keyed by the ROW's stored team (mac.teamID), not the live display scope: loadAll's legacy visibility matches team-less rows under a selected team, and the forget looks the mapping up under the row's own team — a display-keyed echo was never found, leaving the tombstone parked and undeliverable offline. - Both delete uploaders (the concrete-scope flush and the parked echo resolver) now fence against the actor's reentrancy: any sent tombstone whose outbox record vanished during the upload suspension was revived by a concurrent re-pair, so its current local row is re-uploaded — the stale delete can no longer silently wipe the just-revived backup. The concrete flush also retires only the records it SENT, so intents added during the suspension survive to their own flush, and revived records keep their freshly re-saved mapping. - A partially failed batch cleanup clears the markers of rows it DID delete — narrowly: only the deleted row's own team key and the user-wide key, never the display scope, which the failed scope (the retry owner) shares. Rows deleted before the failure can never be re-enumerated on retry, so this is the only moment their markers can be cleared. FakeBackup applies record uploads to its per-team buckets only; the legacy single-bucket mode serves its seeded list to every team, so applying uploads there would leak one team's mirror into every other team's restore. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-20 review findings - The account-wide parked intent is inserted only AFTER the batch's local deletes have awaited; a Mac re-registering during that window clears the routed tombstone but cannot clear the not-yet-created parked intent, which then suppresses the revived pairing forever. - The flush retires sent tombstones by set subtraction computed AFTER its post-upload awaits; a re-pair plus second forget during those awaits re-adds the identical encoded record, which the subtraction silently consumes — an undelivered second tombstone loses its retry. - The persisted backup-team mapping grows without bound: entries retire only when THIS device delivers the pairing's tombstone. Test doubles: a paired-Mac store and a team-mapping store that fire a one-shot hook inside their suspension windows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: park before deletes, atomic flush retirement, bounded team mapping Round-20 review fixes: - removeExactScopes resolves accounts and persists the account-wide parked intents BEFORE the first local-delete suspension, so a Mac re-registering during a delete clears every tombstone covering its pairing — routed and parked alike — instead of leaving a stale account-wide intent that would suppress the revived pairing forever. The parked scope now also dedupes by identity in addPendingDelete and applies the same oldest-first cap there, so a row intent never stacks a second encoding beside its account-wide twin and single exact-scope removes cannot grow the scope unbounded. - The concrete flush retires its sent tombstones atomically in one actor turn right after the upload (synchronous cache read + write), before the mapping-cleanup and repair awaits: a re-pair plus second forget interleaving those awaits re-adds its identical record AFTER retirement and keeps its own retry. - The persisted backup-team mapping is bounded at 512 entries with move-to-newest insertion order and oldest-first eviction; losing an evicted mapping degrades that pairing's next forget to the parked, echo-recovered path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-21 review findings - A parked intent matches later snapshots solely by pairing id and is cleared only by a LOCAL re-pair: when another device re-creates the record, this phone deletes the revival on every restore and keeps the intent forever, making cross-device re-pairing impossible to persist. - The restore echo records every snapshot mapping under the restore team, but LWW can retain a NEWER team-less local row un-stamped; the later forget looks the mapping up under the row's actual nil team, misses, and parks — undeliverable when the network drops. The third round-21 finding (a same-account sign-in advances the session generation but the long-lived activation runtimes stay pinned to the old generation and return nil credentials until restart) is fixed in the same round; it lives in the iOS-only and macOS app targets, where no host-runnable test exists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: account-pinned runtimes, revival-aware tombstones, retained-row echoes Round-21 review fixes: - The LONG-LIVED activation runtimes (iOS composition and the macOS host) pin their broker token sources to the ACCOUNT only, not the session generation: every completed sign-in now advances the generation, and a same-account re-sign-in must keep the runtime serviceable — it is the same user, so serving the new session's credentials via the atomic snapshot is correct, where the generation pin stranded the runtime on nil credentials until relaunch. The forget's short-lived frozen pair stays strictly generation-pinned. - The restore echo now fires AFTER the merge and carries, per snapshot record, the RETAINED local row's actual team and the record's creation time. Mappings are keyed by the retained row's own scope (LWW can keep a newer team-less row un-stamped, and the forget looks the mapping up under the row's real team), falling back to the restore scope for records with no local row (the reinstall case). - A snapshot record CREATED after a parked intent's stamp is a REVIVAL — another device re-paired the Mac — and retires the intent instead of feeding it a delete; without this the forgetting phone deleted the revival on every restore forever. Unstamped legacy intents keep the old delete behavior (no boundary is known for them). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-22 review findings - A revived record is recognized only AFTER suppression already filtered it out of the merge; with the completed restore memoized, the re-paired Mac stays missing locally until relaunch. - The revival signal compared client-authored createdAt, which another phone preserves across a re-pair; the genuine revival misclassifies as stale and is deleted on every restore. The record model gains the SERVER-authored serverUpdatedAtMs (decoded from the snapshot, never uploaded). - Restore echoes persist mappings one save per record; the production store rewrites its whole state per save, so a large restore does quadratic UserDefaults work. The mapping protocol gains a batched saveAll (default forwards per entry). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: server-authored revival signal, in-merge revivals, batched mappings Round-22 review fixes: - The worker now surfaces the sync machinery's server-authored per-record write time as serverUpdatedAtMs on the restore read (never accepted from clients — sanitize strips it). Revival classification compares THAT against the tombstone's stamp through a shared skew-margined rule biased toward revival: client-authored createdAt is preserved across re-pairs on other phones and proves nothing. - Restore suppression is now stamp-aware: run() takes suppression entries (pairing + tombstone stamp), and a record every covering tombstone sees as revived MERGES in the same restore instead of being filtered out and stranded behind the completed-restore memo until relaunch. The post-merge echo then retires the covering intents. - Restore echoes persist their mappings through one batched saveAll — the UserDefaults store performs a single read-modify-write of its dictionary and ordering for the whole snapshot instead of a full-state rewrite per record. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-23 review findings - The revival skew allowance accepts server writes up to a minute BEFORE the forget as revivals. Forgetting a currently-online Mac whose backup was route-mirrored seconds earlier is the COMMON case; the allowance bypasses suppression, retires the intent, and the supposedly forgotten Mac restores instead of receiving its delete. - A partial batch failure never records a hidden marker for a FAILED undisplayed sibling: the deleted primary's marker turns rowless and is migrated away, so the sibling — with its already-revoked binding — resurfaces as a normal computer with no Hidden Computers entry left to retry from. The third round-23 finding (the sign-out quarantine's destructive retry captures live credentials without pinning them to the pending revocation's account) is fixed in the same round; it lives in the iOS-only cmuxFeature target, where no host-runnable test exists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: strict revival boundary, pinned quarantine retry, sibling retry markers Round-23 review fixes: - The revival boundary is STRICT: only a server write after the tombstone's stamp counts. Forgetting a currently-online Mac whose backup was mirrored seconds earlier is the common case, and the skew allowance let those pre-forget writes bypass suppression and retire the intent. The residual (phone clock behind the server) fails in the recoverable direction: the revival is deleted once and the other device's next mirror re-uploads it with a fresh server stamp. - The sign-out quarantine's destructive retry pins its credentials to the pending revocation's account through the atomic session snapshot, failing closed if the user switched accounts between the guard and the credential capture. - A partial batch failure records a hidden marker for every SURVIVING failed scope in its own team, so an undisplayed sibling with a revoked binding keeps a durable Hidden Computers retry entry even offline — where the account-wide parked intent cannot yet finish the cleanup. Once any restore completes it, the marker turns rowless and the existing migration clears it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing test — round-24 review finding The tombstone stamp is floored to whole seconds while server write times carry milliseconds, so a server write from the same second but BEFORE the forget classifies as a post-forget revival: the intent retires and the stale record restores instead of being deleted. Of the two sibling round-24 findings: the forget deadline race is fixed in the same round (the throwing task group structurally awaits an unresponsive cancelled child past the deadline; it lives in the iOS-only cmuxFeature target with no host-runnable test), and the retained-teams dictionary finding is factually incorrect — assigning a String? through the subscript wraps it (Swift removes only when the assigned expression is already the subscript's doubly-optional type), which the passing restoreEchoTracksTheRetainedTeamlessRow regression proves — but the code switches to updateValue(_:forKey:) to make the retention explicit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: millisecond forget boundary, non-blocking deadline, explicit retention Round-24 review fixes: - Tombstone stamps carry epoch MILLISECONDS with an explicit `ms` unit marker in the encoding (bare-integer third fields from earlier builds decode as whole seconds). Flooring to seconds classified a server write from the same second but before the forget as a revival, retiring the intent and restoring the stale record. - The forget deadline no longer structurally awaits the losing racer: a throwing task group waits for every child, so a revoke suspended on a dependency that ignores cooperative cancellation kept the forget busy past the deadline — the exact stalled-request case it exists to recover from. Unstructured racers resolve a one-shot gate; the deadline returns immediately, cancellation is still requested, and the stalled work unwinds in the background. - The restore's retained-row map uses updateValue(_:forKey:) so the retention of a TEAM-LESS row is explicit rather than relying on optional-wrapping subscript semantics (behavior unchanged — the routed-delete regression already proved the entry was stored). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-25 review findings (bounded pair) - One tagged instance's revival retires the whole DEVICE-WIDE tombstone, dropping suppression and deletion for a stale different-tag record that exists only in another team's backup. - The account-wide parked record stores a nil local team, so offline crash recovery replays only nil-team rows: a concrete-team row whose local delete never landed survives every offline launch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: exact revival retirement; parked records carry their row's team Round-25 review fixes (the two bounded findings): - A revival retires only its EXACT pairing's intent, and the revive-clear mirrors it: one tagged instance returning no longer retires the device-wide tombstone (or clears it on local re-pair), so a stale different-tag record in another team's backup keeps its suppression and still receives its delete. Per-record revival classification lets the revived pairing through everywhere, so retaining the wildcard intent costs the revival nothing; deletes explicitly spare records every covering intent classifies as revived. - Account-wide parked records preserve the captured ROW's local team, so offline crash recovery replays the exact delete for concrete-team rows (a nil local team replayed only nil-team rows). Coverage semantics are unchanged — suppression and echo matching key on the pairing id alone, and the revive-clear cancels the pairing's intents regardless of the recorded team. The two remaining round-25 findings are deferred with rationale in the PR discussion: cross-clock revival ordering (a sound fix needs server-issued causal revisions — a worker protocol change reintroducing a form of server-side tombstones, which this codebase deliberately retired; the strict boundary fails only in the recoverable direction) and post-deadline task abandonment (every dependency in the revoke path is URLSession-backed and cancellation-aware; the detached racer is cancellation-requested and cannot outlive its own bounded requests). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: widen developmentStoreDirectory to fileprivate for the evidence probe The DEBUG same-device evidence probe struct lives at file scope in MobileIrohRuntimeComposition.swift and cannot reach a type-scoped private static. Caught by the on-device build; host-side SwiftPM tests do not compile the iOS-only cmuxFeature target. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Drop committed review logs from the branch Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Restore main's ghostty submodule pin (theme picker fix from #9218) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> | 2 个月前 | |
Keep iOS New Task button clear of the bottom search pill (#9136) * Add regression test for New Task vs search pill overlap On iOS 26 the workspace list preview now renders the New Task button the live shell mounts next to the system search pill, and a UI test asserts the two controls do not intersect and stay tappable. The fix lands in the next commit, so this run documents the overlap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Keep iOS New Task button clear of the bottom search pill On iOS 26 the workspace list mounted New Task as a bottomBar toolbar item, but the TabView search-role tab renders its pill in the same bottom-trailing slot, so the two controls stacked and New Task was occluded and untappable. Mount the shared TaskComposerButton in the bottom safe-area bar instead, which the system lays out above the tab bar chrome, and move the pre-iOS-26 overlay mounting from both shell layouts into the same WorkspaceListSearchHost so the button has one shared layout path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> | 2 个月前 | |
iOS: public App Store lane (com.cmux.app), privacy manifest, fastlane screenshots (#6697) * iOS: public App Store lane (com.cmux.app), privacy manifest, fastlane screenshots Prep cmux iOS for a public App Store release alongside the existing dev.cmux.app.beta dogfood channel. - PrivacyInfo.xcprivacy wired into the app target: NSPrivacyTracking=false, UserDefaults (CA92.1) + file-timestamp (DDA9.1) reasons, product-interaction analytics label. No Sentry/IDFA in iOS. - upload-testflight.sh + cloud-testflight.sh: new appstore lane (com.cmux.app, on-device name "cmux", cmux Distribution profile), sharing the existing release entitlements and cmux-ios URL scheme. - web/services/apns/routePolicy.ts: route com.cmux.app to production APNs (+ test). - MobileBuildType: document/test com.cmux.app as a prod bundle id. - ios/fastlane: snapshot config (en-US + ja; iPhone 6.9" + iPad 13") driving the CMUX_UITEST_MOCK_DATA DEBUG state via a SnapshotUITests case. - .github/workflows/ios-screenshots.yml: capture screenshots in CI on a DEBUG build (no signing), resolving the required iPhone/iPad classes at runtime; optional upload to App Store Connect on workflow_dispatch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios-screenshots: read SNAPSHOT_DEVICES from GITHUB_ENV (set -u fix) The resolve step exported SNAPSHOT_DEVICES to $GITHUB_ENV (for later steps) but then echoed $SNAPSHOT_DEVICES, which is unset in the current shell, so set -u aborted the step. Confirm by grepping the env file instead. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios-screenshots: pick devices from available simulators, skip RAM variants Resolver picked the device TYPE 'iPad Pro 13-inch (M5) (16GB)', which has no pre-created simulator, so fastlane errored 'not in list of available simulators'. Resolve against available simulator DEVICES and exclude RAM-variant (GB) names; prefer iPhone NN Pro Max + iPad Pro/Air 13-inch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios-screenshots: drive devices/languages from Fastfile, not Snapfile The Snapfile's devices([...]) overrode the action's devices param, so CI's runtime-resolved simulators were ignored and fastlane looked for the stale 'iPhone 16 Pro Max'. Move devices+languages to the Fastfile (env-overridable, SNAPSHOT_DEVICES/SNAPSHOT_LANGUAGES) as the single source. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios-screenshots: capture via standalone preview screens (real images) CMUX_UITEST_MOCK_DATA alone lands on the add-device screen, so snapshots were empty (0 images). Use the standalone preview hooks that render real UI with no sign-in/pairing: WORKSPACE_LIST_PREVIEW + TERMINAL_PREVIEW (+ fake keyboard), settling on window/foreground instead of identifiers the preview views do not expose. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios-screenshots: populated terminal, clean status bar, dismiss banner Make the captured screenshots presentable for the App Store: - TerminalLayoutPreviewView feeds a sample ANSI agent-session transcript when CMUX_UITEST_TERMINAL_PREVIEW_CONTENT=1, so the terminal shot shows real content instead of a blank surface (blank layout preview unchanged). - SnapshotUITests enables that flag, drops the debug zoom overlay, and swipes away the one-time 'Ready for Apple Intelligence' notification banner. - Fastfile capture uses override_status_bar for a clean 9:41 status bar. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios-screenshots: feed terminal sample content on didResize updateUIView never re-ran with a non-zero size, so the sample transcript was never fed and the terminal shot came out blank. Feed it from the surface's first didResize (grid sized = can render). Also trigger the screenshots workflow on preview-view changes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * iOS: localize app into 12 more languages (machine translation) Add zh-Hans, zh-Hant, ko, de, fr, es, pt-BR, it, ru, nl, tr, pl to all iOS xcstrings (app, agent chat UI, InfoPlist permission strings) and the project knownRegions, alongside the existing en + ja. Translations are a machine-translation first pass (placeholders/format specifiers preserved, brand/tech terms kept) and should get native review before public release. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: localized App Store listing metadata (14 locales) Store the App Store listing copy (description, keywords, promotional text, URLs; en-US also name/subtitle) for en-US + ja, zh-Hans, zh-Hant, ko, de-DE, fr-FR, es-ES, pt-BR, it, ru, nl-NL, tr, pl. Applied to App Store Connect and kept here so the listing is reproducible via fastlane deliver and the machine-translated copy is reviewable before public release. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios-screenshots: realistic framed shots (agents, keyboard, notifications, frameit) Make App Store screenshots realistic and on-message: - TerminalPreviewTranscripts: Claude Code / Codex / OpenCode / pi sample sessions, selected via CMUX_UITEST_TERMINAL_TRANSCRIPT. - ScreenshotKeyboardView: drawn dark iOS keyboard overlaid in the reserved keyboard region (CMUX_UITEST_SCREENSHOT_KEYBOARD=1); the simulator won't render the system keyboard in CI. Device-aware height (iPhone vs iPad). - ScreenshotNotificationBanner: iOS push banner over the workspace list (CMUX_UITEST_NOTIFICATION_BANNER=1) to show agent notifications. - SnapshotUITests: 7 screens (workspaces, notifications, 4 agents w/ keyboard, full Ghostty terminal). - frameit pipeline: tranquil gradient background, Framefile.json, localized title.strings (prepare_frames.py from titles.*.json), framed in the lane after capture. Workflow installs imagemagick; deliver uploads the framed images. Dynamic island comes from the frameit device frame. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci: re-trigger screenshots workflow Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios-screenshots: fix MainActor setupSnapshot call; add localized frame titles setupSnapshot is @MainActor; calling it from nonisolated setUpWithError failed to compile, so the snapshot test never ran (0 screenshots, frameit found nothing). Call it from the @MainActor test method. Also add titles.json (localized screenshot captions, 13 locales) consumed by prepare_frames.py. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios-screenshots: real frames (frameit) + real recorded agent TUIs Make everything real: - Device frame: fastlane frameit photographic frames (font supplied via the Framefile fonts array; ImageMagick has no default font). iPhone 17 Pro Max frames natively; iPad captures are resized 2064x2752 -> 2048x2732 in prepare_frames.py so frameit's real 12.9 iPad Pro frame applies. - Terminal content: replay REAL recorded sessions from the actual claude/codex/ opencode/pi CLIs (tmux capture-pane), base64-embedded in TerminalPreviewTranscripts. record_sessions.sh + embed_sessions.py reproduce them; the screenshot CI replays the committed fixtures (no agent auth needed on the runner). - Removed the drawn fake keyboard (ScreenshotKeyboardView) and the custom PIL compositor (compose_frames.py); terminal shots show the full real terminal, keyboard down. - SnapshotUITests: 6 screens (workspaces, notifications, claude, codex, opencode, pi). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios-screenshots: grid probe + font override + 39-locale titles - Add CMUX_UITEST_TERMINAL_FONT_SIZE override and a 'probe' transcript that prints the live cols x rows + ruler, to measure the exact iOS terminal grid and re-record agent fixtures at matching width (fixes OpenCode/Pi wrap). - titles.json localized to all 39 App Store locales; titles.en trimmed to the 6 current screens. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios-screenshots: auto-fit terminal width, real 76-col agent sessions, dark mode, real notif icon, landscape iPad - Terminal auto-fits font to CMUX_UITEST_TERMINAL_TARGET_COLS=76 (via setLiveFontSize on first didResize), so one 76-col fixture fills the width edge-to-edge on both iPhone (portrait) and iPad (landscape). Measured grids: iPhone 93x88, iPad 205 cols @ font 8 -> fixed-width fixtures couldn't fill both. - Re-recorded all 4 agents (claude/codex/opencode/pi) at 76 cols with richer prompts that fill the screen (fixes narrow + OpenCode/Pi breakage). - Capture in dark mode (Fastfile dark_mode: true). - Notification banner uses the real embedded cmux app icon + tighter iOS styling. - iPad captured in landscape and composited bezel-less (latest real screen, no dated 2020 frame); iPhone keeps the real frameit frame. Removed grid probe. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios-screenshots: generate HTML gallery in CI (screenshots/preview/index.html) frame lane now builds a self-contained preview gallery referencing the framed PNGs in place; CI uploads it inside the screenshots artifact (automatable, not a local /tmp one-off). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios-screenshots: install Pillow in CI for the iPad compositor compose_ipad.py needs PIL; the runner didn't have it (ModuleNotFoundError), failing the frame step after the iPhone frames succeeded. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios-screenshots: iPad compositor via ImageMagick (fast, no Pillow) Rewrite compose_ipad.py to use magick (the C lib frameit already requires) instead of Pillow: faster on the large landscape iPad images and removes the extra Python dependency from CI. Validated locally (rounded screen + soft shadow + caption on the tranquil background). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios-screenshots: gallery at screenshots root with subdir+URL-encoded img refs Browsers block file:// access to parent dirs (../) and don't auto-encode spaces in filenames, so the preview images showed broken. Write index.html at the screenshots root referencing <locale>/<file> (a subdir) with URL-encoded paths. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios-screenshots: premium composition (stitch real frame, large device, bold header) Self-review: frameit shrank the tall device under the title leaving big dead margins, and the header read weak. Replace with compose_shots.py: stitch the screenshot into the real iPhone 17 Pro Max frame and place it LARGE (bleeding off the bottom) under a bold 2-line header; iPad is a large bezel-less landscape screen with a bold header. Removed frameit action + prepare_frames/compose_ipad/ Framefile. Uses the same frameit frame PNG (downloaded on demand) + ImageMagick. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios-screenshots: SF Pro header + nature backgrounds Per review: switch the header to Apple's SF Pro (SFNS, heavy weight; Unicode fallback for CJK/RTL) and replace the gradient with tranquil nature photos (mountains for iPhone portrait, lake+mountains for iPad landscape), darkened with a top gradient so the device + header pop. Compositor picks bg by orientation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios-screenshots: round capture corners before stitching (clean frame top) The square simulator capture poked its corners past the frame's rounded screen opening, leaving square artifacts at the top corners. Round the capture to the screen corner radius before compositing under the device frame. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios screenshots: opening-mask frame stitch + agent logos in headers - compose_shots.py: mask each iPhone screenshot to the device frame's real screen opening (extracted from the frame alpha) so the screen follows the bezel's exact rounded corners (fixes the square-corner artifacts poking past the frame). Render headers in bold SF Pro with the agent logo (Claude/Codex/ OpenCode/pi) smushed in before the localized title. - logos/: agent mark PNGs used in the headers. - Fastfile: accept the machine-store secret names (ASC_KEY_ID / ASC_ISSUER_ID / ASC_PRIVATE_KEY_PATH) in addition to the ASC_API_* names so upload_screenshots works from ~/.secrets without manual remapping. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios screenshots: real terminal titlebar, smaller inline logos, brighter bg, pi title - TerminalLayoutPreviewView: wrap the capture in a NavigationStack with the real terminal nav bar (back chevron + centered workspace name + chat/terminal icons, mirroring WorkspaceDetailView). The terminal no longer bleeds under the status bar / Dynamic Island; there is a proper cmux titlebar below the safe area, so the device-frame stitch reads cleanly at the top. SnapshotUITests passes a believable per-agent workspace name via CMUX_UITEST_TERMINAL_TITLE. - compose_shots.py: shrink the header agent logo to ~cap height and tighten the gap so it sits inline before the title instead of as a large badge. - titles: Pi screen now names pi (mirrors each locale's Claude title, Claude Code -> pi) instead of 'Ship from anywhere', with the pi logo inline. - bg_portrait/bg_landscape: brighter nature photos (sunlit meadow + sky) with a top gradient kept for white-header legibility. - propagate_locales.py + frame lane: capture shoots only en-US+ja (the localized app UI); fan those raws out to all ~39 App Store locales before framing so the whole pipeline is reproducible in CI. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios screenshots: unified Dynamic Island, per-screen bg variation, raw toggle - compose_shots.py: paint a single rounded Dynamic Island over the frame's physical cutouts (the frame PNG draws a pill + a separate camera hole; iOS shows one unified black pill). Fixes the weird double-cutout header. - Per-screen backgrounds: 6 bright, clean nature scenes (sky gradients + ocean, all Pexels License = free for commercial use, no attribution) under frame_assets/backgrounds/{p,l}; each screen (01..06) gets its own, so the listing has varied backdrops instead of one repeated photo. iPhone + iPad of the same screen share a theme. - generate_preview.py: add a 'Show original (unframed) captures' toggle so the raw screenshots can be inspected next to the framed ones. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios screenshots: terminal preview fills header with terminal color, real glass nav chrome The screenshot preview only extended the terminal background (#272822 Monokai) under the horizontal + bottom safe areas, so the status-bar / nav-bar region fell back to black — which is NOT what the running app shows. WorkspaceDetailView fills the whole window (including under the top) with the terminal color and uses mobileTerminalNavigationChrome() (translucent Liquid Glass on iOS 26, material on iOS 18). Mirror both here so the captured header matches the real device instead of showing a black band. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios screenshots: glass-pill nav title in terminal preview Put the preview's nav title on a Liquid Glass pill (mobileGlassNavigationTitle), matching WorkspaceDetailView.glassTitle, so it stays legible over terminal text now that the bar background is cleared on iOS 26. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios screenshots: bigger device, smaller title, horizon-free backgrounds - compose_iphone: enlarge the device (0.885 -> 0.95 width), raise it slightly, and shrink the header (120 -> 104pt, wider box) so titles like 'OpenCode, pi, any agent' fit on one line and the device is the prominent element. - backgrounds 03/04/05: replace the ocean/beach photos (visible horizon band behind the device read as 'weird', esp. OpenCode) with clean horizon-free skies; keeps the bright per-screen variation without a busy seam. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios screenshots: strip OpenCode's explicit bg so it renders uniformly The OpenCode fixture painted its content on its own near-black (#0a0a0a) and let bold headings reset to the terminal default bg, which on the mobile terminal is Monokai #272822 — so heading/emphasis spans showed as olive boxes against the dark content (the 'weird rendering'). The other agents never set an explicit bg, so they render cleanly. Strip OpenCode's background SGR codes so it renders uniformly on the terminal background like the others. Also add the transcripts file to the screenshot workflow triggers so fixture changes re-capture. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios screenshots: OpenCode keeps its dark card, made uniform (no olive boxes) Stripping OpenCode's background lost its distinctive near-black card. Instead, pin the background to OpenCode's own #0a0a0a everywhere (re-assert it after every reset / default-bg) so the card stays dark and uniform with no fallback to the Monokai #272822 default (which caused the olive boxes). The status panel's #1e1e1e is set explicitly and survives. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios screenshots: render OpenCode on a matching #0a0a0a terminal background Definitive fix for OpenCode's olive boxes. The boxes were cells that fell back to the Monokai #272822 default (heading resets AND line-ends the agent didn't pad to the 76-col display width); ANSI surgery on the fixture couldn't cover every case. Instead set libghostty's default background to #0a0a0a for the OpenCode shot via CMUX_UITEST_TERMINAL_BG (each screenshot is its own app launch, so it's scoped to that one shot), and match the preview chrome fill to it. Now every cell — painted, reset, or unpadded — is #0a0a0a, so OpenCode is a clean uniform dark card with no boxes, and the original (unedited) capture fixture is restored. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios screenshots: re-record OpenCode with a full-screen response The previous OpenCode capture was sparse (a short answer + a large empty 'Build' panel), so it left a big empty area at the bottom while the other agents fill the screen. Re-recorded with a richer prompt captured after completion, so OpenCode now shows a dense full-screen response (entry point + readability + #Preview) matching the fill of claude/codex/pi. Renders on the #0a0a0a terminal background added previously, so no boxes and no surgery needed. * ios screenshots: real notification (not a drawn banner) Replace the hand-drawn ScreenshotNotificationBanner with a REAL local notification: in the workspace-list preview, request notification authorization and schedule a genuine UNNotificationRequest, so the system renders the actual banner (real blur/fonts, the app's real icon, and the 'cmux' display name — lowercase). The snapshot UITest taps the springboard 'Allow' prompt and captures the real NotificationShortLookView instead of swiping it away. Deletes the fake banner view + its embedded icon. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios screenshots: auto-derive terminal bg from transcript (no hardcoded color) Replace the hardcoded per-agent #0a0a0a override with auto-derivation: scan each transcript for its dominant explicit background color and render the terminal on it (OpenCode -> its near-black card; claude/codex/pi -> terminal default). The preview sets CMUX_UITEST_TERMINAL_BG from the derived value before the surface is created, so libghostty's default background matches and no reset/unpadded cell falls back to Monokai. Removes the magic constant from the snapshot test. * ios screenshots: capture the real notification banner at fixed timing The foreground notification banner renders correctly (verified: real icon, 'cmux', over the workspace list) but is a transient system overlay (~5s) that isn't reliably queryable, so the UITest was snapshotting after it dismissed. Fire the notification ~0.6s after the auth grant and snapshot at a fixed 2.5s (inside the banner's visible window) instead of waiting on an element. * ios screenshots: real Mac-streamed capture orchestration (WIP) Adds the orchestration for capturing the live Mac-streamed agent terminals on a paired simulator (capture-streamed.py) + the recorded agent sessions as raw .ans files for deterministic Mac-side content. Proven end to end (live OpenCode terminal streamed to the sim, framed, approved). Known blockers being worked: the device's workspace list only resyncs grouped workspaces after a pairing reconnect, and the device mirrors a workspace's own streamed surface (so the workspace must be created with its --command), plus local machine contention drops the tagged Mac app/pairing intermittently. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci: Blacksmith streamed-screenshot validation workflow (WIP, manual) Validates the real Mac-streamed screenshot pipeline headless on Blacksmith: build + run the desktop Mac app, pair an iOS sim, capture the live streamed terminal. De-risks running the desktop app + paired sim in CI (cmux streams terminal content rendered on-device, so the Mac's GUI rendering should not matter). Gated on dogfood account secrets (CMUX_DOGFOOD_STACK_EMAIL/PASSWORD) for sim sign-in — not yet configured in CI. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci: trigger streamed-validate via push-to-self on the feature branch workflow_dispatch needs the workflow on the default branch (unavailable here), so fire the validation by pushing edits to the workflow file on the feature branch. * ci: streamed-validate stands up dev web backend (Postgres + Next.js) on Blacksmith Run the dev web app locally on the runner (local Postgres via db-local.sh + bun dev, dev Stack project) so the DEBUG sim signs into localhost:3000 + the dev project where the dedicated CI account lives — no prod, no email verification. Dev secret bundle provided via CMUXTERM_DEV_ENV_B64 (dogfood creds swapped to the dedicated CI account). Then build the Mac + iOS apps, pair the sim, capture one streamed terminal. * ci: streamed-validate uses native Postgres + next dev (no Docker) Blacksmith macOS has no Docker, so run native Postgres (cmux:cmux@localhost:13000) + drizzle migrate, and run 'next dev --port 3000' directly instead of bun dev / dev-local.sh (which call the Docker db-local.sh). Detach the server with setsid so it survives into the pairing step. * ci: detach web server with nohup (macOS has no setsid) Native Postgres + drizzle migrate work; the server step failed only because 'setsid' doesn't exist on macOS. Use nohup + disown, and dump webdev.log on failure for faster diagnosis. * ci: run streamed-validate on GitHub-hosted macos-26 (Blacksmith queue dead) The Blacksmith run sat queued 24h with no runner and was auto-cancelled, so use a GitHub-hosted macOS runner (separate pool) to actually execute the validation. macos-26 has Xcode 26 / iOS 26 sims. (Burns paid macOS minutes.) * ios-streamed-validate: run on Blacksmith macos-26, not paid GitHub-hosted Use the same Blacksmith iOS lane as test-ios.yml (vars.MACOS_RUNNER_IOS || blacksmith-6vcpu-macos-26). The desktop Mac app link failure (undefined libc symbols) was a GitHub-hosted macos-26 toolchain quirk; Blacksmith macos-26 is the proven fleet that builds cmux releases, and the prebuilt GhosttyKit is SHA-pinned for the current ghostty submodule so no from-source build runs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios-streamed-validate: pin Blacksmith macos-26 + Xcode 26 (not Depot/16.4) The vars.MACOS_RUNNER_IOS override routes the iOS lane to a Depot macOS image whose default Xcode is 16.4; cmux's iOS targets need Xcode 26's Swift toolchain (Swift 6 actor-isolation + interpolation errors otherwise) and the iPhone 17 sim only exists on iOS 26. Pin blacksmith-6vcpu-macos-26 directly and select the newest Xcode 26 explicitly instead of the default symlink. The desktop Mac app already builds clean on this lane; this unblocks the iOS sim build + the in-CI pairing/capture. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios-streamed-validate: mirror the Blacksmith fleet build recipe (fix Xcode 26 link) The Mac build failed under Xcode 26 with undefined libc symbols (_abort/_free/_malloc_size/...): the AArch64 GlobalISel codegen path miscompiles under -O/wholemodule. Adopt reload-build.yml's exact fleet recipe: - Select Xcode via scripts/select-ci-xcode.sh (ranks by macOS SDK, picks 26.x, aligns xcode-select) instead of the default Xcode.app symlink. - Provision GhosttyKit via download-prebuilt-ghosttykit.sh (SHA-pinned), the path ci.yml/reload-build use. - Build the Mac app with --swift-frontend-workaround (disables GlobalISel). - Add CMUX_SKIP_ZIG_BUILD=1 + SWIFT_BACKTRACE env. The iOS sim build forces -O/wholemodule too, so it needs the same workaround. ios/scripts/reload.sh had no escape hatch, so add --swift-frontend-workaround (also via CMUX_SWIFT_FRONTEND_WORKAROUND=1), mirroring scripts/reload.sh, and apply it to both the simulator and device xcodebuild invocations. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios-streamed-validate: make the streamed capture actually run (idb, sign-in, pairing) The capture step passed only because of '|| true'; it produced no screenshot. Three real fixes: - idb: install fb-idb into a pinned-Python venv (/tmp/idbvenv) and put it on GITHUB_PATH. The runner's default python3 (3.14) is too new for fb-idb, so the 'idb' CLI was missing (FileNotFoundError) and all device navigation failed. - sign-in: inject the DEDICATED CI screenshot account (secrets CMUX_DOGFOOD_STACK_EMAIL/PASSWORD, @cmux.com, not a personal account) as env; dev-secrets reads CMUX_DOGFOOD_STACK_* from the environment first, so mobile-dev-launch signs the device in instead of erroring 'no credentials'. - pairing order: launch the tagged Mac app and wait for its debug socket BEFORE mobile-dev-launch --ensure-mac, which mints the pairing ticket from the running Mac app; otherwise the device is signed-in-only with no workspaces to stream. Also drop '|| true' on capture and assert a *.png exists, so the step fails loudly if no real streamed shot is produced; use --detach so the sim launch returns instead of blocking on --console-pty. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios-streamed-validate: capture device-state diagnostics on capture failure Sign-in + pairing + Mac workspace creation now all succeed, but the device shows 'workspace not visible' (likely the new workspace was created after the device paired and the dev workspace list didn't resync). Before guessing the fix across CI iterations, capture hard evidence: a device screenshot + accessibility tree right after pairing and again post-capture, plus the Mac workspace list, into _diag/ artifacts. Keep the top-level *.png assert (diagnostics live in _diag/). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios-streamed-validate: install idb_companion (facebook/fb tap) + /usr/local/bin symlink The device pairs and shows the workspace list fine; the capture failed only because idb ui describe-all threw FileNotFoundError: '/usr/local/bin/idb_companion'. Two causes: (1) 'brew install idb-companion' => 'No available formula' (it lives in the facebook/fb tap), so the companion was never installed; (2) the python idb client spawns it from the hardcoded /usr/local/bin path while arm64 brew installs to /opt/homebrew/bin. Install from the tap and symlink to /usr/local/bin, and assert idb_companion in the capture prereqs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios-streamed-validate: real claude agent content in the streamed shot The streamed terminals run real agents on the runner, but the agents weren't installed (zsh: command not found). Install claude/codex/opencode/pi and symlink them into /usr/local/bin (cmux workspace shells launch from the GUI Mac app and lack ~/.bun/bin + ~/.local/bin on PATH). Propagate agent auth into the GUI session with launchctl setenv BEFORE (open uses launchd env, not the step env), so workspace terminals inherit CLAUDE_CODE_OAUTH_TOKEN (claude, native) and DEEPSEEK_API_KEY (the others, via DeepSeek's OpenAI-compatible API). Validate the real-agent path with claude first (strongest auth, no provider config): launch with --permission-mode plan (clean read-only UI) and pre-seed ~/.claude.json so fresh-$HOME onboarding + folder-trust don't block. pi switched to --provider deepseek. Capture --agents claude; opencode/codex provider config to follow once claude proves the path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios-streamed-validate: trust claude sandbox by realpath (/private/tmp) The claude shot proved auth propagation works (no login screen) but stalled on the folder-trust prompt: the pre-seed keyed /tmp/cmux-stream-claude, while macOS resolves /tmp -> /private/tmp and claude keys its trust map by the realpath it sees (/private/tmp/cmux-stream-claude, visible in the screenshot). Seed both spellings so the trust prompt is pre-accepted and claude answers the prompt. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios-streamed-validate: test the DeepSeek-backed agent path (pi) claude renders a real session now (trust cleared) but the CLAUDE_CODE_OAUTH_TOKEN account is over its monthly spend limit, so it shows the rate-limit screen. Verify the DeepSeek-backed path independently by capturing pi (--provider deepseek, reads DEEPSEEK_API_KEY propagated via launchctl) before fanning out to opencode/codex. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios-streamed-validate: replay recorded agent sessions (claude) instead of live Live agents in CI hit auth/billing limits (the CLAUDE_CODE_OAUTH_TOKEN account is over its monthly spend limit) and are nondeterministic. Instead, record a real session locally with a funded account, commit the transcript, seed it into the agent's on-disk session store at the matching project cwd (/private/tmp/cmux-stream-claude), and resume it read-only in CI. claude: recorded via 'claude -p' locally (genuine answer w/ #Preview code block, no account/email/token fields in the jsonl), committed as ios/fastlane/streamed-sessions/claude/<sessionId>.jsonl, copied into ~/.claude/projects/-private-tmp-cmux-stream-claude/ in CI, launched with 'claude --continue'. Resuming renders the transcript without an API call, so the over-budget token still works. opencode/codex/pi transcripts to follow once this proves out. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios-streamed-validate: resume claude by session id, seed both cwd encodings claude --continue returned 'No conversation found' even though the jsonl seeded correctly: --continue relies on the ~/.claude.json history index (not seeded), and the CI cwd may encode as -tmp- (logical) vs -private-tmp- (resolved symlink). Switch to 'claude --resume <session-id>' (opens the jsonl by id) and seed the transcript into both project-dir encodings. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios-streamed-validate: real recorded sessions for all 4 agents (claude/codex/opencode/pi) claude --resume <id> proved the record->resume approach renders a real session in the streamed capture. Extend to all four: - codex: seed rollout jsonl into ~/.codex/sessions/<date>/, resume by id. - pi: seed cwd-keyed jsonl (both /tmp spellings), resume via --session <uuid>. - opencode: sessions live in a sqlite DB, so import the exported JSON, resume via --session <id>. All transcripts recorded locally with funded accounts (genuine answers with #Preview code blocks), scanned clean of secrets. Capture all four in one run. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios-streamed-validate: reliable back-navigation between agent captures Only claude captured (1/4): the nav-bar back chevron has no accessibility label, so navigate_back (which searched for '<'/'Back'/'chevron') failed and the device stayed on claude's terminal, so codex/opencode/pi workspace rows were never found. Tap the back chevron by its known nav-bar position with an iOS edge-swipe pop fallback, and verify we returned to the list (>=2 'Terminal' rows) before moving to the next agent. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios-streamed-validate: give codex a DeepSeek provider so resume renders (4/4) 3/4 agents rendered real resumed sessions (claude, opencode, pi). codex showed the 'Sign in with ChatGPT' onboarding because it has no CI login, which blocks codex resume. Write ~/.codex/config.toml with a DeepSeek (OpenAI-compatible) provider keyed on DEEPSEEK_API_KEY so codex is authenticated and resumes the seeded rollout. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * capture-streamed: screencapture the Mac per agent (theme diagnostic) To debug the white bands on the phone (likely the Mac streaming a light/default theme), grab the CI Mac's cmux window right after each workspace is created (foreground), into _diag/mac-<agent>.png, so we can view Mac vs phone side by side for the same agent. * streamed shots: force dark theme on CI Mac + codex press-enter nudge White bands on the phone: the fresh CI runner boots in Light Mode, so cmux resolves a LIGHT terminal theme and streams a white background; unpainted cells render white on the phone. Force cmux to dark (appearanceMode=dark for the cistream bundle + system AppleInterfaceStyle=Dark) before launch — temporary CI screenshot setup. codex: send one Enter after 'codex resume' to advance past the 'Press enter to continue' welcome to the resumed session. * streamed shots: codex uses fake OpenAI auth (real codex, no DeepSeek) Per feedback: fake auth values are enough to pass codex's login gate, and resume makes no API call, so seed a dummy ~/.codex/auth.json instead of a DeepSeek provider. Codex authenticates as itself and renders the real recorded gpt-5 session with no DeepSeek label. * streamed shots: drop DeepSeek, fake per-provider auth so each agent shows its real model All three (codex/opencode/pi) fell back to DeepSeek because DEEPSEEK_API_KEY was the only provider in the GUI env. Remove it entirely and seed a fake credential for each agent's OWN recorded provider so it restores its real model with no DeepSeek label: - codex: fake ~/.codex/auth.json (openai apikey) -> gpt-5 - pi: fake ~/.pi/agent/auth.json (openai-codex oauth) -> gpt-5.5 - opencode: fake ~/.local/share/opencode/auth.json (zai) -> glm-5.2 Resume makes no API call, so the fake keys are never validated. * capture-streamed: set terminal font once up front (fix OpenCode bottom gap) OpenCode still showed a bottom letterbox even on latest main (with #7150/#7175/ #7172): the capture set the font PER-AGENT right before each screenshot, and OpenCode doesn't repaint the newly-added bottom rows after that late resize (its pure-black bg exposes the terminal-default gray in the unpainted rows; claude/ codex/pi hide it because their bg matches the default). Set the font ONCE before opening any terminal so each surface opens at its final grid with no resize-while-shown, and give it a longer settle to fully paint. * capture-streamed: per-agent set_font + long settle (consistent size AND fills) Setting the font once up front left each agent at whatever size its surface opened with, so OpenCode rendered larger (scaled-up narrow grid) than the others. Restore the per-agent focused set_font (all four at the same size) but keep the long 6s settle so OpenCode fully repaints the resized grid and fills the height (a short settle was the original cause of its bottom gap, not the resize itself). * capture-streamed: log each agent's Mac PTY grid (diagnose OpenCode scaling) OpenCode still renders ~20% larger than the others at the same set_font, which points to its terminal grid being narrower (scaled up to fill width) rather than a font-value difference. Log the read-screen cols x rows per agent so we can see the actual grids and target the real cause. * capture-streamed: smaller font for OpenCode so it matches the others' size OpenCode's TUI negotiates a narrower grid that the phone scales up, so its glyphs look ~1.3x larger than claude/codex/pi at the same font. Add a per-agent font override and set OpenCode to 11 (vs 15) to compensate; grid diagnostic stays so we can dial it in. * streamed shots: opencode layout=stretch (full width), revert font hack Root cause of OpenCode looking larger/narrower: its TUI defaults to layout=auto, which caps content to a narrow readable column, so on the phone it's narrower than claude/codex/pi (scaled up or big margins). The per-agent font hack made it worse (clipped + margins). Revert the font override and seed ~/.config/opencode/opencode.json with layout=stretch so OpenCode uses the full terminal width and matches the others. Grid diagnostic stays to verify. * iOS streamed capture: claude theme dark-ansi so it renders on cmux Monokai bg The 'dark' theme paints claude's own #1e1e1e background, overriding cmux's Monokai (#272822) terminal default; codex/pi never paint a full-screen bg so they already show Monokai. dark-ansi uses the terminal's ANSI palette + background, so claude matches. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: add dispatch-only App Store upload workflow + --marketing-version CI plumbing for public com.cmux.app builds, mirroring the beta lane's signing/ ASC setup but with the prod 'cmux Distribution' profile and no schedule (App Store builds are cut deliberately, one per submission). Gated to main so only reviewed code ships. Needs the new IOS_PROD_PROVISIONING_PROFILE_BASE64 secret (set from ASC profile VF3CDPFLX9, app id 7WLXT3NR37.com.cmux.app, aps=production). upload-testflight.sh gains --marketing-version <X.Y[.Z]> to stamp an explicit version train (mutually exclusive with the beta --auto-version bump) so an appstore build lands in the exact ASC store-version train (1.0) and is attachable, instead of auto-bumping to 1.0.4. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * screenshots: drop pi from OpenCode header (pi has its own shot) 05-Opencode said 'OpenCode, pi, any agent' but pi is the very next screenshot (06-Pi). Reworded to 'OpenCode and any agent' (and each locale's equivalent) so the two shots don't overlap. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Fix App Store lane alias guard * Address screenshot preview policy findings * Localize screenshot notification fixture * Limit app-declared iOS locales * Guard screenshot workflow secrets * Address App Store screenshot PR feedback * Fix streamed simulator lookup * Handle localized notification permission in screenshots * Fix screenshot preview task modifier * Address remaining screenshot review feedback * Fix terminal preview keyboard height parse * Restrict streamed validation secrets to main * Preserve raw App Store screenshot captures * Parallelize App Store screenshot framing --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> | 2 个月前 | |
Enforce iPhone+simulator default for iOS verification with an offline install queue (#9232) * Enforce iPhone+simulator default for iOS verification with an offline install queue iOS verification reloads now target BOTH an isolated per-tag simulator (cmux-dev-<slug>, created on demand) and the configured iPhone (CMUX_IPHONE_DEVICE_ID or ~/.config/cmux/iphone-device-id; never hardcoded). When the phone is unreachable at build time, the signed build is parked in a persistent queue (scripts/iphone-install-queue.sh, under ~/Library/Application Support/cmux-dev/iphone-install-queue) and a LaunchAgent (scripts/install-iphone-queue-agent.sh) auto-installs and launches it within seconds of the phone reconnecting, via launchd IOKit matching on Apple USB attach, WatchPaths on the queue, and a periodic network backstop, then sends a cmux notification. Every phone build hard-requires the same-tag Mac dev build: ios/scripts/reload.sh builds the Mac tag first when missing and refuses phone-only otherwise. scripts/ios-sim-install.sh installs cloud-built simulator apps into the isolated simulator for the reload-cloud-ios path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Probe device reachability through the queue script in ios/scripts/reload.sh One probe implementation (iphone-install-queue.sh probe) now decides "unreachable" for both the local and cloud reload paths, including the CMUX_IPHONE_QUEUE_FORCE_UNREACHABLE test hook; select_device still owns name/ambiguity resolution for reachable devices and its failure is treated as unreachable as before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Address review findings: name-target queueing, enqueue race, fail-closed sim install A --device-name target no longer probes or queues against the DEFAULT device id (queueing for a different phone than the one named would install on the wrong device); name targets error with a hint to use --device-id when unreachable. drain_entry now re-reads enqueued_at before every terminal action so a re-enqueue during an in-flight drain leaves the newer build queued instead of silently deleting or failing it. ios-sim-install.sh fails closed on an unreadable CFBundleIdentifier. Also: quote $tab expansions (SC2295), correct help sed ranges, document the one-time LaunchAgent install in CLAUDE.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Nudge PR sync Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> | 2 个月前 | |
Separate iOS beta and App Store versions (#7854) | 2 个月前 | |
Fix iOS reconnect and build isolation (#8299) * test(ios): cover reconnect overlap cleanup * fix(ios): retire superseded reconnect sessions * test(ios): isolate saved dev Mac instances * fix(ios): enforce build compatibility boundaries * test(ios): cover startup status auth race * fix(ios): reuse connect token for identity check * test(auth): preserve selected team during refresh outage * fix(auth): keep selected team effective during startup * test(auth): keep cached sessions restoring until ready * fix(ios): wait for auth restore before reconnect * test(ios): cover compatibility review regressions * fix(ios): address compatibility review findings * test(ios): use deterministic compatibility timestamps --------- Co-authored-by: cmux reload-cloud <cmux-reload-cloud@users.noreply.github.com> | 2 个月前 |
cmux iOS
SwiftUI iOS/iPadOS shell for the CMUXMobileCore production path.
Current phase:
- Stack Auth sign-in gate with Apple, Google, email code, and a debug-only
42shortcut - QR/manual pairing surface
- CMUXMobileCore pairing payload and attach-ticket decoding
- injectable
CmxByteTransportFactoryruntime hook - isolated preview host data when no concrete transport is installed
- workspace list, workspace detail, terminal dropdown, and input bar
No Rust, Iroh, or Zig dependency is linked into this shell. Concrete route implementations should enter through CMUXMobileRuntime.
Build and reload the simulator:
ios/scripts/reload.sh --tag iossh
Run package tests:
swift test --package-path ios/cmuxPackage
Build compatibility and production-auth DEV builds
A DEV iOS build connects only to the Mac DEV build with the same tag. BETA, INTERNAL, and App Store iOS builds connect only to Stable or Nightly Mac builds. Account environment does not change that compatibility boundary.
Use --prod-auth only when a tagged DEV build needs to test production account,
registry, or API behavior:
ios/scripts/reload.sh --tag my-tag --device-only --prod-auth
What --prod-auth does:
- Bakes
CMUXAuthEnvironment=productioninto the app's Info.plist (via theCMUX_IOS_AUTH_ENVbuild setting), so the build signs in against the production Stack project and useshttps://cmux.comfor the device registry/API and the magic-link callback. - Makes the presence worker follow the auth channel: the app resolves the
production presence instance (see
PresenceClient.productionServiceURL) so compatible Macs appear in Computers. The worker URLs live only in Swift; the script bakes no copy, and an explicitCMUX_PRESENCE_BASE_URLstill wins. - Skips the dogfood auto sign-in/auto-pair (those credentials belong to the development Stack project). Sign in in-app with the same account as your matching tagged DEV Mac.
- On first launch after switching auth environments on the same install, the app clears the previous environment's session/caches (tokens and user ids are per-Stack-project), so you start signed out instead of restoring a stale identity.
The system Camera routes release QR links (cmux-ios://…) to an official iOS
app and DEV QR links (cmux-ios-dev://…) to a DEV iOS app. The authenticated
Mac status supplies the exact instance tag, which the app validates before it
saves or adopts the connection.
Without the flag, the same override is available by bundling a
LocalConfig.plist with an AuthEnvironment string of production (see
MobileAuthComposition); a LocalConfig.plist entry wins over the baked
Info.plist value.
TestFlight beta (cloud lane)
ios/scripts/cloud-testflight.sh is the turnkey lane for cutting a TestFlight
beta. It builds the heavy GhosttyKit + Swift Release compile on a leased fleet
Mac (same maclease pool as the device cloud reload, m1ultra excluded), so the
build stays off this Mac's CPU. The fleet produces an UNSIGNED Release archive
for the beta bundle id dev.cmux.app.beta (no signing material ever lands on
the shared Macs), downloads it locally, then hands it to
ios/scripts/upload-testflight.sh --archive-path, which does the local export,
re-sign with the Apple Distribution cert (re-adding aps-environment=production),
strict codesign verification, and TestFlight upload.
# Dry run: build + export + re-sign + verify aps-environment=production, NO upload
ios/scripts/cloud-testflight.sh --no-upload
# Full lane: build on the fleet and upload to TestFlight (internal "cmux beta" group)
ios/scripts/cloud-testflight.sh
# Also make the build eligible for external testers
ios/scripts/cloud-testflight.sh --external
A standalone cmux clone with no cmuxterm-hq checkout transparently falls back to
a LOCAL Release archive (--local forces it), then takes the same export path.
Internal testers (the cmux beta group) get every uploaded build instantly with
no review. An --external build is different: the FIRST external build of a new
MARKETING_VERSION must pass a one-time Apple Beta App Review (~24h) before any
external tester can install it. Subsequent external builds of the same version
ship without re-review. The scheduled main sync lane now uploads
external-eligible builds too, so founders track main once the current beta
version has cleared that review gate. That lane reuses
CMUX_IOS_BETA_MARKETING_VERSION from ios/Config/Shared.xcconfig; bump it
only when you want a fresh Beta App Review cycle. The upload path assigns the
processed build to the app's external beta group automatically, auto-selecting
the single external
group or using IOS_TESTFLIGHT_EXTERNAL_GROUP_ID / IOS_TESTFLIGHT_EXTERNAL_GROUP_NAME
repo variables when the app has multiple external groups. When Apple reports the
build as READY_FOR_BETA_SUBMISSION, the same lane also creates the beta app
review submission automatically so a new MARKETING_VERSION is not left stuck
at "Ready to Submit".
If CI is moved back from a pending higher version to the last approved version, external testers are unblocked because they could not install the pending build. Internal testers who already installed that higher internal-only build will not see lower-version builds as updates in TestFlight. They need a one-time app reinstall, or operators need to cut an internal-only build on the higher version.
TestFlight GitHub Actions signing
.github/workflows/ios-testflight.yml uses manual export signing because Xcode's
automatic App Store Connect export has produced IPAs whose signed app entitlements
omit aps-environment=production. That upload is intentionally blocked because
TestFlight push would silently fail. The workflow tracks main on a schedule and
uploads beta builds as external-eligible. Internal testers get the build
immediately, and the post-upload external distribution step both assigns the
build to the founders group and keeps using the checked-in approved
CMUX_IOS_BETA_MARKETING_VERSION. When that version is intentionally bumped, the same
distribution step auto-submits the first build of the new version for Beta App
Review.
Required GitHub secrets:
ASC_API_KEY_IDASC_API_ISSUER_IDASC_API_KEY_P8_BASE64IOS_DISTRIBUTION_CERTIFICATE_BASE64(base64-encoded.p12for an Apple Distribution certificate on team7WLXT3NR37)IOS_DISTRIBUTION_CERTIFICATE_PASSWORDIOS_BETA_PROVISIONING_PROFILE_BASE64(base64-encoded App Store profile fordev.cmux.app.beta, withaps-environment=production)
App Store production lane
The production App Store lane is separate from the TestFlight beta lane. It uses the same archive/export/re-sign verification path, but switches the submitted identity to the App Store bundle id and stops before App Review submission unless the operator explicitly confirms submission in CI.
# Build, export, re-sign, verify, and upload the production App Store build
ios/scripts/upload-app-store.sh
# Dry run: export + re-sign + verify aps-environment=production, no upload
ios/scripts/upload-app-store.sh --export-only
# Run the read-only ASC readiness package after upload
ios/scripts/validate-app-store-release.sh --app "$ASC_APP_ID" --build-number "$CF_BUNDLE_VERSION" --wait-build --strict
Defaults:
- Bundle ID:
com.cmux.app - Marketing version:
CMUX_IOS_APPSTORE_MARKETING_VERSIONinios/Config/Shared.xcconfig - Display name:
cmux - Provisioning profile:
cmux App Store Distribution - Entitlements:
Config/cmux-release.entitlements
The review package lives in ios/AppStoreReview/:
review-notes.mdis the pasteable App Store Connect Review Information notes source.metadata-screenshots-checklist.mdis the blocking checklist for metadata, screenshots, privacy, account deletion, and payment gating.
.github/workflows/ios-app-store.yml is manual-only. It uploads a production
build, waits for ASC processing, runs ios/scripts/validate-app-store-release.sh,
and submits for review only when submit_for_review is set.
Additional production workflow requirements:
- Repository variable
IOS_APPSTORE_APP_ID - Secret
IOS_APPSTORE_PROVISIONING_PROFILE_BASE64(base64-encoded App Store profile forcom.cmux.app, withaps-environment=production)