| 文件 | 最后提交记录 | 最后更新时间 |
|---|---|---|
Device presence service: Cloudflare Durable Objects realtime layer over the device registry (#5792) * Add cmux-presence Cloudflare Worker: per-team Durable Object presence service Realtime device presence (online/offline) layered over the durable devices/device_app_instances registry. POST /v1/presence/heartbeat, GET /v1/presence/snapshot, GET /v1/presence/subscribe (WebSocket or SSE), Stack bearer auth mirroring web/services/vms/auth.ts, alarm-driven timeout-offline transitions (15s heartbeat / 45s timeout), 24h prune. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add presence worker local end-to-end proof script Drives wrangler dev with real dev-Stack credentials through the full lifecycle: 401 unauthenticated, heartbeat online, SSE + WebSocket subscribe, seen tick, goodbye offline, and alarm-driven timeout offline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add presence deploy-on-push workflow and service docs Path-filtered GitHub Actions: typecheck + unit tests + wrangler dry-run on PRs, wrangler deploy on push to main (DO migrations applied atomically with the deploy). docs/presence-service.md carries the DO-vs-RivetKit decision memo and the ephemeral-presence migration story. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add flagged Mac presence heartbeat sender and iOS typed presence client stub Mac: PresenceHeartbeatClient follows the DeviceRegistryClient pattern (same device UUID and tag, best-effort, auth-gated), default OFF behind the presenceHeartbeatEnabled + presenceServiceURL defaults keys, with a server-owned cadence and a clean-quit goodbye. iOS: PresenceWire typed models + WebSocket subscribe stub in CmuxMobileShell, the seam for the device tree (https://github.com/manaflow-ai/cmux/pull/5648). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Bound subscribe streams to token expiry and harden request reading Subscribe streams now carry a worker-computed deadline (verified token expiry, capped at 15 minutes) enforced by the DO at delivery and via the alarm, so a revoked token or removed team member cannot keep an old stream alive; clients reconnect with a fresh token and get a fresh snapshot. Adds a per-team subscriber cap (64) and drops stalled SSE readers instead of buffering unboundedly. readBoundedJson now reads the body incrementally and aborts the moment it crosses the 16 KiB cap, so a chunked or lying-Content-Length body can never over-buffer; covered by new unit tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Bind presence devices to their first authenticated owner Mirrors the device-registry ownership guard (a device row pins the registering userId and rejects other users' writes): the first authenticated team member to announce a deviceId owns it in DO storage, and a co-member's heartbeat for that device is rejected with 403 device_owner_mismatch, so presence cannot be forged online or force-cleared offline by another member who learned the device id from snapshots or the registry. Owner pins are pruned with the same 24h alarm pass that bounds the instance map. The local proof now exercises the guard with a real second Stack account in a shared team. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Make device-owner pins durable and harden the local proof script Owner pins are no longer pruned with the 24h presence tail (an idle device could be re-claimed by a co-member through the prune window), and new pins are bounded by MAX_OWNERS_PER_TEAM. The proof script keeps secrets off argv via curl config files and skips the owner-guard step with an explanation when both accounts resolve to the same Stack user instead of mistaking a legitimate same-owner 200 for a guard failure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Split presence Swift types one-per-file with DocC coverage Flatten the PresenceWire namespace into top-level PresenceInstance / PresenceDevice / PresenceSnapshot / PresenceOfflineReason / PresenceUpdate / PresenceClientError / PresenceTokenSource files, each holding one documented major type, and decode the tagged wire frame via PresenceUpdate's custom Decodable (CodingKeys) instead of function-local payload structs. The Mac client moves PresenceSettings to its own pbxproj-wired file and reads the server interval with JSONSerialization to keep PresenceHeartbeatClient single-type. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Republish attach routes on network path changes The mobile-host listener stays bound when the Mac moves networks or Tailscale flips, and .mobileHostStatusDidChange only fired on listener and connection transitions, so the advertised route set (and the team device registry DeviceRegistryClient mirrors from statusUpdates()) kept the old network's routes until the next listener restart. An NWPathMonitor now runs for the listener's lifetime: a changed path signature (status + interfaces + gateways, order-insensitive) invalidates the resolved-Tailscale-host cache and republishes routes through the same two-phase publish the listener-ready handler uses. A generation guard in MobileRouteResolver discards a resolution that raced the invalidation, so old-path hosts can never land late in the cache. Route-level dedup downstream means path flaps that do not change the route set produce no registry write. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Document the live cmux-presence-dev staging instance cmux-presence-dev is deployed on the team Cloudflare account with dev Stack Worker secrets provisioned; record its URL, the manual redeploy command, and how to point a dev Mac build at it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Mirror the registry's real per-team caps in the presence DO The DO capped instances and owner pins at a flat 5000 per team, so one authenticated member could mint thousands of fake deviceIds or tags, bloat every snapshot, and starve legitimate devices out of the budget. checkPresenceCaps (pure, unit-tested) now mirrors the registry route's actual limits: 200 devices per team (owner pins) and 25 instances per device, which structurally bounds the instance map at 5000 without an aggregate check since every stored instance's device holds a pin. Counts are fetched lazily with bounded list() calls only on new-device or new-instance heartbeats. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Reject expired subscribe deadlines and republish on first path observation Two review findings. The DO treated a forwarded x-presence-expires-at that was already past as missing and minted a fresh 15-minute window, so a token that expired between worker verification and DO handling could keep a stream open; resolveSubscribeDeadline (pure, unit-tested) now rejects missing/garbled/past deadlines with 401 and defensively re-caps the rest. The Mac path monitor treated its initial callback as a silent baseline, which swallowed a path change that landed between the listener-ready route publish and the monitor's first observation; the first observation now republishes too (deduped downstream), and only duplicate consecutive observations are skipped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Bound the iOS presence stream buffer and scrub proof-script tokens The subscribe AsyncThrowingStream used the default unbounded buffering while the receive loop yields every frame (including the team's 15s seen ticks), so a stalled consumer would grow memory without limit; bufferingNewest(256) bounds it, and a dropped frame at worst leaves the map stale until the snapshot the deadline-bounded resubscribe protocol already guarantees. The local proof script kept $WORK for transcript logs but its curl configs carry live Stack bearer tokens; the cleanup trap now scrubs every token-bearing file on all exit paths. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * End the presence stream on buffer overflow instead of dropping silently Presence is a stateful snapshot+delta protocol, so a silently dropped transition frame could render wrong live state until the next reconnect (up to the 15-minute deadline). The receive loop now checks the yield result: a .dropped frame finishes the stream with the new PresenceClientError.updatesDropped, so the consumer's reconnect delivers a fresh snapshot first, and .terminated stops the loop. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Deterministic handshake in the stale-resolution race test async let does not guarantee the child task entered the resolver and captured the old cache generation before the invalidation runs, so the test could nondeterministically exercise the wrong interleaving. A started semaphore now proves the resolution is in flight before the invalidation, and the gate holds it there until after. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Extract network path observation into MobileHostNetworkPathMonitor MobileHostService owned both the republish action and the raw NWPathMonitor observation (signature computation, duplicate suppression, baseline state). The observation concerns now live in a small dedicated type with the same tested pure functions, so the service keeps a single responsibility: deciding what to do when the path changes. Behavior is unchanged; the existing path-refresh tests now target the monitor type directly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Push route changes through presence: heartbeat routes + routes event Heartbeats now carry the instance's attach routes (tri-state: absent = unchanged, [] = no routes), the DO stores them on the presence record as a live cache of the registry row, and a changed set on an online instance broadcasts a 'routes' event so subscribed phones reconnect on the fresh port/IP without polling the registry. Entry filtering and the 16-route bound mirror the registry route; a non-array routes value is rejected rather than coerced so a client bug can never silently wipe pushed routes. * Mac presence heartbeats carry attach routes and beat immediately on change The heartbeat is the realtime twin of the registry write-through: every beat states the full current route set from MobileHostService (empty means pairing off), and a route-set change observed via statusUpdates() fires one immediate out-of-cadence beat so the presence DO can push the fresh port/IP to subscribed phones within a round trip. Debug builds now default the gate on against the dev/staging worker (dev Stack identity matches what cmux-presence-dev verifies), keeping Release default off; both stay explicitly overridable via defaults/env. * Phone subscribes to live presence: device tree online/offline + pushed-route reconnect The phone-side half of the presence service. MobileShellComposite owns one presence subscription (PresenceSubscribing seam, PresenceClient transport) that follows the session: starts on sign-in, tears down with a blanked map on sign-out, restarts from foreground refresh. Stream frames reduce into a pure PresenceMap (snapshot replaces, events upsert) that the device tree overlays on registry rows as live Online/Offline instead of last-seen guesses (en+ja). Route pushes (routes/online events and reconcile snapshots) write through to the local paired-Mac store via the same selectReconnectRoutes merge the registry refresh uses, and kick a reconnect when the active Mac is online but the phone sits disconnected, so a port change reattaches without re-pairing. PresenceInstance decodes routes with per-entry leniency (unknown kinds drop, frames never fail), matching the registry contract. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Presence doc reflects shipped clients; deploy job names missing CF secrets explicitly Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Review fixes: offline alarm defers to prune deadline; snapshot route sync is one batch Greptile P1: ensureAlarmFor scheduled offline instances at the 45s offline timeout, so every goodbye burned one no-op DO alarm before the real 24h prune alarm. Delegate to core's nextAlarmTime so the deadline rule lives in one place. Greptile P2: the presence snapshot fanned out one Task per online instance, so a multi-tag Mac could queue duplicate recoverMobileConnection kicks (a late one lands as a spurious resync after reconnect succeeds) with nondeterministic route-upsert order. Process the snapshot's instances sequentially in one task and kick at most one reconnect per delivery. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Refresh swift file length budget for presence client growth MobileShellComposite +176 (presence subscription lifecycle), MobileHostService +49 (network path monitor wiring), AppDelegate +4 (heartbeat client). Known debt accepted; MobileHostNetworkPathMonitor was already extracted to its own file to bound the growth. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Autoreview fixes: heartbeat test asserts real wire shape; explicit empty route push clears the tree The heartbeat body test read host/port at the route's top level, but mobileHostJSONObject nests them under endpoint, so the assertions could never pass once the suite ran. Assert the nested shape (the same wire contract the registry POST and iOS parser use). applyPushedRoutes treated routes nil and [] identically and returned before touching registryDevices, so an explicit empty push (host advertises no routes) left stale Connect affordances in the device tree. nil now means "not announced" (no-op); an announced set, including [], mirrors to the tree, while the paired-Mac store still keeps last-known-good reconnect routes and only updates on non-empty pushes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Presence route sync discards stale frames after sign-out or account switch The unstructured sync task can suspend in loadPairedMacs/upsert and resume after a different user signed in. Re-check isSignedIn plus the captured requesting user after every suspension, mirroring refreshRegistryDevices' account-switch guard, so a stale frame can never write routes into or kick reconnects for the next session. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Path observation always invalidates the Tailscale host cache; PresenceMap rollups are per-device A pre-ready initial path observation advanced the monitor's dedup baseline but returned before invalidating the resolver cache, so the .ready publish could reuse TTL-fresh hosts from the previous network with no further path callback coming (toggle pairing off, move networks, toggle on). Invalidate on every observation, before the no-port early return. PresenceMap stored instances flat by deviceId:tag, so deviceSummary scanned the whole team map; the device tree recomputes every visible row's summary per heartbeat mutation, making row projection O(devices x all instances). Group storage by device so a rollup only touches that device's instances (25 max). Adds direct PresenceMap reduction tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Presence route pushes respect the registry's multi-instance ambiguity guard The paired-Mac store is device-level (no tag); the registry refresh only substitutes reconnect routes when exactly one instance advertises any, but the presence push path wrote every instance's routes through, so a tagged debug build's push could repoint the phone's persisted reconnect routes at the wrong build. Gate the store write on PresenceMap's new soleRouteAdvertisingInstance(deviceId:) (exactly one online route-bearing instance, and it is the pusher). The per-tag device-tree mirror stays unconditional. Covered in PresenceMapTests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Bound cumulative serialized route bytes per heartbeat Route entries were individually unbounded (only the 16KiB request cap applied), so one authenticated member could fill the admitted 200x25 instance caps with near-16KiB route payloads (~78MiB) and blow the Workers isolate memory budget whenever snapshot/alarm materialize the team map, DoSing presence for the team. Cap cumulative serialized routes at 2KiB per instance (worst-case team state ~10MiB), dropping entries past the budget so the host's preferred-first prefix survives. Real route sets are ~100-200 bytes per entry and fit untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Scope presence service-resolution statics onto PresenceClient (conventions lint) The caseless namespace enum tripped the package-conventions namespace-enum rule; the members now live directly on the owning type. Covariant Self in the default argument replaced with the concrete type. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Serve production presence at presence.cmux.dev custom_domain route in wrangler.toml (cmux.dev zone is on the same Cloudflare account, so the deploy provisions DNS + TLS). Release clients keep a nil default service URL; flipping them to this domain is a follow-up gated on the first production deploy and dogfood. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Serialize presence route deliveries on the paired-Mac write chain Greptile P1: the per-delivery fire-and-forget task raced on reconnect (snapshot immediately followed by online/routes for the same device), producing concurrent pairedMacStore upserts for one Mac and a possible double reconnect kick. Deliveries now run through performSerializedPairedMacWrite, which appends synchronously on the main actor, so they execute strictly in arrival order; userIsCurrent doubles as the chain's ifStillCurrent entry check. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Negative-cache rejected presence auth tokens (security audit MED) An opaque (non-JWT) bearer token skips the client-side expiry short-circuit, so every request carrying a bad token forced an outbound Stack /users/me subrequest — an unauthenticated amplification vector against Stack's rate limits and CF subrequest budget. Rejected tokens are now cached for 10s (bounded by the token's own exp), keyed by token hash like the positive cache. Test asserts 3 rejected requests cost 1 Stack call. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Fix test fetch cast for typecheck * Refresh swift file length budget after rebase onto main Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Path signature includes local IPv4 addresses so same-gateway network moves republish routes Codex review (P2) on the presence PR: two networks can present the same interface name and gateway (two LANs both en0 + 192.168.1.1) while assigning a different local address; the old signature deduped that move and never invalidated/republished routes. The signature now includes the machine's local IPv4 addresses (getifaddrs, up non-loopback interfaces), injectable for tests. IPv6 is excluded deliberately: temporary-address rotation would cause spurious republish churn. Also corrects the reconnect-kick comment in MobileShellComposite: under the multi-instance ambiguity guard, pushed routes are deliberately not persisted and the reconnect uses stored last-known-good routes (cursor bot flagged the old comment's claim that routes were always persisted). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(presence): how to upgrade running Durable Objects safely Class migrations vs data-schema: class migrations manage the DO class registry (append-only, atomic with deploy); they do not migrate the shape of stored data. Running objects keep old code until evicted, then hydrate new code against persisted storage, so upgrades = make new code read old data (additive fields, schemaVersion + lazy upgrade, rollout-window tolerance). For presence only the never-pruned owner pins need that care; the live map self-heals via 15s re-announce. * Refresh swift file length budget for post-rebase file sizes --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> | 3 个月前 | |
Run cmux iOS over authenticated Iroh transport (#7908) * feat(iroh): bridge the production Swift endpoint * feat(iroh): run the mac host transport * test(iroh): reject local binding substitution * test(ios): cover pooled fallback route * fix(iroh): bind discovery to the local app instance * fix(ios): retain successful pooled route * test(iroh): require online-first host policy fallback * feat(iroh): start hosts from verified offline policy * feat(iroh): persist mac offline host policy * fix(iroh): reject partial online binding rotation * test(iroh): reject unvalidated private fallback * feat(iroh): own iOS endpoint and multistream sessions * fix(iroh): revalidate private fallback paths * fix(iroh): accept JSON media type parameters * ci(iroh): test full app on Intel Sonoma * ci(iroh): run transport tests on Intel Sonoma * test(iroh): cover abandoned relay reservations * fix(iroh): expire abandoned relay reservations * feat(iroh): expose admitted host multistream sessions * test(iroh): require bounded incoming streams * fix(iroh): bound peer-created QUIC streams * feat(iroh): defer iOS transport activation * feat(ios): prefer verified Iroh routes * test(iroh): preserve endpoint on preferred port collision * test(iroh): cover LAN rendezvous consistency * fix(iroh): fall back from occupied preferred port * feat(iroh): derive private rotating LAN aliases * fix(iroh): serialize LAN discovery with revocation * feat(iroh): make secure pairing the default * docs(iroh): record offline and LAN trust boundaries * feat(iroh): cache verified client policy offline * docs(iroh): add Apple and proxy launch caveats * docs(iroh): clarify Apple local network prompting * test(iroh): cover offline cache teardown races * fix(iroh): fence offline cache teardown * test(iroh): cover online admission leases * feat(iroh): gate online admission leases * feat(iroh): add authenticated Bonjour LAN fallback * feat(iroh): enforce online revocation leases * test(iroh): cover offline admission leases * test(iroh): cover canonical trust errors * fix(iroh): harden trust broker boundaries * fix(iroh): bound offline admission leases * test(iroh): cover policy refresh revision races * fix(iroh): fence admission policy refreshes * docs(iroh): narrow private network release scope * test(tailscale): reject unbound bearer routes * fix(mobile): state private network boundaries * test(tailscale): reject unbound bearer routes Cover numeric-only Tailscale bearer routes and reject authorization, DNS, and route substitution before transport writes. * docs(iroh): specify NAT authorization barrier * fix(tailscale): bind bearer writes to live tunnel * test(iroh): require acknowledged NAT admission barrier * test(tailscale): reject route-only transport bypass * fix(tailscale): close route-only transport bypass * fix(iroh): acknowledge NAT admission before app streams * test(iroh): hide database failure details * fix(iroh): defer reservation constraint validation * test(iroh): retain revocation monitor after handoff * fix(iroh): retain revocation monitor for connection * test(iroh): reject broker credential redirects * fix(iroh): block broker credential redirects * test(iroh): fail closed on terminal foreground policy * fix(iroh): fail closed on terminal policy refresh * test(iroh): prevent raw fallback after admission failure * fix(iroh): pin authenticated pairings to Iroh * test(auth): reject device registry redirects * test(iroh): lock registration identity and relay bootstrap * fix(iroh): preserve registration trust identity * fix(auth): reject credentialed API redirects * test(iroh): keep direct paths out of cloud storage * fix(iroh): keep direct paths device local * test(iroh): evict remotely closed client sessions * test(iroh): recover dead session on foreground * test(iroh): prevent server path-hint disclosure * fix(iroh): recover suspended client sessions * fix(iroh): keep private paths off server surfaces * test(iroh): reject overlapping LAN bootstrap routes * fix(iroh): reject ambiguous LAN interfaces * test(iroh): bound pending admissions per identity * fix(iroh): limit pending admissions per peer * test(iroh): require owned server event stream * test(auth): bound credentialed HTTP responses * test(iroh): reject concurrent control owners * fix(auth): cap credentialed HTTP responses * test(iroh): cover firewall dependency failures * fix(iroh): bound firewall availability checks * feat(iroh): deliver server events on owned stream * test(iroh): cap stalled firewall work * test(iroh): bound active sessions per binding * fix(iroh): cap stalled firewall work * fix(iroh): cap active sessions per binding * test(iroh): require firewall timeout recovery * fix(iroh): abort stalled firewall checks * fix(ci): isolate Iroh transport test suites * test(iroh): route revocation to broker delete * fix(iroh): send revocation to broker route * test(mobile): bound concurrent RPC work * fix(mobile): cap concurrent RPC work * test(mobile): bound decoded frame batches * fix(mobile): cap decoded frame batches * test(iroh): bound pending Bonjour resolves * test(iroh): gate reserved application lanes * test(iroh): retain failed binding revocations * fix(iroh): bound pending Bonjour resolves * fix(iroh): gate reserved application lanes * docs(iroh): narrow production multistream claims * build(iroh): pin attested Swift fork release * test(iroh): require retry-safe binding revocation * fix(iroh): make binding revocation retry-safe * fix(iroh): durably retry binding revocations * build(iroh): lock iOS Swift fork release * test(iroh): retain Bonjour observation lifetime * test(auth): prepare before raced sign-out clear * test(iroh): quarantine failed sign-out persistence * test(ios): quarantine failed Iroh sign-out * fix(iroh): quarantine incomplete sign-out teardown * fix(iroh): clear host network state in quarantine * fix(auth): quarantine Iroh before sign-out clear * fix(ios): quarantine incomplete Iroh sign-out * fix(mobile): type Iroh binding snapshot * fix(ios): wait for auth clear before Iroh recovery * build(iroh): lock app Swift fork release * fix(iroh): persist secrets in ad-hoc debug builds * test(iroh): require local-only HTTP minter opt-in * feat(iroh): add loopback relay minter runner * test(tailscale): require numeric registry targets * feat(iroh): gate local relay minter HTTP * fix(iroh): normalize local minter opt-in * test(ios): require tagged API origin bake * fix(ios): bake tagged API origin * fix(tailscale): pin MagicDNS remotes to peer IPs * fix(tailscale): reject inactive peer snapshots * build(iroh): pin hardened FFI release * test(auth): preserve auto-login during token reads * test(auth): preserve manual sign-in during token reads * test(iroh): require startup network event delivery * fix(iroh): establish endpoint observation before activation * fix(auth): preserve active session writers * test(iroh): accept existing binding registration responses * fix(iroh): accept existing binding relay status * test(iroh): keep host active after refresh throttling * fix(iroh): preserve host during broker throttling * test(iroh): preserve client during broker throttling * fix(iroh): retain verified policy during broker outages * fix(iroh): decode broker dates on older macOS * test(iroh): reject synthetic network change floods * fix(iroh): observe address changes without feedback loop * test(iroh): accept canonical UUID identity case * fix(iroh): canonicalize pinned device UUIDs * fix(iroh): harden compatibility and private routes * refactor(iroh): split runtime ownership boundaries * test(iroh): repair authorization suite split boundaries * test(iroh): link mobile RPC authorization tests * test(iroh): support compatibility compilers * test(iroh): cover uppercase UUID fallback paths * fix(iroh): canonicalize device UUID authority * test(iroh): support Intel Sonoma compiler * test(iroh): avoid non-Sendable fixture captures * fix(updater): handle Intel-only Sparkle reason * test(iroh): cover bearer and discovery overload * fix(iroh): close route and discovery gaps * fix(ci): close Iroh compatibility regressions * feat(iroh): integrate endpoint-bound relay fleet * fix(ci): wrap command timers for Intel Swift * fix(ios): expose relay deployment to Sendable factory * test(ci): cover private networking on Intel Sonoma * test(ci): support Intel Swift Testing macros * test(iroh): expose relay refresh expiry gap * fix(iroh): retry relay refresh before expiry * fix(ios): serialize Iroh quarantine recovery * refactor(auth): isolate lifecycle revision API * fix(ci): eliminate Iroh Swift 6 warnings * fix(ci): support Intel Xcode 16.2 * fix(ci): mark canvas clock sleep sendable * fix(ci): bridge canvas preferences to main actor * fix(ci): support sidebar git on Xcode 16.2 * fix(ci): mark RPC termination handler sendable * fix(ci): support CLI on Xcode 16.2 * fix(ci): support app target on Xcode 16.2 * fix(ci): finish Xcode 16.2 source compatibility * iroh: point the broker relay fleet at the 7 self-hosted relay.cmux.dev URLs Replaces the 4 hosted iroh.link relays with our self-hosted fleet in both allowlists (web MANAGED_RELAY_URLS + presence worker APPROVED_IROH_RELAY_URLS, kept in lockstep) and the tests that referenced hosted URLs. The self-hosted relays run iroh-relay 1.0.2 behind per-region MIG+L4-LB (zero-downtime upgrades), gated by the cmux EdDSA JWT that /api/relay/token (merged, #7879) mints. * fix(ci): support trailing closure on Xcode 16.2 * ci: allow Intel compatibility suite to finish * test(ci): avoid Xcode 16.2 require recursion * ci: focus Intel compatibility coverage * fix(ci): stabilize replay ownership and Intel budget * test(ios): isolate authoritative resync coverage * feat(iroh): add secure flexible relay policy * test(iroh): split relay runtime coverage * test(iroh): allow self-hosted broker without legacy minter * fix(iroh): make hosted relay minter optional * test(iroh): cover public firewall host fallback * fix(iroh): use public host for firewall checks * fix(iroh): keep accepts and sign-out responsive * test(iroh): reproduce lost online reachability * fix(iroh): republish endpoint online routes * test(iroh): reproduce coalesced route refresh * fix(iroh): replay coalesced route refreshes * refactor(iroh): split oversized runtime files * test(iroh): cover lifecycle refresh races * fix(iroh): fence lifecycle refresh work * test(ios): reproduce loopback dev auto-pair race * test(ios): cover redacted dev Iroh attach URLs * fix(ios): wait for redacted Iroh dev attach ticket * test(ios): reproduce Iroh cold-start attach race * fix(ios): await Iroh before dev auto-pair * feat(iroh): add server-driven relay preferences * feat(iroh): complete relay controls and multistream runtime * ci: rehearse staging migrations from dispatched branch * Make managed Iroh credentials server-driven * feat(iroh): expose redacted live path diagnostics * security(iroh): stage relay policy key rotation * fix(iroh): use instance-scoped host display name * test(iroh): require dev attach targets to prefer identity routes * fix(iroh): prefer identity routes for dev attach * fix(web): include shared relay catalog in Next root * test(web): keep relay catalog inside Next boundary * fix(web): generate relay catalog inside runtime boundaries * test(mobile): cover transport lifetime ownership * fix(mobile): retain Iroh transport lifetime * test(iroh): cover relay policy clock skew * fix(iroh): tolerate bounded relay policy clock skew * test(iroh): cover admitted session lifetime * fix(iroh): separate admission and session lifetimes * test(iroh): cover relay and route renewal stalls * fix(iroh): keep relay routes renewed through storage stalls * test(iroh): cover nonblocking binding persistence * fix(iroh): publish bindings before secure persistence * test(iroh): cover strict transport verification modes * feat(iroh): add strict transport verification modes * test(iroh): await nonblocking relay persistence * test(iroh): cover live peer connection quotas * fix(iroh): bound live sessions per endpoint * test(iroh): cover broker-aware route renewal backoff * fix(iroh): back off broker route renewal retries * test(mobile): cover superseded Iroh transport cleanup * fix(iroh): close unowned mobile sessions * test(iroh): reproduce stale reconnect sessions * fix(iroh): replace stale peer sessions on admission * feat(iroh): add debug transport mode menu * Add regression coverage for Iroh merge blockers * Fix Iroh relay and reconnect merge blockers --------- Co-authored-by: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Co-authored-by: cmux reload-cloud <cmux-reload-cloud@users.noreply.github.com> Co-authored-by: Aziz Albahar <aziz@manaflow.ai> | 2 个月前 | |
Directed presence channel: server can wake the Mac (nudge push) (#9012) * Give the Mac a directed presence channel so the server can wake it The Mac publishes to presence and the broker but receives nothing, so a server-side change to its iroh binding (revocation, re-key replacement) only reached it on the next scheduled broker round trip, up to ~45 minutes later. The phone already holds a presence WebSocket; this adds the Mac-side equivalent as a quiet directed channel. Presence worker: `?deviceScope=<deviceId>` on the subscribe route turns the stream into a WebSocket-only nudge channel — no snapshot, no team presence chatter, no sync — gated by the same first-heartbeat owner pin as heartbeats (subscribing never writes the pin). A new owner-only `POST /v1/presence/nudge {deviceId, tag?, kind}` delivers a `{type: "nudge"}` frame to that device's scoped sockets. Nudges are never sent to normal subscribers, mirroring how sync frames are gated on `sync.hello`, so legacy presence decoders that throw on unknown event types never see one. Kinds are a server-side allowlist (`iroh-binding-changed`); the frame carries no route or binding data. Mac app: `PresenceNudgeSubscriber` mirrors `PresenceHeartbeatClient`'s gating and holds the directed stream with 1s→60s reconnect backoff. A nudge for this device (and build tag, when given) calls the new `CmxIrohHostRuntime.requestRegistrationRefresh()` — one immediate registration/policy round through the existing coalesced refresh path — plus `retryIfNeeded()` for absent runtimes. Against a pre-nudge worker the same endpoint serves snapshot/presence frames, which the subscriber ignores, so old servers degrade to a no-op. The broker-side hook that fires the nudge on revocation/replacement is deliberately a follow-up: those mutation paths are being rewritten by the in-flight binding re-key work (PR 8883), and the endpoint is independently drivable until then. Worker: bun test 183 pass (9 new), typecheck clean. Package: 37 CmxIrohHostRuntime tests pass (2 new for requestRegistrationRefresh). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Address autoreview: wss scheme, replaced-binding rebuild, socket lifecycle, delivery ownership Five review findings, all confirmed against the code: - The subscribe URL kept the https scheme; URLSessionWebSocketTask needs wss, so the channel never connected. Convert https/http to wss/ws, same as the iOS PresenceClient. - A nudge-triggered refresh that discovers the binding was replaced (different binding id) fails closed into the terminal .failed phase and nothing rebuilt it. requestRegistrationRefresh now awaits the refresh round settling, and the composition root reads the post-refresh snapshot and rebuilds through reconcile with restartActiveRuntime so a fresh activation re-registers under the new server state. New package test pins the fail-closed contract for a replaced binding id. - evaluate() only toggled on/off, so a team or service-URL change rode the old socket to the 15-minute deadline. The loop is now keyed by a team+URL scope and restarts when the scope changes. - URLSessionWebSocketTask.receive() ignores Swift task cancellation, so disabling presence left the socket suspended in receive until expiry. The receive loop runs under withTaskCancellationHandler that cancels the socket, and frames received after cancellation are dropped. - The DO delivered nudges by deviceScope alone; a subscriber who lost the first-heartbeat pin race could still receive owner-only frames. Delivery now also requires the socket's verified user to equal the device's current pinned owner. Worker: 183 bun tests pass, typecheck clean. Package: 38 CmxIrohHostRuntime tests pass (replaced-binding case new). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Address review round 2: refresh await, scope key, nudge coalescing, quiet-close backoff Four fixes from the second structured review pass: - requestRegistrationRefresh() now awaits across the coalesced replay round, not just the in-flight one, so a caller that rebuilds on `.failed` observes the state AFTER the replay the pending bit scheduled. - PresenceNudgeSubscriber's scope key includes the authenticated user id and requires isAuthenticated, so two solo accounts (nil resolvedTeamID) can never share a directed stream scope, and auth identity changes restart the loop via an @Observable tracking re-arm. - MobileHostIrohRuntime.refreshRegistrationFromServerSignal() is single-flight with a pending bit: a burst of nudge frames coalesces into one follow-up refresh instead of fanning out one main-actor waiter per frame. - subscribeOnce() treats a normal/going-away close as healthy service: a directed stream is silent between nudges, so the quiet 15-minute renewal close must reset backoff instead of doubling it toward 60s gaps that could swallow a one-shot nudge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Extract and test the owner-only nudge delivery decision Review round 3 flagged that the security-sensitive delivery filter (owner re-check per frame, directed-socket routing) had no behavior coverage. Following the suite's no-Workers-runtime pattern (checkDeviceOwner), the per-socket decision moves into a pure shouldDeliverNudge in core.ts, the DO delivery loop calls it, and tests cover: normal presence subscribers never receive nudges, wrong-device scopes and expired sockets are excluded, a subscriber who lost the first-heartbeat pin race is excluded at delivery despite an accepted subscription, legacy sockets without a verified user id never match, and a mixed subscriber set delivers to exactly the pinned owner's directed socket. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Detect legacy presence endpoints instead of decoding their traffic on main Review round 4 P1: against a pre-nudge worker, ?deviceScope= is ignored and the directed socket degrades to a full presence subscription — a team snapshot (megabytes at the service's caps) followed by seen events, each JSON-parsed on the main actor before being discarded. The receive loop now classifies each frame before parsing: anything over a 2 KiB bound (a real nudge is ~200 bytes) is foreign in O(1), so a snapshot is never parsed. The first foreign frame proves the endpoint is legacy — a nudge-aware worker sends only nudge frames on a directed stream — so the subscriber closes immediately and re-probes every 15 minutes instead of pumping team traffic. A legacy worker has no nudges to deliver, so the slow probe loses nothing; once the worker upgrades, the next probe holds a normal directed stream. Also documents the deliberately accepted first-writer pin residual on the nudge authorization path (do.ts, README): the presence worker keeps no synchronous registry dependency by design, and a squatted pin only suppresses the acceleration — the Mac falls back to its pre-nudge renewal cadence, never to a correctness failure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Split subscriber pools and require lifetime before trusting a clean close Review round 5: - Directed (device-scoped) sockets no longer draw from the shared 64-subscriber presence pool. Every enabled Mac instance holds one, so a fleet of Macs or tagged dev builds could deterministically 429 the phones' presence streams. Admission is now a pure, tested decision (checkSubscriberAdmission): directed sockets get their own bounded pool of 256 and each pool only rejects its own kind. - An EMPTY cleanly-closed stream counts as served only after living 60 seconds. The close code alone let an accept-then-close loop (persistent drain, misbehaving proxy) pin every Mac at one WebSocket handshake per second forever; the healthy quiet close arrives at the service's 15-minute deadline, far above the threshold, so normal renewals still resubscribe promptly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Bound the directed pool per user and the nudge frame at the transport Review round 6: - Directed subscribe admits unpinned devices by design (a Mac subscribes before its first heartbeat), which let one member park sockets on arbitrary fresh UUIDs until the 256-socket team pool 429'd legitimate owners. Admission now also enforces a per-user slice (32), so one member can never reach the team ceiling; the pure decision and its tests cover both pools and the slice. - The Mac's 2 KiB nudge bound moved from post-receive classification to URLSessionWebSocketTask.maximumMessageSize, so a legacy worker's team snapshot fails the receive (EMSGSIZE) before it is buffered instead of after megabytes land in memory. That failure classifies as .legacyEndpoint, converging with the parsed-foreign-frame path on the 15-minute reprobe. The in-classifier length check stays as a second layer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Document that a nudge accelerates the renewal round without changing it Comment-only. Review round 7 flagged that a superseded host answering a replacement nudge re-registers (mutating the newest-wins slot) before it detects the changed binding id. That ordering is the pre-existing renewal path; the nudge deliberately reuses it unchanged, and the displaced-instance disposition (stand down without re-taking the slot) belongs to the nudge-emission hook that fires from the authoritative broker mutation — deferred with it to the follow-up PR behind https://github.com/manaflow-ai/cmux/pull/8883. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> | 1 个月前 | |
design+phase1: local-first device list (Durable Object source + local SQLite cache + extensible sync protocol) (#6120) * Add DESIGN.md for local-first sync (device list first consumer) Generic local SQLite <-> presence DO sync substrate; device list is the first consumer. Protocol nails snapshot+delta with a per-(team,collection) rev logical clock, contiguous-prefix cursor advanced per atomic frame, rev-filtered snapshots with concurrent deltas queued, tombstone GC floor for forced resync, server-authoritative LWW, and an extensibility contract. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * presence: additive sync/v1 substrate + device-list projection (phase 1 worker) The cloud half of the local-first sync layer designed in plans/feat-do-device-list/DESIGN.md. Additive to the live TeamPresence DO: new storage keys only, presence path untouched, old instances ignore sync.hello and clients fall back to the registry under the flag. - sync.ts: pure sync/v1 wire layer (SyncRecord, hello/snapshot/delta/tick frames, rev as a per-(team,collection) logical clock, snapshot paging, resolveHello floor decision, tombstone GC predicate, schemaVersion stamp). - syncStorage.ts: storage-bound orchestration over a minimal SyncStorage interface (unit-testable with a Map fake): per-collection rev head, upsert-if-shape-changed (quiet cursor on steady heartbeat), tombstone + rev-ordered synctomb: index, gcTombstones raising syncgcfloor:, rev-filtered snapshots, delta catch-up, schemaVersion lazy upgrade. - syncDevices.ts: the devices collection (first consumer). Derives a DeviceRecord projection from presence instances + owner pins; reconciles the whole collection on each write (upsert living, tombstone departed). - do.ts: wires reconciliation onto BOTH write paths (heartbeat + alarm), GC in the alarm, sync.hello over the existing presence WS, sync delta broadcast on the same socket. Single SyncStorage narrowing cast. - tests: 119 pass. syncStorage.test.ts covers the three protocol holes DESIGN flags (frame-atomic cursor, snapshot-races-delete, gc-floor forced resync) plus schemaVersion lazy upgrade, tombstone GC, and derivation idempotency (seen tick / online-offline flip do NOT bump rev; routes/identity/membership do). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: CmuxSyncStore local-first sync package (phase 1 client) The phone half of the local-first sync layer (DESIGN.md §4/§6/§9/§10/§12). A new raw-SQLite3 package mirroring MobilePairedMacStore exactly (actor over a FULLMUTEX connection, CmuxSyncStoring protocol seam, CmuxSyncStoreError enum, PRAGMA user_version lazy migrations, BindValue binder), generalized to one generic sync_records + sync_cursors schema. - CmuxSyncStore: generic local store. Atomic-per-frame apply (applyDelta / applySnapshot commit records + cursor in one transaction = the contiguous-prefix watermark), local.rev>=r.rev stale guard, snapshot rev>=1 reconciliation that EXEMPTS provisional rev=0 rows, monotone cursor, team-scoped keys, the single wire-ms -> stored-seconds boundary. - SyncProtocol: sync/v1 frame codec mirroring the worker; presence frames on the shared socket parse as .unknown (cleanly ignored). - SyncFrameApplier: client apply state machine — snapshot paging buffer + concurrent-delta queue so a delete racing a snapshot is applied after the commit (no ghost), tick advances cursor when idle. - SyncClient: generic transport-agnostic driver (sends sync.hello with the persisted cursors, feeds frames to the applier). - DeviceSyncFacade: typed devices facade -> SyncedDeviceRecord and the existing RegistryDevice UI shape (no new UI model); skips undecodable rows. - PairedMacMigration: transparent, idempotent local->local-cache seeding of existing paired Macs as provisional rev=0 records for instant first render. - MobileDeviceListLocalFirst: the flag (DEBUG-on/Release-off, env + UserDefaults overridable), same seam as PresenceServiceConfiguration. - tests: 27 pass. Covers apply guard, snapshot reconciliation (incl. the rev=0 exemption), cursor monotonicity, paging + concurrent-delete race, local-first render with no network, migration idempotency, ms/seconds boundary, frame codec, flag, and an end-to-end SyncClient hello->apply. Compiles for macOS and iOS arm64 simulator. Shell UI wiring (loadRegistryDevices local-first branch via DeviceSyncFacade.registryDevices, behind the flag with registry fallback) is the final integration step, landing on architecture approval; the facade mapping is in place and tested. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * presence: fix sync integration regressions from review Three fixes to the worker sync wiring (autoreview P1/P1/P2): 1. Sync frames no longer reach legacy presence-only sockets. A socket is marked sync-subscribed (its negotiated collections persisted on the WS attachment) only after it sends sync.hello; broadcastSync skips any socket not subscribed to the frame's collection. Without this, a list-shape heartbeat would push a sync.delta to old iOS clients whose PresenceUpdate decoder throws on unknown message types, killing their subscribe stream. 2. The heartbeat path reconciles ONLY the heartbeating device (reconcileSingleDevice over its own inst:<deviceId>: instances + owner), not the whole team. Previously every ~15s beat scanned all instances, all owners, and all stored sync records — O(team) per beat / O(N^2) per interval on a hot DO path. Full-collection reconcile (with the pruned- device tombstone sweep) stays on the periodic alarm only. 3. The alarm now includes the next tombstone-GC deadline (nextTombstoneGcTime) in its next-fire calculation, so a fully-offline team still wakes to GC tombstones and advance syncgcfloor past the 7-day retention window. Previously, with no instances left, no alarm was scheduled and tombstones lingered forever. Tests: 124 pass (added reconcileSingleDevice bounded-work + tombstone + single-device-isolation cases, and nextTombstoneGcTime). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: fix sync client correctness from review Three CmuxSyncStore fixes (autoreview P1/P2/P2): 1. Malformed sync frames no longer silently advance the cursor. A sync.delta/sync.snapshot whose `records` field is missing or not an array now throws (SyncFrameCodec.requireRecords) instead of being treated as an empty frame. Previously a broken delta at rev=N could move the durable cursor to N without applying records, and a broken snapshot could reconcile against an empty set — durably losing records. The client now reconnects/resyncs instead. 2. The sync UI-invalidation callback fires only on an actual commit. SyncFrameApplier.apply now returns whether the store was written / cursor advanced; SyncClient gates onApplied on it. A presence frame (.unknown), an incomplete snapshot page, or a delta queued during paging returns false, so high-frequency presence `seen` traffic on the shared socket no longer drives spurious SQLite reloads and UI invalidations. 3. The migration marker is keyed by (account, team), matching the team scope of the rows it seeds. Previously a single account-only marker suppressed seeding for the same account in a different team, and survived clear(teamID). The marker key is now `migrated:<teamId>:<accountId>` and clear(teamID) deletes the team's markers, so a different team seeds and a re-sign-in after sign-out re-seeds the fallback. Tests: 31 pass (added malformed-frame-throws, apply commit-flag, cross-team re-seed, and clear-removes-marker cases). iOS arm64 + macOS compile clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * sync: resync on malformed frames, resilient route decode, atomic head writes Round 2 of review fixes (autoreview P1/P1/P1): 1. SyncClient resyncs on a malformed sync frame instead of skipping it. run() now rethrows a SyncFrameParseError.malformed (a frame that claims to be sync but is structurally broken) after resetting in-flight state, so the session reconnects and re-hellos to fill the gap. Only .notJSON / presence noise is skipped. Skipping a malformed delta could leave a rev permanently absent while a later tick advanced the cursor past it. 2. The device facade decodes routes failably per entry. A future route kind or one malformed route no longer drops the whole device row (which would hide a device the registry/presence paths still render). SyncedDeviceRecord .InstanceRecord has a custom decoder that keeps valid routes and skips bad ones, matching the existing per-route decode contract. 3. The DO sync writes are atomic. upsertRecord / tombstoneRecord / lazyUpgradeRecord now commit the record + head (+ tombstone GC index) via a single DurableObjectStorage.put(entries) multi-key write, so storage can never hold a record whose rev exceeds the head (which would make it invisible to catch-up deltas and rev-filtered snapshots). SyncStorage gained the batched-put overload; the Map fake mirrors it. Tests: 124 worker + 32 swift pass (added malformed-frame-resync, one-bad-route keeps device, atomic-write coverage). iOS arm64 + macOS compile clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * sync: crash-safe tombstone GC floor + raw migration key Round 3 of review fixes (autoreview P1/P2): 1. Tombstone GC raises the resync floor BEFORE deleting any tombstone (and only when it advances). If the alarm is interrupted between the floor write and the deletes, the tombstones linger and are re-GC'd next pass (idempotent) while the floor already forces a client whose cursor predates a GC'd deletion onto a full snapshot — so a missed delete can never be silently lost. Previously the floor was raised last, so a crash after the delete left a stale floor and a permanent ghost device. GC is now a decide-then-mutate two pass. 2. The migration marker stores the RAW team id in its sync_meta key and escapes the team id only when building the LIKE pattern in clear(teamID). Previously the key stored an escaped team id AND clear escaped again, so a team id with `_`/`%`/`\` (e.g. team_1) never matched its own stored key and clear left the marker behind, blocking re-seed on re-sign-in. Tests: 124 worker + 33 swift pass (added a clear-re-seeds test for a team id with LIKE metacharacters). iOS arm64 + macOS compile clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: snapshot reconciliation tombstones instead of hard-deleting Round 4 review fix (autoreview P1): snapshot missing-record reconciliation preserves the per-record rev watermark. When a completed snapshot omits a local authoritative record, the store now writes a TOMBSTONE at rev = snapshotRev for that id instead of hard-deleting the row. A hard delete dropped the local.rev watermark applyOneRecord relies on, so a delayed/duplicate delta with rev <= snapshotRev (e.g. a queued delta from a reconnect/snapshot overlap) would resurrect the record the snapshot just proved deleted, producing a ghost device. The tombstone is excluded from the live read and its rev makes the guard ignore any later rev <= snapshotRev delta, while a genuinely newer delta (rev > snapshotRev) can still legitimately bring the device back. Provisional rev = 0 rows remain exempt. Tests: 34 swift pass (added staleDeltaCannotResurrectSnapshotDeletedRecord). iOS arm64 + macOS compile clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * presence/ci: backfill on hello, skip sync on idle ticks, wire tests into CI Round 5 review fixes (autoreview P2/P2/P2): 1. Rollout backfill on sync.hello. An existing DO has inst:* presence but no synced:devices:* projection until a heartbeat/alarm rebuilds it after this deploys. handleSyncHello now backfills the projection from the live presence map (syncDeviceRecords) when the devices head is still 0, before resolving frames, so a client subscribing in that window sees currently- present devices instead of an empty snapshot. Additive + idempotent. 2. Steady-state heartbeats do zero sync storage work. The heartbeat path now calls syncOneDevice only when the beat could change list-shape (heartbeatMayChangeListShape: new instance, owner pin, routes/identity change). A pure `seen` tick on a known instance with unchanged identity — the common ~15s beat for every instance — skips the prefix-list + owner read + compare entirely, instead of doing it per instance per interval for no possible delta. 3. CmuxSyncStore tests run in CI. Added a `swift test --package-path Packages/CmuxSyncStore` step to test-ios.yml and added the package to the should_run change detector, so the new sync-store coverage is a real PR gate (the worktree CLAUDE.md warns that unwired tests pass with 0 executed). Tests: 124 worker pass; CmuxSyncStore swift test (34) green via the exact CI command. Worker bundles; typecheck clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: make sync codec + flag instantiable (package-conventions lint) Round 6 review fix (autoreview P1/P1): the new public all-static namespace types SyncFrameCodec and MobileDeviceListLocalFirst failed the repo-wide namespace-type rule that package-conventions-lint enforces for any Packages/ change (now triggered for this PR by the test-ios workflow edit). - SyncFrameCodec is now an instantiable `struct` with `init()` and instance parse/encodeHello methods (matching CmxAttachTicketCompactCoder). Callers hold one instance (SyncClient gained a stored codec). - MobileDeviceListLocalFirst is now a resolved value: `struct` with an `isEnabled` property and a `resolved(environment:defaults:isDebugBuild:)` factory, instead of a static `isEnabled(...)` namespace. `./scripts/lint-ios-package-conventions.sh` passes. Tests: 34 swift pass. iOS arm64 + macOS compile clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * presence: complete rollout backfill + resnapshot ahead-of-head cursors Round 7 review fixes (autoreview P1/P2): 1. The rollout backfill now uses an explicit one-time marker (syncbackfill:<collection>), not head !== 0. A single device's list-shape change makes the head nonzero while other devices that only seen-heartbeat were never projected, so head !=0 did not prove the projection was complete and a sync.hello could serve a partial device set. handleSyncHello now runs the full syncDeviceRecords backfill once, gated on the marker, so every pre-existing presence instance is projected before the first hello resolves. 2. resolveHello forces a snapshot when cursor > head. A client whose cursor exceeds the current DO head (storage reset, rollback, or a cache from a previous DO history) was treated as current — delta mode sent nothing (head <= cursor) and stale/deleted devices persisted forever. cursor > head now triggers a full snapshot + reconciliation so the client converges to current state. Tests: 127 worker pass (added cursor>head resnapshot and backfill-marker independence cases). Typecheck clean; worker bundles. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * sync: guard huge JSON revs, project goodbye-with-routes changes Round 8 review fixes (autoreview P1/P2): 1. SyncFrameCodec.intValue no longer traps on an out-of-range JSON number. A valid JSON sync frame with a huge `rev`/`snapshotRev` (e.g. 1e100) used to crash on Int(d); it now goes through intFromDouble which requires finite, integral, in-Int-range values and returns nil otherwise, so the frame surfaces .malformed and the client resyncs (the broken-sync-frame contract). 2. The heartbeat sync gate compares routes directly instead of relying on the `routes` event. A stopping goodbye emits only an `offline` event but can carry new routes (e.g. an empty set); the old gate skipped sync on it, leaving the synced record's attach routes stale until a later alarm. heartbeatMayChangeListShape now returns true when existing.routes != instance.routes (via core routesEqual), so a goodbye-with-routes projects. Tests: 127 worker + 35 swift pass (added huge-rev-is-malformed case). Typecheck clean; iOS arm64 + macOS compile clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * sync: cursor 0 always snapshots; fix 2^63 Int boundary Round 9 review fixes (autoreview P1/P2): 1. resolveHello forces a snapshot for cursor 0 (was returning a delta when the GC floor was also 0). A first-time client has nothing and needs the paged snapshot + reconciliation, not an unpaged catch-up delta. This also matches the documented protocol (DESIGN.md §3.5) and fixes a stale-row hole: a client whose cursor was reset to 0 while local records survived now gets a full snapshot that tombstones the stale authoritative rows. 2. intFromDouble compares against the exactly-representable 2^63 with a strict `<`, not `Double(Int.max)`. Int.max (2^63-1) rounds UP to 2^63 as a Double, so `9223372036854775808` passed the old `<= Double(Int.max)` guard and then trapped on Int(d). The new bound rejects it as malformed. Tests: 127 worker + 35 swift pass (updated the cursor-0 resolveHello / resolveHelloFrames expectations to snapshot; added the 2^63 boundary case). Typecheck clean; iOS + macOS compile clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: reset-aware snapshot recovers from a DO history reset Round 10 review fix (autoreview P1): the worker forces a snapshot when a client's cursor is ahead of the DO head (a storage reset/rollback), but the client's monotone apply path could not consume that lower-rev snapshot — present records were ignored (localRev >= snapshotRev), absent records fell outside the [1, snapshotRev] reconciliation, and setCursor's MAX kept the stale ahead cursor — so stale/deleted devices survived forever behind an unrecoverable cursor. applySnapshot now detects a reset (local cursor > snapshotRev) and treats the snapshot as the new ground truth: it force-applies snapshot records unconditionally, reconciles ALL authoritative rows (any rev, not capped at snapshotRev) absent from the snapshot into tombstones, and forces the cursor DOWN to snapshotRev. Provisional rev-0 rows stay exempt. The normal (non-reset) path is unchanged. Tests: 37 swift pass (added reset-recovers-from-ahead-cursor and reset-keeps-provisional). iOS arm64 + macOS compile clean; conventions lint passes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * sync: add collection epoch to detect equal-head DO resets Round 11 review fix (autoreview P1): a DO storage reset/rollback that backfills to the SAME head as a client's cached old history aliased the old rev space — resolveHello returned an empty delta (cursor == head), the client never reconciled, and stale devices survived forever. Adds a collection-history epoch (syncepoch:<collection>, minted once per DO- storage lifetime, re-minted on a reset). It rides every sync.snapshot frame and is sent back in sync.hello: - Worker: readOrMintEpoch/readEpoch; resolveHello forces a snapshot when the client epoch != the server epoch even at an equal head; snapshot frames and resolveHelloFrames carry the epoch; the hello parses an optional epoch. - iOS: SyncWireRecord snapshot frame carries epoch; sync_cursors gains an epoch column; applySnapshot treats an epoch change (or cursor > snapshotRev) as a reset (force-apply records, reconcile all authoritative rows, force cursor + epoch down); the store exposes epoch(); SyncClient sends cursor + epoch in the hello. Tests: 132 worker + 38 swift pass (added epoch mint/stability, snapshot-carries- epoch, resolveHello epoch-mismatch, and the iOS equal-head-epoch-change reset). Typecheck/lint/bundle clean; iOS arm64 + macOS compile clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * sync: mint epoch on first write; reset tombstones dominate old-history revs Round 12 review fixes (autoreview P1/P1): 1. The collection epoch is minted on the FIRST collection write (prior head 0), atomically with the head, not only lazily on a snapshot read. After a reset wipes storage to head 0, the rollout backfill / first heartbeat rebuild now mints a fresh epoch immediately, so a stale-epoch client at an equal head is still force-snapshotted (previously serverEpoch could read 0, disabling the guard). resolveHelloFrames also mints when head > 0 but epoch 0, covering pre-epoch records written before this shipped. 2. Reset reconciliation tombstones a stale row at max(snapshotRev, localRev), not snapshotRev. A reset drops to a low head while old-history rows carry high revs; tombstoning at the low snapshotRev let a queued old-history delta (rev > snapshotRev) pass the monotone guard and resurrect the row. The higher tombstone rev now dominates any old-history delta for that id. Tests: 134 worker + 39 swift pass (added first-write-mints-epoch, pre-epoch-record minting, and reset-tombstone-blocks-old-history-delta). Typecheck/lint/bundle clean; iOS arm64 + macOS compile clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: treat a nonzero epoch vs local epoch 0 as a reset Round 13 review fix (autoreview P1): reset detection now triggers on any nonzero incoming snapshot epoch that differs from the local epoch, INCLUDING when the local epoch is 0 (a pre-epoch cache or pre-migration state). The worker force-snapshots a clientEpoch-0 client against a real (epoch-aware) server, but the client previously treated that snapshot as non-reset, so a same-id/same-rev record with a changed payload was skipped by the monotone guard and stale routes/metadata survived the forced resync. The snapshot is now applied authoritatively in that case. A pure first sync (no local rows) is unaffected, and provisional rev-0 rows stay exempt. Tests: 40 swift pass (added nonzero-epoch-vs-local-epoch-0 same-rev-changed- payload reset). iOS arm64 + macOS compile clean; lint passes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * sync: reject live records with no payload; cover package in convention guard Round 14 review fixes (autoreview P1/P?): 1. A LIVE (non-deleted) wire record whose payload is missing or unserializable now throws .malformed instead of being stored as `{}`. The `{}` fallback produced a row the device facade cannot decode (hidden from the list) while the cursor advanced past it — a durably lost row with no resync. The client now resyncs. Tombstones legitimately keep `{}` and are unaffected. 2. Added Packages/CmuxSyncStore to the lint-ios-package-conventions.sh SCOPES so the architecture guard (singleton/Combine/locks/Dispatch/timers/KVO/ free-function/untyped rules) covers the new package, not just the repo-wide namespace-type check. Lint passes (only the expected [String: Any] WARNs for JSON wire parsing, matching the existing MobileCoreRPCSession pattern). Tests: 41 swift pass (added live-record-without-payload-is-malformed; tombstone without payload still parses). iOS arm64 + macOS compile clean; lint passes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * presence: bound inbound sync.hello size before parsing Round 15 review fix (autoreview P1): the DO's webSocketMessage parsed client-controlled JSON (the sync.hello) on the live presence DO without an input size bound, a resource-exhaustion vector. The message byte length is now checked against MAX_SYNC_HELLO_BYTES (4 KiB) before JSON.parse; an over-large frame is dropped silently like any other non-hello message. A real hello (a few short collection names + integer cursors/epochs) is well under the cap, and parseHello already bounds the collection-list count, so the two caps together bound the work the DO does per inbound frame. Typecheck/test/bundle clean (134 worker tests pass). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * presence: one sync.hello per collection per connection Round 16 review fix (autoreview P2): a repeated sync.hello for an already-subscribed collection is now ignored. Previously every well-formed hello ran the full resolution path (backfill check, storage scan, snapshot serialization + send), so an authenticated member could spam tiny <4 KiB hellos and force repeated full device snapshots on the live DO. The socket already records its subscribed collections on the attachment; handleSyncHello now skips a collection already present there. A client resubscribes/resyncs by reconnecting, which the snapshot-first-on-connect protocol already supports. Typecheck/test/bundle clean (134 worker tests pass). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * sync: isolate sync from presence path; reject bool/negative revs Round 17 review fixes (autoreview P1/P2): 1. Sync projection is best-effort and cannot fail the presence path. The heartbeat wraps syncOneDevice in try/catch (presence already succeeded, so a DO storage hiccup or bad stored payload must not turn the live heartbeat RPC into a 5xx for existing hosts). The alarm wraps the sync projection + GC so a sync failure never aborts the alarm before it closes expired subscribers and reschedules — the presence-critical alarm duties. Matches the DESIGN §5 "presence path untouched / additive" guarantee. 2. The Swift codec rejects boolean and negative integer fields. rev/snapshotRev/ cursor/epoch are non-negative; a JSON boolean (bridged to a CFBoolean NSNumber) no longer parses as 1, and a negative value is rejected. These drive SQLite revs and cursor advancement, so an invalid value now forces .malformed/resync instead of persisting an impossible cursor. Tests: 134 worker + 42 swift pass (added boolean/negative-rev malformed cases). Typecheck/lint/bundle clean; iOS arm64 + macOS compile clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * sync: bound client snapshot-page + queued-delta buffers (DoS hardening) A compromised or misbehaving DO could stream an endless run of `complete: false` snapshot pages, or flood deltas while stalling a never-completing snapshot, growing SyncFrameApplier's in-flight buffers without limit. Cap both (defaults far above the server-bounded record cardinality) and on overflow drop the in-flight build + surface a malformed frame so the transport tears down and re-hellos, the same recovery path as any other structurally broken frame. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * sync: split CmuxSyncStore + tests under the 500-line file guard Move the low-level SQLite statement helpers (BindValue, exec/bind/ transaction, user_version + cursor/tombstone writers) into CmuxSyncStore+SQLite.swift, and split the test suites into SyncFrameAndProtocolTests.swift, so both the store (490) and each test file (<500) stay under the swift-file-length-budget threshold without a budget bump. Pure code-motion; behavior unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * sync: close 3 autoreview P2 trust-boundary gaps 1. Reject records whose rev exceeds the frame head (SyncProtocol). A forged delta carrying rev=5 with a record at rev=1000000 would persist the poison-high rev and the per-record monotone guard would then ignore every legitimate future update for that id until the server head caught up (durable local-cache poisoning). requireRecords now throws .malformed when any record.rev > head, forcing a clean resync. 2. Dedup repeated collection names within one sync.hello (parseHello). The DO's per-connection guard only dedups across separate hellos; a single hello repeating 'devices' N times amplified into N backfills + N snapshot serializations. parseHello now keeps the first occurrence per name; handleSyncHello also marks each name seen immediately (defense in depth). 3. Bound queued deltas by total RETAINED RECORDS, not frame count (SyncFrameApplier). The prior frame-count bound let one oversized multi-record delta blow past the ceiling. Now sums records across the queue and rejects before append. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * sync: tighten queued-delta default bound to 10k records Queued deltas during snapshot paging are transient overhead the completing snapshot subsumes, so cut a stalled-snapshot producer off an order of magnitude sooner here than on the snapshot pages. The legitimate count is tiny (devices collection is presence-capped well under 10k). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * sync: keep CmuxSyncStore one actor-private file; budget it instead of splitting Autoreview flagged that splitting the store into a +SQLite extension forced the nonisolated(unsafe) sqlite3 handle from file-private to module-internal, weakening the actor-isolation invariant (any future module file could touch the raw handle off-actor). Revert the store split to keep `db` private to the actor, and accept the 619-line file as documented known debt in swift-file-length-budget.tsv (the guard's explicit escape). The test-file split is kept (pure test code, no concurrency invariant). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * sync: add frame-count bound to queued deltas (close empty-delta flood bypass) Autoreview P1: switching the queued-delta guard to a records-only bound opened a bypass — a producer can hold a snapshot open and flood empty (records: []) deltas, each growing queuedDeltas by one entry while adding 0 to the record count, so the bound never trips (unbounded memory). Add an independent frame-count bound (default 10k) enforced alongside the record bound; either overflow drops the build and forces a resync. Test covers the empty-delta flood. Rebalanced the two test files to stay under the 500-line guard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * sync: allowlist collections in the applier (bound unrequested-collection growth) Autoreview P2: the per-collection buffer/cursor bounds did not bound the NUMBER of collections, so a misbehaving endpoint could stream incomplete snapshots/deltas/ticks for many distinct collection names (each just under the per-collection ceiling) and grow `builds` + create local cursor state for collections the client never requested. SyncFrameApplier now takes an allowedCollections set and rejects any frame outside it as .malformed (routing through SyncClient's reset+rethrow). SyncClient documents that the composition root must build the applier with allowedCollections matching its subscribed list. Test covers rejection + no leaked cursor state. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * sync: enforce collection allowlist in SyncClient by construction Autoreview P2 follow-up: the applier's allowlist defaulted to accept-all, making the unrequested-collection bound opt-in (a production caller could forget to pass it). SyncClient now derives the allowlist from its subscribed `collections` and rejects any inbound frame for a collection outside that set in run() directly, independent of the injected applier's config — the safety invariant is enforced by the client API, not left to each caller. Test uses a default (accept-all) applier and proves the client still rejects. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * sync: extract SyncDatabase so the SQLite handle is file-private AND files split Resolves the tension between the two prior review preferences: a +SQLite extension widened the raw handle to module-internal (actor-isolation weakening), while keeping one 619-line file tripped the file-length guard. Extract a SyncDatabase type that owns the raw sqlite3 OpaquePointer as a PRIVATE member and exposes only the binder/exec/transaction/prepare helpers; CmuxSyncStore holds one as a private let. The handle is now never module-visible (isolation invariant holds by construction), and the package splits into focused files (store 492, SyncDatabase 120, row-decode 32) so no budget exception is needed. Pure code-motion + indirection; behavior unchanged, all 49 package tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> | 3 个月前 | |
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> | 1 个月前 | |
Run cmux iOS over authenticated Iroh transport (#7908) * feat(iroh): bridge the production Swift endpoint * feat(iroh): run the mac host transport * test(iroh): reject local binding substitution * test(ios): cover pooled fallback route * fix(iroh): bind discovery to the local app instance * fix(ios): retain successful pooled route * test(iroh): require online-first host policy fallback * feat(iroh): start hosts from verified offline policy * feat(iroh): persist mac offline host policy * fix(iroh): reject partial online binding rotation * test(iroh): reject unvalidated private fallback * feat(iroh): own iOS endpoint and multistream sessions * fix(iroh): revalidate private fallback paths * fix(iroh): accept JSON media type parameters * ci(iroh): test full app on Intel Sonoma * ci(iroh): run transport tests on Intel Sonoma * test(iroh): cover abandoned relay reservations * fix(iroh): expire abandoned relay reservations * feat(iroh): expose admitted host multistream sessions * test(iroh): require bounded incoming streams * fix(iroh): bound peer-created QUIC streams * feat(iroh): defer iOS transport activation * feat(ios): prefer verified Iroh routes * test(iroh): preserve endpoint on preferred port collision * test(iroh): cover LAN rendezvous consistency * fix(iroh): fall back from occupied preferred port * feat(iroh): derive private rotating LAN aliases * fix(iroh): serialize LAN discovery with revocation * feat(iroh): make secure pairing the default * docs(iroh): record offline and LAN trust boundaries * feat(iroh): cache verified client policy offline * docs(iroh): add Apple and proxy launch caveats * docs(iroh): clarify Apple local network prompting * test(iroh): cover offline cache teardown races * fix(iroh): fence offline cache teardown * test(iroh): cover online admission leases * feat(iroh): gate online admission leases * feat(iroh): add authenticated Bonjour LAN fallback * feat(iroh): enforce online revocation leases * test(iroh): cover offline admission leases * test(iroh): cover canonical trust errors * fix(iroh): harden trust broker boundaries * fix(iroh): bound offline admission leases * test(iroh): cover policy refresh revision races * fix(iroh): fence admission policy refreshes * docs(iroh): narrow private network release scope * test(tailscale): reject unbound bearer routes * fix(mobile): state private network boundaries * test(tailscale): reject unbound bearer routes Cover numeric-only Tailscale bearer routes and reject authorization, DNS, and route substitution before transport writes. * docs(iroh): specify NAT authorization barrier * fix(tailscale): bind bearer writes to live tunnel * test(iroh): require acknowledged NAT admission barrier * test(tailscale): reject route-only transport bypass * fix(tailscale): close route-only transport bypass * fix(iroh): acknowledge NAT admission before app streams * test(iroh): hide database failure details * fix(iroh): defer reservation constraint validation * test(iroh): retain revocation monitor after handoff * fix(iroh): retain revocation monitor for connection * test(iroh): reject broker credential redirects * fix(iroh): block broker credential redirects * test(iroh): fail closed on terminal foreground policy * fix(iroh): fail closed on terminal policy refresh * test(iroh): prevent raw fallback after admission failure * fix(iroh): pin authenticated pairings to Iroh * test(auth): reject device registry redirects * test(iroh): lock registration identity and relay bootstrap * fix(iroh): preserve registration trust identity * fix(auth): reject credentialed API redirects * test(iroh): keep direct paths out of cloud storage * fix(iroh): keep direct paths device local * test(iroh): evict remotely closed client sessions * test(iroh): recover dead session on foreground * test(iroh): prevent server path-hint disclosure * fix(iroh): recover suspended client sessions * fix(iroh): keep private paths off server surfaces * test(iroh): reject overlapping LAN bootstrap routes * fix(iroh): reject ambiguous LAN interfaces * test(iroh): bound pending admissions per identity * fix(iroh): limit pending admissions per peer * test(iroh): require owned server event stream * test(auth): bound credentialed HTTP responses * test(iroh): reject concurrent control owners * fix(auth): cap credentialed HTTP responses * test(iroh): cover firewall dependency failures * fix(iroh): bound firewall availability checks * feat(iroh): deliver server events on owned stream * test(iroh): cap stalled firewall work * test(iroh): bound active sessions per binding * fix(iroh): cap stalled firewall work * fix(iroh): cap active sessions per binding * test(iroh): require firewall timeout recovery * fix(iroh): abort stalled firewall checks * fix(ci): isolate Iroh transport test suites * test(iroh): route revocation to broker delete * fix(iroh): send revocation to broker route * test(mobile): bound concurrent RPC work * fix(mobile): cap concurrent RPC work * test(mobile): bound decoded frame batches * fix(mobile): cap decoded frame batches * test(iroh): bound pending Bonjour resolves * test(iroh): gate reserved application lanes * test(iroh): retain failed binding revocations * fix(iroh): bound pending Bonjour resolves * fix(iroh): gate reserved application lanes * docs(iroh): narrow production multistream claims * build(iroh): pin attested Swift fork release * test(iroh): require retry-safe binding revocation * fix(iroh): make binding revocation retry-safe * fix(iroh): durably retry binding revocations * build(iroh): lock iOS Swift fork release * test(iroh): retain Bonjour observation lifetime * test(auth): prepare before raced sign-out clear * test(iroh): quarantine failed sign-out persistence * test(ios): quarantine failed Iroh sign-out * fix(iroh): quarantine incomplete sign-out teardown * fix(iroh): clear host network state in quarantine * fix(auth): quarantine Iroh before sign-out clear * fix(ios): quarantine incomplete Iroh sign-out * fix(mobile): type Iroh binding snapshot * fix(ios): wait for auth clear before Iroh recovery * build(iroh): lock app Swift fork release * fix(iroh): persist secrets in ad-hoc debug builds * test(iroh): require local-only HTTP minter opt-in * feat(iroh): add loopback relay minter runner * test(tailscale): require numeric registry targets * feat(iroh): gate local relay minter HTTP * fix(iroh): normalize local minter opt-in * test(ios): require tagged API origin bake * fix(ios): bake tagged API origin * fix(tailscale): pin MagicDNS remotes to peer IPs * fix(tailscale): reject inactive peer snapshots * build(iroh): pin hardened FFI release * test(auth): preserve auto-login during token reads * test(auth): preserve manual sign-in during token reads * test(iroh): require startup network event delivery * fix(iroh): establish endpoint observation before activation * fix(auth): preserve active session writers * test(iroh): accept existing binding registration responses * fix(iroh): accept existing binding relay status * test(iroh): keep host active after refresh throttling * fix(iroh): preserve host during broker throttling * test(iroh): preserve client during broker throttling * fix(iroh): retain verified policy during broker outages * fix(iroh): decode broker dates on older macOS * test(iroh): reject synthetic network change floods * fix(iroh): observe address changes without feedback loop * test(iroh): accept canonical UUID identity case * fix(iroh): canonicalize pinned device UUIDs * fix(iroh): harden compatibility and private routes * refactor(iroh): split runtime ownership boundaries * test(iroh): repair authorization suite split boundaries * test(iroh): link mobile RPC authorization tests * test(iroh): support compatibility compilers * test(iroh): cover uppercase UUID fallback paths * fix(iroh): canonicalize device UUID authority * test(iroh): support Intel Sonoma compiler * test(iroh): avoid non-Sendable fixture captures * fix(updater): handle Intel-only Sparkle reason * test(iroh): cover bearer and discovery overload * fix(iroh): close route and discovery gaps * fix(ci): close Iroh compatibility regressions * feat(iroh): integrate endpoint-bound relay fleet * fix(ci): wrap command timers for Intel Swift * fix(ios): expose relay deployment to Sendable factory * test(ci): cover private networking on Intel Sonoma * test(ci): support Intel Swift Testing macros * test(iroh): expose relay refresh expiry gap * fix(iroh): retry relay refresh before expiry * fix(ios): serialize Iroh quarantine recovery * refactor(auth): isolate lifecycle revision API * fix(ci): eliminate Iroh Swift 6 warnings * fix(ci): support Intel Xcode 16.2 * fix(ci): mark canvas clock sleep sendable * fix(ci): bridge canvas preferences to main actor * fix(ci): support sidebar git on Xcode 16.2 * fix(ci): mark RPC termination handler sendable * fix(ci): support CLI on Xcode 16.2 * fix(ci): support app target on Xcode 16.2 * fix(ci): finish Xcode 16.2 source compatibility * iroh: point the broker relay fleet at the 7 self-hosted relay.cmux.dev URLs Replaces the 4 hosted iroh.link relays with our self-hosted fleet in both allowlists (web MANAGED_RELAY_URLS + presence worker APPROVED_IROH_RELAY_URLS, kept in lockstep) and the tests that referenced hosted URLs. The self-hosted relays run iroh-relay 1.0.2 behind per-region MIG+L4-LB (zero-downtime upgrades), gated by the cmux EdDSA JWT that /api/relay/token (merged, #7879) mints. * fix(ci): support trailing closure on Xcode 16.2 * ci: allow Intel compatibility suite to finish * test(ci): avoid Xcode 16.2 require recursion * ci: focus Intel compatibility coverage * fix(ci): stabilize replay ownership and Intel budget * test(ios): isolate authoritative resync coverage * feat(iroh): add secure flexible relay policy * test(iroh): split relay runtime coverage * test(iroh): allow self-hosted broker without legacy minter * fix(iroh): make hosted relay minter optional * test(iroh): cover public firewall host fallback * fix(iroh): use public host for firewall checks * fix(iroh): keep accepts and sign-out responsive * test(iroh): reproduce lost online reachability * fix(iroh): republish endpoint online routes * test(iroh): reproduce coalesced route refresh * fix(iroh): replay coalesced route refreshes * refactor(iroh): split oversized runtime files * test(iroh): cover lifecycle refresh races * fix(iroh): fence lifecycle refresh work * test(ios): reproduce loopback dev auto-pair race * test(ios): cover redacted dev Iroh attach URLs * fix(ios): wait for redacted Iroh dev attach ticket * test(ios): reproduce Iroh cold-start attach race * fix(ios): await Iroh before dev auto-pair * feat(iroh): add server-driven relay preferences * feat(iroh): complete relay controls and multistream runtime * ci: rehearse staging migrations from dispatched branch * Make managed Iroh credentials server-driven * feat(iroh): expose redacted live path diagnostics * security(iroh): stage relay policy key rotation * fix(iroh): use instance-scoped host display name * test(iroh): require dev attach targets to prefer identity routes * fix(iroh): prefer identity routes for dev attach * fix(web): include shared relay catalog in Next root * test(web): keep relay catalog inside Next boundary * fix(web): generate relay catalog inside runtime boundaries * test(mobile): cover transport lifetime ownership * fix(mobile): retain Iroh transport lifetime * test(iroh): cover relay policy clock skew * fix(iroh): tolerate bounded relay policy clock skew * test(iroh): cover admitted session lifetime * fix(iroh): separate admission and session lifetimes * test(iroh): cover relay and route renewal stalls * fix(iroh): keep relay routes renewed through storage stalls * test(iroh): cover nonblocking binding persistence * fix(iroh): publish bindings before secure persistence * test(iroh): cover strict transport verification modes * feat(iroh): add strict transport verification modes * test(iroh): await nonblocking relay persistence * test(iroh): cover live peer connection quotas * fix(iroh): bound live sessions per endpoint * test(iroh): cover broker-aware route renewal backoff * fix(iroh): back off broker route renewal retries * test(mobile): cover superseded Iroh transport cleanup * fix(iroh): close unowned mobile sessions * test(iroh): reproduce stale reconnect sessions * fix(iroh): replace stale peer sessions on admission * feat(iroh): add debug transport mode menu * Add regression coverage for Iroh merge blockers * Fix Iroh relay and reconnect merge blockers --------- Co-authored-by: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Co-authored-by: cmux reload-cloud <cmux-reload-cloud@users.noreply.github.com> Co-authored-by: Aziz Albahar <aziz@manaflow.ai> | 2 个月前 | |
Isolate and label tagged iOS dev computers (#7864) * Test tagged iOS dev computer identity * Isolate and label tagged iOS dev computers * Test simulator soak attach target * Request simulator attach URL in soak * Scope tagged iOS reconnect routes * Extract tagged mobile attach coverage * Import shared build scope in iOS UI * Test filtered physical QR route encoding * Canonicalize filtered physical QR routes * Version tagged iOS backup scopes * Preserve presence events outside route authority * Import mobile client ID test dependency * Remove production presence test seam * Split mobile attach target type * Isolate current iOS backup scope capacity * Separate iOS and Mac build scopes * Test missing mobile host route error * Preserve missing mobile host route error * Keep mobile route guard within file budgets * Test empty mobile host route selection * Preserve empty mobile host route error * Test authenticated host instance tags * Expose authenticated Mac instance tag * Fix tagged iOS paired Mac isolation * Fix iOS package convention lint * Preserve paired Mac authority on metadata sync * Preserve active state across scoped Mac duplicates * Optimize batched presence route sync * Test legacy mobile attach URL response * Preserve legacy mobile attach URLs * Keep attach compatibility within file budgets * Update iOS attach handshake fixtures * Test legacy backup tag preservation * Preserve authenticated tag across legacy restore * Run paired Mac package tests in iOS CI * Test atomic legacy backup authority * Reject authority-less backup host tuples | 2 个月前 | |
Run cmux iOS over authenticated Iroh transport (#7908) * feat(iroh): bridge the production Swift endpoint * feat(iroh): run the mac host transport * test(iroh): reject local binding substitution * test(ios): cover pooled fallback route * fix(iroh): bind discovery to the local app instance * fix(ios): retain successful pooled route * test(iroh): require online-first host policy fallback * feat(iroh): start hosts from verified offline policy * feat(iroh): persist mac offline host policy * fix(iroh): reject partial online binding rotation * test(iroh): reject unvalidated private fallback * feat(iroh): own iOS endpoint and multistream sessions * fix(iroh): revalidate private fallback paths * fix(iroh): accept JSON media type parameters * ci(iroh): test full app on Intel Sonoma * ci(iroh): run transport tests on Intel Sonoma * test(iroh): cover abandoned relay reservations * fix(iroh): expire abandoned relay reservations * feat(iroh): expose admitted host multistream sessions * test(iroh): require bounded incoming streams * fix(iroh): bound peer-created QUIC streams * feat(iroh): defer iOS transport activation * feat(ios): prefer verified Iroh routes * test(iroh): preserve endpoint on preferred port collision * test(iroh): cover LAN rendezvous consistency * fix(iroh): fall back from occupied preferred port * feat(iroh): derive private rotating LAN aliases * fix(iroh): serialize LAN discovery with revocation * feat(iroh): make secure pairing the default * docs(iroh): record offline and LAN trust boundaries * feat(iroh): cache verified client policy offline * docs(iroh): add Apple and proxy launch caveats * docs(iroh): clarify Apple local network prompting * test(iroh): cover offline cache teardown races * fix(iroh): fence offline cache teardown * test(iroh): cover online admission leases * feat(iroh): gate online admission leases * feat(iroh): add authenticated Bonjour LAN fallback * feat(iroh): enforce online revocation leases * test(iroh): cover offline admission leases * test(iroh): cover canonical trust errors * fix(iroh): harden trust broker boundaries * fix(iroh): bound offline admission leases * test(iroh): cover policy refresh revision races * fix(iroh): fence admission policy refreshes * docs(iroh): narrow private network release scope * test(tailscale): reject unbound bearer routes * fix(mobile): state private network boundaries * test(tailscale): reject unbound bearer routes Cover numeric-only Tailscale bearer routes and reject authorization, DNS, and route substitution before transport writes. * docs(iroh): specify NAT authorization barrier * fix(tailscale): bind bearer writes to live tunnel * test(iroh): require acknowledged NAT admission barrier * test(tailscale): reject route-only transport bypass * fix(tailscale): close route-only transport bypass * fix(iroh): acknowledge NAT admission before app streams * test(iroh): hide database failure details * fix(iroh): defer reservation constraint validation * test(iroh): retain revocation monitor after handoff * fix(iroh): retain revocation monitor for connection * test(iroh): reject broker credential redirects * fix(iroh): block broker credential redirects * test(iroh): fail closed on terminal foreground policy * fix(iroh): fail closed on terminal policy refresh * test(iroh): prevent raw fallback after admission failure * fix(iroh): pin authenticated pairings to Iroh * test(auth): reject device registry redirects * test(iroh): lock registration identity and relay bootstrap * fix(iroh): preserve registration trust identity * fix(auth): reject credentialed API redirects * test(iroh): keep direct paths out of cloud storage * fix(iroh): keep direct paths device local * test(iroh): evict remotely closed client sessions * test(iroh): recover dead session on foreground * test(iroh): prevent server path-hint disclosure * fix(iroh): recover suspended client sessions * fix(iroh): keep private paths off server surfaces * test(iroh): reject overlapping LAN bootstrap routes * fix(iroh): reject ambiguous LAN interfaces * test(iroh): bound pending admissions per identity * fix(iroh): limit pending admissions per peer * test(iroh): require owned server event stream * test(auth): bound credentialed HTTP responses * test(iroh): reject concurrent control owners * fix(auth): cap credentialed HTTP responses * test(iroh): cover firewall dependency failures * fix(iroh): bound firewall availability checks * feat(iroh): deliver server events on owned stream * test(iroh): cap stalled firewall work * test(iroh): bound active sessions per binding * fix(iroh): cap stalled firewall work * fix(iroh): cap active sessions per binding * test(iroh): require firewall timeout recovery * fix(iroh): abort stalled firewall checks * fix(ci): isolate Iroh transport test suites * test(iroh): route revocation to broker delete * fix(iroh): send revocation to broker route * test(mobile): bound concurrent RPC work * fix(mobile): cap concurrent RPC work * test(mobile): bound decoded frame batches * fix(mobile): cap decoded frame batches * test(iroh): bound pending Bonjour resolves * test(iroh): gate reserved application lanes * test(iroh): retain failed binding revocations * fix(iroh): bound pending Bonjour resolves * fix(iroh): gate reserved application lanes * docs(iroh): narrow production multistream claims * build(iroh): pin attested Swift fork release * test(iroh): require retry-safe binding revocation * fix(iroh): make binding revocation retry-safe * fix(iroh): durably retry binding revocations * build(iroh): lock iOS Swift fork release * test(iroh): retain Bonjour observation lifetime * test(auth): prepare before raced sign-out clear * test(iroh): quarantine failed sign-out persistence * test(ios): quarantine failed Iroh sign-out * fix(iroh): quarantine incomplete sign-out teardown * fix(iroh): clear host network state in quarantine * fix(auth): quarantine Iroh before sign-out clear * fix(ios): quarantine incomplete Iroh sign-out * fix(mobile): type Iroh binding snapshot * fix(ios): wait for auth clear before Iroh recovery * build(iroh): lock app Swift fork release * fix(iroh): persist secrets in ad-hoc debug builds * test(iroh): require local-only HTTP minter opt-in * feat(iroh): add loopback relay minter runner * test(tailscale): require numeric registry targets * feat(iroh): gate local relay minter HTTP * fix(iroh): normalize local minter opt-in * test(ios): require tagged API origin bake * fix(ios): bake tagged API origin * fix(tailscale): pin MagicDNS remotes to peer IPs * fix(tailscale): reject inactive peer snapshots * build(iroh): pin hardened FFI release * test(auth): preserve auto-login during token reads * test(auth): preserve manual sign-in during token reads * test(iroh): require startup network event delivery * fix(iroh): establish endpoint observation before activation * fix(auth): preserve active session writers * test(iroh): accept existing binding registration responses * fix(iroh): accept existing binding relay status * test(iroh): keep host active after refresh throttling * fix(iroh): preserve host during broker throttling * test(iroh): preserve client during broker throttling * fix(iroh): retain verified policy during broker outages * fix(iroh): decode broker dates on older macOS * test(iroh): reject synthetic network change floods * fix(iroh): observe address changes without feedback loop * test(iroh): accept canonical UUID identity case * fix(iroh): canonicalize pinned device UUIDs * fix(iroh): harden compatibility and private routes * refactor(iroh): split runtime ownership boundaries * test(iroh): repair authorization suite split boundaries * test(iroh): link mobile RPC authorization tests * test(iroh): support compatibility compilers * test(iroh): cover uppercase UUID fallback paths * fix(iroh): canonicalize device UUID authority * test(iroh): support Intel Sonoma compiler * test(iroh): avoid non-Sendable fixture captures * fix(updater): handle Intel-only Sparkle reason * test(iroh): cover bearer and discovery overload * fix(iroh): close route and discovery gaps * fix(ci): close Iroh compatibility regressions * feat(iroh): integrate endpoint-bound relay fleet * fix(ci): wrap command timers for Intel Swift * fix(ios): expose relay deployment to Sendable factory * test(ci): cover private networking on Intel Sonoma * test(ci): support Intel Swift Testing macros * test(iroh): expose relay refresh expiry gap * fix(iroh): retry relay refresh before expiry * fix(ios): serialize Iroh quarantine recovery * refactor(auth): isolate lifecycle revision API * fix(ci): eliminate Iroh Swift 6 warnings * fix(ci): support Intel Xcode 16.2 * fix(ci): mark canvas clock sleep sendable * fix(ci): bridge canvas preferences to main actor * fix(ci): support sidebar git on Xcode 16.2 * fix(ci): mark RPC termination handler sendable * fix(ci): support CLI on Xcode 16.2 * fix(ci): support app target on Xcode 16.2 * fix(ci): finish Xcode 16.2 source compatibility * iroh: point the broker relay fleet at the 7 self-hosted relay.cmux.dev URLs Replaces the 4 hosted iroh.link relays with our self-hosted fleet in both allowlists (web MANAGED_RELAY_URLS + presence worker APPROVED_IROH_RELAY_URLS, kept in lockstep) and the tests that referenced hosted URLs. The self-hosted relays run iroh-relay 1.0.2 behind per-region MIG+L4-LB (zero-downtime upgrades), gated by the cmux EdDSA JWT that /api/relay/token (merged, #7879) mints. * fix(ci): support trailing closure on Xcode 16.2 * ci: allow Intel compatibility suite to finish * test(ci): avoid Xcode 16.2 require recursion * ci: focus Intel compatibility coverage * fix(ci): stabilize replay ownership and Intel budget * test(ios): isolate authoritative resync coverage * feat(iroh): add secure flexible relay policy * test(iroh): split relay runtime coverage * test(iroh): allow self-hosted broker without legacy minter * fix(iroh): make hosted relay minter optional * test(iroh): cover public firewall host fallback * fix(iroh): use public host for firewall checks * fix(iroh): keep accepts and sign-out responsive * test(iroh): reproduce lost online reachability * fix(iroh): republish endpoint online routes * test(iroh): reproduce coalesced route refresh * fix(iroh): replay coalesced route refreshes * refactor(iroh): split oversized runtime files * test(iroh): cover lifecycle refresh races * fix(iroh): fence lifecycle refresh work * test(ios): reproduce loopback dev auto-pair race * test(ios): cover redacted dev Iroh attach URLs * fix(ios): wait for redacted Iroh dev attach ticket * test(ios): reproduce Iroh cold-start attach race * fix(ios): await Iroh before dev auto-pair * feat(iroh): add server-driven relay preferences * feat(iroh): complete relay controls and multistream runtime * ci: rehearse staging migrations from dispatched branch * Make managed Iroh credentials server-driven * feat(iroh): expose redacted live path diagnostics * security(iroh): stage relay policy key rotation * fix(iroh): use instance-scoped host display name * test(iroh): require dev attach targets to prefer identity routes * fix(iroh): prefer identity routes for dev attach * fix(web): include shared relay catalog in Next root * test(web): keep relay catalog inside Next boundary * fix(web): generate relay catalog inside runtime boundaries * test(mobile): cover transport lifetime ownership * fix(mobile): retain Iroh transport lifetime * test(iroh): cover relay policy clock skew * fix(iroh): tolerate bounded relay policy clock skew * test(iroh): cover admitted session lifetime * fix(iroh): separate admission and session lifetimes * test(iroh): cover relay and route renewal stalls * fix(iroh): keep relay routes renewed through storage stalls * test(iroh): cover nonblocking binding persistence * fix(iroh): publish bindings before secure persistence * test(iroh): cover strict transport verification modes * feat(iroh): add strict transport verification modes * test(iroh): await nonblocking relay persistence * test(iroh): cover live peer connection quotas * fix(iroh): bound live sessions per endpoint * test(iroh): cover broker-aware route renewal backoff * fix(iroh): back off broker route renewal retries * test(mobile): cover superseded Iroh transport cleanup * fix(iroh): close unowned mobile sessions * test(iroh): reproduce stale reconnect sessions * fix(iroh): replace stale peer sessions on admission * feat(iroh): add debug transport mode menu * Add regression coverage for Iroh merge blockers * Fix Iroh relay and reconnect merge blockers --------- Co-authored-by: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Co-authored-by: cmux reload-cloud <cmux-reload-cloud@users.noreply.github.com> Co-authored-by: Aziz Albahar <aziz@manaflow.ai> | 2 个月前 |
| 文件 | 最后提交记录 | 最后更新时间 |
|---|---|---|
| 3 个月前 | ||
| 2 个月前 | ||
| 1 个月前 | ||
| 3 个月前 | ||
| 1 个月前 | ||
| 2 个月前 | ||
| 2 个月前 | ||
| 2 个月前 |