Compare commits

...
Author SHA1 Message Date
lawrencecchen c718f0f1cb Add standalone CodeRouter CLI for Codex subscriptions 2026-08-03 23:08:39 -07:00
Austin Wangandcmux reload-cloud a9c351be97 Fix Google Sheets browser identity replay loop (#9482)
* test: reproduce Google Sheets identity replay

* fix: make browser identity replay idempotent

* test: reject stale Sheets identity fallback

* revert: remove stale browser identity fallback

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-08-03 22:28:58 -07:00
Abdulaziz Albahar d953ceda39 Match workspace group pin tint (#9519)
* Add regression test for group pin tint

* Match workspace group pin tint
2026-08-04 00:12:45 -05:00
Abdulaziz Albahar 6f99395b78 Focus Mac pairing QR flow on Tailscale (#9493)
* test: require Tailscale-only Mac pairing QR

* fix: focus Mac pairing on Tailscale QR

* test: require Tailscale pairing action names

* fix: name QR entrypoints for Tailscale

* test: require Tailscale setup guidance in scanner

* fix: explain Tailscale pairing prerequisites
2026-08-03 23:08:42 -05:00
Abdulaziz AlbaharandClaude Fable 5 87edd70966 iOS: remove redundant Switch Computer settings screen (#9490)
The workspace list's computer picker already switches Macs, pairing
lives in the Connection Method section and onboarding, and hiding a
computer lives in the Hidden Computers list. Settings > Switch Computer
duplicated all three, so drop MobileHostPickerView, its Settings entry,
and the 15 mobile.hostPicker.*/switchMac localization keys (en+ja) it
alone used. The Connection section now renders only when it has a live
connection row, so its header never sits empty.

Co-authored-by: Claude Fable 5 <[email protected]>
2026-08-03 22:52:28 -05:00
Abdulaziz AlbaharandClaude Fable 5 ed44c7daf4 Add model selection lab to the New Task composer (#8800)
* Add model selection lab to the New Task composer

Adds a curated per-provider model catalog (Claude, Codex, OpenCode) with
opt-in model-flag injection into template commands, and five UX variants
for picking the model in the New Task sheet (combined agent menu, model
row, trailing chip, pill strip, context row), switchable at runtime from
the DEBUG-only CMUX Labs 'New Task Model Lab'. No model selected keeps
template commands byte-for-byte verbatim; release builds stay off.

Co-Authored-By: Claude Fable 5 <[email protected]>

* Add Codex-style composer layout to the New Task sheet

New minimal layout (lab-switchable, DEBUG default): full-bleed prompt
canvas titled by the working directory, back chevron, and a bottom
control bar with a + options sheet (name, Mac, directory), agent pill,
model pill, and a circular submit button. Classic card layout stays
available via CMUX Labs and renders unchanged; release builds keep
classic. Adds GPT-5.6 Luna to the Codex model catalog.

Co-Authored-By: Claude Fable 5 <[email protected]>

* Address review findings on the model picker

- Dissolve the MobileTaskAgentModelCatalog static namespace into
  MobileTaskAgentProvider (detection init, models, model(id:),
  command(applying:to:)) per the no-static-namespace policy.
- Replace an existing --model/-m/--model= value in place instead of
  injecting a duplicate flag that the template's own value would
  override; stop scanning at the -- end-of-options token.
- Pin the task-composer accessibility preview to the classic layout and
  Off variant on fresh installs so the XCUITest suite keeps a stable
  element tree; CMUX_UITEST_TASK_COMPOSER_LAYOUT/_MODEL_VARIANT opt in.
- Give each lab variant exactly one placement in the composer layout:
  combined stays in the agent submenu, contextRow stays in Task Options,
  the rest collapse to the standalone bottom-bar pill.
- Gate the composer submit button on blocking completed-operation
  recovery, matching the classic layout.
- Make combined menu taps a single atomic template+model mutation.
- Share the model display-name fallback and accessibility triple; share
  the directory search/list fallback closures.

Co-Authored-By: Claude Fable 5 <[email protected]>

* Address round-2 review findings

- Quote-aware token scanning in command(applying:to:): flag text inside
  single/double-quoted arguments is one opaque token, so quoted mentions
  of --model are never rewritten; every real model flag before -- is
  replaced (not just the first); a flag directly before -- gets its
  value supplied in place.
- Gate selectedModel on the rendered picker variant so a draft-restored
  model cannot ride into snapshots or submissions while the picker is
  Off; the stored selection survives for when a variant is re-enabled.
- Show the selected model in the composer layout's combined variant
  (agent pill title gains ' · <model>') and add visible checkmarks to
  the combined submenu rows.
- Extend compact composer controls (+, submit, pills, chip, row, pill
  strip) to 44pt activation targets without changing their visuals.

Co-Authored-By: Claude Fable 5 <[email protected]>

* Address round-3 review findings

- Stop the model-flag scan at the first simple command's end: a newline
  separator or a token carrying an unquoted ;, |, or &. A compound
  template like 'claude "$CMUX_TASK_PROMPT"; formatter --model compact'
  now inserts Claude's flag after the first token and leaves the later
  command untouched.
- Route every submission through effectiveSubmissionSnapshot: while the
  picker variant is Off, a hidden model captured by the cached restored
  request (or an adopted recovery request) is stripped and the command
  recomposed with the same operation identifier, closing the untouched-
  draft bypass of the selectedModel gate.

Co-Authored-By: Claude Fable 5 <[email protected]>

* Address round-4 review findings

- Build the composer options sheet lazily: MinimalLayout now takes a
  deferred builder, so directory-candidate construction (a workspace
  walk) runs only when Task Options is presented, not on every prompt
  keystroke's body rebuild.
- Reconcile a hidden model at the submission boundary by marking the
  request dirty instead of post-hoc snapshot surgery: resolution runs
  through makeSubmissionSnapshot (whose selectedModel gate strips the
  model) and MobileTaskSubmissionIdentity mints a fresh operation ID for
  the changed bytes, keeping retries idempotent.
- Process a model flag attached to a command separator: --model=old;,
  --model old;, and --model; are rewritten before scanning stops, so a
  stale value can no longer override the selection.

Co-Authored-By: Claude Fable 5 <[email protected]>

* Address round-5 review findings

- Reconcile a hidden model at BOTH request-resolution boundaries via one
  shared resolver: when the Off picker hides a model a clean cached
  request still carries, resolution is forced through the selectedModel
  gate and the identity mints a fresh operation ID, so a persisted draft
  can never pair model-less bytes with an ID previously bound to
  model-bearing bytes. Replaces the submit-only proxy check.
- Treat redirection operators as part of the simple command: & adjacent
  to > (2>&1, >&2, &>file) and | preceded by > (>|file) no longer end
  the flag scan, so a stale --model after a redirection is still
  replaced. Control operators (;, |, &, &&, ||) still end it.

Co-Authored-By: Claude Fable 5 <[email protected]>

* Address final review round: comments and delisted-model drafts

- Stop the model-flag scan at an unquoted word-initial #: a commented
  flag is never rewritten, and the selection is inserted after the first
  token instead of being silently swallowed by a comment edit.
- Do not reuse a draft's operation ID (or restore its completed-
  operation recovery) when the draft's model no longer survives
  curated-list validation; the resulting default-model command gets a
  fresh idempotency key.

Co-Authored-By: Claude Fable 5 <[email protected]>

* Use the adjustments glyph for the composer options button

Dogfood feedback: + implied adding something; the button configures the
task (name, Mac, directory), so it now shows slider.horizontal.3.

Co-Authored-By: Claude Fable 5 <[email protected]>

* Stop pill labels clipping when the selection gets longer

Dogfood feedback: switching the agent or model to a longer title left
the pill label clipped for the length of the resize animation. The pill
content now uses fixedSize so the capsule adopts the new intrinsic
width immediately, and the label subtree is identity-keyed on the title
so it swaps instead of animating through stale-width frames.

Co-Authored-By: Claude Fable 5 <[email protected]>

* Add mobile task attachments

* Always show the model pill in the composer layout; fix iOS 26 pill clipping

Dogfood feedback: the combined lab variant hid the standalone model
pill (models lived only in the agent submenu), which read as the model
picker disappearing. The composer layout now has one canonical model
treatment: a dedicated pill beside the agent pill for every non-Off
variant; the agent menu is forced plain and the options sheet never
repeats the contextRow, so the pill stays the single entry point.

The label clipping on longer titles survived the fixedSize fix because
the identity key sat on the label content while the UIKit menu button
still animated its frame. The .id now keys the whole Menu, so a title
change swaps the button instead of animating through stale bounds.

Co-Authored-By: Claude Fable 5 <[email protected]>

* Fade the composer pill scroller into the bar at both edges

Dogfood request: pills should dissolve toward the neighboring options
and submit buttons instead of clipping at the scroller bounds. iOS 26
uses the native soft scroll edge effect (progressive blur + fade);
earlier systems approximate it with a 14pt alpha-mask fade per edge.

Co-Authored-By: Claude Fable 5 <[email protected]>

* Discover task models from connected Macs

* Adopt MacPairingKey lookups in task capability checks

Main's typed MacPairingKey re-key changed the secondary-subscription
registry key from a device-id string to the full pairing key. The
attachment and model-discovery capability checks now resolve through a
shared controlSubscriptionMatching helper that keeps the old semantics:
exact pairing when a tag is given, any same-device pairing otherwise.

Co-Authored-By: Claude Fable 5 <[email protected]>

* Scroll the composer pills under the bar buttons with a real edge effect

Dogfood feedback: the scroll edge effect never rendered because the
buttons sat NEXT TO the scroller, so no content ever passed beneath an
edge. The pill scroller now spans the bar with the attachment/options
buttons and the submit button living in its leading/trailing safe-area
insets: pills genuinely scroll under them, which is what activates the
native iOS 26 soft scroll edge effect (progressive blur + fade). Pre-26
keeps an opaque button background as the fallback occlusion.

Also stop the prompt editor from yanking long text back down while
scrolling up: interactive keyboard dismissal resized the editor every
drag frame and UITextView re-scrolled to the caret each time; dismissal
is now immediate.

Co-Authored-By: Claude Fable 5 <[email protected]>

* Render the pill scroller edge effect through UIKit's container interaction

SwiftUI's scrollEdgeEffectStyle only styles effects the system already
owns (bars/glass), so pills merely underlapped the buttons. The bar row
is now a thin UIKit host: a horizontal UIScrollView spans the bar, the
button clusters float above it, and on iOS 26 each cluster carries a
UIScrollEdgeElementContainerInteraction bound to the scroll view's
edge, which renders the real progressive blur+fade beneath the buttons
as pills pass under. Pre-26 clusters keep an opaque background.
Content insets track the cluster widths (the attachment button is
capability-gated), resting the pills between the clusters.

Co-Authored-By: Claude Fable 5 <[email protected]>

* Attach the scroll edge effect via probes instead of rehosting the bar

The UIKit-hosted bar livelocked SwiftUI (hosting-controller sizing
feedback re-rendered every frame) and blanked the composer, which also
made the pills unscrollable. The pills return to the proven SwiftUI
ScrollView under safe-area-inset button clusters; a zero-size probe in
the scroll content walks to the backing UIScrollView and a coordinator
binds UIScrollEdgeElementContainerInteraction to transparent container
views behind each cluster. Fail-soft: if the probe finds no scroll view
or the OS predates iOS 26, nothing attaches and the bar just underlaps.

Co-Authored-By: Claude Fable 5 <[email protected]>

* Reproduce the scroll edge effect deterministically in SwiftUI

Two native attempts failed structurally: SwiftUI's scrollEdgeEffectStyle
never renders for floating siblings, and hosting the bar (or just the
clusters) in UIKit for UIScrollEdgeElementContainerInteraction either
livelocked the view graph or dropped cluster content, because the
effect's shape must come from the container's descendants. The bar now
stays pure SwiftUI: clusters carry an ultraThinMaterial background
(full blur under the buttons) and a 24pt gradient-masked material band
beside each cluster fades passing pills into the bar background --
the same progressive blur+fade the system effect draws, with no
UIKit bridging left to break scrolling or layout.

Co-Authored-By: Claude Fable 5 <[email protected]>

* Use native scroll edge effects in task composer

* Fix task composer scroll edge blur

* Use native shaped scroll edge effects

* Test composer pill scroller hard edges

* Fix composer hard-edge UI test lookup

* Restore hard edges to composer pill scroller

* Exercise overflowing composer pills in hard-edge test

* Test composer prompt scroll gesture ownership

* Prioritize prompt scrolling over sheet drag

* Test composer prompt scroll position stability

* Keep composer prompt at manual scroll position

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-08-03 22:25:17 -05:00
Tanghui Lin f4cd2774de Fix infinite UA-policy restart loop on Google Sheets destinations (#9483)
Fixes #9462
2026-08-03 20:05:09 -07:00
Abdulaziz Albahar 840f8c074f Fetch complete Iroh discovery before Mac host activation (#9478)
* test(iroh): cover incomplete host registration discovery

* fix(iroh): fetch complete discovery for host activation
2026-08-03 21:52:13 -05:00
Abdulaziz Albahar 145b60e893 Fix iOS keyboard focus ownership after photo picker (#9371)
* test: cover mobile input session ownership

* fix: centralize mobile terminal input ownership

* chore: add mobile dock verification geometry

* test: cover keyboard ownership review edges

* fix: close mobile input ownership review gaps

* Test foreground recovery teardown handoff

* Keep disconnected recovery foreground-only

* Respect the active foreground recovery owner

* Test clientless foreground aggregation

* Require a live client for aggregation

* Use UIKit keyboard guide for terminal dock
2026-08-03 19:51:55 -07:00
Austin Wang 37a4d212ab Translate workspace group anchor guidance across locales (#9480)
* Test workspace group docs locale overrides

* Translate workspace group anchor guidance

* Test localized workspace group action labels

* Translate workspace group action labels

* Test workspace group docs match native labels

* Match Khmer docs to workspace group menu

* Use static imports in localization test
2026-08-03 19:49:44 -07:00
Austin Wang 7693b19065 Move mobile telemetry consent into CMUXMobileCore (#9505)
* Test consent provider in CMUXMobileCore

* Move mobile telemetry consent into core
2026-08-03 19:46:44 -07:00
Austin Wang c37ea7a31f Scope shortcut settings notifications to their config file (#9502)
* Test host shortcut notification source identity

* Scope host shortcut notifications to config file
2026-08-03 18:59:30 -07:00
Austin Wang 972084ddb3 Serve cached social preview images without redirects (#9503)
* Add social image delivery regression test

* Serve cached social image URLs directly
2026-08-03 18:58:02 -07:00
Austin Wang 9decec5213 Stop blank Ghostty opener stderr log bursts (#9486)
* test: reproduce Ghostty opener stderr log burst

* Fix empty Ghostty opener stderr log bursts
2026-08-03 18:31:32 -07:00
Austin WangandClaude Opus 5 20390187fd Skip directories when resolving provider executables on PATH (#9476)
* Add regression tests for PATH directory shadowing of provider binaries

FileManager.isExecutableFile(atPath:) returns true for directories on macOS,
so a directory named like a provider binary earlier on PATH is selected by the
CLI and app PATH walks. These tests fail until the resolvers reject directories.

Refs #8743

Co-Authored-By: Claude Opus 5 <[email protected]>

* Skip directories when resolving provider executables on PATH

FileManager.isExecutableFile(atPath:) returns true for directories on macOS, so
a directory named like a provider binary (~/bin/omx/, ~/bin/claude/) earlier on
PATH was selected as the executable and the launch failed at execv with a
confusing "Permission denied". Reject directories with
fileExists(atPath:isDirectory:) before the executable check in all three PATH
walks, mirroring the guard resolveClaudeExecutable already applied to configured
candidates.

Fixes #8743

Co-Authored-By: Claude Opus 5 <[email protected]>

---------

Co-authored-by: Claude Opus 5 <[email protected]>
2026-08-03 18:28:39 -07:00
Austin Wang 9d7cc488b8 Reject unknown flags for surface resume set (#9477)
* Add regression test for resume set flag validation

* Reject unknown surface resume set flags

* Fix surface resume flag regression test
2026-08-03 18:26:56 -07:00
Lawrence Chen 97f4a5d6a3 Add Pi landing page and agent SEO (#9455)
* Add Pi landing page and agent SEO

* Keep homepage copy localized

* Limit Pi discovery to English

* Expand coding agent SEO coverage

* Localize coding agent landing pages

* Localize Pi guide card

* Remove stale English-only Pi link copy

* Keep agent metadata locale-native
2026-08-03 17:41:15 -07:00
Abdulaziz Albahar 490b45503b Require connected iOS dogfood launches (#9252)
* test: cover disconnected iOS dogfood launch

* fix: require connected iOS dogfood launches

* fix: make mobile readiness event driven

* fix: harden mobile readiness lifecycle

* perf: buffer deadline event reads

* test: remove source-shape admission assertion

* test: cover cached host binding publication

* fix: publish cached mobile host binding

* test: cover usable mobile session readiness

* fix: require usable mobile connection readiness

* test: cover injected attach admission ownership

* fix: start injected attach at connection owner

* test: require active Iroh route publication

* fix: publish Iroh route only after activation

* fix: compile weak dictation request capture

* test: align route readiness fixtures with connectivity v2

* chore: expose safe Iroh activation failure type

* test: reject mobile session closed during revalidation

* fix: require stable mobile admission before handoff

* test: reject foreground and control dial overlap

* fix: reserve foreground mobile connection routes

* test: reproduce registry churn disconnect

* test: preserve policy during registry churn

* fix: preserve mobile connectivity during registry churn

* test: preserve active iroh session during candidate admission

* fix: promote mobile sessions only after readiness

* test: reproduce saturated mobile reconnect

* fix: reserve reconnect admission until session readiness

* fix: preserve strict single-session capacity

* test: reproduce relay refresh disconnect

* fix: preserve authorized sessions across route refresh

* test: reproduce paginated host registration wedge

* fix: recover host registration across discovery pages

* test: reproduce orphaned iPhone build process

* fix: terminate iOS app before bundle replacement

* fix: preserve usable-session promotion after main merge

* fix: preserve secondary Mac route owner after merge

* fix(ios): let Ghostty render the cursor

* Pin current GhosttyKit checksum
2026-08-03 19:32:33 -05:00
Lawrence Chen cec7ac3fa7 Suppress Pi notifications after interrupted turns (#9451)
* test: cover silent Pi turn interruption

* fix: suppress Pi notifications after interruption

* perf: keep Pi completion inspection single-pass
2026-08-03 17:22:53 -07:00
Austin Wang d35187ec47 Pin GhosttyKit checksum for iOS startup fix (#9487) 2026-08-03 16:39:35 -07:00
Abdulaziz Albahar 2141de5722 Fix middle tab drag reordering
Fix same-pane tab reordering to middle indices by taking the Bonsplit SwiftUI delegate path as the sole reorder owner. Includes hosted E2E coverage for later-to-middle and earlier-to-middle tab drags.
2026-08-03 18:35:45 -05:00
Abdulaziz AlbaharandClaude Fable 5 4adc8e519a iOS: diff viewer scroll momentum survives finger lift (#9257)
* ios: keep diff scroll momentum by persisting the row only at scroll idle

FileDiffPageView propagated every scrollPosition row change up into the
pager's @State while the finger was still down or the view was
decelerating. Each write re-rendered the pager mid-fling and the bound
scrollPosition(id.top) re-anchored the tracked row on the next
layout pass, cancelling the remaining momentum: lifting the finger
stopped the diff dead.

Route persistence through SettledScrollRowReporter, which reports the
tracked row only when the scroll phase returns to idle (plus once on
page unmount), so nothing re-renders during a fling. Restore-on-remount
behavior is unchanged.

Co-Authored-By: Claude Fable 5 <[email protected]>

* ios: add a many-screen diff to the changes preview fixture

Every hand-written fixture diff fits on one screen, so scroll flings
and deceleration could not be exercised deterministically. Add a
400-line generated diff (Sources/RenderPipeline.swift) to the DEBUG
changes preview fixture.

Co-Authored-By: Claude Fable 5 <[email protected]>

* ios: drop the anchor from the diff page's live scrollPosition binding

On-sim verification showed flings still died with only the idle-phase
reporter fix: scrollPosition(id.top) itself re-aligns the
tracked row flush to the viewport top on every internal position
update during deceleration, so the fling stops at the first row
crossing (the settled frame shows the row pixel-flush at the top).
Removing the anchor removes the alignment contract; tracking and
restore-on-remount keep working via the id binding.

Co-Authored-By: Claude Fable 5 <[email protected]>

* ios: give the diff scroll offset a single owner

Real-path dogfood showed flings, rubber-banding, and pull-to-refresh
displacement all being cut short on real diffs even with the anchor
removed: any live scrollPosition(id:) binding makes SwiftUI a second
continuous owner of the scroll offset, and on heterogeneous multi-
thousand-row diffs every lazy row materialization re-resolves the bound
position against the moving offset (the uniform 400-row fixture never
re-resolved, which is why the earlier sim verification passed).

Drop the binding entirely. The top row is tracked in a plain reference
box via onScrollTargetVisibilityChange (no view state, no body
dependency, no layout participation), persisted at scroll-idle and on
unmount as before, and restore-on-remount becomes a one-shot
ScrollViewReader.scrollTo at appear. After that single command the
offset is owned exclusively by the scroll view's physics.

Co-Authored-By: Claude Fable 5 <[email protected]>

* ios: resolve the settled diff row by document order, not callback order

onScrollTargetVisibilityChange documents no ordering for the ids it
reports, so taking visibleIDs.first as the top row was an assumption.
FileDiffPresentation now carries a rowOrderIndex built once alongside
the rows (off-main on the async paths), and TopVisibleRowPolicy picks
the id earliest in document order. Also balances the braces in the
generated fixture diff and documents why the pre-iOS-18 fallback is
acceptable (app floor is iOS 18.4; macOS builds this package for
tests only).

Co-Authored-By: Claude Fable 5 <[email protected]>

* ios: preserve diff restore across refresh

* ios: avoid fixture lint false positive

* ios: run diff preparation off the main actor

* ios: align sentry-cocoa pins with main (9.24.0)

The fleet builder's shared warm DerivedData precompiles Sentry modules
against main's pin; this branch's older 9.21/9.23 pins invalidated those
.pcm files ("header has been modified since the module file was built")
and failed every cloud iOS build. Pins-only change, byte-identical to
main's lockfiles.

Co-Authored-By: Claude Fable 5 <[email protected]>

* ios: restore the diff scroll row from an explicit target, not the tracker

On-sim the remount restore landed at the file top: the visibility
callback fires for the unrestored top of the list before onAppear runs,
overwriting rowTracker.topRowID, so restoring from the tracker anchored
to row 1. The restore target is now captured explicitly — from the pager
at mount, from the live tracker only when a refresh re-arms the restore —
and scrollTo is re-applied once after the first layout pass because
LazyVStack only estimates offsets for unrealized rows.

Co-Authored-By: Claude Fable 5 <[email protected]>

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-08-03 18:25:50 -05:00
Abdulaziz Albahar a2d28ba765 Keep Mobile Connect available in the command palette (#9467)
* test: cover mobile connect palette availability

* fix: keep mobile connect in command palette
2026-08-03 18:15:57 -05:00
Austin WangandClaude Opus 5 e733aa4954 Pass a valid empty MCP configuration to the auto-naming summarizer (#9473)
* Add failing test for auto-naming --mcp-config argument

Extracts the claude summarizer argv into
AutoNamingEnvironmentPolicy.claudeSummarizerArguments and asserts the
--mcp-config value is a valid MCP configuration object. It currently
emits a bare {}, which Claude Code rejects.

Co-Authored-By: Claude Opus 5 <[email protected]>

* Pass a valid empty MCP configuration to the auto-naming summarizer

Claude Code 2.1.220 validates --mcp-config against a schema requiring an
mcpServers record, so the bare {} cmux passed made the summarizer exit
on argument validation and every workspace auto-naming attempt recorded
category: failed. Emit {"mcpServers":{}} instead.

Fixes #9457

Co-Authored-By: Claude Opus 5 <[email protected]>

---------

Co-authored-by: Claude Opus 5 <[email protected]>
2026-08-03 15:42:32 -07:00
Austin WangandClaude Opus 5 16dbad16e4 Fix Package.resolved policy false positive on leaf local-path packages (#9470)
* Add failing test for leaf local-path Package.resolved false positive

Adding a dependency-free local-path package to a manifest that already has
remote pins makes check-package-resolved-policy.py demand three Package.resolved
diffs that swift package resolve cannot produce.

Co-Authored-By: Claude Opus 5 <[email protected]>

* Key Package.resolved policy off reachable remote dependency calls

check-package-resolved-policy.py demanded a Package.resolved diff whenever a
manifest's dependency calls changed and that manifest's graph had any remote
dependency anywhere. Adding a dependency-free local-path package to such a
manifest therefore reported violations for lockfiles that `swift package
resolve` leaves byte-identical, so the demanded diff could not exist.

The graph now records the normalized text of every `.package(url:)` call per
manifest, and a manifest edit requires a lockfile diff only when the set of
url calls reachable through its local-path closure differs between merge-base
and HEAD. That set is exactly what SwiftPM pins, so version-requirement bumps
on an unchanged URL and newly reachable remote-bearing local packages still
require the diff.

Fixes #8871

Co-Authored-By: Claude Opus 5 <[email protected]>

---------

Co-authored-by: Claude Opus 5 <[email protected]>
2026-08-03 15:38:39 -07:00
Austin WangandClaude Opus 5 ea8c7a6fb8 Retract recovered daemon transport errors from the workspace sidebar (#9472)
* Add failing test for recovered daemon transport bounce leaving sidebar error

Co-Authored-By: Claude Opus 5 <[email protected]>

* Retract recovered daemon transport errors from the workspace sidebar

Fixes #8917

* Move the daemon recovery regression test to Swift Testing

* Drop stray blank line in WorkspaceRemoteConnectionTests

* Import CmuxSidebar in the daemon recovery test

---------

Co-authored-by: Claude Opus 5 <[email protected]>
2026-08-03 15:32:41 -07:00
Abdulaziz Albahar be6516f704 Preserve iOS workspace groups during reconnect
Track whether mobile workspace-list responses actually include groups, preserve the last authoritative group snapshot through reconnect and empty transient states, and allow healthy connected empty snapshots to clear stale group headers.
2026-08-03 17:21:05 -05:00
Austin Wang 8cc5dc4a6e Treat "no update available" as a success in Attempt Update (#9435)
* Add failing test: Attempt Update with no update available must not report install failure

* Add failing UI test: Attempt Update with no update available must not show an error pill

* Treat 'no update available' as a success in Attempt Update

Fixes the red "Update Didn't Start / check your internet connection" pill
shown when Attempt Update runs while already on the latest version.
2026-08-03 14:11:11 -07:00
Austin Wang 004d414746 Rename the focused workspace group with Cmd+Shift+R (#9428)
* Add failing regression test for Cmd+Shift+R on a focused workspace group

Covers https://github.com/manaflow-ai/cmux/issues/9199: renaming from the
shortcut while a group's anchor row is focused leaves the group header
name untouched.

* Rename the focused workspace group with Cmd+Shift+R

A workspace group's header row is backed by an anchor workspace whose own
title is hidden: the row renders the group name. The anchor's title is
seeded from the group name at creation and never resynced, so renaming
the focused anchor workspace prefilled a stale name ("Group 3") and
changed nothing visible.

Resolve the palette rename target through a shared resolver: when the
focused workspace is a group anchor, target the group, matching that
row's "Rename Group..." context menu item. Every other workspace still
renames itself.

Fixes #9199

* Move rename-target resolution onto CommandPaletteRenameTarget

Review feedback: the static-only resolver enum was a namespace type. The
focused-workspace resolution is now an initializer on the value it builds,
and the group anchor descriptor lives in its own file.

* Make the group rename UI test tolerate headless CI activation

* Assert the group rename regression through the control socket

The accessibility-label assertion passed on the unfixed build, so it was
not catching the bug. Setup and verification now go through the control
socket: create a group, rename only the group so the anchor workspace
title goes stale, focus the anchor, press Cmd+Shift+R, and assert the
group's name in the model changed.

* Drop the non-discriminating group rename UI test

The test passed on a build without the fix (run 30786168603), so it did
not capture the regression. Coverage stays with the resolver unit tests
until a UI-level check that actually fails on the bug is written.

* Restore the group rename UI test with a launch path that cannot pass vacuously

The previous version wrapped app.launch() in a non-strict XCTExpectFailure.
On a headless runner that absorbs the launch failure and, with
continueAfterFailure = false, abandons the rest of the test body without
recording anything, so the test reported success without running a single
assertion (proved by a variant carrying an unconditional XCTFail that also
passed: run 30787440944).

* Drop the group rename UI test: it cannot run on the e2e runner

With the vacuous-pass workaround removed, the test fails on both a fixed
and an unfixed build at the same line: app.activate() raises "Failed to
activate application (current state: Running Background)". The hosted
runner has no foreground GUI session, and XCUITest keystrokes only reach
a frontmost app, so a keystroke-driven test cannot work there.

Runs: 30788920066 (no fix) and 30788928069 (fix) — identical failure.
2026-08-03 14:10:06 -07:00
Abdulaziz Albahar f4787432f2 Fix INTERNAL iOS Ghostty startup crash
Pins the cmux Ghostty submodule to the locale-before-crash-reporting startup fix. ASC evidence for local 2026-08-02 showed all three cmux INTERNAL reports shared EXC_BAD_ACCESS in ghostty_init + 1388 with the main thread in setlocale/loadlocale via GhosttyRuntime.swift:110.
2026-08-03 15:46:37 -05:00
Abdulaziz AlbaharandClaude Fable 5 eee859d354 Harden route-content equivalence against reorders, missing baselines, and install races (#9402)
* Add failing tests for route-content equivalence hardening

Pins six behaviors from the cubic review of #9342: reorder-only
capability, relay fleet, and grant verification key revisions keep
live sessions; a snapshot installed for a revision recorded without
content fails closed; an older route revision install cannot roll
back a newer one; a redundant-dial close raced by invalidation
redials instead of returning the closed winner.

Co-Authored-By: Claude Fable 5 <[email protected]>

* Harden route-content equivalence against reorders and races

Canonicalizes route content so order carries no meaning where the
admission policy reads sets: binding capabilities, the relay fleet,
and grant verification keys (by kid) are sorted when the content is
built, so reorder-only revision bumps keep live sessions.

didInstallRouteRevision now drops installs older than the recorded
revision, so an older completion of an overlapping reconciliation
cannot roll back a newer installed revision. The same-revision branch
compares the stored baseline and fails closed through the standard
superseded-peer invalidation when the baseline is missing or differs,
instead of silently adopting the content.

The peer session no longer returns a stale winner capture after the
redundant-dial close: settleRedundantDial re-reads the active slot
and its liveness after the close suspension and redials when the
winner was invalidated, replaced, or remotely closed.

Co-Authored-By: Claude Fable 5 <[email protected]>

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-08-03 14:59:42 -05:00
Lawrence Chen 249d0ff799 Include Pi session titles in notifications (#9452)
Include the target Pi surface title in notification titles while preserving the Pi fallback and leaving other agents unchanged.
2026-08-03 05:37:22 -07:00
Lawrence Chen 6edf2570b8 Publish the Rust SDK as cmux-sdk (#9445)
* Rename Rust SDK package to cmux-sdk

* Preserve Python release artifact digests

* Make crate bootstrap recovery independent

* Pin crate bootstrap tests to build job

* Stop crate bootstrap publication on cancellation
2026-08-03 03:31:33 -07:00
1927f130f6 Make iOS workspace groups and reconnect dogfood-ready (#9326)
* test(ios): cover workspace group row actions

* fix(ios): restore workspace group row actions

* test(ios): cover group destructive confirmations

* test(ios): cover group read-state action refresh

* fix(ios): refresh group native action state

* test(ios): cover group native action inputs

* fix(ios): refresh group native action inputs

* test(ios): cover group swipe completion and rename alert

* fix(ios): restore workspace preview compilation

* test(ios): target visible group rename fixture

* fix(ios): preserve group swipe completion and compact rename

* test(ios): exercise group action presentation lifecycles

* test(ios): preserve workspace actions on group menus

* fix(ios): preserve workspace actions on group menus

* test(ios): exercise full group read swipe

* test(ios): isolate native group menu assertions

* test(ios): cover preserved group actions

* test(ios): keep preview fixture state owned

* test(ios): cover preserved group create actions

* fix(ios): preserve group creation entrypoints

* fix(ios): make destructive group requests atomic

* test(ios): cover configured group icons

* fix(ios): sync effective group icons

* test(ios): target live group row swipe

* test(ios): disambiguate group workspace rename

* fix(ios): disambiguate group workspace rename

* test: cover disconnected iOS dogfood launch

* fix: require connected iOS dogfood launches

* fix: make mobile readiness event driven

* Add failing iroh wake reconnect regressions

* Guarantee bounded foreground reconnect

* fix: harden mobile readiness lifecycle

* perf: buffer deadline event reads

* Add failing test: session snapshot mid-revalidation must classify transient

Every launch/foreground kicks a /users/me revalidation and
sessionTokenTransitionIsActive is true for its whole round trip.
authenticatedSessionSnapshot() throws .unauthorized for that window, which
the iroh broker token source treats as signed out, so endpoint activation
fails closed (endpointFailed authorizationFailed) on every app launch until
the revalidation completes. The same state is already classified
.networkError by accessToken(); the snapshot must match.

Co-Authored-By: Claude Fable 5 <[email protected]>

* Classify transient token misses as connectivity, not authorization failure

Three-layer fix for the launch-time wedge where every iroh endpoint
activation failed closed (endpointFailed authorizationFailed) while a
foreground session revalidation owned the token store:

1. AuthCoordinator.authenticatedSessionSnapshot() now throws .networkError
   while sessionTokenTransitionIsActive, matching accessToken()'s
   classification. Every launch/foreground kicks a network /users/me
   revalidation, and that window previously read as "signed out".

2. CmxIrohBrokerTokenSource.credentialPair is now throwing. A throw means
   "cannot read a coherent pair right now" and the broker classifies it
   .connectivity, so retry policies, verified-policy preservation, and the
   cached offline-policy bootstrap all apply. nil still means definitively
   signed out and fails closed with .missingAuthentication.

3. The iOS activation token source maps AuthError.unauthorized to nil
   (fail closed) and rethrows every transient failure instead of collapsing
   both into nil with try?.

Diagnosed from cmuxdiag exports on build 1.0.4 (20260731034828): three
consecutive relayPolicyRefreshFailed/endpointFailed(authorizationFailed)
within 10ms each (no network round trip) at launch, recovering only ~15s
later when the revalidation settled and the backoff retried.

Co-Authored-By: Claude Fable 5 <[email protected]>

* Apply the same transient-token classification to the Mac host runtime

The Mac host's activation token source had the identical try? collapse:
a session revalidation window read as signed-out and tore the host
runtime down as unauthorized. Same mapping as iOS: unauthorized fails
closed with nil, transient failures rethrow and classify connectivity.

Co-Authored-By: Claude Fable 5 <[email protected]>

* Add failing wake-auth transport regressions

A broker 401 at app wake (token pair rotated by another lane between
capture and server validation) must not tear down the verified iroh
runtime, and the Mac being redialed must not be dialed a second time as
a background-control aggregation candidate.

Co-Authored-By: Claude Fable 5 <[email protected]>

* Survive wake-time broker auth rejections without endpoint teardown

At app wake the relay-policy refresh races the RPC lane's force token
refresh: the pair captured coherently a moment earlier reaches the
broker after rotation and gets a 401. That single 401 used to fail the
endpoint, clear routes and the offline cache (or tear down the whole
runtime on warm wakes), and nap 30-36s of flat backoff, turning a
seconds-long token race into the 30s-2.5min reconnect outages visible
in every wake ring.

Four changes:
- CmxIrohTrustBrokerClient recovers exactly once from a 401: the token
  source re-captures (force-minting only when the rejected access token
  is unchanged) and the request retries with the recovered pair. Frozen
  pinned sources (sign-out revocation) opt out by default.
- 401/403 now preserve verified policy during refresh, and 401 retries
  initial activation; resolvePolicy falls back to the verified offline
  bootstrap on auth rejections like it already did for connectivity, so
  LAN and cached-relay dials keep working while auth settles.
- The relay-policy refresh loop retries authorization failures on a
  2s..120s ladder instead of the flat 30s+jitter schedule.
- The Mac being redialed is excluded from secondary aggregation while a
  stored-Mac reconnect is in flight, removing the duplicate
  background-control dial (and its drain wait) from every recovery.

Co-Authored-By: Claude Fable 5 <[email protected]>

* Handle route-gated diagnostics in iOS settings

* test: remove source-shape admission assertion

* test(iroh): cover cached registration recovery

* fix(iroh): recover cached host registration

* test connection readiness failures

* test: cover cached host binding publication

* fix: publish cached mobile host binding

* iOS: replace disconnect chrome with Mail-style status line under the computers picker

While a reconnect attempt has not been rejected, the last visible workspace
list and terminals stay accessible. The workspace list shows a caption status
line (spinner + Reconnecting… / Not Connected) under the computers picker,
like Mail's Checking for Mail…; the terminal keeps only the compact status
pill. The full-screen TerminalDisconnectedOverlay, the list's
Disconnected/Reconnecting status row for non-startup states, and the
connection status toasts are removed. The reauth banner (rejected
connection, Sign Out is the only fix) and the initial-restore status row
(Retry / Add Computer, possibly no cached content) remain. Input gating and
the pill's recovery folding, previously behind the Toasts beta flag, are now
unconditional; a Reconnect item appears in the picker menu while Not
Connected.

Co-Authored-By: Claude Fable 5 <[email protected]>

* Harden mobile connection readiness

* Keep subscription readiness separate from recovery

* Model delayed subscription acknowledgements

* test: cover usable mobile session readiness

* fix: require usable mobile connection readiness

* test: fail closed across broker auth cancellation

* test: cover complete iOS dogfood readiness

* test: fail closed when Mac pairing setup is unavailable

* fix: make iOS dev reload dogfood ready

* test: require ensure-mac to self-heal exact tag

* fix: let ensure-mac relaunch its exact tag

* fix(ios): keep list probe state coordinator-owned

* test(ios): pass active listener to recovery validation

* test: cover unsigned simulator identity evidence

* fix: trust seeded identity in unsigned simulator

* test: disambiguate group rename alert save

* test: expose expired-ticket group rename failure

* Authorize mac-scoped workspace mutations by Stack account, not ticket lifetime

The mobile data plane's design authority is the signed-in Stack account;
attach tickets are route discovery plus scope narrowing. Four verbs
(workspace.move, workspace.group.action, workspace.group.create, and
workspace.create with group_id) still hard-required a current attach
ticket, and minted tickets default to a 600s TTL, so iOS drag-and-drop
and the + button's New Workspace Group item silently disappeared ten
minutes after pairing (and never appeared for tokenless zero-touch
pairings).

Host: ticketAuthorizationResultIfNeeded no longer fails these verbs when
the attach token is missing, unknown, or expired; a token that maps to a
current stored ticket still narrows scope, so workspace-pinned tickets
remain rejected for Mac-wide mutations. Advertised as
workspace.mutations.account_auth.v1.

iOS: MobileShellWorkspaceMutationTicketPolicy mirrors the host: against
hosts advertising the capability, mutations stay allowed unless a
current workspace-scoped ticket narrows the connection; legacy hosts
keep the fail-closed behavior. Applied to the foreground gate, the
per-target mutation gate, and secondary-Mac handle capabilities.

Co-Authored-By: Claude Fable 5 <[email protected]>

* test(ios): cover recovery transport drain

* fix(ios): drain stale route before recovery

* test: expose process-local readiness clock

* fix: use system monotonic readiness clock

* test(ios): expose scoped-ticket group rename gap

* fix(ios): preserve account-authorized group actions

* test(ios): keep group menus group scoped

* fix(ios): keep workspace group menus group scoped

* fix(ios): pass readiness clock after main merge

* test(ios): close group action review gaps

* Fix missing return in restoreCLIArgument (main compile break)

5bf9595804 (#9265) left the final expression of a multi-statement String?
method without an explicit return; every target compiling this file fails,
which currently blocks all merge-gate runs.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(ios): redact workspace mutation failure diagnostics

An rpcError message is an arbitrary host string; exported diagnostics now
carry only the bounded DiagnosticFailureKind plus the short RPC code, and
the os.log line marks the raw error private.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(ios): stop presenting gated connect attempts as timeouts

connectAttemptGated means another attempt owns the route, not that the
Mac failed to respond. New pairing category with wait-for-active-attempt
copy and guidance (en+ja) instead of 'No response from …' timeout text.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(ios): add missing statusLine keys to MobileShellUI catalog

mobile.workspaces.statusLine.reconnecting/notConnected were referenced by
WorkspaceConnectionStatusLineView but absent from the package catalog, so
Japanese fell back to English defaults.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(cli): monotonic events timeout budget, deterministic reconnect wait

The --timeout budget now runs on ContinuousClock so wall-clock changes
cannot expire or extend it; each socket call derives a fresh short-lived
Date from the monotonic remainder and authentication re-checks the budget
first. The reconnect pause replaces the Timer+RunLoop pump (which can spin
or park on the CLI's unpumped command thread) with a bounded thread sleep
clamped to the remaining budget.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(ios): drop stale swiped-row identity on structural refresh

A structural update invalidates the row identity captured at swipe start;
keeping editedItemID could defer a reload against a row that no longer
exists.

Co-Authored-By: Claude Fable 5 <[email protected]>

* test(ios): align drop fixture with connection chrome

* fix: return validated restore argument

* test(ios): port drop tests to the status-line WorkspaceListTable API

Main's drop tests (from #8602) still passed connectionRecoveryFailed,
isRecoveringConnection, and retryConnectionRecovery, which this branch's
status-line rework removed from WorkspaceListTable; the package no longer
compiled on the merged tree.

Co-Authored-By: Claude Fable 5 <[email protected]>

* test(ios): drop superseded relayPolicyRetrySchedule test

The cause-aware relayPolicyRetrySchedule(for:) API this test pinned was
replaced by the shared foreground reconnect-backoff ladder during the
connection-supervisor cross-merge (see the scheduleRelayPolicyRefresh
comment); the symbol exists nowhere, so cmuxFeatureTests did not compile.
The fast-auth-retry concern lives in the ladder's own coverage.

Co-Authored-By: Claude Fable 5 <[email protected]>

* test(ios): drop superseded relay schedule assertion

* test(ios): identify inherited group menu actions

* test(ios): lock group menu action order

* test(iroh): expose truncated registration discovery

* fix(iroh): distrust truncated registration discovery

* test(connectivity): expose truncated sync snapshots

* fix(connectivity): prove complete sync snapshots

* test(connectivity): expose discovery revision races

* fix(connectivity): snapshot routes atomically

* test(ios): expose discovery blocking saved reconnect

* fix(ios): prioritize saved routes during recovery

* test(connectivity): expose endpoint recovery race

* fix(connectivity): await endpoint recovery before dialing

* fix(connectivity): fail closed on offline auth fallback

* Harden mobile group actions and reconnect readiness

* Include mobile debug registry source

* Fix group rename alert target lifetime

* Address workspace merge policy findings

* Scope reconnect policy to owning view

* Fix SSH retry test diagnostic compilation

* Align host refresh tests with auth recovery

---------

Co-authored-by: Claude Fable 5 <[email protected]>
Co-authored-by: cmux reload-cloud <[email protected]>
2026-08-03 03:15:09 -05:00
432 changed files with 23578 additions and 3785 deletions
+11 -4
View File
@@ -10,7 +10,7 @@ concurrency:
jobs:
build-ghosttykit:
runs-on: ${{ vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }}
timeout-minutes: 20
timeout-minutes: 35
env:
GHOSTTYKIT_CRASH_REPORT_SUBDIR: cmux/crash
GHOSTTYKIT_BUILD_FLAVOR: crashsubdir-cmux-crash-sentry-off-v1
@@ -54,7 +54,6 @@ jobs:
fi
- name: Select Xcode
if: steps.check-release.outputs.exists == 'false'
run: |
set -euo pipefail
if [ -d "/Applications/Xcode.app/Contents/Developer" ]; then
@@ -78,7 +77,6 @@ jobs:
xcodebuild -version
- name: Cache Zig packages
if: steps.check-release.outputs.exists == 'false'
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.cache/zig
@@ -86,11 +84,20 @@ jobs:
restore-keys: zig-packages-
- name: Install zig
if: steps.check-release.outputs.exists == 'false'
run: |
set -euo pipefail
./scripts/install-zig-ci.sh
- name: Test Ghostty OS opener stderr reader
run: |
set -euo pipefail
cd ghostty
zig build test \
-Dapp-runtime=none \
-Demit-macos-app=false \
-Dsentry=false \
-Dtest-filter="open stderr reader exits"
- name: Build GhosttyKit.xcframework
if: steps.check-release.outputs.exists == 'false'
run: |
+4 -1
View File
@@ -258,7 +258,9 @@ jobs:
run: python3 scripts/check-workspace-package-groups.py --check
- name: Validate SwiftPM lockfile policy
run: python3 scripts/check-package-resolved-policy.py
run: |
python3 tests/test_check_package_resolved_policy.py
python3 scripts/check-package-resolved-policy.py
- name: Validate bash shell integration job control
run: python3 tests/test_bash_integration_no_done_notifications.py
@@ -1062,6 +1064,7 @@ jobs:
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_omo_fallback_path.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_omx_fallback_path.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_omc_fallback_path.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_issue_8743_path_directory_shadowing.py
python3 tests/test_issue_2448_shell_claude_wrapper_dispatch.py
python3 tests/test_issue_8093_ghostty_ssh_binary_path.py
python3 tests/test_issue_6714_zsh_shim_noclobber.py
+7 -7
View File
@@ -231,32 +231,32 @@ jobs:
if: matrix.language == 'rust'
working-directory: cmux-tui
run: |
cargo +1.88.0 fmt -p cmux-client -p cmux-sidebar -- --check
cargo +1.88.0 fmt -p cmux-sdk -p cmux-sidebar -- --check
cargo +1.88.0 test \
-p cmux-client \
-p cmux-sdk \
-p cmux-sidebar \
--all-targets \
--locked
cargo +1.88.0 test \
-p cmux-client \
-p cmux-sdk \
-p cmux-sidebar \
--doc \
--locked
cargo +1.88.0 clippy \
-p cmux-client \
-p cmux-sdk \
-p cmux-sidebar \
--all-targets \
--locked \
-- -D warnings
RUSTDOCFLAGS="-D warnings" \
cargo +1.88.0 doc \
-p cmux-client \
-p cmux-sdk \
-p cmux-sidebar \
--locked \
--no-deps
cargo +1.88.0 package -p cmux-client --locked
cargo +1.88.0 package -p cmux-sdk --locked
# Full sidebar packaging resolves its versioned crates.io dependency.
# Publish cmux-client first; CI still verifies the exact sidebar file set.
# Publish cmux-sdk first; CI still verifies the exact sidebar file set.
cargo +1.88.0 package -p cmux-sidebar --locked --list
- name: Test Go SDK
+40
View File
@@ -0,0 +1,40 @@
name: coderouter CLI
on:
pull_request:
paths:
- "coderouter/**"
- ".github/workflows/coderouter-*.yml"
push:
branches: [main]
paths:
- "coderouter/**"
- ".github/workflows/coderouter-*.yml"
permissions: {}
jobs:
test:
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: coderouter
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy,rustfmt
- uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.2
with:
workspaces: coderouter
- run: cargo fmt --check
- run: cargo clippy --all-targets -- -D warnings
- run: cargo test --all-targets
- run: node scripts/check-version.mjs
- run: npm pack --dry-run
working-directory: coderouter/npm
@@ -0,0 +1,52 @@
name: coderouter publish npm
on:
workflow_dispatch:
inputs:
version:
description: Stable X.Y.Z release version
required: true
type: string
permissions: {}
jobs:
publish:
runs-on: ubuntu-latest
environment: npm-coderouter
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
fetch-depth: 0
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22.14.0"
registry-url: https://registry.npmjs.org
- name: Validate release tag
env:
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]
[[ "$GITHUB_REF" == "refs/tags/coderouter-v$VERSION" ]]
[[ "$(jq -r .version coderouter/npm/package.json)" == "$VERSION" ]]
git fetch origin main
git merge-base --is-ancestor "$GITHUB_SHA" origin/main
- name: Download verified release packages
uses: robinraju/release-downloader@daf26c55d821e836577a15f77d86ddc078948b05 # v1.12
with:
tag: coderouter-v${{ inputs.version }}
fileName: "coderouter-npm-*.tgz"
out-file-path: dist
- run: npm install -g [email protected]
- name: Publish platform packages, then launcher
run: |
set -euo pipefail
for package in dist/coderouter-npm-cli-*.tgz; do
npm publish --provenance "$package"
done
npm publish --provenance "dist/coderouter-npm-launcher.tgz"
@@ -0,0 +1,45 @@
name: coderouter publish pypi
on:
workflow_dispatch:
inputs:
version:
description: Stable X.Y.Z release version
required: true
type: string
permissions: {}
jobs:
publish:
runs-on: ubuntu-latest
environment: pypi-coderouter
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
fetch-depth: 0
- name: Validate protected release tag
env:
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]
[[ "$GITHUB_REF" == "refs/tags/coderouter-v$VERSION" ]]
[[ "$(node coderouter/scripts/check-version.mjs)" == "$VERSION" ]]
git fetch origin main
git merge-base --is-ancestor "$GITHUB_SHA" origin/main
- name: Download wheels from the signed GitHub release
uses: robinraju/release-downloader@daf26c55d821e836577a15f77d86ddc078948b05 # v1.12
with:
tag: coderouter-v${{ inputs.version }}
fileName: "coderouter-*.whl"
out-file-path: dist
- name: Publish through PyPI Trusted Publishing
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
with:
packages-dir: dist
attestations: true
+141
View File
@@ -0,0 +1,141 @@
name: coderouter release
on:
push:
tags:
- "coderouter-v*"
permissions: {}
jobs:
validate:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
fetch-depth: 0
- id: version
run: |
set -euo pipefail
version="${GITHUB_REF_NAME#coderouter-v}"
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]
actual="$(node coderouter/scripts/check-version.mjs)"
[[ "$actual" == "$version" ]] || {
echo "tag version $version does not match package version $actual" >&2
exit 1
}
git fetch origin main
git merge-base --is-ancestor "$GITHUB_SHA" origin/main
echo "version=$version" >> "$GITHUB_OUTPUT"
build:
needs: validate
strategy:
fail-fast: false
matrix:
include:
- runner: macos-14-xlarge
rust_target: aarch64-apple-darwin
npm_target: darwin-arm64
executable: coderouter
- runner: macos-15-intel
rust_target: x86_64-apple-darwin
npm_target: darwin-x64
executable: coderouter
- runner: ubuntu-latest
rust_target: x86_64-unknown-linux-gnu
npm_target: linux-x64
executable: coderouter
- runner: windows-latest
rust_target: x86_64-pc-windows-msvc
npm_target: win32-x64
executable: coderouter.exe
runs-on: ${{ matrix.runner }}
permissions:
contents: read
defaults:
run:
working-directory: coderouter
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.rust_target }}
- uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.2
with:
workspaces: coderouter
key: ${{ matrix.rust_target }}
- run: cargo test --release --target ${{ matrix.rust_target }}
- run: cargo build --release --target ${{ matrix.rust_target }} --bin coderouter
- name: Package npm platform binary
shell: bash
env:
VERSION: ${{ needs.validate.outputs.version }}
NPM_TARGET: ${{ matrix.npm_target }}
RUST_TARGET: ${{ matrix.rust_target }}
EXECUTABLE: ${{ matrix.executable }}
run: |
set -euo pipefail
mkdir -p dist/npm
node scripts/package-npm.mjs \
"$VERSION" "$NPM_TARGET" \
"target/$RUST_TARGET/release/$EXECUTABLE" dist/npm
npm pack "dist/npm/cli-$NPM_TARGET" --pack-destination dist
package="$(find dist -maxdepth 1 -name 'coderouter-cli-*.tgz' -print -quit)"
mv "$package" "dist/coderouter-npm-cli-$NPM_TARGET.tgz"
- name: Build PyPI wheel
uses: PyO3/maturin-action@86b9d133d34bc1b40018696f782949dac11bd380 # v1.49.4
with:
command: build
target: ${{ matrix.rust_target }}
args: --release --out coderouter/dist --manifest-path coderouter/Cargo.toml
manylinux: auto
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: coderouter-${{ matrix.npm_target }}
path: |
coderouter/dist/coderouter-npm-cli-${{ matrix.npm_target }}.tgz
coderouter/dist/*.whl
if-no-files-found: error
release:
needs: [validate, build]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
pattern: coderouter-*
path: dist
merge-multiple: true
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22.14.0"
- name: Package npm launcher
env:
VERSION: ${{ needs.validate.outputs.version }}
run: |
set -euo pipefail
[[ "$(node coderouter/scripts/check-version.mjs)" == "$VERSION" ]]
npm pack coderouter/npm --pack-destination dist
mv "dist/coderouter-$VERSION.tgz" dist/coderouter-npm-launcher.tgz
sha256sum dist/* > dist/SHA256SUMS
- name: Create immutable GitHub release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "$GITHUB_REF_NAME" dist/* \
--verify-tag \
--title "CodeRouter ${{ needs.validate.outputs.version }}" \
--generate-notes
+318 -58
View File
@@ -21,12 +21,13 @@ jobs:
permissions:
contents: read
outputs:
artifact_sha256: ${{ steps.package.outputs.artifact_sha256 }}
sdk_sha256: ${{ steps.package.outputs.sdk_sha256 }}
sidebar_sha256: ${{ steps.package.outputs.sidebar_sha256 }}
steps:
- name: Require explicit bootstrap confirmation
if: github.event.client_payload.confirm_bootstrap != true
run: |
echo "Refusing to reserve cmux-sidebar without confirm_bootstrap=true." >&2
echo "Refusing to reserve the Rust SDK crates without confirm_bootstrap=true." >&2
exit 1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -60,46 +61,72 @@ jobs:
id: package
run: |
set -euo pipefail
source_dir="cmux-tui/bindings/bootstrap/rust-sidebar"
bootstrap_dir="$RUNNER_TEMP/cmux-sidebar-bootstrap"
cp -R "$source_dir" "$bootstrap_dir"
manifest="$bootstrap_dir/Cargo.toml"
cargo test --manifest-path "$manifest" --locked
cargo package --manifest-path "$manifest" --locked --no-verify
artifact="$bootstrap_dir/target/package/cmux-sidebar-$BOOTSTRAP_VERSION.crate"
[[ -f "$artifact" ]] || {
echo "bootstrap crate was not created" >&2
exit 1
}
verify_dir="$RUNNER_TEMP/cmux-sidebar-bootstrap-verify"
mkdir -p "$verify_dir"
tar -xzf "$artifact" -C "$verify_dir"
cargo test \
--manifest-path \
"$verify_dir/cmux-sidebar-$BOOTSTRAP_VERSION/Cargo.toml" \
--locked
artifact_sha256="$(sha256sum "$artifact" | cut -d ' ' -f 1)"
[[ "$artifact_sha256" =~ ^[0-9a-f]{64}$ ]] || {
echo "bootstrap crate digest is malformed" >&2
exit 1
}
echo "artifact_sha256=$artifact_sha256" >> "$GITHUB_OUTPUT"
for specification in \
"cmux-sdk:rust-sdk:sdk_sha256" \
"cmux-sidebar:rust-sidebar:sidebar_sha256"; do
IFS=: read -r package source output_name <<< "$specification"
source_dir="cmux-tui/bindings/bootstrap/$source"
bootstrap_dir="$RUNNER_TEMP/$package-bootstrap"
cp -R "$source_dir" "$bootstrap_dir"
manifest="$bootstrap_dir/Cargo.toml"
cargo test --manifest-path "$manifest" --locked
cargo package --manifest-path "$manifest" --locked --no-verify
artifact="$bootstrap_dir/target/package/$package-$BOOTSTRAP_VERSION.crate"
[[ -f "$artifact" ]] || {
echo "$package bootstrap crate was not created" >&2
exit 1
}
verify_dir="$RUNNER_TEMP/$package-bootstrap-verify"
mkdir -p "$verify_dir"
tar -xzf "$artifact" -C "$verify_dir"
cargo test \
--manifest-path \
"$verify_dir/$package-$BOOTSTRAP_VERSION/Cargo.toml" \
--locked
artifact_dir="$RUNNER_TEMP/$package-bootstrap-artifact"
mkdir -p "$artifact_dir"
cp "$artifact" "$artifact_dir/"
artifact_sha256="$(sha256sum "$artifact" | cut -d ' ' -f 1)"
[[ "$artifact_sha256" =~ ^[0-9a-f]{64}$ ]] || {
echo "$package bootstrap crate digest is malformed" >&2
exit 1
}
echo "$output_name=$artifact_sha256" >> "$GITHUB_OUTPUT"
done
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- name: Upload the cmux-sdk bootstrap crate
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: cmux-sdk-bootstrap-crate
path: ${{ runner.temp }}/cmux-sdk-bootstrap-artifact
if-no-files-found: error
overwrite: true
- name: Upload the cmux-sidebar bootstrap crate
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: cmux-sidebar-bootstrap-crate
path: ${{ runner.temp }}/cmux-sidebar-bootstrap/target/package/*.crate
path: ${{ runner.temp }}/cmux-sidebar-bootstrap-artifact
if-no-files-found: error
overwrite: true
preflight:
needs: build
strategy:
fail-fast: false
max-parallel: 1
matrix:
include:
- package: cmux-sdk
artifact: cmux-sdk-bootstrap-crate
decision: cmux-sdk-bootstrap-decision
- package: cmux-sidebar
artifact: cmux-sidebar-bootstrap-crate
decision: cmux-sidebar-bootstrap-decision
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 10
permissions:
contents: read
outputs:
need_publish: ${{ steps.decision.outputs.need_publish }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -108,14 +135,16 @@ jobs:
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: cmux-sidebar-bootstrap-crate
name: ${{ matrix.artifact }}
path: bootstrap-crate
- name: Inspect the crates.io bootstrap state
id: project
env:
PACKAGE: ${{ matrix.package }}
run: |
set -euo pipefail
metadata="$RUNNER_TEMP/cmux-sidebar-bootstrap-registry.json"
metadata="$RUNNER_TEMP/$PACKAGE-bootstrap-registry.json"
status="$(
curl \
--silent \
@@ -127,15 +156,15 @@ jobs:
--user-agent 'cmux-sdk-bootstrap/1 (https://github.com/manaflow-ai/cmux; contact: https://github.com/manaflow-ai/cmux/issues)' \
--output "$metadata" \
--write-out '%{http_code}' \
https://crates.io/api/v1/crates/cmux-sidebar
"https://crates.io/api/v1/crates/$PACKAGE"
)"
case "$status" in
404)
echo "cmux-sidebar is unclaimed; bootstrap may continue."
echo "$PACKAGE is unclaimed; bootstrap may continue."
project_status=missing
;;
200)
echo "cmux-sidebar exists; bootstrap bytes must match."
echo "$PACKAGE exists; bootstrap bytes must match."
project_status=exists
;;
*)
@@ -144,11 +173,12 @@ jobs:
;;
esac
echo "status=$project_status" >> "$GITHUB_OUTPUT"
# Keep the next crates.io API client inside the one-request-per-second policy.
sleep 1
- name: Reconcile an existing crates.io ownership bootstrap
if: steps.project.outputs.status == 'exists'
env:
PACKAGE: ${{ matrix.package }}
run: |
set -euo pipefail
shopt -s nullglob
@@ -159,38 +189,102 @@ jobs:
}
python3 cmux-tui/bindings/reconcile_registry_artifact.py check \
--registry crates \
--package cmux-sidebar \
--package "$PACKAGE" \
--version "$BOOTSTRAP_VERSION" \
--artifact "${artifacts[0]}" \
--require-match
sleep 1
python3 cmux-tui/bindings/verify_crates_ownership.py \
--package cmux-sidebar \
--package "$PACKAGE" \
--repository https://github.com/manaflow-ai/cmux \
--owner-id 431397 \
--owner-login lawrencecchen \
--bootstrap-ownership-only
- name: Request publication for an unclaimed project
id: decision
if: steps.project.outputs.status == 'missing'
run: echo "need_publish=true" >> "$GITHUB_OUTPUT"
- name: Record the credential-job decision
env:
PACKAGE: ${{ matrix.package }}
PROJECT_STATUS: ${{ steps.project.outputs.status }}
run: |
set -euo pipefail
case "$PROJECT_STATUS" in
missing) decision=publish ;;
exists) decision=skip ;;
*)
echo "unexpected $PACKAGE project state: $PROJECT_STATUS" >&2
exit 1
;;
esac
decision_dir="$RUNNER_TEMP/$PACKAGE-bootstrap-decision"
mkdir -p "$decision_dir"
printf '%s\n' "$decision" > "$decision_dir/decision.txt"
publish:
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ matrix.decision }}
path: ${{ runner.temp }}/${{ matrix.package }}-bootstrap-decision
if-no-files-found: error
overwrite: true
decisions:
needs:
- preflight
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 5
permissions:
actions: read
outputs:
sdk_need_publish: ${{ steps.read.outputs.sdk_need_publish }}
sidebar_need_publish: ${{ steps.read.outputs.sidebar_need_publish }}
steps:
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: cmux-sdk-bootstrap-decision
path: sdk-decision
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: cmux-sidebar-bootstrap-decision
path: sidebar-decision
- name: Export protected-environment decisions
id: read
run: |
set -euo pipefail
read_decision() {
local path="$1"
local output_name="$2"
local decision
decision="$(cat "$path")"
case "$decision" in
publish) need_publish=true ;;
skip) need_publish=false ;;
*)
echo "invalid bootstrap publication decision: $decision" >&2
exit 1
;;
esac
echo "$output_name=$need_publish" >> "$GITHUB_OUTPUT"
}
read_decision sdk-decision/decision.txt sdk_need_publish
read_decision sidebar-decision/decision.txt sidebar_need_publish
publish-sdk:
needs:
- build
- preflight
if: needs.preflight.outputs.need_publish == 'true'
- decisions
if: needs.decisions.outputs.sdk_need_publish == 'true'
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 10
permissions: {}
environment:
name: crates-bootstrap
url: https://crates.io/crates/cmux-sidebar
url: https://crates.io/crates/cmux-sdk
steps:
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: cmux-sidebar-bootstrap-crate
name: cmux-sdk-bootstrap-crate
path: bootstrap-crate
- name: Install pinned Rust toolchain
@@ -203,7 +297,8 @@ jobs:
- name: Verify protected source and reproduce the tested crate
id: prepare
env:
EXPECTED_SHA256: ${{ needs.build.outputs.artifact_sha256 }}
EXPECTED_SHA256: ${{ needs.build.outputs.sdk_sha256 }}
PACKAGE: cmux-sdk
run: |
set -euo pipefail
[[ "$GITHUB_REPOSITORY" == "manaflow-ai/cmux" ]] || {
@@ -224,6 +319,7 @@ jobs:
echo "workflow commit $GITHUB_SHA is not current main $main_sha" >&2
exit 1
}
[[ "$EXPECTED_SHA256" =~ ^[0-9a-f]{64}$ ]] || {
echo "validated crate digest is malformed" >&2
exit 1
@@ -235,14 +331,18 @@ jobs:
exit 1
}
BOOTSTRAP_ARTIFACT="${artifacts[0]}"
[[ "$(basename "$BOOTSTRAP_ARTIFACT")" == "$PACKAGE-$BOOTSTRAP_VERSION.crate" ]] || {
echo "unexpected bootstrap crate filename" >&2
exit 1
}
actual_sha256="$(sha256sum "$BOOTSTRAP_ARTIFACT" | cut -d ' ' -f 1)"
[[ "$actual_sha256" == "$EXPECTED_SHA256" ]] || {
echo "downloaded crates.io bootstrap artifact digest mismatch" >&2
exit 1
}
package_prefix="cmux-sidebar-$BOOTSTRAP_VERSION"
publish_root="$RUNNER_TEMP/cmux-sidebar-publish"
package_prefix="$PACKAGE-$BOOTSTRAP_VERSION"
publish_root="$RUNNER_TEMP/$PACKAGE-publish"
mkdir -p "$publish_root"
python3 - \
"$BOOTSTRAP_ARTIFACT" \
@@ -294,10 +394,157 @@ jobs:
--no-verify
REPACKED_ARTIFACT="$package_root/target/package/$package_prefix.crate"
cmp "$BOOTSTRAP_ARTIFACT" "$REPACKED_ARTIFACT"
{
echo "artifact=$BOOTSTRAP_ARTIFACT"
echo "manifest=$package_root/Cargo.toml"
} >> "$GITHUB_OUTPUT"
echo "manifest=$package_root/Cargo.toml" >> "$GITHUB_OUTPUT"
- name: Publish the exact tested ownership bootstrap
continue-on-error: true
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_BOOTSTRAP_TOKEN }}
PUBLISH_MANIFEST: ${{ steps.prepare.outputs.manifest }}
run: |
set -euo pipefail
[[ -n "$CARGO_REGISTRY_TOKEN" ]] || {
echo "crates-bootstrap environment secret CARGO_BOOTSTRAP_TOKEN is required." >&2
exit 1
}
cargo publish \
--manifest-path "$PUBLISH_MANIFEST" \
--locked \
--no-verify
publish-sidebar:
needs:
- build
- preflight
- decisions
- publish-sdk
if: >-
always() &&
!cancelled() &&
needs.build.result == 'success' &&
needs.preflight.result == 'success' &&
needs.decisions.result == 'success' &&
needs.decisions.outputs.sidebar_need_publish == 'true'
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 10
permissions: {}
environment:
name: crates-bootstrap
url: https://crates.io/crates/cmux-sidebar
steps:
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: cmux-sidebar-bootstrap-crate
path: bootstrap-crate
- name: Install pinned Rust toolchain
run: |
rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal
rustup default "$RUST_TOOLCHAIN"
cargo --version
rustc --version
- name: Verify protected source and reproduce the tested crate
id: prepare
env:
EXPECTED_SHA256: ${{ needs.build.outputs.sidebar_sha256 }}
PACKAGE: cmux-sidebar
run: |
set -euo pipefail
[[ "$GITHUB_REPOSITORY" == "manaflow-ai/cmux" ]] || {
echo "bootstrap repository must be manaflow-ai/cmux" >&2
exit 1
}
[[ "$GITHUB_REF" == "refs/heads/main" ]] || {
echo "bootstrap credential job must run from main" >&2
exit 1
}
main_sha="$(
git ls-remote \
https://github.com/manaflow-ai/cmux.git \
refs/heads/main |
awk 'NR == 1 { print $1 }'
)"
[[ "$main_sha" == "$GITHUB_SHA" ]] || {
echo "workflow commit $GITHUB_SHA is not current main $main_sha" >&2
exit 1
}
[[ "$EXPECTED_SHA256" =~ ^[0-9a-f]{64}$ ]] || {
echo "validated crate digest is malformed" >&2
exit 1
}
shopt -s nullglob
artifacts=(bootstrap-crate/*.crate)
[[ "${#artifacts[@]}" == 1 ]] || {
echo "expected one tested crate, found ${#artifacts[@]}" >&2
exit 1
}
BOOTSTRAP_ARTIFACT="${artifacts[0]}"
[[ "$(basename "$BOOTSTRAP_ARTIFACT")" == "$PACKAGE-$BOOTSTRAP_VERSION.crate" ]] || {
echo "unexpected bootstrap crate filename" >&2
exit 1
}
actual_sha256="$(sha256sum "$BOOTSTRAP_ARTIFACT" | cut -d ' ' -f 1)"
[[ "$actual_sha256" == "$EXPECTED_SHA256" ]] || {
echo "downloaded crates.io bootstrap artifact digest mismatch" >&2
exit 1
}
package_prefix="$PACKAGE-$BOOTSTRAP_VERSION"
publish_root="$RUNNER_TEMP/$PACKAGE-publish"
mkdir -p "$publish_root"
python3 - \
"$BOOTSTRAP_ARTIFACT" \
"$publish_root" \
"$package_prefix" <<'PY'
import pathlib
import shutil
import sys
import tarfile
archive_path = pathlib.Path(sys.argv[1])
publish_root = pathlib.Path(sys.argv[2])
package_prefix = sys.argv[3]
expected = {
f"{package_prefix}/Cargo.lock",
f"{package_prefix}/Cargo.toml",
f"{package_prefix}/Cargo.toml.orig",
f"{package_prefix}/README.md",
f"{package_prefix}/src/lib.rs",
}
with tarfile.open(archive_path, "r:gz") as archive:
members = archive.getmembers()
names = [member.name for member in members]
if len(names) != len(expected) or set(names) != expected:
raise SystemExit(
f"bootstrap crate paths differ from the allowlist: {names!r}"
)
for member in members:
if not member.isfile():
raise SystemExit(
f"bootstrap crate member is not a regular file: {member.name}"
)
source = archive.extractfile(member)
if source is None:
raise SystemExit(
f"bootstrap crate member cannot be read: {member.name}"
)
destination = publish_root / member.name
destination.parent.mkdir(parents=True, exist_ok=True)
with source, destination.open("wb") as output:
shutil.copyfileobj(source, output)
destination.chmod(member.mode & 0o777)
PY
package_root="$publish_root/$package_prefix"
cp "$package_root/Cargo.toml.orig" "$package_root/Cargo.toml"
cargo package \
--manifest-path "$package_root/Cargo.toml" \
--locked \
--no-verify
REPACKED_ARTIFACT="$package_root/target/package/$package_prefix.crate"
cmp "$BOOTSTRAP_ARTIFACT" "$REPACKED_ARTIFACT"
echo "manifest=$package_root/Cargo.toml" >> "$GITHUB_OUTPUT"
- name: Publish the exact tested ownership bootstrap
continue-on-error: true
@@ -319,12 +566,23 @@ jobs:
needs:
- build
- preflight
- publish
- decisions
- publish-sdk
- publish-sidebar
if: >-
always() &&
needs.build.result == 'success' &&
needs.preflight.result == 'success' &&
(needs.publish.result == 'success' || needs.publish.result == 'skipped')
needs.decisions.result == 'success'
strategy:
fail-fast: false
max-parallel: 1
matrix:
include:
- package: cmux-sdk
artifact: cmux-sdk-bootstrap-crate
- package: cmux-sidebar
artifact: cmux-sidebar-bootstrap-crate
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 10
permissions:
@@ -337,10 +595,12 @@ jobs:
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: cmux-sidebar-bootstrap-crate
name: ${{ matrix.artifact }}
path: bootstrap-crate
- name: Reconcile the exact crates.io ownership bootstrap
env:
PACKAGE: ${{ matrix.package }}
run: |
set -euo pipefail
shopt -s nullglob
@@ -351,7 +611,7 @@ jobs:
}
python3 cmux-tui/bindings/reconcile_registry_artifact.py check \
--registry crates \
--package cmux-sidebar \
--package "$PACKAGE" \
--version "$BOOTSTRAP_VERSION" \
--artifact "${artifacts[0]}" \
--retry-missing-project \
@@ -359,7 +619,7 @@ jobs:
--require-match
sleep 1
python3 cmux-tui/bindings/verify_crates_ownership.py \
--package cmux-sidebar \
--package "$PACKAGE" \
--repository https://github.com/manaflow-ai/cmux \
--owner-id 431397 \
--owner-login lawrencecchen \
+8 -8
View File
@@ -126,19 +126,19 @@ jobs:
CMUX_SDK_VERSION: ${{ needs.version.outputs.version }}
run: |
set -euo pipefail
cargo test -p cmux-client -p cmux-sidebar --locked
cargo package -p cmux-client --locked
cargo test -p cmux-sdk -p cmux-sidebar --locked
cargo package -p cmux-sdk --locked
cargo package \
-p cmux-sidebar \
--locked \
--no-verify \
--config \
"patch.crates-io.cmux-client.path='$GITHUB_WORKSPACE/cmux-tui/bindings/rust'"
"patch.crates-io.cmux-sdk.path='$GITHUB_WORKSPACE/cmux-tui/bindings/rust'"
verify_root="$RUNNER_TEMP/cmux-rust-package-verify"
mkdir -p "$verify_root"
tar -xzf \
"target/package/cmux-client-$CMUX_SDK_VERSION.crate" \
"target/package/cmux-sdk-$CMUX_SDK_VERSION.crate" \
-C "$verify_root"
tar -xzf \
"target/package/cmux-sidebar-$CMUX_SDK_VERSION.crate" \
@@ -147,14 +147,14 @@ jobs:
--manifest-path \
"$verify_root/cmux-sidebar-$CMUX_SDK_VERSION/Cargo.toml" \
--config \
"patch.crates-io.cmux-client.path='$verify_root/cmux-client-$CMUX_SDK_VERSION'" \
"patch.crates-io.cmux-sdk.path='$verify_root/cmux-sdk-$CMUX_SDK_VERSION'" \
--all-targets
- name: Upload validated cmux-client crate
- name: Upload validated cmux-sdk crate
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: cmux-rust-client-crate
path: cmux-tui/target/package/cmux-client-${{ needs.version.outputs.version }}.crate
name: cmux-rust-sdk-crate
path: cmux-tui/target/package/cmux-sdk-${{ needs.version.outputs.version }}.crate
if-no-files-found: error
overwrite: true
+40 -40
View File
@@ -164,11 +164,11 @@ jobs:
persist-credentials: false
ref: ${{ github.sha }}
- name: Download the validated cmux-client crate
- name: Download the validated cmux-sdk crate
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: cmux-rust-client-crate
path: validated-client
name: cmux-rust-sdk-crate
path: validated-rust-sdk
- name: Download the validated cmux-sidebar crate
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
@@ -213,9 +213,9 @@ jobs:
python3 cmux-tui/bindings/reconcile_registry_artifact.py check \
--registry crates \
--package cmux-client \
--package cmux-sdk \
--version "$CMUX_SDK_VERSION" \
--artifact "validated-client/cmux-client-$CMUX_SDK_VERSION.crate"
--artifact "validated-rust-sdk/cmux-sdk-$CMUX_SDK_VERSION.crate"
sleep 1
python3 cmux-tui/bindings/reconcile_registry_artifact.py check \
--registry crates \
@@ -252,7 +252,7 @@ jobs:
run: |
sleep 1
python3 cmux-tui/bindings/verify_crates_ownership.py \
--package cmux-client \
--package cmux-sdk \
--package cmux-sidebar \
--repository https://github.com/manaflow-ai/cmux \
--owner-id 431397 \
@@ -375,11 +375,11 @@ jobs:
ref: ${{ github.sha }}
fetch-depth: 0
- name: Download the validated cmux-client crate for final revalidation
- name: Download the validated cmux-sdk crate for final revalidation
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: cmux-rust-client-crate
path: validated-client
name: cmux-rust-sdk-crate
path: validated-rust-sdk
- name: Download the validated cmux-sidebar crate for final revalidation
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
@@ -846,7 +846,7 @@ jobs:
verify_tag: true
release_ref: refs/tags/${{ needs.validate-release.outputs.go_tag }}
publish-crate-client:
publish-crate-sdk:
needs:
- validate-release
- cut-tags
@@ -859,7 +859,7 @@ jobs:
timeout-minutes: 30
environment:
name: crates-io
url: https://crates.io/crates/cmux-client
url: https://crates.io/crates/cmux-sdk
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -867,11 +867,11 @@ jobs:
ref: ${{ github.sha }}
fetch-depth: 0
- name: Download the validated cmux-client crate
- name: Download the validated cmux-sdk crate
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: cmux-rust-client-crate
path: validated-client
name: cmux-rust-sdk-crate
path: validated-rust-sdk
- name: Require the coordinated release source
env:
@@ -905,50 +905,50 @@ jobs:
cargo --version
rustc --version
- name: Reproduce the validated cmux-client crate without executing it
- name: Reproduce the validated cmux-sdk crate without executing it
working-directory: cmux-tui
env:
CMUX_SDK_VERSION: ${{ needs.validate-release.outputs.version }}
run: |
set -euo pipefail
cargo package -p cmux-client --locked --no-verify
validated="$GITHUB_WORKSPACE/validated-client/cmux-client-$CMUX_SDK_VERSION.crate"
reproduced="target/package/cmux-client-$CMUX_SDK_VERSION.crate"
cargo package -p cmux-sdk --locked --no-verify
validated="$GITHUB_WORKSPACE/validated-rust-sdk/cmux-sdk-$CMUX_SDK_VERSION.crate"
reproduced="target/package/cmux-sdk-$CMUX_SDK_VERSION.crate"
expected_digest="$(sha256sum "$validated" | awk '{print $1}')"
actual_digest="$(sha256sum "$reproduced" | awk '{print $1}')"
[[ "$actual_digest" == "$expected_digest" ]] || {
echo "validated crate digest mismatch for cmux-client@$CMUX_SDK_VERSION" >&2
echo "validated crate digest mismatch for cmux-sdk@$CMUX_SDK_VERSION" >&2
exit 1
}
- name: Revalidate crates.io ownership immediately before authentication
run: bash cmux-tui/bindings/verify_release_registry_authority.sh crates
- name: Authenticate cmux-client with crates.io trusted publishing
id: auth_client
- name: Authenticate cmux-sdk with crates.io trusted publishing
id: auth_sdk
uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1.0.5
- name: Publish cmux-client
- name: Publish cmux-sdk
working-directory: cmux-tui
env:
CARGO_REGISTRY_TOKEN: ${{ steps.auth_client.outputs.token }}
CARGO_REGISTRY_TOKEN: ${{ steps.auth_sdk.outputs.token }}
CMUX_SDK_VERSION: ${{ needs.validate-release.outputs.version }}
run: |
python3 bindings/reconcile_registry_artifact.py publish \
--registry crates \
--package cmux-client \
--package cmux-sdk \
--version "$CMUX_SDK_VERSION" \
--artifact "$GITHUB_WORKSPACE/validated-client/cmux-client-$CMUX_SDK_VERSION.crate" \
--artifact "$GITHUB_WORKSPACE/validated-rust-sdk/cmux-sdk-$CMUX_SDK_VERSION.crate" \
--wait-seconds 120 \
--publish-timeout-seconds 600 \
-- cargo publish -p cmux-client --locked --no-verify
-- cargo publish -p cmux-sdk --locked --no-verify
publish-crate-sidebar:
needs:
- validate-release
- cut-tags
- verify-go-tag
- publish-crate-client
- publish-crate-sdk
permissions:
actions: read
contents: read
@@ -971,11 +971,11 @@ jobs:
name: cmux-rust-sidebar-crate
path: validated-sidebar
- name: Download the validated cmux-client crate
- name: Download the validated cmux-sdk crate
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: cmux-rust-client-crate
path: validated-client
name: cmux-rust-sdk-crate
path: validated-rust-sdk
- name: Require the coordinated release source
env:
@@ -1009,7 +1009,7 @@ jobs:
cargo --version
rustc --version
- name: Confirm the validated cmux-client crate reached crates.io
- name: Confirm the validated cmux-sdk crate reached crates.io
working-directory: cmux-tui
env:
CMUX_SDK_VERSION: ${{ needs.validate-release.outputs.version }}
@@ -1017,9 +1017,9 @@ jobs:
set -euo pipefail
python3 bindings/reconcile_registry_artifact.py check \
--registry crates \
--package cmux-client \
--package cmux-sdk \
--version "$CMUX_SDK_VERSION" \
--artifact "$GITHUB_WORKSPACE/validated-client/cmux-client-$CMUX_SDK_VERSION.crate" \
--artifact "$GITHUB_WORKSPACE/validated-rust-sdk/cmux-sdk-$CMUX_SDK_VERSION.crate" \
--wait-seconds 300 \
--require-match
@@ -1034,7 +1034,7 @@ jobs:
--locked \
--no-verify \
--config \
"patch.crates-io.cmux-client.path='$GITHUB_WORKSPACE/cmux-tui/bindings/rust'"
"patch.crates-io.cmux-sdk.path='$GITHUB_WORKSPACE/cmux-tui/bindings/rust'"
validated="$GITHUB_WORKSPACE/validated-sidebar/cmux-sidebar-$CMUX_SDK_VERSION.crate"
reproduced="target/package/cmux-sidebar-$CMUX_SDK_VERSION.crate"
expected_digest="$(sha256sum "$validated" | awk '{print $1}')"
@@ -1069,7 +1069,7 @@ jobs:
--locked \
--no-verify \
--config \
"patch.crates-io.cmux-client.path='$GITHUB_WORKSPACE/cmux-tui/bindings/rust'"
"patch.crates-io.cmux-sdk.path='$GITHUB_WORKSPACE/cmux-tui/bindings/rust'"
publish-npm:
needs:
@@ -1429,7 +1429,7 @@ jobs:
- validate-release
- typescript-preflight
- python-preflight
- publish-crate-client
- publish-crate-sdk
- publish-crate-sidebar
- publish-npm
- publish-python-wheel
@@ -1447,7 +1447,7 @@ jobs:
- name: Revalidate crates.io ownership and publishing policy
run: |
python3 cmux-tui/bindings/verify_crates_ownership.py \
--package cmux-client \
--package cmux-sdk \
--package cmux-sidebar \
--repository https://github.com/manaflow-ai/cmux \
--owner-id 431397 \
@@ -1541,7 +1541,7 @@ jobs:
- revalidate-tags
- cut-tags
- verify-go-tag
- publish-crate-client
- publish-crate-sdk
- publish-crate-sidebar
- publish-npm
- publish-python-wheel
@@ -1564,7 +1564,7 @@ jobs:
REVALIDATE_TAGS_RESULT: ${{ needs.revalidate-tags.result }}
CUT_TAGS_RESULT: ${{ needs.cut-tags.result }}
GO_TAG_RESULT: ${{ needs.verify-go-tag.result }}
CRATE_CLIENT_RESULT: ${{ needs.publish-crate-client.result }}
CRATE_SDK_RESULT: ${{ needs.publish-crate-sdk.result }}
CRATE_SIDEBAR_RESULT: ${{ needs.publish-crate-sidebar.result }}
NPM_RESULT: ${{ needs.publish-npm.result }}
PYTHON_WHEEL_RESULT: ${{ needs.publish-python-wheel.result }}
@@ -1585,7 +1585,7 @@ jobs:
echo "- Approval-fresh revalidation: \`$REVALIDATE_TAGS_RESULT\`"
echo "- Tag creation: \`$CUT_TAGS_RESULT\`"
echo "- Public Go tag: \`$GO_TAG_RESULT\`"
echo "- cmux-client publish: \`$CRATE_CLIENT_RESULT\`"
echo "- cmux-sdk publish: \`$CRATE_SDK_RESULT\`"
echo "- cmux-sidebar publish: \`$CRATE_SIDEBAR_RESULT\`"
echo "- npm publish: \`$NPM_RESULT\`"
echo "- PyPI wheel publish: \`$PYTHON_WHEEL_RESULT\`"
+4
View File
@@ -162,6 +162,10 @@ jobs:
run: |
swift test --package-path Packages/iOS/CmuxMobilePairedMac
- name: Run CmuxMobileChanges package tests
run: |
swift test --package-path Packages/iOS/CmuxMobileChanges
- name: Run CmuxMobileShell package tests
run: |
# iOS shell replay/liveness regressions live in this package target.
+20
View File
@@ -189,6 +189,26 @@ enum AgentHookNotificationClassifier {
enum AgentHookNotificationPolicy {
static let dedupeEligibleAgents: Set<String> = ["grok", "antigravity"]
static func notificationTitle(
agentName: String,
displayName: String,
surfaceTitle: String?
) -> String {
guard agentName == "pi",
let surfaceTitle = surfaceTitle?.trimmingCharacters(in: .whitespacesAndNewlines),
!surfaceTitle.isEmpty else {
return displayName
}
if surfaceTitle.caseInsensitiveCompare(displayName) == .orderedSame
|| surfaceTitle.range(
of: "\(displayName) · ",
options: [.anchored, .caseInsensitive]
) != nil {
return surfaceTitle
}
return "\(displayName) · \(surfaceTitle)"
}
/// Stable per-session fingerprint. Grok 0.2.91 emits an identical generic
/// "Tool permission requested" Notification for every tool step, even in
/// auto-approve mode where nothing awaits the user; those repeats dedupe by
+20
View File
@@ -141,6 +141,26 @@ struct AutoNamingEnvironmentPolicy: Sendable {
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return override.isEmpty ? "haiku" : override
}
/// Inline MCP configuration passed with `--strict-mcp-config` so the
/// summarizer starts no MCP servers. Claude Code validates this JSON
/// against a schema requiring an `mcpServers` record, so a bare `{}` is
/// rejected during argument parsing and the subprocess exits before it
/// can produce a title (cmux#9457).
static let emptyMCPConfigJSON = #"{"mcpServers":{}}"#
/// Argument vector for the tool-disabled `claude -p` summarizer call.
func claudeSummarizerArguments(from env: [String: String]) -> [String] {
[
"-p",
"--model", claudeModel(from: env),
"--tools", "",
"--disable-slash-commands",
"--no-session-persistence",
"--strict-mcp-config",
"--mcp-config", Self.emptyMCPConfigJSON
]
}
}
/// Pure auto-naming logic: throttle decisions, transcript extraction,
+1 -9
View File
@@ -123,15 +123,7 @@ extension CMUXCLI {
guard let executable else { return nil }
return runAutoNamingSummarizer(
executable: executable,
arguments: [
"-p",
"--model", policy.claudeModel(from: env),
"--tools", "",
"--disable-slash-commands",
"--no-session-persistence",
"--strict-mcp-config",
"--mcp-config", "{}"
],
arguments: policy.claudeSummarizerArguments(from: env),
prompt: prompt,
environment: policy.summarizerEnvironment(from: env),
timeout: timeout
+94 -20
View File
@@ -3,6 +3,7 @@ import Darwin
import Foundation
private struct EventStreamLimitReached: Error {}
private struct EventStreamSnapshotCaptured: Error {}
extension CMUXCLI {
private struct EventsCommandOptions {
@@ -12,6 +13,8 @@ extension CMUXCLI {
var categories: [String] = []
var reconnect = false
var limit: Int?
var timeout: TimeInterval?
var snapshotOnly = false
var printAck = true
var printHeartbeats = true
}
@@ -28,15 +31,56 @@ extension CMUXCLI {
var lastSeq = options.afterSeq
var emittedEvents = 0
// The --timeout budget is measured on a MONOTONIC clock so a
// wall-clock change (NTP step, timezone, manual set) can neither
// expire the whole command instantly nor extend it indefinitely.
// The socket layer takes wall-clock Dates, so each blocking call
// derives a fresh short-lived Date from the monotonic remainder;
// a wall jump can then only skew the single wait in flight, never
// the accumulated budget.
let budgetClock = ContinuousClock()
let budgetDeadline = options.timeout.map { budgetClock.now.advanced(by: .seconds($0)) }
func remainingBudget() -> TimeInterval? {
guard let budgetDeadline else { return nil }
let remaining = budgetClock.now.duration(to: budgetDeadline)
let seconds = Double(remaining.components.seconds)
+ Double(remaining.components.attoseconds) / 1e18
return max(0, seconds)
}
func socketDeadline() -> Date? {
remainingBudget().map { Date(timeIntervalSinceNow: $0) }
}
func timeoutError() -> CLIError {
CLIError(message: String(
localized: "cli.events.error.timeout",
defaultValue: "Timed out waiting for a matching event"
))
}
while true {
if let remaining = remainingBudget(), remaining <= 0 {
throw timeoutError()
}
let client = SocketClient(path: socketPath)
do {
try client.connect()
if let connectDeadline = socketDeadline() {
try client.connect(deadline: connectDeadline)
} else {
try client.connect()
}
// Connection setup may have consumed the rest of the budget;
// re-check before starting authentication so it always gets a
// non-negative timeout.
let authRemaining = remainingBudget()
if let authRemaining, authRemaining <= 0 {
throw timeoutError()
}
try authenticateClientIfNeeded(
client,
explicitPassword: explicitPassword,
socketPath: socketPath
socketPath: socketPath,
responseTimeout: authRemaining,
deadline: socketDeadline()
)
var params: [String: Any] = [
@@ -52,7 +96,11 @@ extension CMUXCLI {
params["categories"] = options.categories
}
try client.streamV2(method: "events.stream", params: params) { line in
try client.streamV2(
method: "events.stream",
params: params,
deadline: socketDeadline()
) { line in
guard !line.isEmpty else { return }
let frame = try parseEventStreamFrame(line)
let type = frame["type"] as? String ?? ""
@@ -67,15 +115,17 @@ extension CMUXCLI {
eventSequence = nil
}
if type == "ack", !options.printAck {
return
}
if type == "heartbeat", !options.printHeartbeats {
return
let shouldPrint =
(type != "ack" || options.printAck)
&& (type != "heartbeat" || options.printHeartbeats)
if shouldPrint {
print(line)
fflush(stdout)
}
print(line)
fflush(stdout)
if type == "ack", options.snapshotOnly {
throw EventStreamSnapshotCaptured()
}
if let eventSequence {
if let cursorFile = options.cursorFile {
@@ -88,15 +138,25 @@ extension CMUXCLI {
}
}
}
} catch is EventStreamSnapshotCaptured {
client.close()
return
} catch is EventStreamLimitReached {
client.close()
return
} catch {
client.close()
if let remaining = remainingBudget(), remaining <= 0 {
throw timeoutError()
}
guard options.reconnect, isTransientEventStreamError(error) else {
throw error
}
waitBeforeReconnectingEventStream()
let remaining = remainingBudget() ?? 1
guard remaining > 0 else {
throw timeoutError()
}
waitBeforeReconnectingEventStream(maximumDelay: remaining)
continue
}
}
@@ -133,15 +193,16 @@ extension CMUXCLI {
|| description.contains("timed out")
}
func waitBeforeReconnectingEventStream() {
let deadline = Date(timeIntervalSinceNow: 1.0)
var didFire = false
let timer = Timer(timeInterval: 1.0, repeats: false) { _ in
didFire = true
}
RunLoop.current.add(timer, forMode: .default)
while !didFire, RunLoop.current.run(mode: .default, before: deadline) {}
timer.invalidate()
func waitBeforeReconnectingEventStream(maximumDelay: TimeInterval = 1) {
let delay = min(1, max(0, maximumDelay))
guard delay > 0 else { return }
// This retry path runs on the CLI's synchronous command thread, which
// pumps no run loop: a Timer + RunLoop.run() wait can spin or park
// with `didFire` as its only exit. A bounded thread sleep is the
// deterministic wait; the caller already clamps the delay to the
// command's remaining --timeout budget, and killing the process (the
// CLI's only cancellation) interrupts it.
Thread.sleep(forTimeInterval: delay)
}
private func parseEventsOptions(_ args: [String]) throws -> EventsCommandOptions {
@@ -178,6 +239,19 @@ extension CMUXCLI {
throw CLIError(message: "--limit must be greater than 0")
}
options.limit = limit
case "--timeout":
let raw = try requireValue()
guard let timeout = TimeInterval(raw),
timeout.isFinite,
timeout > 0 else {
throw CLIError(message: String(
localized: "cli.events.error.invalidTimeout",
defaultValue: "--timeout must be greater than 0"
))
}
options.timeout = timeout
case "--snapshot":
options.snapshotOnly = true
case "--no-ack":
options.printAck = false
case "--no-heartbeat", "--no-heartbeats":
+8 -1
View File
@@ -85,7 +85,14 @@ extension CMUXCLI {
let candidate = URL(fileURLWithPath: entry, isDirectory: true)
.appendingPathComponent(name, isDirectory: false)
.path
guard FileManager.default.isExecutableFile(atPath: candidate) else { continue }
// `isExecutableFile(atPath:)` is true for directories, so a directory named
// like the provider binary would otherwise shadow the real executable and
// fail at execv (#8743). Reject directories the way the configured-candidate
// path in `resolveClaudeExecutable` already does.
var isDirectory: ObjCBool = false
guard FileManager.default.fileExists(atPath: candidate, isDirectory: &isDirectory),
!isDirectory.boolValue,
FileManager.default.isExecutableFile(atPath: candidate) else { continue }
guard !isBundledProviderExecutable(at: candidate) else { continue }
if let skip, skip(candidate) { continue }
return candidate
+25 -4
View File
@@ -17,6 +17,7 @@ interface PendingCompletion {
lastAssistantMessage?: string;
notificationType: string;
turnId: string;
suppressNotification: boolean;
}
interface SessionState {
@@ -375,18 +376,38 @@ function textFromContent(content: unknown): string | null {
return parts.join("\n") || null;
}
function lastAssistantMessage(event: unknown): string | undefined {
interface AssistantCompletion {
lastAssistantMessage?: string;
suppressNotification: boolean;
}
function assistantCompletionFrom(event: unknown): AssistantCompletion {
const messagesValue = objectValue(event, ["messages"]);
const messages = Array.isArray(messagesValue) ? messagesValue : [];
let suppressNotification = false;
let inspectedLatestAssistant = false;
// Resolve text and interruption metadata in one reverse pass. agent_end may
// carry a large message array, so notification support must not rescan it.
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (!message || typeof message !== "object") continue;
const typed = message as { role?: unknown; content?: unknown };
const typed = message as {
role?: unknown;
content?: unknown;
stopReason?: unknown;
cmuxSuppressNotification?: unknown;
};
if (typed.role !== "assistant") continue;
if (!inspectedLatestAssistant) {
// Input extensions may normalize an abort to `stop` to keep Pi's UI quiet;
// the marker preserves the interruption intent across that normalization.
suppressNotification = typed.stopReason === "aborted" || typed.cmuxSuppressNotification === true;
inspectedLatestAssistant = true;
}
const text = firstString(textFromContent(typed.content));
if (text) return text;
if (text) return { lastAssistantMessage: text, suppressNotification };
}
return undefined;
return { suppressNotification };
}
function sessionIdFrom(ctx: ExtensionContext): string | null {
+8 -3
View File
@@ -353,7 +353,11 @@ async function publishPendingCompletion(
last_assistant_message: completion.lastAssistantMessage,
turn_id: completion.turnId,
};
if (feedDelivered) {
if (completion.suppressNotification) {
// Stop normally creates cmux's native fallback notification when no explicit
// notification was routed. Mark intentional interruption as already handled.
stopPayload.cmux_notification_routed = true;
} else if (feedDelivered) {
const notificationRouted = await sendHook(dispatcher, "notification", context, {
message: completion.lastAssistantMessage || "Task completed",
turn_id: completion.turnId,
@@ -453,12 +457,13 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) {
const sessionId = context.sessionId;
if (!sessionId) return;
const state = stateFor(sessionStates, sessionId);
const message = lastAssistantMessage(event);
const assistantCompletion = assistantCompletionFrom(event);
// Preserve the latest low-level result until Pi confirms no automatic work remains.
state.pendingCompletion = {
lastAssistantMessage: message || state.pendingCompletion?.lastAssistantMessage,
lastAssistantMessage: assistantCompletion.lastAssistantMessage || state.pendingCompletion?.lastAssistantMessage,
notificationType: firstString(objectValue(event, ["stopReason", "reason", "terminationReason"])) || "completed",
turnId: currentTurnId(sessionStates, sessionId, event),
suppressNotification: assistantCompletion.suppressNotification,
};
// Older Pi versions do not emit agent_settled, so retain their established completion behavior.
if (!supportsAgentSettled()) {
+133 -18
View File
@@ -1795,6 +1795,7 @@ final class SocketClient {
private let path: String
private(set) var socketFD: Int32 = -1
private var streamReadBuffer = Data()
private var lastConfiguredReceiveTimeout: TimeInterval?
private var lastOperationTelemetry: CLISocketOperationTelemetry.State?
private static let defaultResponseTimeoutSeconds: TimeInterval = 15.0
@@ -1959,6 +1960,7 @@ final class SocketClient {
Darwin.close(socketFD)
socketFD = -1
}
streamReadBuffer.removeAll(keepingCapacity: true)
lastConfiguredReceiveTimeout = nil
}
@@ -2943,6 +2945,7 @@ final class SocketClient {
func streamV2(
method: String,
params: [String: Any] = [:],
deadline: Date? = nil,
onLine: (String) throws -> Void
) throws {
guard socketFD >= 0 else { throw CLIError(message: "Not connected") }
@@ -2960,26 +2963,60 @@ final class SocketClient {
try writeAll(
Data((capabilityWrappedCommand(requestLine) + "\n").utf8),
timeoutMessage: "Stream request timed out",
failureMessage: "Failed to write stream request"
failureMessage: "Failed to write stream request",
deadline: deadline
)
while true {
let line = try readStreamLine()
let line = try readStreamLine(deadline: deadline)
try onLine(line)
}
}
private func readStreamLine(maxBytes: Int = 4 * 1024 * 1024) throws -> String {
var data = Data()
try configureReceiveTimeout(45)
while data.count < maxBytes {
var byte: UInt8 = 0
let count = Darwin.read(socketFD, &byte, 1)
private func readStreamLine(
maxBytes: Int = 4 * 1024 * 1024,
deadline: Date? = nil
) throws -> String {
if deadline == nil {
try configureReceiveTimeout(45)
}
while true {
if let newlineIndex = streamReadBuffer.firstIndex(of: 0x0A) {
let lineByteCount = streamReadBuffer.distance(
from: streamReadBuffer.startIndex,
to: newlineIndex
)
guard lineByteCount < maxBytes else {
throw CLIError(message: "Event stream frame exceeded \(maxBytes) bytes")
}
let lineData = streamReadBuffer[..<newlineIndex]
guard let line = String(data: Data(lineData), encoding: .utf8) else {
throw CLIError(message: "Invalid UTF-8 event stream frame")
}
streamReadBuffer.removeSubrange(...newlineIndex)
return line.trimmingCharacters(in: .whitespacesAndNewlines)
}
guard streamReadBuffer.count < maxBytes else {
throw CLIError(message: "Event stream frame exceeded \(maxBytes) bytes")
}
if let deadline {
try waitForReadableStream(deadline: deadline)
}
var chunk = [UInt8](repeating: 0, count: 8 * 1_024)
let count = chunk.withUnsafeMutableBytes { bytes in
Darwin.read(socketFD, bytes.baseAddress, bytes.count)
}
if count < 0 {
if errno == EINTR {
continue
}
if errno == EAGAIN || errno == EWOULDBLOCK {
if let deadline {
guard deadline.timeIntervalSinceNow > 0 else {
throw CLIError(message: "Event stream deadline exceeded")
}
continue
}
throw CLIError(message: "Timed out waiting for event stream frame")
}
throw CLIError(message: "Event stream socket read error")
@@ -2987,15 +3024,36 @@ final class SocketClient {
if count == 0 {
throw CLIError(message: "Event stream closed")
}
if byte == 0x0A {
guard let line = String(data: data, encoding: .utf8) else {
throw CLIError(message: "Invalid UTF-8 event stream frame")
}
return line.trimmingCharacters(in: .whitespacesAndNewlines)
streamReadBuffer.append(contentsOf: chunk.prefix(count))
}
}
private func waitForReadableStream(deadline: Date) throws {
while true {
let remaining = deadline.timeIntervalSinceNow
guard remaining > 0 else {
throw CLIError(message: "Event stream deadline exceeded")
}
data.append(byte)
var descriptor = pollfd(fd: socketFD, events: Int16(POLLIN), revents: 0)
let timeoutMilliseconds = min(
max(Int(ceil(remaining * 1_000)), 0),
Int(Int32.max)
)
let ready = Darwin.poll(
&descriptor,
1,
Int32(timeoutMilliseconds)
)
if ready > 0 {
return
}
if ready == 0 {
throw CLIError(message: "Event stream deadline exceeded")
}
if errno != EINTR {
throw CLIError(message: "Event stream socket read error")
}
}
throw CLIError(message: "Event stream frame exceeded \(maxBytes) bytes")
}
}
@@ -7093,6 +7151,17 @@ struct CMUXCLI {
if shellCommand != nil, let unexpected = (remaining + (splitArgs.argv ?? [])).first {
throw CLIError(message: "surface resume set: unexpected argument '\(unexpected)' after --shell. Quote the full shell command or use -- <argv...>")
}
if let unknownFlag = remaining.first(where: { $0.hasPrefix("-") && $0 != "-" }) {
let knownFlags = Self.surfaceResumeSetValueOptions.sorted().joined(separator: ", ")
throw CLIError(message: String(
format: String(
localized: "cli.surfaceResume.set.error.unknownFlag",
defaultValue: "surface resume set: unknown flag '%1$@'. Known flags: %2$@. Use -- <argv...> for command arguments."
),
unknownFlag,
knownFlags
))
}
if splitArgs.argv != nil, let unexpected = remaining.first {
throw CLIError(message: "surface resume set: unexpected argument '\(unexpected)' before --")
}
@@ -15575,6 +15644,14 @@ struct CMUXCLI {
case "ios":
return iosSubcommandUsage()
case "events":
let timeoutDescription = String(
localized: "cli.events.help.timeout",
defaultValue: "Exit unsuccessfully if no matching event arrives before the deadline"
)
let snapshotDescription = String(
localized: "cli.events.help.snapshot",
defaultValue: "Print the subscription snapshot and exit"
)
return """
Usage: cmux events [options]
@@ -15587,6 +15664,8 @@ struct CMUXCLI {
--category <name> Filter by category, repeatable
--reconnect Reconnect forever and resume from the last received sequence
--limit <n> Exit after printing n event frames
--timeout <seconds> \(timeoutDescription)
--snapshot \(snapshotDescription)
--no-ack Do not print the subscription ack frame
--no-heartbeat Do not print heartbeat frames
@@ -22471,7 +22550,12 @@ struct CMUXCLI {
let candidate = URL(fileURLWithPath: entry, isDirectory: true)
.appendingPathComponent(name, isDirectory: false)
.path
if FileManager.default.isExecutableFile(atPath: candidate) {
// `isExecutableFile(atPath:)` is true for directories, so a directory named
// like the binary would otherwise shadow the real executable (#8743).
var isDirectory: ObjCBool = false
if FileManager.default.fileExists(atPath: candidate, isDirectory: &isDirectory),
!isDirectory.boolValue,
FileManager.default.isExecutableFile(atPath: candidate) {
return candidate
}
}
@@ -31023,6 +31107,27 @@ export default CMUXSessionRestore;
body: body
)
}
func notificationTitle(workspaceId: String, surfaceId: String) -> String {
let surfaceTitle: String? = {
guard def.name == "pi",
let listed = try? client.sendV2(
method: "surface.list",
params: ["workspace_id": workspaceId]
),
let surfaces = listed["surfaces"] as? [[String: Any]],
let surface = surfaces.first(where: {
surfaceHandleMatches(surfaceId, item: $0)
}) else {
return nil
}
return surface["title"] as? String
}()
return AgentHookNotificationPolicy.notificationTitle(
agentName: def.name,
displayName: def.displayName,
surfaceTitle: surfaceTitle
)
}
func hasActiveAntigravityBackgroundWork() -> Bool {
def.name == "antigravity" && (input.rawObject?["fullyIdle"] as? Bool) == false
}
@@ -31903,7 +32008,12 @@ export default CMUXSessionRestore;
let stopMeta: String? = stopNotificationStatus == .idle
? AgentHookNotifyCategory.turnComplete.metaSegment(pending: antigravityHasActiveBackgroundWork)
: nil
let payload = notificationPayload(title: def.displayName, subtitle: subtitle, body: body, meta: stopMeta)
let payload = notificationPayload(
title: notificationTitle(workspaceId: workspaceId, surfaceId: surfaceId),
subtitle: subtitle,
body: body,
meta: stopMeta
)
let notifyCommand = "notify_target_async \(workspaceId) \(surfaceId) \(payload)"
#if DEBUG
agentHookDebugLog(
@@ -32302,7 +32412,12 @@ export default CMUXSessionRestore;
pending: (summary.notifyCategory == .turnComplete || summary.notifyCategory == .idleReminder)
&& hasActiveAntigravityBackgroundWork()
)
let payload = notificationPayload(title: def.displayName, subtitle: summary.subtitle, body: summary.body, meta: notificationMeta)
let payload = notificationPayload(
title: notificationTitle(workspaceId: workspaceId, surfaceId: surfaceId),
subtitle: summary.subtitle,
body: summary.body,
meta: notificationMeta
)
let notifyCommand = "notify_target_async \(workspaceId) \(surfaceId) \(payload)"
#if DEBUG
agentHookDebugLog(
+18
View File
@@ -0,0 +1,18 @@
# CMUXMobileCore
Shared protocol seams and value types used by both the iOS and macOS apps.
Higher-level mobile packages depend on this package instead of importing one
another for shared contracts.
## Testing telemetry consent
Inject a suite-scoped defaults store so tests do not read or mutate the user's
preferences:
```swift
let defaults = UserDefaults(suiteName: "example.telemetry-test")!
let consent = UserDefaultsAnalyticsConsentProvider(defaults: defaults)
defaults.set(true, forKey: UserDefaultsAnalyticsConsentProvider.telemetryKey)
#expect(consent.isTelemetryEnabled)
```
@@ -0,0 +1,12 @@
/// The shared opt-out gate consulted before sending telemetry.
///
/// Analytics and crash-reporting infrastructure depend on this lower-level
/// seam so both obey the same live consent source without depending on each
/// other.
public protocol AnalyticsConsentProviding: Sendable {
/// Whether anonymous product telemetry may currently be sent.
///
/// A conformer must return its current value on every read so consent
/// changes take effect without rebuilding the telemetry graph.
var isTelemetryEnabled: Bool { get }
}
@@ -2,7 +2,7 @@ import Foundation
/// The minimal pairing-QR grammars for Iroh identity and Tailscale routes.
///
/// Current Iroh codes carry only the stable EndpointID:
/// Retained Iroh codes carry only the stable EndpointID:
/// `cmux-ios://attach?v=3&i=<endpoint-id>`.
///
/// The EndpointID is the only value the phone needs before dialing. The
@@ -41,13 +41,12 @@ import Foundation
/// Plain text is also smaller, which lowers the QR version (fewer, larger
/// modules) and makes the code scan faster from a Mac screen.
///
/// Compatibility: these grammars only ever appear in the Mac's pairing QR.
/// v2 remains decodable; an older iPhone presented with a v3 Iroh code gets
/// the existing update-app error and can use the Tailscale compatibility code
/// when one is available. Workspace-scoped tickets, dev loopback tickets, and
/// every RPC consumer
/// keep the compact v1 JSON payload (``CmxAttachTicketCompactCoder``), and the
/// decoder keeps accepting both that and the legacy full-key grammar.
/// Compatibility: the Mac pairing window emits only a Tailscale pairing
/// payload. v3 remains decodable for existing Iroh links and explicit
/// device-attach flows. Workspace-scoped tickets, dev loopback tickets, and
/// every RPC consumer keep the compact v1 JSON payload
/// (``CmxAttachTicketCompactCoder``), and the decoder keeps accepting both that
/// and the legacy full-key grammar.
public struct CmxPairingQRCode: Sendable {
/// The newest grammar version this build can decode.
///
@@ -1,13 +1,14 @@
/// The private-route disclosure policy for a scannable attach payload.
///
/// Callers must choose explicitly so adding a route to a ticket cannot silently
/// add it to a QR code. The legacy mode exists only while released clients still
/// require Tailscale host routes during the Iroh migration.
/// add it to a QR code. The compatibility name is retained because its grammar
/// remains readable by released clients; the Mac pairing window uses it only
/// for the user-selected Tailscale path.
public enum CmxPairingRouteDisclosureMode: Equatable, Sendable {
/// Encode only Iroh EndpointIDs. All Iroh hints and every host/port or URL
/// route are removed.
case irohIdentityOnly
/// Preserve the pre-Iroh compact route grammar for released clients.
/// This may disclose private-network routes and must not become a default.
/// Preserve the pre-Iroh compact route grammar for a Tailscale pairing
/// code. This discloses the selected tailnet destination.
case legacyPrivateNetworkCompatibility
}
@@ -8,8 +8,9 @@ import Foundation
/// decode here with both intentionally dropped: a pairing QR never expires,
/// and the Mac's name is read post-handshake from `mobile.host.status`.
/// Compact Iroh fallbacks disclose only EndpointID identity. The primary
/// scannable Iroh code uses ``CmxPairingQRCode`` instead; the explicit
/// compatibility mode temporarily retains released clients' legacy routes.
/// retained Iroh attach-code path uses ``CmxPairingQRCode`` instead; the
/// explicit compatibility mode temporarily retains released clients' legacy
/// routes.
struct CompactAttachTicket: Codable {
let v: Int
let w: String?
@@ -128,10 +128,15 @@ public struct DiagnosticReport: Sendable, Codable, Equatable {
}
/// The latest event that marks a failed connection/lifecycle milestone.
/// A `cancelled` outcome is an abandoned attempt, not a failure: callers
/// cancel dials on supersession and teardown, so surfacing one here would
/// report routine churn as the connection's latest problem.
public var lastFailureEvent: DiagnosticEvent? {
events.last(where: { event in
event.code.isDiagnosticFailure
|| event.diagnosticFailureKind.map { $0 != .none } == true
if let kind = event.diagnosticFailureKind {
return kind != .none && kind != .cancelled
}
return event.code.isDiagnosticFailure
})
}
@@ -203,6 +203,8 @@ public struct GroupSyncRecord: MobileSyncRecord {
public let isCollapsed: Bool
/// Whether the group is pinned on the Mac.
public let isPinned: Bool
/// SF Symbol rendered by the corresponding group row on the Mac.
public let iconSymbol: String?
/// The anchor workspace that owns this group.
public let anchorWorkspaceID: String
/// Position in the Mac's presented section order.
@@ -219,6 +221,7 @@ public struct GroupSyncRecord: MobileSyncRecord {
name: String,
isCollapsed: Bool,
isPinned: Bool,
iconSymbol: String? = nil,
anchorWorkspaceID: String,
sortIndex: Int
) {
@@ -226,6 +229,7 @@ public struct GroupSyncRecord: MobileSyncRecord {
self.name = name
self.isCollapsed = isCollapsed
self.isPinned = isPinned
self.iconSymbol = iconSymbol
self.anchorWorkspaceID = anchorWorkspaceID
self.sortIndex = sortIndex
}
@@ -235,6 +239,7 @@ public struct GroupSyncRecord: MobileSyncRecord {
case name
case isCollapsed = "is_collapsed"
case isPinned = "is_pinned"
case iconSymbol = "icon_symbol"
case anchorWorkspaceID = "anchor_workspace_id"
case sortIndex = "sort_index"
}
@@ -0,0 +1,34 @@
public import Foundation
/// A consent provider backed by the shared telemetry opt-out in `UserDefaults`.
///
/// This provider reads the same backing key as the app's anonymous-telemetry
/// setting. A missing value defaults to disabled, and every access reads the
/// store again so live setting changes apply without rebuilding consumers.
///
/// ```swift
/// let consent = UserDefaultsAnalyticsConsentProvider(defaults: .standard)
/// if consent.isTelemetryEnabled {
/// // Start telemetry infrastructure.
/// }
/// ```
public struct UserDefaultsAnalyticsConsentProvider: AnalyticsConsentProviding {
/// The `UserDefaults` key shared with the anonymous-telemetry setting.
public static let telemetryKey = "sendAnonymousTelemetry"
// UserDefaults is Apple-documented thread-safe; OK to hold nonisolated.
private nonisolated(unsafe) let defaults: UserDefaults
/// Creates a consent provider over the given defaults store.
///
/// - Parameter defaults: The store holding the opt-out flag. Inject a
/// suite-scoped store in tests; the app uses `.standard`.
public init(defaults: UserDefaults) {
self.defaults = defaults
}
/// Whether anonymous product telemetry is enabled in the defaults store.
public var isTelemetryEnabled: Bool {
defaults.object(forKey: Self.telemetryKey) as? Bool ?? false
}
}
@@ -289,7 +289,6 @@ import os
#expect(DiagnosticSessionLifecycleKind.runtimeReconfigured.rawValue == 9)
#expect(DiagnosticSessionLifecycleKind.explicitlyInvalidated.rawValue == 10)
#expect(DiagnosticSessionLifecycleKind.allPathsClosed.rawValue == 11)
#expect(DiagnosticPathKind(.unavailable) == .unknown)
#expect(DiagnosticPathKind(.direct) == .direct)
#expect(DiagnosticPathKind(.privateNetwork) == .privateNetwork)
@@ -442,6 +441,38 @@ import os
}
}
@Test func cancelledDialOutcomesDoNotCountAsFailures() {
let realFailure = DiagnosticEvent(
code: .rpcFailed,
tNanos: 2,
b: DiagnosticFailureKind.protocolViolation.rawValue
)
let abandonedDial = DiagnosticEvent(
code: .transportDialFailed,
tNanos: 3,
a: DiagnosticTransportKind.iroh.rawValue,
b: DiagnosticFailureKind.cancelled.rawValue,
c: 7
)
let onlyAbandoned = DiagnosticReport(
anchorWallNanos: 1_000_000_000,
anchorMonotonicNanos: 1,
events: [abandonedDial]
)
#expect(onlyAbandoned.lastFailureEvent == nil)
#expect(onlyAbandoned.lastFailureKind == nil)
#expect(onlyAbandoned.lastFailureDate == nil)
let abandonedAfterRealFailure = DiagnosticReport(
anchorWallNanos: 1_000_000_000,
anchorMonotonicNanos: 1,
events: [realFailure, abandonedDial]
)
#expect(abandonedAfterRealFailure.lastFailureEvent == realFailure)
#expect(abandonedAfterRealFailure.lastFailureKind == .protocolViolation)
}
@Test func gatedDialRefusalsReportRouteGatedNotTimedOut() {
// A connect-registry gate refusal is instantaneous and never touched
// the network. It used to be classified as `.timedOut`, fabricating
@@ -80,6 +80,35 @@ struct MobileStateSyncFrameCodingTests {
#expect(!decoded.customDescriptionIsTruncated)
}
@Test func groupRecordCarriesIconAndDecodesOlderFrames() throws {
let group = GroupSyncRecord(
id: "group-1",
name: "Release",
isCollapsed: false,
isPinned: true,
iconSymbol: "shippingbox.fill",
anchorWorkspaceID: "workspace-1",
sortIndex: 0
)
let object = try MobileSyncFrameCoder().jsonObject(from: group)
#expect(object["icon_symbol"] as? String == "shippingbox.fill")
let decodedOlder = try MobileSyncFrameCoder().decode(
GroupSyncRecord.self,
fromJSONString: """
{
"id": "group-older",
"name": "Older Mac",
"is_collapsed": false,
"is_pinned": false,
"anchor_workspace_id": "workspace-older",
"sort_index": 0
}
"""
)
#expect(decodedOlder.iconSymbol == nil)
}
@Test func deltaEventRoundTripsThroughJSONObject() throws {
let event = MobileSyncDeltaEvent(
epoch: "e1",
@@ -0,0 +1,21 @@
import Foundation
import Testing
@testable import CMUXMobileCore
@Suite struct UserDefaultsAnalyticsConsentProviderTests {
@Test func defaultsOffAndTracksLiveChanges() throws {
let suiteName = "cmux.analytics-consent.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suiteName))
defer { defaults.removePersistentDomain(forName: suiteName) }
let consent = UserDefaultsAnalyticsConsentProvider(defaults: defaults)
#expect(!consent.isTelemetryEnabled)
defaults.set(true, forKey: UserDefaultsAnalyticsConsentProvider.telemetryKey)
#expect(consent.isTelemetryEnabled)
defaults.set(false, forKey: UserDefaultsAnalyticsConsentProvider.telemetryKey)
#expect(!consent.isTelemetryEnabled)
}
}
@@ -35,13 +35,23 @@ struct CmxAuthoritativeDiscoveryResolver: Sendable {
let response = try await authority.syncConnectivity(
knownRevision: cached?.revision
)
if let snapshot = response.snapshot {
if let snapshot = response.snapshot,
response.snapshotComplete == true {
try Self.requireRevision(snapshot, atLeast: minimumRevision)
if !response.reset {
try Self.requireRevision(snapshot, atLeast: cached?.revision)
}
return snapshot
}
if response.snapshot != nil {
let discovery = try await broker.discover()
try Self.requireRevision(discovery, atLeast: response.revision)
try Self.requireRevision(discovery, atLeast: minimumRevision)
if !response.reset {
try Self.requireRevision(discovery, atLeast: cached?.revision)
}
return discovery
}
guard !response.reset,
let cached,
cached.revision == response.revision else {
@@ -13,6 +13,12 @@ public actor CmxConnectivityEngine {
let task: Task<Void, any Error>
}
private struct EndpointReadinessOperation {
let id: UUID
let revision: UInt64
let task: Task<Void, any Error>
}
private let supervisor: CmxIrohEndpointSupervisor
private let contextProvider: (any CmxIrohClientContextProvider)?
private let protocolConfiguration: CmxIrohProtocolConfiguration
@@ -27,6 +33,7 @@ public actor CmxConnectivityEngine {
private var routeRevision: UInt64?
private var routeContent: CmxConnectivityRouteContent?
private var endpointEventTask: Task<Void, Never>?
private var endpointReadinessOperation: EndpointReadinessOperation?
private var routeSyncOperation: RouteSyncOperation?
private var peers: [CmxConnectivityPeerID: CmxConnectivityPeerSession] = [:]
private var peerSnapshots: [CmxConnectivityPeerID: CmxConnectivityPeerSnapshot] = [:]
@@ -199,9 +206,10 @@ public actor CmxConnectivityEngine {
/// Verifies the preserved endpoint after suspension and recreates it if stale.
public func resume() async throws {
guard desiredActive, phase == .active else {
guard desiredActive else {
throw CmxConnectivityEngineError.inactive
}
try await ensureEndpointReady()
let revision = lifecycleRevision
let endpoint = try await supervisor.ensureHealthy()
guard desiredActive, lifecycleRevision == revision else {
@@ -225,6 +233,8 @@ public actor CmxConnectivityEngine {
publishSnapshot()
endpointEventTask?.cancel()
endpointEventTask = nil
endpointReadinessOperation?.task.cancel()
endpointReadinessOperation = nil
routeSyncOperation?.task.cancel()
routeSyncOperation = nil
let stoppedNetworkObservers = networkObservers.values
@@ -244,14 +254,24 @@ public actor CmxConnectivityEngine {
///
/// Peers whose material route content is unchanged keep their live
/// sessions; every other peer is invalidated before the new revision
/// becomes visible.
/// becomes visible. Account route revisions are monotonic, so an older
/// completion of an overlapping reconciliation cannot roll back a newer
/// installed revision or its content baseline.
public func didInstallRouteRevision(
_ revision: UInt64,
routes: CmxIrohDiscoveryResponse
) async {
if let routeRevision, revision < routeRevision { return }
let content = CmxConnectivityRouteContent(snapshot: routes)
guard routeRevision != revision else {
routeContent = content
// The recorded revision can lack a content baseline when a sync
// stored it from an unchanged response without a snapshot. A
// missing or differing baseline fails closed like any other
// material change before the content becomes the baseline.
if routeContent != content {
await invalidatePeersSuperseded(by: content)
routeContent = content
}
return
}
await invalidatePeersSuperseded(by: content)
@@ -262,27 +282,21 @@ public actor CmxConnectivityEngine {
/// Returns the exact active local endpoint identity.
public func localEndpointIdentity() async throws -> CmxIrohPeerIdentity {
guard desiredActive, endpointGeneration != nil else {
throw CmxConnectivityEngineError.inactive
}
try await ensureEndpointReady()
let endpoint = try await supervisor.activeEndpoint()
return await endpoint.identity()
}
/// Returns the active endpoint's public reachability snapshot.
public func endpointAddress() async throws -> CmxIrohEndpointAddress {
guard desiredActive, endpointGeneration != nil else {
throw CmxConnectivityEngineError.inactive
}
try await ensureEndpointReady()
let endpoint = try await supervisor.activeEndpoint()
return await endpoint.address()
}
/// Returns raw local direct addresses for authenticated registration only.
public func localDirectAddresses() async throws -> [String] {
guard desiredActive, endpointGeneration != nil else {
throw CmxConnectivityEngineError.inactive
}
try await ensureEndpointReady()
let endpoint = try await supervisor.activeEndpoint()
return await endpoint.localDirectAddresses()
}
@@ -296,6 +310,7 @@ public actor CmxConnectivityEngine {
public func waitForUsableHomeRelay(
timeout: Duration = .seconds(15)
) async throws {
try await ensureEndpointReady()
try await supervisor.waitForUsableHomeRelay(timeout: timeout)
}
@@ -420,6 +435,7 @@ public actor CmxConnectivityEngine {
lane: CmxIrohLane,
priority: Int32
) async throws -> CmxIrohBidirectionalStream {
try await ensureEndpointReady()
let peer = try activePeer(for: request)
return try await peer.openBidirectionalLane(
for: request,
@@ -432,6 +448,7 @@ public actor CmxConnectivityEngine {
public func serverEventByteStream(
for request: CmxByteTransportRequest
) async throws -> CmxIndependentEventByteStream {
try await ensureEndpointReady()
let peer = try activePeer(for: request)
return try await peer.serverEventByteStream(for: request)
}
@@ -452,6 +469,7 @@ public actor CmxConnectivityEngine {
for request: CmxByteTransportRequest,
ownerID: UUID
) async throws -> any CmxConnectivitySession {
try await ensureEndpointReady()
let peer = try activePeer(for: request)
return try await peer.acquireControl(for: request, ownerID: ownerID)
}
@@ -552,12 +570,80 @@ public actor CmxConnectivityEngine {
return peer
}
/// Waits for the desired-active endpoint to finish recovery before admitting
/// endpoint consumers. The engine owns this barrier because it is the sole
/// owner of both endpoint phase and the installed generation.
private func ensureEndpointReady() async throws {
try Task.checkCancellation()
guard desiredActive else {
throw CmxConnectivityEngineError.inactive
}
if phase == .active, endpointGeneration != nil { return }
let revision = lifecycleRevision
let operation: EndpointReadinessOperation
if let current = endpointReadinessOperation,
current.revision == revision {
operation = current
} else {
endpointReadinessOperation?.task.cancel()
let id = UUID()
let task = Task { [weak self] in
guard let self else {
throw CmxConnectivityEngineError.inactive
}
try await self.performEndpointReadiness(revision: revision)
}
operation = EndpointReadinessOperation(
id: id,
revision: revision,
task: task
)
endpointReadinessOperation = operation
}
do {
try await operation.task.value
if endpointReadinessOperation?.id == operation.id {
endpointReadinessOperation = nil
}
} catch {
if endpointReadinessOperation?.id == operation.id {
endpointReadinessOperation = nil
}
throw error
}
try Task.checkCancellation()
guard desiredActive,
lifecycleRevision == revision,
phase == .active,
endpointGeneration != nil else {
throw CmxConnectivityEngineError.superseded
}
}
private func performEndpointReadiness(revision: UInt64) async throws {
let endpoint = try await supervisor.activate()
guard desiredActive, lifecycleRevision == revision else {
throw CmxConnectivityEngineError.superseded
}
try await installEndpoint(endpoint)
try await reconcileRoutesPreservingVerifiedPolicy()
guard desiredActive, lifecycleRevision == revision else {
throw CmxConnectivityEngineError.superseded
}
phase = .active
publishSnapshot()
}
private func recoverEndpointForServer(
expectedGeneration: UInt64
) async throws -> CmxIrohEndpointSnapshot {
guard desiredActive, phase == .active else {
guard desiredActive else {
throw CmxConnectivityEngineError.inactive
}
try await ensureEndpointReady()
let revision = lifecycleRevision
let endpoint = try await supervisor.ensureHealthy()
guard desiredActive, lifecycleRevision == revision else {
@@ -625,15 +711,7 @@ public actor CmxConnectivityEngine {
phase = .starting
publishSnapshot()
}
do {
try await reconcileRoutes()
} catch {
guard routeRevision != nil,
CmxIrohTrustBrokerClientError
.preservesVerifiedPolicyDuringRefresh(error) else {
throw error
}
}
try await reconcileRoutesPreservingVerifiedPolicy()
guard desiredActive else { return }
phase = .active
publishSnapshot()
@@ -651,6 +729,18 @@ public actor CmxConnectivityEngine {
}
}
private func reconcileRoutesPreservingVerifiedPolicy() async throws {
do {
try await reconcileRoutes()
} catch {
guard routeRevision != nil,
CmxIrohTrustBrokerClientError
.preservesVerifiedPolicyDuringRefresh(error) else {
throw error
}
}
}
private func installEndpoint(
_ endpoint: CmxIrohEndpointSnapshot
) async throws {
@@ -186,10 +186,16 @@ actor CmxConnectivityPeerSession {
}
if let installed = activeConnection {
if installed.id != pending.id {
await connected.close()
if installed.id == pending.id {
return installed.session
}
return installed.session
if let winner = await settleRedundantDial(
connected,
installedID: installed.id
) {
return winner
}
continue redial
}
if await connected.isClosed() {
await connected.close()
@@ -205,10 +211,16 @@ actor CmxConnectivityPeerSession {
// installing over it would leak its session and double-record
// an established lifecycle for the same peer.
if let installed = activeConnection {
if installed.id != pending.id {
await connected.close()
if installed.id == pending.id {
return installed.session
}
return installed.session
if let winner = await settleRedundantDial(
connected,
installedID: installed.id
) {
return winner
}
continue redial
}
install(
connected,
@@ -278,6 +290,25 @@ actor CmxConnectivityPeerSession {
publishSnapshot()
}
/// Closes a redundant dial that lost to an installed winner.
///
/// Closing suspends this actor, so the winner can be invalidated,
/// replaced, or remotely closed before the close settles. Only a
/// still-installed live winner may be handed out; a nil result means
/// the caller must redial.
private func settleRedundantDial(
_ connected: any CmxConnectivitySession,
installedID: UUID
) async -> (any CmxConnectivitySession)? {
await connected.close()
guard let current = activeConnection,
current.id == installedID,
!(await current.session.isClosed()) else {
return nil
}
return current.session
}
private func install(
_ connected: any CmxConnectivitySession,
id: UUID,
@@ -7,10 +7,28 @@
/// keep healthy sessions whose routes did not materially change.
struct CmxConnectivityRouteContent: Equatable, Sendable {
/// Trust material shared by every route in one account snapshot.
///
/// Relay fleet and verification key order carries no trust meaning, so
/// both are canonicalized here and a reorder-only revision compares
/// equal to the installed material.
struct AccountMaterial: Equatable, Sendable {
let relayFleet: [String]
let lanRendezvous: CmxIrohLANRendezvous
let grantVerificationKeys: CmxIrohGrantVerificationKeySet
init(snapshot: CmxIrohDiscoveryResponse) {
relayFleet = snapshot.relayFleet.sorted()
lanRendezvous = snapshot.lanRendezvous
let keySet = snapshot.grantVerificationKeys
grantVerificationKeys = CmxIrohGrantVerificationKeySet(
version: keySet.version,
currentKeyID: keySet.currentKeyID,
keys: keySet.keys.sorted {
($0.kid, $0.alg, $0.spkiDerBase64)
< ($1.kid, $1.alg, $1.spkiDerBase64)
}
)
}
}
/// Admission-relevant material of one broker binding.
@@ -30,7 +48,8 @@ struct CmxConnectivityRouteContent: Equatable, Sendable {
platform = binding.platform
identityGeneration = binding.identityGeneration
pairingEnabled = binding.pairingEnabled
capabilities = binding.capabilities
// The admission policy reads capabilities with set semantics.
capabilities = binding.capabilities.sorted()
}
}
@@ -38,11 +57,7 @@ struct CmxConnectivityRouteContent: Equatable, Sendable {
private let peerRoutes: [CmxConnectivityPeerID: [BindingMaterial]]
init(snapshot: CmxIrohDiscoveryResponse) {
account = AccountMaterial(
relayFleet: snapshot.relayFleet,
lanRendezvous: snapshot.lanRendezvous,
grantVerificationKeys: snapshot.grantVerificationKeys
)
account = AccountMaterial(snapshot: snapshot)
var routes: [CmxConnectivityPeerID: [BindingMaterial]] = [:]
for binding in snapshot.bindings {
let peerID = CmxConnectivityPeerID(
@@ -18,12 +18,17 @@ public struct CmxConnectivitySyncResponse: Decodable, Equatable, Sendable {
/// Complete authoritative discovery state when `changed` is true.
public let snapshot: CmxIrohDiscoveryResponse?
/// True only when the server proves `snapshot` covers every active binding.
/// Older servers omit this field, so clients fetch paginated discovery.
public let snapshotComplete: Bool?
private enum CodingKeys: String, CodingKey {
case protocolVersion = "protocol_version"
case revision
case changed
case reset
case snapshot
case snapshotComplete = "snapshot_complete"
}
/// Decodes and validates one atomic reconciliation response.
@@ -37,9 +42,14 @@ public struct CmxConnectivitySyncResponse: Decodable, Equatable, Sendable {
CmxIrohDiscoveryResponse.self,
forKey: .snapshot
)
let snapshotComplete = try container.decodeIfPresent(
Bool.self,
forKey: .snapshotComplete
)
guard protocolVersion == Self.protocolVersion,
changed == (snapshot != nil),
!reset || changed,
snapshot != nil || snapshotComplete == nil,
(snapshot?.routeContractVersion ?? 1) == 1,
(snapshot?.revision ?? revision) == revision else {
throw DecodingError.dataCorrupted(
@@ -54,16 +64,19 @@ public struct CmxConnectivitySyncResponse: Decodable, Equatable, Sendable {
self.changed = changed
self.reset = reset
self.snapshot = snapshot
self.snapshotComplete = snapshotComplete
}
init(
legacySnapshot: CmxIrohDiscoveryResponse,
knownRevision: UInt64?
knownRevision: UInt64?,
snapshotComplete: Bool? = true
) {
protocolVersion = Self.protocolVersion
revision = legacySnapshot.revision ?? (knownRevision ?? 0) &+ 1
changed = true
reset = false
snapshot = legacySnapshot
self.snapshotComplete = snapshotComplete
}
}
@@ -14,16 +14,28 @@ public struct CmxIrohAdmittedServerSession: Sendable {
public let controlTransport: any CmxByteTransport
private let session: CmxIrohServerSession
private let promoteUsableSession: @Sendable () async -> Bool
init(
peer: CmxIrohAdmittedPeer,
session: CmxIrohServerSession
session: CmxIrohServerSession,
promoteUsableSession: @escaping @Sendable () async -> Bool = { true }
) {
self.peer = peer
self.session = session
self.promoteUsableSession = promoteUsableSession
controlTransport = CmxIrohServerByteTransport(session: session)
}
/// Promotes this connection after the application protocol is usable.
///
/// Promotion is generation-scoped and retires older connections from the
/// same authenticated endpoint identity without risking a known-good
/// session during transport admission.
public func markUsable() async -> Bool {
await promoteUsableSession()
}
/// Accepts one client-created terminal or artifact lane.
public func acceptBidirectionalLane() async throws -> (
lane: CmxIrohLane,
@@ -11,6 +11,7 @@ public struct CmxIrohBrokerBindingMetadata: Codable, Equatable, Sendable {
case platform
case endpointID
case identityGeneration
case pathHints
}
/// The broker-owned binding UUID.
@@ -34,6 +35,9 @@ public struct CmxIrohBrokerBindingMetadata: Codable, Equatable, Sendable {
/// The monotonically increasing endpoint identity generation.
public let identityGeneration: Int
/// Broker-validated route hints that accelerate reconnect to this endpoint.
public let pathHints: [CmxIrohPathHint]
/// Creates validated broker binding metadata.
///
/// - Parameters:
@@ -44,6 +48,7 @@ public struct CmxIrohBrokerBindingMetadata: Codable, Equatable, Sendable {
/// - platform: The endpoint's platform role.
/// - endpointID: The registered Iroh endpoint identity.
/// - identityGeneration: The positive endpoint identity generation.
/// - pathHints: Broker-validated route hints for the endpoint.
/// - Throws: ``CmxIrohBrokerCredentialRepositoryError/invalidBinding`` for malformed input.
public init(
bindingID: String,
@@ -52,7 +57,8 @@ public struct CmxIrohBrokerBindingMetadata: Codable, Equatable, Sendable {
tag: String,
platform: CmxIrohPlatform,
endpointID: CmxIrohPeerIdentity,
identityGeneration: Int
identityGeneration: Int,
pathHints: [CmxIrohPathHint] = []
) throws {
guard Self.isCanonicalUUID(bindingID),
Self.isCanonicalUUID(deviceID),
@@ -68,6 +74,7 @@ public struct CmxIrohBrokerBindingMetadata: Codable, Equatable, Sendable {
self.platform = platform
self.endpointID = endpointID
self.identityGeneration = identityGeneration
self.pathHints = pathHints
}
/// Copies the exact recovery tuple from a validated broker response.
@@ -81,6 +88,7 @@ public struct CmxIrohBrokerBindingMetadata: Codable, Equatable, Sendable {
platform = binding.platform
endpointID = binding.endpointID
identityGeneration = binding.identityGeneration
pathHints = binding.pathHints
}
/// Decodes and revalidates persisted broker binding metadata.
@@ -96,7 +104,11 @@ public struct CmxIrohBrokerBindingMetadata: Codable, Equatable, Sendable {
tag: container.decode(String.self, forKey: .tag),
platform: container.decode(CmxIrohPlatform.self, forKey: .platform),
endpointID: container.decode(CmxIrohPeerIdentity.self, forKey: .endpointID),
identityGeneration: container.decode(Int.self, forKey: .identityGeneration)
identityGeneration: container.decode(Int.self, forKey: .identityGeneration),
pathHints: container.decodeIfPresent(
[CmxIrohPathHint].self,
forKey: .pathHints
) ?? []
)
}
@@ -298,6 +298,14 @@ public struct CmxIrohDiscoveryResponse: Decodable, Equatable, Sendable {
/// Registration response. Relay bootstrap failure never rolls back the binding.
public struct CmxIrohRegistrationResponse: Decodable, Equatable, Sendable {
private enum CodingKeys: String, CodingKey {
case revision
case binding
case relay
case discovery
case discoveryComplete = "discovery_complete"
}
/// Monotonic account route revision after this registration commit.
public let revision: UInt64?
public let binding: CmxIrohBrokerBinding
@@ -305,18 +313,23 @@ public struct CmxIrohRegistrationResponse: Decodable, Equatable, Sendable {
/// The authoritative post-registration account snapshot when supplied by
/// connectivity v2. Older brokers omit it and retain the separate sync.
public let discovery: CmxIrohDiscoveryResponse?
/// True only when the embedded snapshot covers every active binding.
/// Older brokers omit this proof, so clients must fetch paginated discovery.
public let discoveryComplete: Bool?
/// Creates a registration response for alternate brokers and tests.
public init(
revision: UInt64? = nil,
binding: CmxIrohBrokerBinding,
relay: CmxIrohRegistrationRelay,
discovery: CmxIrohDiscoveryResponse? = nil
discovery: CmxIrohDiscoveryResponse? = nil,
discoveryComplete: Bool? = nil
) {
self.revision = revision
self.binding = binding
self.relay = relay
self.discovery = discovery
self.discoveryComplete = discoveryComplete
}
}
@@ -109,4 +109,14 @@ extension CmxIrohClientRuntime {
static func isConnectivity(_ error: any Error) -> Bool {
(error as? CmxIrohTrustBrokerClientError) == .connectivity
}
/// Failures that may fall back to the verified offline policy cache.
///
/// Only transport availability qualifies. Authorization rejections fail
/// closed even when an older policy was previously verified: the broker
/// has explicitly withdrawn this session's authority after the client's
/// exactly-once credential recovery.
static func recoversWithCachedPolicy(_ error: any Error) -> Bool {
isConnectivity(error)
}
}
@@ -109,7 +109,7 @@ extension CmxIrohClientRuntime {
registration = nil
} else {
guard !prefetchedDiscoveryRejectedCachedBinding,
Self.isConnectivity(error),
Self.recoversWithCachedPolicy(error),
let cached = try await offlineBootstrap(
expectation: offlineExpectation,
confirmedLocalBinding: nil
@@ -131,15 +131,25 @@ extension CmxIrohClientRuntime {
}
let discovery: CmxIrohDiscoveryResponse
do {
if let embedded = registration?.discovery {
if let embedded = registration?.discovery,
registration?.discoveryComplete == true {
guard let snapshotRevision = embedded.revision,
let registrationRevision = registration?.revision,
snapshotRevision == registrationRevision,
snapshotRevision >= registrationRevision,
snapshotRevision >= (authoritativeDiscovery?.revision ?? 0) else {
throw CmxIrohTrustBrokerClientError.invalidResponse
}
authoritativeDiscovery = embedded
discovery = embedded
let localMatches = embedded.bindings.filter(expectation.matches)
if embedded.bindings.count
== CmxIrohDiscoveryPage.legacyBindingLimit
|| localMatches.count != 1 {
discovery = try await discoverAuthoritatively(
minimumRevision: registrationRevision
)
} else {
authoritativeDiscovery = embedded
discovery = embedded
}
} else {
discovery = try await discoverAuthoritatively(
minimumRevision: registration?.revision
@@ -147,7 +157,7 @@ extension CmxIrohClientRuntime {
}
} catch {
guard let registration,
Self.isConnectivity(error),
Self.recoversWithCachedPolicy(error),
let cached = try await offlineBootstrap(
expectation: offlineExpectation,
confirmedLocalBinding: registration.binding
@@ -60,10 +60,37 @@ public struct CmxIrohConnectionCloseAttribution: Sendable, Equatable {
|| cause.contains("ConnectionLost(Reset)") {
return .remote
}
// Connection.closed()/close_reason() cross the uniffi boundary as
// quinn ConnectionError DISPLAY strings, which start with the variant
// text. Prefix-anchoring keeps a peer-chosen close reason from
// spoofing a different initiator.
if cause.hasPrefix("closed by peer")
|| cause.hasPrefix("aborted by peer")
|| cause.hasPrefix("reset by peer") {
return .remote
}
if cause == "timed out" {
return .timedOut
}
if cause == "closed" {
return .local
}
return .unknown
}
private static func applicationErrorCode(in cause: String) -> Int64? {
// Display format of a peer application close is either
// "closed by peer: {code}" or "closed by peer: {reason} (code {code})";
// the formatter always appends the authentic code last, so a code-like
// fragment inside the peer-chosen reason cannot shadow it.
let displayPeerClose = "closed by peer: "
if cause.hasPrefix(displayPeerClose) {
let payload = cause.dropFirst(displayPeerClose.count)
if let range = payload.range(of: "(code ", options: .backwards) {
return firstInteger(in: payload[range.upperBound...])
}
return firstInteger(in: payload)
}
guard cause.contains("ApplicationClosed(") else { return nil }
for label in [
"application error code",
@@ -103,12 +130,19 @@ public struct CmxIrohConnectionCloseAttribution: Sendable, Equatable {
}
private static func failureKind(in cause: String) -> DiagnosticFailureKind {
if cause.contains("ConnectionLost(TimedOut)") {
if cause.contains("ConnectionLost(TimedOut)") || cause == "timed out" {
return .transportIdleTimedOut
}
if cause.contains("ConnectionLost(LocallyClosed)") {
if cause.contains("ConnectionLost(LocallyClosed)") || cause == "closed" {
return .cancelled
}
// Display-format peer closes are prefix-anchored so a peer-chosen
// reason cannot rewrite the kind via the keyword fallbacks below.
if cause.hasPrefix("closed by peer")
|| cause.hasPrefix("aborted by peer")
|| cause.hasPrefix("reset by peer") {
return .connectionClosed
}
if cause.contains("ConnectionLost(TransportError(")
&& (cause.contains("Code::crypto(")
|| cause.contains("TLS error:")) {
@@ -9,9 +9,33 @@ public actor CmxIrohEndpointServer {
public typealias ConnectionHandler = @Sendable (
_ connection: any CmxIrohConnection,
_ runtimeGeneration: UInt64,
_ markAdmitted: @escaping AdmissionMarker
_ admission: AdmissionMarker
) async throws -> Void
public typealias AdmissionMarker = @Sendable () async -> Bool
/// Generation-scoped application lifecycle for one accepted connection.
///
/// Calling the value authenticates the connection. `markUsable()` promotes
/// it only after the application protocol has proved ready end to end.
public struct AdmissionMarker: Sendable {
private let admit: @Sendable () async -> Bool
private let promote: @Sendable () async -> Bool
fileprivate init(
admit: @escaping @Sendable () async -> Bool,
promote: @escaping @Sendable () async -> Bool
) {
self.admit = admit
self.promote = promote
}
public func callAsFunction() async -> Bool {
await admit()
}
public func markUsable() async -> Bool {
await promote()
}
}
typealias EndpointRecovery = @Sendable (
_ expectedGeneration: UInt64
) async throws -> CmxIrohEndpointSnapshot
@@ -29,6 +53,8 @@ public actor CmxIrohEndpointServer {
let remoteIdentity: CmxIrohPeerIdentity
let connection: any CmxIrohConnection
let handlerTask: Task<Void, Never>
let sequence: UInt64
var isUsable: Bool
}
private let supervisor: CmxIrohEndpointSupervisor
@@ -44,6 +70,7 @@ public actor CmxIrohEndpointServer {
private var acceptTask: Task<Void, Never>?
private var pendingAdmissions: [UUID: PendingAdmission] = [:]
private var activeConnections: [UUID: ActiveConnection] = [:]
private var nextConnectionSequence: UInt64 = 0
private var currentGeneration: UInt64?
public init(
@@ -234,14 +261,20 @@ public actor CmxIrohEndpointServer {
let activeForIdentity = activeConnections.values.lazy.filter {
$0.remoteIdentity == remoteIdentity
}.count
let isSameIdentityReplacement = pendingForIdentity == 0 && activeForIdentity > 0
let hasReplaceableConnection = activeConnections.values.contains {
$0.remoteIdentity == remoteIdentity && !$0.isUsable
}
let canReserveReplacement = pendingForIdentity == 0
&& maximumConnectionsPerIdentity > 1
&& activeForIdentity >= maximumConnectionsPerIdentity
&& hasReplaceableConnection
guard pendingAdmissions.count + activeConnections.count < maximumConnections
|| isSameIdentityReplacement else {
|| canReserveReplacement else {
await connection.close(errorCode: 1, reason: "connection_capacity")
return
}
guard pendingForIdentity + activeForIdentity < maximumConnectionsPerIdentity
|| isSameIdentityReplacement else {
|| canReserveReplacement else {
await connection.close(
errorCode: 1,
reason: "connection_identity_capacity"
@@ -252,9 +285,18 @@ public actor CmxIrohEndpointServer {
let handler = handler
let handlerTask = Task { [weak self] in
do {
try await handler(connection, generation) { [weak self] in
await self?.markAdmitted(id, generation: generation) ?? false
}
try await handler(
connection,
generation,
AdmissionMarker(
admit: { [weak self] in
await self?.markAdmitted(id, generation: generation) ?? false
},
promote: { [weak self] in
await self?.markUsable(id, generation: generation) ?? false
}
)
)
await self?.finishHandler(id, error: nil)
} catch {
await self?.finishHandler(id, error: error)
@@ -286,25 +328,64 @@ public actor CmxIrohEndpointServer {
}
admission.deadlineTask.cancel()
// One endpoint identity represents one installed client identity. A
// newly authenticated connection from that identity is therefore the
// authoritative replacement for older connections that may still look
// alive after the client was force-quit, crashed, or changed networks.
// Wait until admission succeeds before evicting them so an unauthenticated
// or failed reconnect cannot disrupt a healthy session.
let superseded = activeConnections.filter { _, connection in
// An authenticated replacement may use the one admission reservation
// above the steady identity bound. Reclaim only the oldest connection
// that never became application-usable. A known-good session is retired
// exclusively by markUsable below.
let activeForIdentity = activeConnections.filter { _, connection in
connection.generation == generation
&& connection.remoteIdentity == admission.remoteIdentity
}
for supersededID in superseded.keys {
activeConnections[supersededID] = nil
let requiresReplacement = activeConnections.count >= maximumConnections
|| activeForIdentity.count >= maximumConnectionsPerIdentity
let replaced = requiresReplacement
? activeForIdentity
.filter { !$0.value.isUsable }
.min { $0.value.sequence < $1.value.sequence }
: nil
if requiresReplacement, replaced == nil {
return false
}
if let replaced {
activeConnections[replaced.key] = nil
}
nextConnectionSequence &+= 1
activeConnections[id] = ActiveConnection(
generation: generation,
remoteIdentity: admission.remoteIdentity,
connection: admission.connection,
handlerTask: admission.handlerTask
handlerTask: admission.handlerTask,
sequence: nextConnectionSequence,
isUsable: false
)
if let replaced {
replaced.value.handlerTask.cancel()
await replaced.value.connection.close(
errorCode: 0,
reason: "superseded_unready_connection"
)
}
return true
}
private func markUsable(_ id: UUID, generation: UInt64) async -> Bool {
guard currentGeneration == generation,
var promoted = activeConnections[id],
promoted.generation == generation else {
return false
}
if promoted.isUsable { return true }
let superseded = activeConnections.filter { otherID, connection in
otherID != id
&& connection.generation == generation
&& connection.remoteIdentity == promoted.remoteIdentity
}
for supersededID in superseded.keys {
activeConnections[supersededID] = nil
}
promoted.isUsable = true
activeConnections[id] = promoted
for connection in superseded.values {
connection.handlerTask.cancel()
await connection.connection.close(
@@ -132,17 +132,33 @@ extension CmxIrohHostRuntime {
try validateLocalBinding(registration.binding, endpointID: expectedEndpointID)
let discovery: CmxIrohDiscoveryResponse
do {
if let embedded = registration.discovery {
if let embedded = registration.discovery,
registration.discoveryComplete == true {
guard let snapshotRevision = embedded.revision,
let registrationRevision = registration.revision,
snapshotRevision == registrationRevision,
snapshotRevision >= (authoritativeDiscovery?.revision ?? 0) else {
throw CmxIrohTrustBrokerClientError.invalidResponse
}
authoritativeDiscovery = embedded
discovery = embedded
if embedded.bindings.contains(where: {
$0.bindingID == registration.binding.bindingID
}) {
authoritativeDiscovery = embedded
discovery = embedded
} else {
// Legacy registration responses embed only the first
// discovery page. Once an account has enough dev builds,
// the binding just registered can land on a later page.
// Resolve the complete snapshot instead of misclassifying
// pagination as a replaced local identity.
discovery = try await discoverAuthoritatively(
minimumRevision: registration.revision
)
}
} else {
discovery = try await discoverAuthoritatively()
discovery = try await discoverAuthoritatively(
minimumRevision: registration.revision
)
}
} catch {
return try cachedPolicy(
@@ -190,10 +206,15 @@ extension CmxIrohHostRuntime {
)
}
func discoverAuthoritatively() async throws -> CmxIrohDiscoveryResponse {
func discoverAuthoritatively(
minimumRevision: UInt64? = nil
) async throws -> CmxIrohDiscoveryResponse {
let discovery = try await CmxAuthoritativeDiscoveryResolver(
broker: broker
).resolve(cached: authoritativeDiscovery)
).resolve(
cached: authoritativeDiscovery,
minimumRevision: minimumRevision
)
authoritativeDiscovery = discovery
return discovery
}
@@ -419,7 +419,7 @@ public actor CmxIrohHostRuntime {
connection: any CmxIrohConnection,
runtimeGeneration: UInt64,
lifecycleRevision revision: UInt64,
markAdmitted: @escaping CmxIrohEndpointServer.AdmissionMarker
markAdmitted: CmxIrohEndpointServer.AdmissionMarker
) async throws {
try requireCurrent(revision)
guard let admissionController,
@@ -485,7 +485,13 @@ public actor CmxIrohHostRuntime {
publishSelectedPathChange()
}
await handleTransport(
CmxIrohAdmittedServerSession(peer: peer, session: session),
CmxIrohAdmittedServerSession(
peer: peer,
session: session,
promoteUsableSession: {
await markAdmitted.markUsable()
}
),
isCurrent
)
}
@@ -720,6 +720,6 @@ public actor CmxIrohRegistryContextProvider: CmxIrohClientContextProvider {
}
private static func isConnectivity(_ error: any Error) -> Bool {
CmxIrohTrustBrokerClientError.preservesVerifiedPolicyDuringRefresh(error)
(error as? CmxIrohTrustBrokerClientError) == .connectivity
}
}
@@ -1,3 +1,4 @@
public import CMUXMobileCore
public import Foundation
/// Computes bounded exponential retry delays with a server-provided floor.
@@ -60,4 +61,17 @@ public struct CmxIrohRetrySchedule: Equatable, Sendable {
let jitterWindow = min(available, floor * jitterFraction)
return floor + jitterWindow * jitter
}
/// Shared relay-policy retry cadence for both app platforms.
///
/// A broker authorization failure already survived exactly-once
/// credential recovery and should re-check on auth-store timescales.
/// Availability failures keep the ordinary network backoff.
public static func relayPolicy(
for failureKind: DiagnosticFailureKind
) -> Self {
failureKind == .authorizationFailed
? Self(initialDelay: 2, maximumDelay: 120)
: Self()
}
}
@@ -25,6 +25,24 @@ public struct CmxIrohBrokerCredentials: Sendable, CustomStringConvertible,
public var debugDescription: String { description }
}
/// One authenticated account and credential pair captured atomically.
///
/// Platform auth coordinators map their native session snapshot into this
/// transport-owned value so account pinning and exactly-once rejection
/// recovery stay identical on macOS and iOS.
public struct CmxIrohAccountCredentialSnapshot: Sendable {
public let accountID: String
public let credentials: CmxIrohBrokerCredentials
public init(
accountID: String,
credentials: CmxIrohBrokerCredentials
) {
self.accountID = accountID
self.credentials = credentials
}
}
/// Supplies the short-lived Stack credentials required by native API calls.
///
/// The ONLY construction input is `credentialPair`, which must return BOTH
@@ -49,14 +67,86 @@ public struct CmxIrohBrokerTokenSource: Sendable {
/// Both tokens from ONE snapshot, so a request can never mix an old access
/// token with a rotated refresh token.
public let credentialPair: @Sendable () async throws -> CmxIrohBrokerCredentials?
/// Replaces a pair the broker just rejected as unauthorized.
///
/// A pair that was coherent at capture can still be rejected when another
/// lane rotates the session between capture and server validation (the
/// wake-time RPC force refresh, most commonly). Live sources force-mint
/// through their session owner and return the replacement pair; frozen
/// pinned sources (sign-out revocation) return nil so a destructive flow
/// never silently switches credentials. The client retries the rejected
/// request at most once with the recovered pair.
public let recoveredCredentialPair:
@Sendable (_ rejected: CmxIrohBrokerCredentials) async throws
-> CmxIrohBrokerCredentials?
public init(
credentialPair: @escaping @Sendable () async throws -> CmxIrohBrokerCredentials?
credentialPair: @escaping @Sendable () async throws -> CmxIrohBrokerCredentials?,
recoveredCredentialPair: @escaping @Sendable (
_ rejected: CmxIrohBrokerCredentials
) async throws -> CmxIrohBrokerCredentials? = { _ in nil }
) {
self.credentialPair = credentialPair
self.recoveredCredentialPair = recoveredCredentialPair
self.accessToken = { try await credentialPair()?.accessToken }
self.refreshToken = { try await credentialPair()?.refreshToken }
}
/// Builds a live token source pinned to one account.
///
/// A rejected pair first re-reads the atomic session snapshot. If another
/// lane already rotated it, that newer pair is reused. Otherwise the
/// platform auth owner is asked to refresh once, followed by one final
/// account-pinned snapshot. Account switches and missing sessions fail
/// closed throughout.
public static func accountPinned(
to expectedAccountID: String,
snapshot: @escaping @Sendable () async throws
-> CmxIrohAccountCredentialSnapshot?,
forceRefresh: @escaping @Sendable () async throws -> Void
) -> Self {
Self(
credentialPair: {
guard let captured = try await snapshot(),
captured.accountID == expectedAccountID else {
return nil
}
return captured.credentials
},
recoveredCredentialPair: { rejected in
do {
if let captured = try await snapshot(),
captured.accountID == expectedAccountID,
captured.credentials.accessToken != rejected.accessToken {
return captured.credentials
}
} catch is CancellationError {
throw CancellationError()
} catch {
// A transient snapshot read can still be repaired by the
// one explicit refresh below.
}
do {
try await forceRefresh()
} catch is CancellationError {
throw CancellationError()
} catch {
return nil
}
let refreshed: CmxIrohAccountCredentialSnapshot?
do {
refreshed = try await snapshot()
} catch is CancellationError {
throw CancellationError()
} catch {
return nil
}
guard let refreshed,
refreshed.accountID == expectedAccountID else { return nil }
return refreshed.credentials
}
)
}
}
/// Injectable URL-loading boundary used by the trust broker client.
@@ -78,6 +168,8 @@ struct CmxIrohURLSessionTransport: CmxIrohHTTPTransport {
}
/// Authenticated client for endpoint registration, discovery, grants, and relay tokens.
private struct DiscoverySnapshotChanged: Error {}
public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
private struct ConnectivitySyncRequest: Encodable {
let protocolVersion: Int
@@ -403,6 +495,24 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
}
private func discoverAllPages() async throws -> CmxIrohDiscoveryResponse {
for attempt in 0 ..< 3 {
do {
return try await discoverSnapshotAttempt()
} catch is DiscoverySnapshotChanged {
if attempt == 2 {
throw CmxIrohTrustBrokerClientError.invalidResponse
}
// Older brokers expose discovery as optimistic pages. Restart
// immediately from page one when an account mutation makes
// those pages disagree. The next request captures the newly
// committed revision, so a timing delay would add no safety.
continue
}
}
throw CmxIrohTrustBrokerClientError.invalidResponse
}
private func discoverSnapshotAttempt() async throws -> CmxIrohDiscoveryResponse {
var bindings: [CmxIrohBrokerBinding] = []
var bindingIDs: Set<String> = []
var seenCursors: Set<String> = []
@@ -419,12 +529,18 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
if let cursor {
queryItems.append(URLQueryItem(name: "cursor", value: cursor))
}
let page: CmxIrohDiscoveryPage = try await performRequest(
path: "api/devices/iroh",
method: "GET",
body: nil,
queryItems: queryItems
)
let page: CmxIrohDiscoveryPage
do {
page = try await performRequest(
path: "api/devices/iroh",
method: "GET",
body: nil,
queryItems: queryItems
)
} catch let error as CmxIrohTrustBrokerClientError
where cursor != nil && Self.isStaleDiscoveryCursor(error) {
throw DiscoverySnapshotChanged()
}
if let first {
guard page.discovery.routeContractVersion == first.routeContractVersion,
page.discovery.revision == first.revision,
@@ -432,7 +548,7 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
page.discovery.lanRendezvous == first.lanRendezvous,
page.discovery.grantVerificationKeys
== first.grantVerificationKeys else {
throw CmxIrohTrustBrokerClientError.invalidResponse
throw DiscoverySnapshotChanged()
}
} else {
first = page.discovery
@@ -464,6 +580,13 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
)
}
private static func isStaleDiscoveryCursor(
_ error: CmxIrohTrustBrokerClientError
) -> Bool {
guard case let .rejected(statusCode, code) = error else { return false }
return statusCode == 409 && code == "discovery_cursor_stale"
}
private func sendUngated<Response: Decodable & Sendable, Body: Encodable>(
path: String,
method: String,
@@ -518,8 +641,55 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
guard let pair = capturedPair else {
throw CmxIrohTrustBrokerClientError.missingAuthentication
}
let accessToken = pair.accessToken
let refreshToken = pair.refreshToken
do {
return try await performAuthenticatedRequest(
path: path,
method: method,
body: body,
queryItems: queryItems,
credentials: pair
)
} catch let error as CmxIrohTrustBrokerClientError
where Self.isUnauthorizedRejection(error) {
// A pair that was coherent at capture can be rejected when another
// lane rotated the session before the server validated it. Recover
// ONCE with a pair minted after the rejection; a second rejection
// is authoritative and propagates.
let recovered: CmxIrohBrokerCredentials?
do {
recovered = try await tokenSource.recoveredCredentialPair(pair)
} catch is CancellationError {
throw CancellationError()
} catch {
throw CmxIrohTrustBrokerClientError.connectivity
}
guard let recovered else { throw error }
return try await performAuthenticatedRequest(
path: path,
method: method,
body: body,
queryItems: queryItems,
credentials: recovered
)
}
}
private static func isUnauthorizedRejection(
_ error: CmxIrohTrustBrokerClientError
) -> Bool {
guard case let .rejected(statusCode, _) = error else { return false }
return statusCode == 401
}
private func performAuthenticatedRequest<Response: Decodable & Sendable>(
path: String,
method: String,
body: Data?,
queryItems: [URLQueryItem],
credentials: CmxIrohBrokerCredentials
) async throws -> Response {
let accessToken = credentials.accessToken
let refreshToken = credentials.refreshToken
guard Self.isSafeHeaderValue(accessToken), Self.isSafeHeaderValue(refreshToken) else {
throw CmxIrohTrustBrokerClientError.invalidAuthentication
}
@@ -28,7 +28,17 @@ public enum CmxIrohTrustBrokerClientError:
case .rateLimited:
return true
case let .rejected(statusCode, _):
return statusCode == 408
// A 401 here already survived the
// broker client's single force-refresh retry, so it is a session
// transition still settling (rotation race, locked token store) or
// a server-side availability condition not a trust change. The
// cached policy was verified when stored; tearing the runtime down
// buys nothing and turns a seconds-long auth blip into a full
// endpoint rebuild. A genuinely dead session clears auth state
// through the coordinator, which stops the runtime through the
// lifecycle owner instead.
return statusCode == 401
|| statusCode == 408
|| statusCode == 425
|| statusCode == 429
|| (500...599).contains(statusCode)
@@ -53,6 +63,8 @@ public enum CmxIrohTrustBrokerClientError:
case let .rejected(statusCode, _):
// A server failure cannot establish trust, so retrying the request
// is safe while the lifecycle-owned start task remains current.
// An authentication rejection cannot establish initial trust. It
// must return to the auth lifecycle instead of retrying forever.
return statusCode == 408
|| statusCode == 425
|| statusCode == 429
@@ -124,6 +124,47 @@ struct CmxConnectivityEngineTests {
await engine.stop()
}
@Test
func endpointConsumerWaitsForUnexpectedClosureRecovery() async throws {
let identity = try CmxIrohPeerIdentity(
endpointID: String(repeating: "f", count: 64)
)
let firstEndpoint = TestIrohEndpoint(identity: identity)
let replacementEndpoint = TestIrohEndpoint(identity: identity)
let factory = GatedReplacementEndpointFactory(
first: firstEndpoint,
replacement: replacementEndpoint
)
let engine = CmxConnectivityEngine(
factory: factory,
endpointConfiguration: try Self.endpointConfiguration(),
contextProvider: FailingConnectivityContextProvider()
)
try await engine.start()
await firstEndpoint.emit(.closedUnexpectedly)
try await Self.waitUntil {
let bindCallCount = await factory.bindCallCount()
let snapshot = await engine.snapshot()
return bindCallCount == 2 && snapshot.phase == .starting
}
let lookupStarted = ConnectivityObservationFlag()
let lookup = Task {
await lookupStarted.markFinished()
return try await engine.localEndpointIdentity()
}
try await Self.waitUntil { await lookupStarted.value() }
for _ in 0 ..< 100 { await Task.yield() }
await factory.releaseReplacement()
#expect(try await lookup.value == identity)
#expect(await engine.snapshot().phase == .active)
#expect(await engine.snapshot().endpointGeneration == 2)
await engine.stop()
}
@Test
func equivalentRouteRevisionBumpKeepsTheLivePeerSession() async throws {
let rig = try await Self.admittedPeerRig(responses: [
@@ -286,6 +327,186 @@ struct CmxConnectivityEngineTests {
await rig.engine.stop()
}
@Test
func reorderedCapabilitiesOnRevisionBumpKeepsTheLivePeerSession() async throws {
let rig = try await Self.admittedPeerRig(responses: [
Self.peerRouteResponse(
revision: 9,
lastSeenAt: "2026-07-30T00:00:00Z",
capabilities: ["artifact", "terminal"]
),
Self.peerRouteResponse(
revision: 10,
lastSeenAt: "2026-07-30T00:00:45Z",
capabilities: ["terminal", "artifact"]
),
])
let session = try await rig.engine.acquireControl(
for: rig.request,
ownerID: UUID()
)
try await rig.engine.reconcileRoutes()
#expect(await rig.engine.snapshot().routeRevision == 10)
#expect(await rig.connection.observedCloseCallCount() == 0)
#expect(await session.isClosed() == false)
await rig.engine.stop()
}
@Test
func reorderedRelayFleetOnRevisionBumpKeepsTheLivePeerSession() async throws {
let rig = try await Self.admittedPeerRig(responses: [
Self.peerRouteResponse(
revision: 9,
lastSeenAt: "2026-07-30T00:00:00Z",
relayFleet: [
"https://relay-a.example/",
"https://relay-b.example/",
]
),
Self.peerRouteResponse(
revision: 10,
lastSeenAt: "2026-07-30T00:00:45Z",
relayFleet: [
"https://relay-b.example/",
"https://relay-a.example/",
]
),
])
let session = try await rig.engine.acquireControl(
for: rig.request,
ownerID: UUID()
)
try await rig.engine.reconcileRoutes()
#expect(await rig.engine.snapshot().routeRevision == 10)
#expect(await rig.connection.observedCloseCallCount() == 0)
#expect(await session.isClosed() == false)
await rig.engine.stop()
}
@Test
func reorderedGrantVerificationKeysOnRevisionBumpKeepsTheLivePeerSession() async throws {
let rig = try await Self.admittedPeerRig(responses: [
Self.peerRouteResponse(
revision: 9,
lastSeenAt: "2026-07-30T00:00:00Z",
grantVerificationKeyIDs: ["current", "previous"]
),
Self.peerRouteResponse(
revision: 10,
lastSeenAt: "2026-07-30T00:00:45Z",
grantVerificationKeyIDs: ["previous", "current"]
),
])
let session = try await rig.engine.acquireControl(
for: rig.request,
ownerID: UUID()
)
try await rig.engine.reconcileRoutes()
#expect(await rig.engine.snapshot().routeRevision == 10)
#expect(await rig.connection.observedCloseCallCount() == 0)
#expect(await session.isClosed() == false)
await rig.engine.stop()
}
@Test
func snapshotInstallForARevisionRecordedWithoutContentFailsClosed() async throws {
let rig = try await Self.admittedPeerRig(
responses: [
Self.peerRouteResponse(
revision: 9,
lastSeenAt: "2026-07-30T00:00:00Z"
),
Self.unchangedResponse(revision: 12),
],
dialableConnections: 2
)
let first = try await rig.engine.acquireControl(
for: rig.request,
ownerID: UUID()
)
try await rig.engine.reconcileRoutes()
#expect(await first.isClosed())
let second = try await rig.engine.acquireControl(
for: rig.request,
ownerID: UUID()
)
#expect(await second.isClosed() == false)
let snapshot = try #require(Self.peerRouteResponse(
revision: 12,
lastSeenAt: "2026-07-30T00:00:45Z"
).snapshot)
await rig.engine.didInstallRouteRevision(12, routes: snapshot)
#expect(await rig.engine.snapshot().routeRevision == 12)
#expect(await rig.connections[1].observedCloseCallCount() == 1)
#expect(await second.isClosed())
await rig.engine.stop()
}
@Test
func sameRevisionReinstallWithUnchangedContentKeepsTheLivePeerSession() async throws {
let rig = try await Self.admittedPeerRig(responses: [
Self.peerRouteResponse(
revision: 9,
lastSeenAt: "2026-07-30T00:00:00Z"
),
])
let session = try await rig.engine.acquireControl(
for: rig.request,
ownerID: UUID()
)
let snapshot = try #require(Self.peerRouteResponse(
revision: 10,
lastSeenAt: "2026-07-30T00:00:45Z"
).snapshot)
await rig.engine.didInstallRouteRevision(10, routes: snapshot)
await rig.engine.didInstallRouteRevision(10, routes: snapshot)
#expect(await rig.engine.snapshot().routeRevision == 10)
#expect(await rig.connection.observedCloseCallCount() == 0)
#expect(await session.isClosed() == false)
await rig.engine.stop()
}
@Test
func olderRouteRevisionInstallCannotRollBackANewerInstall() async throws {
let rig = try await Self.admittedPeerRig(responses: [
Self.peerRouteResponse(
revision: 9,
lastSeenAt: "2026-07-30T00:00:00Z"
),
])
let session = try await rig.engine.acquireControl(
for: rig.request,
ownerID: UUID()
)
let newer = try #require(Self.peerRouteResponse(
revision: 11,
lastSeenAt: "2026-07-30T00:00:45Z"
).snapshot)
let older = try #require(Self.peerRouteResponse(
revision: 10,
lastSeenAt: "2026-07-30T00:00:30Z",
identityGeneration: 2
).snapshot)
await rig.engine.didInstallRouteRevision(11, routes: newer)
await rig.engine.didInstallRouteRevision(10, routes: older)
#expect(await rig.engine.snapshot().routeRevision == 11)
#expect(await rig.connection.observedCloseCallCount() == 0)
#expect(await session.isClosed() == false)
await rig.engine.stop()
}
@Test
func stopFinishesNetworkChangeObservers() async throws {
let identity = try CmxIrohPeerIdentity(
@@ -317,34 +538,38 @@ struct CmxConnectivityEngineTests {
private struct AdmittedPeerRig {
let engine: CmxConnectivityEngine
let connection: TestIrohConnection
let connections: [TestIrohConnection]
let authority: ScriptedConnectivityAuthority
let request: CmxByteTransportRequest
var connection: TestIrohConnection { connections[0] }
}
private static func admittedPeerRig(
responses: [CmxConnectivitySyncResponse]
responses: [CmxConnectivitySyncResponse],
dialableConnections: Int = 1
) async throws -> AdmittedPeerRig {
let localIdentity = try CmxIrohPeerIdentity(
endpointID: String(repeating: "1", count: 64)
)
let peerIdentity = try CmxIrohPeerIdentity(endpointID: peerEndpointID)
let control = CmxIrohBidirectionalStream(
receiveStream: TestIrohReceiveStream(
buffer: CmxIrohAdmissionAckCodec()
.encodeFrame(.acceptedPendingNatTraversal)
+ admissionFrame(status: 3)
),
sendStream: TestIrohSendStream()
)
let connection = TestIrohConnection(
remoteIdentity: peerIdentity,
bidirectionalStreams: [control],
selectedPath: .direct
)
let connections = (0 ..< dialableConnections).map { _ in
TestIrohConnection(
remoteIdentity: peerIdentity,
bidirectionalStreams: [CmxIrohBidirectionalStream(
receiveStream: TestIrohReceiveStream(
buffer: CmxIrohAdmissionAckCodec()
.encodeFrame(.acceptedPendingNatTraversal)
+ admissionFrame(status: 3)
),
sendStream: TestIrohSendStream()
)],
selectedPath: .direct
)
}
let endpoint = TestDialingIrohEndpoint(
localIdentity: localIdentity,
dialResults: [.connection(connection)]
dialResults: connections.map { .connection($0) }
)
let supervisor = CmxIrohEndpointSupervisor(
factory: TestIrohEndpointFactory(endpoints: [endpoint]),
@@ -373,7 +598,7 @@ struct CmxConnectivityEngineTests {
)
return AdmittedPeerRig(
engine: engine,
connection: connection,
connections: connections,
authority: authority,
request: request
)
@@ -384,8 +609,13 @@ struct CmxConnectivityEngineTests {
lastSeenAt: String,
identityGeneration: Int = 1,
relayFleet: [String] = ["https://relay.example/"],
capabilities: [String] = ["terminal"],
grantVerificationKeyIDs: [String] = [],
includesPeerBinding: Bool = true
) throws -> CmxConnectivitySyncResponse {
let capabilityList = capabilities
.map { "\"\($0)\"" }
.joined(separator: ", ")
let binding = """
{
"binding_id": "0a0a0a0a-0000-4000-8000-000000000001",
@@ -396,7 +626,7 @@ struct CmxConnectivityEngineTests {
"endpoint_id": "\(peerEndpointID)",
"identity_generation": \(identityGeneration),
"pairing_enabled": true,
"capabilities": ["terminal"],
"capabilities": [\(capabilityList)],
"path_hints": [],
"last_seen_at": "\(lastSeenAt)"
}
@@ -404,6 +634,13 @@ struct CmxConnectivityEngineTests {
let fleet = relayFleet
.map { "\"\($0)\"" }
.joined(separator: ", ")
let keys = grantVerificationKeyIDs
.map {
"""
{"kid": "\($0)", "alg": "ed25519", "spki_der_base64": "QUJD"}
"""
}
.joined(separator: ", ")
return try decodeResponse(
"""
{
@@ -423,7 +660,7 @@ struct CmxConnectivityEngineTests {
"grant_verification_keys": {
"version": 1,
"current_kid": "current",
"keys": []
"keys": [\(keys)]
}
}
}
@@ -557,6 +794,38 @@ private actor ConnectivityObservationFlag {
func value() -> Bool { finished }
}
private actor GatedReplacementEndpointFactory: CmxIrohEndpointFactory {
private let first: any CmxIrohEndpoint
private let replacement: any CmxIrohEndpoint
private var calls = 0
private var replacementWaiter: CheckedContinuation<any CmxIrohEndpoint, Never>?
init(
first: any CmxIrohEndpoint,
replacement: any CmxIrohEndpoint
) {
self.first = first
self.replacement = replacement
}
func bind(
configuration _: CmxIrohEndpointConfiguration
) async -> any CmxIrohEndpoint {
calls += 1
if calls == 1 { return first }
return await withCheckedContinuation { continuation in
replacementWaiter = continuation
}
}
func bindCallCount() -> Int { calls }
func releaseReplacement() {
replacementWaiter?.resume(returning: replacement)
replacementWaiter = nil
}
}
private actor GatedConnectivityAuthority: CmxConnectivityAuthorityServing {
private let changed: CmxConnectivitySyncResponse
private let unchanged: CmxConnectivitySyncResponse
@@ -266,6 +266,111 @@ struct CmxConnectivityPeerSessionTests {
await peer.invalidate()
}
@Test
func invalidationDuringRedundantDialCloseTriggersAFreshDial() async throws {
let request = try Self.request()
let peerID = try CmxConnectivityPeerID(request: request)
let winner = TestConnectivitySession(
continuityID: 91,
gatesFirstIsClosedCheck: true
)
let loser = TestConnectivitySession(
continuityID: 92,
gatesFirstClose: true
)
let replacement = TestConnectivitySession(continuityID: 93)
let builder = OrderedGatedConnectivitySessionBuilder(
sessions: [winner, loser, replacement]
)
let peer = CmxConnectivityPeerSession(
peerID: peerID,
buildSession: { request in
try await builder.build(request)
}
)
// Park the first caller at the dead-on-arrival probe so the second
// caller starts its own dial, then let the winner install before the
// second dial resolves.
let firstCaller = Task { try await peer.connectedSession(for: request) }
try await Self.waitUntil { await builder.callCount() == 1 }
await builder.release(call: 0)
try await Self.waitUntil { await winner.isClosedGateIsWaiting() }
let secondCaller = Task { try await peer.connectedSession(for: request) }
try await Self.waitUntil { await builder.callCount() == 2 }
await winner.releaseIsClosedGate()
_ = try await firstCaller.value
await builder.release(call: 1)
try await Self.waitUntil { await loser.closeGateIsWaiting() }
// The redundant close is in flight; invalidation evicts the winner
// before that close settles. The second caller must not receive the
// stale winner capture.
await peer.invalidate()
await loser.releaseCloseGate()
try await Self.waitUntil { await builder.callCount() == 3 }
await builder.release(call: 2)
let session = try await secondCaller.value
#expect(await session.connectionContinuityID() == 93)
#expect(await peer.connectionContinuityID() == 93)
#expect(await winner.closeCount() == 1)
#expect(await loser.closeCount() == 1)
await peer.invalidate()
}
@Test
func invalidationDuringPostProbeRedundantDialCloseTriggersAFreshDial() async throws {
let request = try Self.request()
let peerID = try CmxConnectivityPeerID(request: request)
let winner = TestConnectivitySession(
continuityID: 101,
gatesFirstIsClosedCheck: true
)
let loser = TestConnectivitySession(
continuityID: 102,
gatesFirstIsClosedCheck: true,
gatesFirstClose: true
)
let replacement = TestConnectivitySession(continuityID: 103)
let builder = OrderedGatedConnectivitySessionBuilder(
sessions: [winner, loser, replacement]
)
let peer = CmxConnectivityPeerSession(
peerID: peerID,
buildSession: { request in
try await builder.build(request)
}
)
// Park both callers at their dead-on-arrival probes so the winner
// installs while the second caller is past its post-resolve check.
let firstCaller = Task { try await peer.connectedSession(for: request) }
try await Self.waitUntil { await builder.callCount() == 1 }
await builder.release(call: 0)
try await Self.waitUntil { await winner.isClosedGateIsWaiting() }
let secondCaller = Task { try await peer.connectedSession(for: request) }
try await Self.waitUntil { await builder.callCount() == 2 }
await builder.release(call: 1)
try await Self.waitUntil { await loser.isClosedGateIsWaiting() }
await winner.releaseIsClosedGate()
_ = try await firstCaller.value
await loser.releaseIsClosedGate()
try await Self.waitUntil { await loser.closeGateIsWaiting() }
await peer.invalidate()
await loser.releaseCloseGate()
try await Self.waitUntil { await builder.callCount() == 3 }
await builder.release(call: 2)
let session = try await secondCaller.value
#expect(await session.connectionContinuityID() == 103)
#expect(await peer.connectionContinuityID() == 103)
#expect(await winner.closeCount() == 1)
#expect(await loser.closeCount() == 1)
await peer.invalidate()
}
@Test
func deadOnArrivalSessionIsClosedAndRedialedOnce() async throws {
let request = try Self.request()
@@ -563,6 +668,9 @@ private actor TestConnectivitySession: CmxConnectivitySession {
private var isClosedGatePending: Bool
private var isClosedGateWaiting = false
private var isClosedGateWaiter: CheckedContinuation<Void, Never>?
private var closeGatePending: Bool
private var closeGateWaiting = false
private var closeGateWaiter: CheckedContinuation<Void, Never>?
private var received: [Data] = []
private var selectedPath = CmxIrohObservedConnectionPath.direct
private var selectedPathContinuation:
@@ -572,12 +680,14 @@ private actor TestConnectivitySession: CmxConnectivitySession {
continuityID: UInt64,
gatesCloseAttribution: Bool = false,
keepsSelectedPathStreamOpen: Bool = false,
gatesFirstIsClosedCheck: Bool = false
gatesFirstIsClosedCheck: Bool = false,
gatesFirstClose: Bool = false
) {
self.continuityID = continuityID
self.gatesCloseAttribution = gatesCloseAttribution
self.keepsSelectedPathStreamOpen = keepsSelectedPathStreamOpen
isClosedGatePending = gatesFirstIsClosedCheck
closeGatePending = gatesFirstClose
}
func receiveControl(maximumByteCount: Int) -> Data? {
@@ -681,11 +791,28 @@ private actor TestConnectivitySession: CmxConnectivitySession {
}
}
func close() {
func close() async {
if closeGatePending {
closeGatePending = false
closeGateWaiting = true
await withCheckedContinuation { continuation in
closeGateWaiter = continuation
}
closeGateWaiting = false
}
closes += 1
finish(failure: .cancelled)
}
func closeGateIsWaiting() -> Bool {
closeGateWaiting
}
func releaseCloseGate() {
closeGateWaiter?.resume()
closeGateWaiter = nil
}
func finishRemotely(failure: DiagnosticFailureKind) {
finish(failure: failure)
}
@@ -25,7 +25,13 @@ struct CmxIrohBrokerCredentialRepositoryTests {
let (defaults, suiteName) = try isolatedDefaults()
defer { defaults.removePersistentDomain(forName: suiteName) }
let secureStore = TestSecureCredentialStore()
let binding = try metadata()
let pathHint = try CmxIrohPathHint(
kind: .relayURL,
value: relayFleet[0],
source: .native,
privacyScope: .publicInternet
)
let binding = try metadata(pathHints: [pathHint])
let response = relayResponse()
let repository = makeRepository(defaults: defaults, secureStore: secureStore)
@@ -450,7 +456,8 @@ struct CmxIrohBrokerCredentialRepositoryTests {
private func metadata(
bindingID: String = "123e4567-e89b-42d3-a456-426614174010",
endpointByte: String = "ab",
generation: Int = 1
generation: Int = 1,
pathHints: [CmxIrohPathHint] = []
) throws -> CmxIrohBrokerBindingMetadata {
try CmxIrohBrokerBindingMetadata(
bindingID: bindingID,
@@ -461,7 +468,8 @@ struct CmxIrohBrokerCredentialRepositoryTests {
endpointID: CmxIrohPeerIdentity(
endpointID: String(repeating: endpointByte, count: 32)
),
identityGeneration: generation
identityGeneration: generation,
pathHints: pathHints
)
}
@@ -76,7 +76,7 @@ struct CmxIrohClientRuntimeTests {
}
@Test
func embeddedDiscoveryMustExactlyMatchTheRegistrationRevision() async throws {
func embeddedDiscoveryMayFollowTheRegistrationRevision() async throws {
let fixture = try ClientRuntimeTestFixture()
let discovery = try ClientRuntimeTestFixture.discovery(
binding: fixture.binding,
@@ -98,9 +98,11 @@ struct CmxIrohClientRuntimeTests {
now: { fixture.now }
)
await #expect(throws: CmxIrohTrustBrokerClientError.invalidResponse) {
try await runtime.start()
}
try await runtime.start()
#expect(await runtime.snapshot().state == .active)
#expect(await runtime.connectivityEngine.snapshot().routeRevision == 2)
await runtime.stop()
}
@Test
@@ -214,6 +216,44 @@ struct CmxIrohClientRuntimeTests {
await runtime.stop()
}
@Test
func startupFetchesPaginatedDiscoveryWhenRegistrationAndSyncSnapshotsAreUnproven() async throws {
let fixture = try ClientRuntimeTestFixture()
let truncatedRegistrationDiscovery = try ClientRuntimeTestFixture.discovery(
binding: fixture.binding,
includeBinding: false,
revision: 1
)
let completeDiscovery = try ClientRuntimeTestFixture.discovery(
binding: fixture.binding,
revision: 1
)
let broker = TestRevisionedClientBroker(
binding: fixture.binding,
discoveries: [truncatedRegistrationDiscovery, completeDiscovery],
relay: fixture.relayResponse(),
embeddedRegistrationDiscovery: truncatedRegistrationDiscovery,
connectivitySnapshotsProvenComplete: nil
)
let runtime = try CmxIrohClientRuntime(
factory: TestIrohEndpointFactory(endpoints: [
TestIrohEndpoint(identity: fixture.endpointID),
]),
broker: broker,
configuration: fixture.configuration,
pendingRevocations: fixture.pendingRevocations(),
now: { fixture.now }
)
try await runtime.start()
#expect(await broker.registrationCount == 1)
#expect(await broker.syncCount == 1)
#expect(await broker.discoveryCount == 1)
#expect(await runtime.connectivityEngine.snapshot().routeRevision == 1)
await runtime.stop()
}
@Test
func cachedBindingSyncOverlapsBindAndRegistersAfterActivation() async throws {
let fixture = try ClientRuntimeTestFixture()
@@ -790,7 +830,7 @@ struct CmxIrohClientRuntimeTests {
}
@Test
func foregroundTerminalBrokerFailureRevokesLocalPolicy() async throws {
func foregroundUnauthorizedBrokerFailurePreservesLocalPolicy() async throws {
let fixture = try ClientRuntimeTestFixture()
let endpoint = TestIrohEndpoint(identity: fixture.endpointID)
let broker = TestIrohClientBroker(
@@ -820,14 +860,13 @@ struct CmxIrohClientRuntimeTests {
)
await broker.setRegistrationError(terminal)
await #expect(throws: terminal) {
try await runtime.didBecomeActive()
}
try await runtime.didBecomeActive()
#expect(await runtime.snapshot().state == .failed)
#expect(await endpoint.observedCloseCallCount() == 1)
#expect(await offlineStore.deleteAllCount() == 1)
#expect(await recorder.observedPolicyInvalidationCount() == 1)
#expect(await runtime.snapshot().state == .active)
#expect(await endpoint.observedCloseCallCount() == 0)
#expect(await offlineStore.deleteAllCount() == 0)
#expect(await recorder.observedPolicyInvalidationCount() == 0)
await runtime.stop()
}
@Test
@@ -1119,9 +1158,12 @@ private actor TestRevisionedClientBroker:
private let blockedSyncCount: Int?
private let blockedRegistrationCount: Int?
private let embeddedRegistrationDiscovery: CmxIrohDiscoveryResponse?
private let embeddedRegistrationDiscoveryIsComplete: Bool?
private let registrationRevision: UInt64?
private let registrationError: CmxIrohTrustBrokerClientError?
private let connectivitySnapshotsProvenComplete: Bool?
private(set) var registrationCount = 0
private(set) var discoveryCount = 0
private(set) var syncCount = 0
private var blockedSyncReleased = false
private var blockedRegistrationReleased = false
@@ -1133,19 +1175,24 @@ private actor TestRevisionedClientBroker:
blockedSyncCount: Int? = nil,
blockedRegistrationCount: Int? = nil,
embedInitialDiscovery: Bool = false,
embeddedRegistrationDiscovery: CmxIrohDiscoveryResponse? = nil,
embeddedRegistrationDiscoveryIsComplete: Bool? = nil,
registrationRevision: UInt64? = nil,
registrationError: CmxIrohTrustBrokerClientError? = nil
registrationError: CmxIrohTrustBrokerClientError? = nil,
connectivitySnapshotsProvenComplete: Bool? = true
) {
self.binding = binding
self.discoveries = discoveries
self.relay = relay
self.blockedSyncCount = blockedSyncCount
self.blockedRegistrationCount = blockedRegistrationCount
embeddedRegistrationDiscovery = embedInitialDiscovery
? discoveries.first
: nil
self.embeddedRegistrationDiscovery = embeddedRegistrationDiscovery
?? (embedInitialDiscovery ? discoveries.first : nil)
self.embeddedRegistrationDiscoveryIsComplete = embeddedRegistrationDiscoveryIsComplete
?? (embedInitialDiscovery ? true : nil)
self.registrationRevision = registrationRevision
self.registrationError = registrationError
self.connectivitySnapshotsProvenComplete = connectivitySnapshotsProvenComplete
}
func register(
@@ -1165,7 +1212,8 @@ private actor TestRevisionedClientBroker:
?? discoveries.first?.revision,
binding: binding,
relay: .issued(relay),
discovery: embeddedRegistrationDiscovery
discovery: embeddedRegistrationDiscovery,
discoveryComplete: embeddedRegistrationDiscoveryIsComplete
)
}
@@ -1184,11 +1232,13 @@ private actor TestRevisionedClientBroker:
let discovery = discoveries.removeFirst()
return CmxConnectivitySyncResponse(
legacySnapshot: discovery,
knownRevision: knownRevision
knownRevision: knownRevision,
snapshotComplete: connectivitySnapshotsProvenComplete
)
}
func discover() throws -> CmxIrohDiscoveryResponse {
discoveryCount += 1
guard let discovery = discoveries.first else {
throw TestIrohTransportError.unsupported
}
@@ -70,6 +70,99 @@ struct CmxIrohConnectionCloseAttributionTests {
)
}
// The uniffi boundary returns quinn ConnectionError DISPLAY strings from
// Connection.closed()/close_reason() ("timed out", "closed",
// "closed by peer: ..."), not the Debug fragments matched above. Every
// production close cause fell through to unknown/unknown until these
// formats were recognized (https://github.com/manaflow-ai/cmux/issues/9169).
@Test
func classifiesDisplayIdleTimeout() {
#expect(
CmxIrohConnectionCloseAttribution.classify("timed out")
== CmxIrohConnectionCloseAttribution(
initiator: .timedOut,
applicationErrorCode: nil,
failureKind: .transportIdleTimedOut
)
)
}
@Test
func classifiesDisplayLocalClose() {
#expect(
CmxIrohConnectionCloseAttribution.classify("closed")
== CmxIrohConnectionCloseAttribution(
initiator: .local,
applicationErrorCode: nil,
failureKind: .cancelled
)
)
}
@Test
func classifiesDisplayPeerApplicationCloseWithBareCode() {
#expect(
CmxIrohConnectionCloseAttribution.classify("closed by peer: 42")
== CmxIrohConnectionCloseAttribution(
initiator: .remote,
applicationErrorCode: 42,
failureKind: .connectionClosed
)
)
}
@Test
func classifiesDisplayPeerApplicationCloseWithReasonAndCode() {
#expect(
CmxIrohConnectionCloseAttribution.classify(
"closed by peer: going away (code 42)"
) == CmxIrohConnectionCloseAttribution(
initiator: .remote,
applicationErrorCode: 42,
failureKind: .connectionClosed
)
)
}
@Test
func classifiesDisplayPeerReset() {
#expect(
CmxIrohConnectionCloseAttribution.classify("reset by peer")
== CmxIrohConnectionCloseAttribution(
initiator: .remote,
applicationErrorCode: nil,
failureKind: .connectionClosed
)
)
}
@Test
func classifiesDisplayPeerTransportAbortWithoutStealingApplicationCode() {
#expect(
CmxIrohConnectionCloseAttribution.classify(
"aborted by peer: CONNECTION_REFUSED: server busy"
) == CmxIrohConnectionCloseAttribution(
initiator: .remote,
applicationErrorCode: nil,
failureKind: .connectionClosed
)
)
}
@Test
func displayPeerReasonCannotSpoofInitiatorOrTimeoutKind() {
#expect(
CmxIrohConnectionCloseAttribution.classify(
"closed by peer: timed out (code 7)"
) == CmxIrohConnectionCloseAttribution(
initiator: .remote,
applicationErrorCode: 7,
failureKind: .connectionClosed
)
)
}
@Test
func authoritativeDriverCauseSupersedesTentativeLocalClose() async {
let store = CmxIrohConnectionCloseAttributionStore()
@@ -5,7 +5,7 @@ import Testing
extension CmxIrohEndpointServerTests {
@Test
func fullServerReservesOnePendingReconnectForAnActiveIdentity() async throws {
func fullServerRejectsReconnectCandidateWithoutDisruptingActiveConnection() async throws {
let localIdentity = try CmxIrohPeerIdentity(
endpointID: String(repeating: "8", count: 64)
)
@@ -27,8 +27,6 @@ extension CmxIrohEndpointServerTests {
)
_ = try await supervisor.activate()
let started = EndpointServerRecorder()
let admitted = EndpointServerRecorder()
let replacementAuthorization = EndpointServerHandlerBlocker()
let connectionLifetime = EndpointServerHandlerBlocker()
let server = CmxIrohEndpointServer(
supervisor: supervisor,
@@ -37,11 +35,7 @@ extension CmxIrohEndpointServerTests {
) { connection, generation, markAdmitted in
let identity = await connection.remoteIdentity()
await started.record(identity: identity, generation: generation)
if await started.recordedCount() == 2 {
await replacementAuthorization.wait()
}
#expect(await markAdmitted())
await admitted.record(identity: identity, generation: generation)
await connectionLifetime.wait()
}
let active = TestIrohConnection(
@@ -56,28 +50,26 @@ extension CmxIrohEndpointServerTests {
remoteIdentity: newIdentity,
bidirectionalStreams: []
)
var activeCloses = await active.closeEvents().makeAsyncIterator()
var replacementCloses = await replacement.closeEvents().makeAsyncIterator()
var newcomerCloses = await newcomer.closeEvents().makeAsyncIterator()
await server.start()
await endpoint.enqueue(active)
#expect(await started.next().identity == activeIdentity)
#expect(await admitted.next().identity == activeIdentity)
await endpoint.enqueue(replacement)
for _ in 0 ..< 100 {
let startedCount = await started.recordedCount()
let replacementCloseCount = await replacement.observedCloseCallCount()
guard startedCount < 2, replacementCloseCount == 0 else { break }
guard startedCount == 1, replacementCloseCount == 0 else { break }
await Task.yield()
}
let replacementStarted = await started.recordedCount() == 2
#expect(replacementStarted)
guard replacementStarted else {
await connectionLifetime.releaseAll()
await server.stop()
await supervisor.deactivate()
return
#expect(await started.recordedCount() == 1)
let replacementCloseCount = await replacement.observedCloseCallCount()
#expect(replacementCloseCount == 1)
if replacementCloseCount == 1 {
let replacementClose = try #require(await replacementCloses.next())
#expect(replacementClose.reason == "connection_capacity")
}
#expect(await active.observedCloseCallCount() == 0)
@@ -86,12 +78,6 @@ extension CmxIrohEndpointServerTests {
let newcomerClose = try #require(await newcomerCloses.next())
#expect(newcomerClose.reason == "connection_capacity")
await replacementAuthorization.releaseAll()
#expect(await admitted.next().identity == activeIdentity)
let activeClose = try #require(await activeCloses.next())
#expect(activeClose.reason == "superseded_connection")
#expect(await replacement.observedCloseCallCount() == 0)
await connectionLifetime.releaseAll()
await server.stop()
await supervisor.deactivate()
@@ -156,7 +142,7 @@ extension CmxIrohEndpointServerTests {
}
@Test
func sameEndpointReconnectsDoNotConsumeEveryLiveConnectionSlot() async throws {
func sameEndpointReconnectsReplaceOldestUnreadyConnectionAtBound() async throws {
let localIdentity = try CmxIrohPeerIdentity(
endpointID: String(repeating: "1", count: 64)
)
@@ -192,23 +178,42 @@ extension CmxIrohEndpointServerTests {
}
await server.start()
var reconnects: [TestIrohConnection] = []
for _ in 0 ..< 3 {
let reconnect = TestIrohConnection(
remoteIdentity: firstRemoteIdentity,
bidirectionalStreams: []
)
reconnects.append(reconnect)
await endpoint.enqueue(reconnect)
#expect(await recorder.next().identity == firstRemoteIdentity)
if reconnects.count > 1 {
await reconnects[reconnects.count - 2].waitUntilClosed()
}
let first = TestIrohConnection(
remoteIdentity: firstRemoteIdentity,
bidirectionalStreams: []
)
let replacement = TestIrohConnection(
remoteIdentity: firstRemoteIdentity,
bidirectionalStreams: []
)
let excessCandidate = TestIrohConnection(
remoteIdentity: firstRemoteIdentity,
bidirectionalStreams: []
)
var firstCloses = await first.closeEvents().makeAsyncIterator()
await endpoint.enqueue(first)
#expect(await recorder.next().identity == firstRemoteIdentity)
await endpoint.enqueue(replacement)
#expect(await recorder.next().identity == firstRemoteIdentity)
await endpoint.enqueue(excessCandidate)
for _ in 0 ..< 100 {
let recordedCount = await recorder.recordedCount()
let firstCloseCount = await first.observedCloseCallCount()
if recordedCount == 3, firstCloseCount == 1 { break }
await Task.yield()
}
#expect(await reconnects[0].observedCloseCallCount() == 1)
#expect(await reconnects[1].observedCloseCallCount() == 1)
#expect(await reconnects[2].observedCloseCallCount() == 0)
#expect(await recorder.recordedCount() == 3)
#expect(await recorder.next().identity == firstRemoteIdentity)
#expect(await first.observedCloseCallCount() == 1)
#expect(await replacement.observedCloseCallCount() == 0)
#expect(await excessCandidate.observedCloseCallCount() == 0)
let firstCloseCount = await first.observedCloseCallCount()
if firstCloseCount == 1 {
let close = try #require(await firstCloses.next())
#expect(close.reason == "superseded_unready_connection")
}
await endpoint.enqueue(
TestIrohConnection(
@@ -265,7 +265,7 @@ struct CmxIrohEndpointServerTests {
}
@Test
func newlyAdmittedConnectionSupersedesOlderConnectionFromSameEndpointIdentity() async throws {
func newlyAdmittedConnectionPreservesOlderConnectionFromSameEndpointIdentity() async throws {
let localIdentity = try CmxIrohPeerIdentity(
endpointID: String(repeating: "a", count: 64)
)
@@ -304,8 +304,6 @@ struct CmxIrohEndpointServerTests {
remoteIdentity: remoteIdentity,
bidirectionalStreams: []
)
var firstCloses = await first.closeEvents().makeAsyncIterator()
await server.start()
await endpoint.enqueue(first)
#expect(await recorder.next().identity == remoteIdentity)
@@ -313,12 +311,68 @@ struct CmxIrohEndpointServerTests {
#expect(await recorder.next().identity == remoteIdentity)
for _ in 0 ..< 20 { await Task.yield() }
let firstCloseCount = await first.observedCloseCallCount()
#expect(firstCloseCount == 1)
if firstCloseCount == 1 {
let close = try #require(await firstCloses.next())
#expect(close.reason == "superseded_connection")
#expect(await first.observedCloseCallCount() == 0)
#expect(await replacement.observedCloseCallCount() == 0)
await blocker.releaseAll()
await server.stop()
await supervisor.deactivate()
}
@Test
func usableConnectionRetiresOlderConnectionsFromSameEndpointIdentity() async throws {
let localIdentity = try CmxIrohPeerIdentity(
endpointID: String(repeating: "c", count: 64)
)
let remoteIdentity = try CmxIrohPeerIdentity(
endpointID: String(repeating: "d", count: 64)
)
let endpoint = TestAcceptingIrohEndpoint(identity: localIdentity)
let supervisor = CmxIrohEndpointSupervisor(
factory: TestIrohEndpointFactory(endpoints: [endpoint]),
configuration: try CmxIrohEndpointConfiguration(
secretKey: CmxIrohSecretKey(bytes: Data(repeating: 9, count: 32)),
alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn],
managedRelayURLs: [],
relays: []
)
)
_ = try await supervisor.activate()
let blocker = EndpointServerHandlerBlocker()
let recorder = EndpointServerRecorder()
let server = CmxIrohEndpointServer(supervisor: supervisor) {
connection,
generation,
admission in
await recorder.record(
identity: await connection.remoteIdentity(),
generation: generation
)
#expect(await admission())
if await recorder.recordedCount() == 2 {
#expect(await admission.markUsable())
}
await blocker.wait()
}
let first = TestIrohConnection(
remoteIdentity: remoteIdentity,
bidirectionalStreams: []
)
let replacement = TestIrohConnection(
remoteIdentity: remoteIdentity,
bidirectionalStreams: []
)
var firstCloses = await first.closeEvents().makeAsyncIterator()
await server.start()
await endpoint.enqueue(first)
#expect(await recorder.next().identity == remoteIdentity)
await endpoint.enqueue(replacement)
#expect(await recorder.next().identity == remoteIdentity)
await first.waitUntilClosed()
let close = try #require(await firstCloses.next())
#expect(close.reason == "superseded_connection")
#expect(await replacement.observedCloseCallCount() == 0)
await blocker.releaseAll()
@@ -573,6 +573,10 @@ extension CmxIrohHostRuntimeTests {
}
@Test(arguments: [
CmxIrohTrustBrokerClientError.rejected(
statusCode: 401,
code: "unauthorized"
),
CmxIrohTrustBrokerClientError.rejected(
statusCode: 408,
code: "request_timeout"
@@ -6,6 +6,87 @@ import Testing
@testable import CmuxIrohTransport
extension CmxIrohHostRuntimeTests {
@Test
func startupFetchesAuthoritativeDiscoveryWhenRegistrationSnapshotIsIncomplete() async throws {
let fixture = try HostRuntimeFixture()
let pageOneBinding = try HostRuntimeFixture.binding(
endpointID: fixture.endpointID.endpointID,
bindingID: "123e4567-e89b-42d3-a456-426614174099"
)
let pageOne = try HostRuntimeFixture.discovery(
binding: pageOneBinding,
relays: HostRuntimeFixture.relayURLs,
revision: 1
)
let completeDiscovery = try HostRuntimeFixture.discovery(
binding: fixture.binding,
relays: HostRuntimeFixture.relayURLs,
revision: 1
)
let broker = TestIrohHostBroker(
registrationBinding: fixture.binding,
discovery: completeDiscovery,
embeddedRegistrationDiscovery: pageOne,
embeddedRegistrationDiscoveryIsComplete: false,
registrationRevision: 1
)
let runtime = CmxIrohHostRuntime(
factory: TestIrohEndpointFactory(endpoints: [
TestIrohEndpoint(identity: fixture.endpointID),
]),
broker: broker,
configuration: fixture.configuration,
pendingRevocations: fixture.pendingRevocations(),
handleTransport: { session, _ in await session.close() }
)
try await runtime.start()
#expect(await broker.observedDiscoveryCount() == 1)
#expect(await runtime.snapshot().state == .active)
#expect(await runtime.connectivityEngine?.snapshot().routeRevision == 1)
await runtime.stop()
}
@Test
func truncatedEmbeddedDiscoveryFallsBackToAuthoritativeDiscovery() async throws {
let fixture = try HostRuntimeFixture()
let authoritative = try HostRuntimeFixture.discovery(
binding: fixture.binding,
relays: HostRuntimeFixture.relayURLs,
revision: 7
)
let truncated = CmxIrohDiscoveryResponse(
routeContractVersion: authoritative.routeContractVersion,
revision: authoritative.revision,
bindings: [],
relayFleet: authoritative.relayFleet,
lanRendezvous: authoritative.lanRendezvous,
grantVerificationKeys: authoritative.grantVerificationKeys
)
let broker = TestIrohHostBroker(
registrationBinding: fixture.binding,
discovery: authoritative,
embedDiscoveryInRegistration: true,
embeddedRegistrationDiscovery: truncated
)
let runtime = CmxIrohHostRuntime(
factory: TestIrohEndpointFactory(endpoints: [
TestIrohEndpoint(identity: fixture.endpointID),
]),
broker: broker,
configuration: fixture.configuration,
pendingRevocations: fixture.pendingRevocations(),
handleTransport: { session, _ in await session.close() }
)
try await runtime.start()
#expect(await runtime.snapshot().state == .active)
#expect(await broker.observedDiscoveryCount() == 1)
await runtime.stop()
}
@Test
func embeddedDiscoveryMustExactlyMatchTheRegistrationRevision() async throws {
let fixture = try HostRuntimeFixture()
@@ -77,39 +158,6 @@ extension CmxIrohHostRuntimeTests {
#expect(await runtime.snapshot().state == .failed)
}
@Test
func unauthorizedRegistrationRefreshDeactivatesActiveEndpoint() async throws {
let fixture = try HostRuntimeFixture()
let endpoint = TestIrohEndpoint(identity: fixture.endpointID)
let broker = TestIrohHostBroker(
registrationBinding: fixture.binding,
discovery: fixture.discovery,
subsequentRegistrationErrors: [
.rejected(statusCode: 401, code: "unauthorized"),
]
)
let deactivations = HostRuntimeDeactivationRecorder()
let runtime = CmxIrohHostRuntime(
factory: TestIrohEndpointFactory(endpoints: [endpoint]),
broker: broker,
configuration: fixture.configuration,
pendingRevocations: fixture.pendingRevocations(),
handleTransport: { session, _ in await session.close() },
handleDeactivation: { bindingID in
await deactivations.record(bindingID)
}
)
try await runtime.start()
await endpoint.emit(.networkChanged)
await broker.waitForRegistrationCount(2)
await deactivations.waitForCount(1)
#expect(await runtime.snapshot().state == .failed)
#expect(await endpoint.observedCloseCallCount() == 1)
#expect(await deactivations.values() == [fixture.binding.bindingID])
}
@Test
func networkChangeDuringRegistrationIsObservedAfterStartup() async throws {
let fixture = try HostRuntimeFixture()
@@ -297,6 +345,38 @@ extension CmxIrohHostRuntimeTests {
await runtime.stop()
}
@Test
func cachedConnectivityFallbackPublishesResolvedBinding() async throws {
let fixture = try HostRuntimeFixture()
let cachedFixture = try fixture.cachedPolicyFixture()
let now = cachedFixture.now
let resolvedBindings = HostRuntimeResolvedBindingRecorder()
let runtime = CmxIrohHostRuntime(
factory: TestIrohEndpointFactory(
endpoints: [TestIrohEndpoint(identity: fixture.endpointID)]
),
broker: TestIrohHostBroker(
registrationBinding: fixture.binding,
discovery: fixture.discovery,
registrationError: .connectivity
),
configuration: fixture.configuration(
cachedHostPolicy: try cachedFixture.policy()
),
pendingRevocations: fixture.pendingRevocations(),
now: { now },
handleTransport: { session, _ in await session.close() },
handleRoute: { binding, _ in
await resolvedBindings.record(binding)
}
)
try await runtime.start()
#expect(await resolvedBindings.values() == [cachedFixture.binding])
await runtime.stop()
}
@Test
func forgedCachedPolicyFailsAfterConnectivityFailure() async throws {
let fixture = try HostRuntimeFixture()
@@ -433,3 +513,15 @@ extension CmxIrohHostRuntimeTests {
#expect(await runtime.snapshot().state == .failed)
}
}
private actor HostRuntimeResolvedBindingRecorder {
private var bindings: [CmxIrohBrokerBindingMetadata] = []
func record(_ binding: CmxIrohBrokerBindingMetadata) {
bindings.append(binding)
}
func values() -> [CmxIrohBrokerBindingMetadata] {
bindings
}
}
@@ -348,6 +348,8 @@ actor TestIrohHostBroker: CmxIrohHostBrokerServing {
private let subsequentRegistrationHook: (@Sendable () async -> Void)?
private let relayIssueHook: (@Sendable () async -> Void)?
private let embedDiscoveryStartingAtRegistrationCount: Int?
private let embeddedRegistrationDiscovery: CmxIrohDiscoveryResponse?
private let embeddedRegistrationDiscoveryIsComplete: Bool?
private let registrationRevision: UInt64?
private var preflightErrors: [CmxIrohBrokerCooldownError]
private var subsequentRegistrationErrors: [CmxIrohTrustBrokerClientError]
@@ -375,6 +377,8 @@ actor TestIrohHostBroker: CmxIrohHostBrokerServing {
relayIssueHook: (@Sendable () async -> Void)? = nil,
embedDiscoveryInRegistration: Bool = false,
embedDiscoveryStartingAtRegistrationCount: Int? = nil,
embeddedRegistrationDiscovery: CmxIrohDiscoveryResponse? = nil,
embeddedRegistrationDiscoveryIsComplete: Bool? = nil,
registrationRevision: UInt64? = nil,
preflightErrors: [CmxIrohBrokerCooldownError] = [],
subsequentRegistrationErrors: [CmxIrohTrustBrokerClientError] = []
@@ -391,6 +395,9 @@ actor TestIrohHostBroker: CmxIrohHostBrokerServing {
embedDiscoveryInRegistration
? 1
: embedDiscoveryStartingAtRegistrationCount
self.embeddedRegistrationDiscovery = embeddedRegistrationDiscovery
self.embeddedRegistrationDiscoveryIsComplete =
embeddedRegistrationDiscoveryIsComplete
self.registrationRevision = registrationRevision
self.preflightErrors = preflightErrors
self.subsequentRegistrationErrors = subsequentRegistrationErrors
@@ -432,14 +439,17 @@ actor TestIrohHostBroker: CmxIrohHostBrokerServing {
let embedsDiscovery = embedDiscoveryStartingAtRegistrationCount
.map { registrationCount >= $0 }
?? false
let embeddedDiscovery = embeddedRegistrationDiscovery
?? (embedsDiscovery ? discoveryResponses[0] : nil)
return CmxIrohRegistrationResponse(
revision: registrationRevision
?? (embedsDiscovery ? discoveryResponses[0].revision : nil),
?? embeddedDiscovery?.revision,
binding: binding,
relay: .unavailable,
discovery: embedsDiscovery
? discoveryResponses[0]
: nil
discovery: embeddedDiscovery,
discoveryComplete: embeddedRegistrationDiscovery == nil
? (embedsDiscovery ? true : nil)
: embeddedRegistrationDiscoveryIsComplete
)
}
@@ -542,9 +552,62 @@ actor TestIrohHostBroker: CmxIrohHostBrokerServing {
actor HostRuntimeBindingRecorder {
private var recordedCount = 0
private var waiters: [
UUID: (minimum: Int, continuation: CheckedContinuation<Void, Never>)
] = [:]
func record() {
recordedCount += 1
let readyIDs = waiters.compactMap { id, waiter in
recordedCount >= waiter.minimum ? id : nil
}
for id in readyIDs {
waiters.removeValue(forKey: id)?.continuation.resume()
}
}
func record() { recordedCount += 1 }
func count() -> Int { recordedCount }
func waitForCount(_ count: Int, timeout: Duration) async -> Bool {
if recordedCount >= count { return true }
return await withTaskGroup(of: Bool.self) { group in
group.addTask {
await self.waitForCount(count)
return !Task.isCancelled
}
group.addTask {
do {
try await ContinuousClock().sleep(for: timeout)
} catch {
return false
}
return false
}
let result = await group.next() ?? false
group.cancelAll()
return result
}
}
private func waitForCount(_ count: Int) async {
if recordedCount >= count { return }
let id = UUID()
await withTaskCancellationHandler {
await withCheckedContinuation { continuation in
if Task.isCancelled {
continuation.resume()
} else {
waiters[id] = (count, continuation)
}
}
} onCancel: {
Task { await self.cancelWaiter(id) }
}
}
private func cancelWaiter(_ id: UUID) {
waiters.removeValue(forKey: id)?.continuation.resume()
}
}
actor HostRuntimeRouteRecorder {
@@ -63,4 +63,28 @@ struct CmxIrohRetryScheduleTests {
jitterUnitInterval: 1
) == 750)
}
@Test
func relayPolicyScheduleIsCauseAwareOnEveryPlatform() {
let authorization = CmxIrohRetrySchedule.relayPolicy(
for: .authorizationFailed
)
#expect(authorization.delay(
failureCount: 0,
retryAfterSeconds: nil,
jitterUnitInterval: 0
) == 2)
#expect(authorization.delay(
failureCount: 20,
retryAfterSeconds: nil,
jitterUnitInterval: 0
) == 120)
let connectivity = CmxIrohRetrySchedule.relayPolicy(for: .offline)
#expect(connectivity.delay(
failureCount: 0,
retryAfterSeconds: nil,
jitterUnitInterval: 0
) == 30)
}
}
@@ -0,0 +1,37 @@
import Foundation
import Testing
@testable import CmuxIrohTransport
/// Regression coverage for the wake-time authorization outage: a broker 401
/// used to tear down the whole verified runtime (endpoint, routes, offline
/// cache) and nap for 30s+ of backoff, turning a seconds-long token rotation
/// race into a multi-minute connectivity gap on every app foreground.
struct CmxIrohTrustBrokerClientAuthClassifierTests {
@Test
func unauthorizedRejectionPreservesVerifiedPolicyDuringRefresh() {
#expect(CmxIrohTrustBrokerClientError.preservesVerifiedPolicyDuringRefresh(
CmxIrohTrustBrokerClientError.rejected(
statusCode: 401,
code: "unauthorized"
)
))
#expect(!CmxIrohTrustBrokerClientError.preservesVerifiedPolicyDuringRefresh(
CmxIrohTrustBrokerClientError.rejected(statusCode: 403, code: nil)
))
}
@Test
func unauthorizedRejectionRetriesInitialActivation() {
#expect(!CmxIrohTrustBrokerClientError.retriesInitialActivation(
CmxIrohTrustBrokerClientError.rejected(
statusCode: 401,
code: "unauthorized"
)
))
// 401 and 403 can both be durable authorization failures; initial
// activation must return them to the auth lifecycle instead of spin.
#expect(!CmxIrohTrustBrokerClientError.retriesInitialActivation(
CmxIrohTrustBrokerClientError.rejected(statusCode: 403, code: nil)
))
}
}
@@ -0,0 +1,302 @@
import Foundation
import Testing
@testable import CmuxIrohTransport
/// The broker client's exactly-once credential recovery: a pair that was
/// coherent at capture can still be rejected when another lane rotates the
/// session between capture and server validation (the wake-time RPC force
/// refresh, most commonly). One retry with a pair minted after the rejection
/// absorbs the race; a second rejection is authoritative.
@Suite(.serialized)
struct CmxIrohTrustBrokerClientAuthRecoveryTests {
private actor AccountSnapshotSource {
private var snapshots: [CmxIrohAccountCredentialSnapshot]
private var lastSnapshot: CmxIrohAccountCredentialSnapshot?
private(set) var forceRefreshCount = 0
init(_ snapshots: [CmxIrohAccountCredentialSnapshot]) {
self.snapshots = snapshots
self.lastSnapshot = snapshots.last
}
func snapshot() -> CmxIrohAccountCredentialSnapshot? {
guard !snapshots.isEmpty else { return lastSnapshot }
let next = snapshots.removeFirst()
lastSnapshot = next
return next
}
func forceRefresh() {
forceRefreshCount += 1
}
}
private actor RecoveryRecorder {
private(set) var rejectedPairs: [CmxIrohBrokerCredentials] = []
private let recovered: CmxIrohBrokerCredentials?
init(recovered: CmxIrohBrokerCredentials?) {
self.recovered = recovered
}
func recover(
_ rejected: CmxIrohBrokerCredentials
) -> CmxIrohBrokerCredentials? {
rejectedPairs.append(rejected)
return recovered
}
}
@Test
func unauthorizedRejectionRetriesOnceWithRecoveredPair() async throws {
let transport = RecordingBrokerTransport(responses: [
.json(status: 401, body: #"{"error":"unauthorized"}"#),
.json(status: 201, body: Self.challengeBody),
])
let recorder = RecoveryRecorder(recovered: CmxIrohBrokerCredentials(
accessToken: "fresh-access",
refreshToken: "fresh-refresh"
))
let client = try makeClient(transport: transport, recorder: recorder)
let response = try await client.issueChallenge(try Self.challengeRequest)
#expect(response.challengeID == "123e4567-e89b-42d3-a456-426614174000")
let requests = await transport.requests()
#expect(requests.count == 2)
#expect(requests.first?.value(
forHTTPHeaderField: "Authorization"
) == "Bearer stale-access")
#expect(requests.last?.value(
forHTTPHeaderField: "Authorization"
) == "Bearer fresh-access")
#expect(requests.last?.value(
forHTTPHeaderField: "X-Stack-Refresh-Token"
) == "fresh-refresh")
let rejected = await recorder.rejectedPairs
#expect(rejected.count == 1)
#expect(rejected.first?.accessToken == "stale-access")
#expect(rejected.first?.refreshToken == "stale-refresh")
}
@Test
func unauthorizedRejectionWithoutRecoveryPropagates() async throws {
let transport = RecordingBrokerTransport(responses: [
.json(status: 401, body: #"{"error":"unauthorized"}"#),
])
let client = try CmxIrohTrustBrokerClient(
baseURL: #require(URL(string: "https://cmux.example")),
tokenSource: CmxIrohBrokerTokenSource(credentialPair: {
CmxIrohBrokerCredentials(
accessToken: "stale-access",
refreshToken: "stale-refresh"
)
}),
transport: transport
)
await #expect(throws: CmxIrohTrustBrokerClientError.rejected(
statusCode: 401,
code: "unauthorized"
)) {
_ = try await client.issueChallenge(try Self.challengeRequest)
}
#expect(await transport.requests().count == 1)
}
@Test
func repeatedUnauthorizedRejectionStopsAfterOneRetry() async throws {
let transport = RecordingBrokerTransport(responses: [
.json(status: 401, body: #"{"error":"unauthorized"}"#),
.json(status: 401, body: #"{"error":"unauthorized"}"#),
])
let recorder = RecoveryRecorder(recovered: CmxIrohBrokerCredentials(
accessToken: "fresh-access",
refreshToken: "fresh-refresh"
))
let client = try makeClient(transport: transport, recorder: recorder)
await #expect(throws: CmxIrohTrustBrokerClientError.rejected(
statusCode: 401,
code: "unauthorized"
)) {
_ = try await client.issueChallenge(try Self.challengeRequest)
}
#expect(await transport.requests().count == 2)
#expect(await recorder.rejectedPairs.count == 1)
}
@Test
func forbiddenRejectionDoesNotInvokeRecovery() async throws {
let transport = RecordingBrokerTransport(responses: [
.json(status: 403, body: #"{"error":"forbidden"}"#),
])
let recorder = RecoveryRecorder(recovered: CmxIrohBrokerCredentials(
accessToken: "fresh-access",
refreshToken: "fresh-refresh"
))
let client = try makeClient(transport: transport, recorder: recorder)
await #expect(throws: CmxIrohTrustBrokerClientError.rejected(
statusCode: 403,
code: "forbidden"
)) {
_ = try await client.issueChallenge(try Self.challengeRequest)
}
#expect(await transport.requests().count == 1)
#expect(await recorder.rejectedPairs.isEmpty)
}
@Test
func sharedAccountPinnedSourceReusesAnAlreadyRotatedPair() async throws {
let stale = Self.accountSnapshot(
accountID: "account-a",
accessToken: "stale-access"
)
let fresh = Self.accountSnapshot(
accountID: "account-a",
accessToken: "fresh-access"
)
let snapshots = AccountSnapshotSource([stale, fresh])
let transport = RecordingBrokerTransport(responses: [
.json(status: 401, body: #"{"error":"unauthorized"}"#),
.json(status: 201, body: Self.challengeBody),
])
let client = try CmxIrohTrustBrokerClient(
baseURL: #require(URL(string: "https://cmux.example")),
tokenSource: .accountPinned(
to: "account-a",
snapshot: { await snapshots.snapshot() },
forceRefresh: { await snapshots.forceRefresh() }
),
transport: transport
)
_ = try await client.issueChallenge(try Self.challengeRequest)
#expect(await snapshots.forceRefreshCount == 0)
#expect(await transport.requests().count == 2)
}
@Test
func sharedAccountPinnedSourceForceRefreshesAnUnchangedRejectedPairOnce() async throws {
let stale = Self.accountSnapshot(
accountID: "account-a",
accessToken: "stale-access"
)
let fresh = Self.accountSnapshot(
accountID: "account-a",
accessToken: "fresh-access"
)
let snapshots = AccountSnapshotSource([stale, stale, fresh])
let transport = RecordingBrokerTransport(responses: [
.json(status: 401, body: #"{"error":"unauthorized"}"#),
.json(status: 201, body: Self.challengeBody),
])
let client = try CmxIrohTrustBrokerClient(
baseURL: #require(URL(string: "https://cmux.example")),
tokenSource: .accountPinned(
to: "account-a",
snapshot: { await snapshots.snapshot() },
forceRefresh: { await snapshots.forceRefresh() }
),
transport: transport
)
_ = try await client.issueChallenge(try Self.challengeRequest)
#expect(await snapshots.forceRefreshCount == 1)
#expect(await transport.requests().count == 2)
}
@Test
func cachedPolicyRecoveryFailsClosedForAuthRejections() {
#expect(CmxIrohClientRuntime.recoversWithCachedPolicy(
CmxIrohTrustBrokerClientError.connectivity
))
#expect(!CmxIrohClientRuntime.recoversWithCachedPolicy(
CmxIrohTrustBrokerClientError.rejected(
statusCode: 401,
code: "unauthorized"
)
))
#expect(!CmxIrohClientRuntime.recoversWithCachedPolicy(
CmxIrohTrustBrokerClientError.rejected(statusCode: 403, code: nil)
))
#expect(!CmxIrohClientRuntime.recoversWithCachedPolicy(
CmxIrohTrustBrokerClientError.rejected(statusCode: 500, code: nil)
))
#expect(!CmxIrohClientRuntime.recoversWithCachedPolicy(
CmxIrohTrustBrokerClientError.invalidResponse
))
}
private func makeClient(
transport: RecordingBrokerTransport,
recorder: RecoveryRecorder
) throws -> CmxIrohTrustBrokerClient {
try CmxIrohTrustBrokerClient(
baseURL: #require(URL(string: "https://cmux.example")),
tokenSource: CmxIrohBrokerTokenSource(
credentialPair: {
CmxIrohBrokerCredentials(
accessToken: "stale-access",
refreshToken: "stale-refresh"
)
},
recoveredCredentialPair: { rejected in
await recorder.recover(rejected)
}
),
transport: transport
)
}
private static let challengeBody =
#"{"challenge_id":"123e4567-e89b-42d3-a456-426614174000","nonce":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","expires_at":"2026-07-10T01:00:00.000Z"}"#
private static let endpointID =
"03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8"
private static var challengeRequest: CmxIrohChallengeRequest {
get throws {
let secret = try CmxIrohSecretKey(
bytes: Data((0 ..< 32).map(UInt8.init))
)
let identity = try CmxIrohIdentityMaterial(
secretKey: secret,
generation: 1
)
let signer = try CmxIrohRegistrationSigner(
identity: identity,
endpointID: endpointID
)
let payload = try CmxIrohRegistrationPayload(
deviceID: "123e4567-e89b-42d3-a456-426614174001",
appInstanceID: "123e4567-e89b-42d3-a456-426614174002",
tag: "stable",
platform: .ios,
endpointID: endpointID,
identityGeneration: 1,
pairingEnabled: false,
capabilities: ["control"],
pathHints: [],
now: Date(timeIntervalSince1970: 1_782_000_000)
)
return try signer.prepare(payload: payload).challengeRequest
}
}
private static func accountSnapshot(
accountID: String,
accessToken: String
) -> CmxIrohAccountCredentialSnapshot {
CmxIrohAccountCredentialSnapshot(
accountID: accountID,
credentials: CmxIrohBrokerCredentials(
accessToken: accessToken,
refreshToken: "\(accessToken)-refresh"
)
)
}
}
@@ -50,6 +50,7 @@ struct CmxIrohTrustBrokerClientTests {
let response = try await client.register(prepared: prepared, signer: signer)
#expect(response.binding.tag == "stable")
#expect(response.discoveryComplete == nil)
#expect(await transport.requests().compactMap { $0.url?.path } == [
"/api/devices/iroh/challenge",
"/api/devices/iroh/register",
@@ -65,6 +66,7 @@ struct CmxIrohTrustBrokerClientTests {
)
responseObject["revision"] = 7
responseObject["discovery"] = try Self.discoveryObject(revision: 7)
responseObject["discovery_complete"] = true
let transport = RecordingBrokerTransport(responses: [
.json(status: 201, body: try Self.jsonString(responseObject)),
])
@@ -82,6 +84,7 @@ struct CmxIrohTrustBrokerClientTests {
#expect(response.revision == 7)
#expect(response.discovery?.revision == 7)
#expect(response.discovery?.bindings.count == 1)
#expect(response.discoveryComplete == true)
}
@Test
@@ -476,7 +479,7 @@ struct CmxIrohTrustBrokerClientTests {
}
@Test
func paginatedDiscoveryRejectsAnAccountRevisionChange() async throws {
func paginatedDiscoveryRestartsAfterAnAccountRevisionChange() async throws {
let transport = RecordingBrokerTransport(responses: [
.json(
status: 200,
@@ -494,12 +497,110 @@ struct CmxIrohTrustBrokerClientTests {
revision: 42
)
),
.json(
status: 200,
body: try Self.discoveryResponse(
bindingRange: 1 ..< 129,
nextCursor: "cursor-2",
revision: 42
)
),
.json(
status: 200,
body: try Self.discoveryResponse(
bindingRange: 129 ..< 130,
nextCursor: nil,
revision: 42
)
),
])
let client = try makeClient(transport: transport)
let discovery = try await client.discover()
#expect(discovery.revision == 42)
#expect(discovery.bindings.count == 129)
#expect(await transport.requests().map { $0.url?.query } == [
"page_size=128",
"page_size=128&cursor=cursor-1",
"page_size=128",
"page_size=128&cursor=cursor-2",
])
}
@Test
func paginatedDiscoveryRestartsAfterAStaleCursorRejection() async throws {
let transport = RecordingBrokerTransport(responses: [
.json(
status: 200,
body: try Self.discoveryResponse(
bindingRange: 1 ..< 129,
nextCursor: "cursor-1",
revision: 41
)
),
.json(status: 409, body: #"{"error":"discovery_cursor_stale"}"#),
.json(
status: 200,
body: try Self.discoveryResponse(
bindingRange: 1 ..< 129,
nextCursor: "cursor-2",
revision: 42
)
),
.json(
status: 200,
body: try Self.discoveryResponse(
bindingRange: 129 ..< 130,
nextCursor: nil,
revision: 42
)
),
])
let client = try makeClient(transport: transport)
let discovery = try await client.discover()
#expect(discovery.revision == 42)
#expect(discovery.bindings.count == 129)
#expect(await transport.requests().map { $0.url?.query } == [
"page_size=128",
"page_size=128&cursor=cursor-1",
"page_size=128",
"page_size=128&cursor=cursor-2",
])
}
@Test
func paginatedDiscoveryBoundsRepeatedSnapshotRestarts() async throws {
let responses = try (0 ..< 3).flatMap { attempt in
let revision = 41 + attempt
return [
RecordingBrokerTransport.Response.json(
status: 200,
body: try Self.discoveryResponse(
bindingRange: 1 ..< 129,
nextCursor: "cursor-\(attempt)",
revision: revision
)
),
RecordingBrokerTransport.Response.json(
status: 200,
body: try Self.discoveryResponse(
bindingRange: 129 ..< 130,
nextCursor: nil,
revision: revision + 1
)
),
]
}
let transport = RecordingBrokerTransport(responses: responses)
let client = try makeClient(transport: transport)
await #expect(throws: CmxIrohTrustBrokerClientError.invalidResponse) {
_ = try await client.discover()
}
#expect(await transport.requests().count == 6)
}
@Test
@@ -595,13 +696,16 @@ struct CmxIrohTrustBrokerClientTests {
"changed": true,
"reset": false,
"snapshot": snapshot,
"snapshot_complete": true,
])
let transport = RecordingBrokerTransport(responses: [
.json(status: 200, body: responseBody),
])
let client = try makeClient(transport: transport)
_ = try await client.syncConnectivity(knownRevision: nil)
let response = try await client.syncConnectivity(knownRevision: nil)
#expect(response.snapshotComplete == true)
let captured = try #require(await transport.requests().first)
let body = try #require(captured.httpBody)
@@ -632,6 +736,7 @@ struct CmxIrohTrustBrokerClientTests {
#expect(response.revision == 42)
#expect(response.snapshot?.revision == 42)
#expect(response.snapshot?.bindings.count == 1)
#expect(response.snapshotComplete == nil)
let mismatchedBody = try Self.jsonString([
"protocol_version": 2,
@@ -39,7 +39,10 @@ let package = Package(
),
.testTarget(
name: "CmuxMobileAnalyticsTests",
dependencies: ["CmuxMobileAnalytics"],
dependencies: [
"CMUXMobileCore",
"CmuxMobileAnalytics",
],
swiftSettings: [
.swiftLanguageMode(.v6),
.enableUpcomingFeature("ExistentialAny"),
@@ -0,0 +1,26 @@
public import CMUXMobileCore
/// A consent provider backed by an injected closure.
///
/// The closure is read on each capture so a live settings change takes effect
/// immediately.
///
/// ```swift
/// let consent = AnalyticsConsentProvider {
/// settings.sendAnonymousTelemetry
/// }
/// ```
public struct AnalyticsConsentProvider: AnalyticsConsentProviding {
private let isEnabled: @Sendable () -> Bool
/// Wraps a closure that reports the current opt-out state.
///
/// - Parameter isEnabled: Returns `true` when telemetry is allowed. Read on
/// every capture so a live toggle is honored without rewiring.
public init(isEnabled: @escaping @Sendable () -> Bool) {
self.isEnabled = isEnabled
}
/// Whether anonymous product telemetry may currently be sent.
public var isTelemetryEnabled: Bool { isEnabled() }
}
@@ -1,66 +0,0 @@
public import Foundation
/// The opt-out gate the emitter consults before every capture and identify.
///
/// The analytics package must not depend on the settings domain (`CmuxSettings`),
/// so the telemetry opt-out is injected as this seam rather than read directly.
/// The app composition root provides a conformer backed by
/// `CmuxSettings.catalog.app.sendAnonymousTelemetry`; tests provide a fixed
/// value. The gate is evaluated *inside* the emitter so no fire-site can bypass
/// it.
public protocol AnalyticsConsentProviding: Sendable {
/// Whether anonymous product telemetry may currently be sent.
///
/// When `false`, the emitter drops every event and identify call and sends
/// nothing over the network.
var isTelemetryEnabled: Bool { get }
}
/// A consent provider backed by an injected closure.
///
/// Lets the composition root bridge the telemetry opt-out into the analytics
/// package without an import edge. The closure is read on each capture so a live
/// settings change takes effect immediately.
///
/// ```swift
/// let consent = AnalyticsConsentProvider { defaults.bool(forKey: "sendAnonymousTelemetry") }
/// ```
public struct AnalyticsConsentProvider: AnalyticsConsentProviding {
private let isEnabled: @Sendable () -> Bool
/// Wraps a closure that reports the current opt-out state.
/// - Parameter isEnabled: Returns `true` when telemetry is allowed. Read on
/// every capture so a live toggle is honored without rewiring.
public init(isEnabled: @escaping @Sendable () -> Bool) {
self.isEnabled = isEnabled
}
public var isTelemetryEnabled: Bool { isEnabled() }
}
/// A consent provider backed by the shared telemetry opt-out in `UserDefaults`.
///
/// The iOS app cannot import the macOS-only `CmuxSettings` package, so this reads
/// the same backing key that `CmuxSettings.catalog.app.sendAnonymousTelemetry`
/// writes (`"sendAnonymousTelemetry"`). iOS defaults to telemetry off until the
/// user enables the Settings toggle. The value is read on every capture so
/// toggling the switch takes effect immediately without rewiring.
public struct UserDefaultsAnalyticsConsentProvider: AnalyticsConsentProviding {
/// The `UserDefaults` key shared with the settings catalog's
/// `app.sendAnonymousTelemetry` entry.
public static let telemetryKey = "sendAnonymousTelemetry"
// UserDefaults is Apple-documented thread-safe; OK to hold nonisolated.
private nonisolated(unsafe) let defaults: UserDefaults
/// Creates a consent provider over the given defaults.
/// - Parameter defaults: The defaults store holding the opt-out flag. Inject
/// a suite-scoped store in tests; the app uses `.standard`.
public init(defaults: UserDefaults) {
self.defaults = defaults
}
public var isTelemetryEnabled: Bool {
defaults.object(forKey: Self.telemetryKey) as? Bool ?? false
}
}
@@ -1,6 +1,7 @@
import Foundation
import Testing
import CMUXMobileCore
@testable import CmuxMobileAnalytics
private struct FixedConsent: AnalyticsConsentProviding {
@@ -26,21 +27,6 @@ private final class MutableConsent: AnalyticsConsentProviding, @unchecked Sendab
}
@Suite struct AnalyticsEmitterTests {
@Test func userDefaultsConsentDefaultsOffUntilEnabled() {
let suiteName = "cmux.analytics-consent.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defer { defaults.removePersistentDomain(forName: suiteName) }
let consent = UserDefaultsAnalyticsConsentProvider(defaults: defaults)
#expect(!consent.isTelemetryEnabled)
defaults.set(true, forKey: UserDefaultsAnalyticsConsentProvider.telemetryKey)
#expect(consent.isTelemetryEnabled)
defaults.set(false, forKey: UserDefaultsAnalyticsConsentProvider.telemetryKey)
#expect(!consent.isTelemetryEnabled)
}
private func makeEmitter(
uploader: any AnalyticsUploading,
consent: (any AnalyticsConsentProviding)? = nil,
@@ -31,6 +31,7 @@ extension FileDiffPageView {
) {
self.fileIndex = fileIndex
self.file = file
self.initialScrollRowID = initialScrollRowID
self.fontSize = fontSize
self.onFontSizeChanged = onFontSizeChanged
self.onScrollRowIDChanged = onScrollRowIDChanged
@@ -40,7 +41,8 @@ extension FileDiffPageView {
self.onCopy = onCopy
self.inlinePreview = inlinePreview
loadState = initialPresentation.map(FileDiffLoadState.loaded) ?? .loading
scrollRowID = initialScrollRowID
rowTracker = ScrollRowTracker(topRowID: initialScrollRowID)
pendingRestoreRowID = initialScrollRowID
previewRevision = FileDiffPreviewPolicy(kind: file.kind).defaultRevision
}
}
@@ -13,8 +13,16 @@ public struct FileDiffPageView: View {
let onLoadCurrentLines: @MainActor @Sendable (String) async throws -> DiffExpansionCurrentFile
let onCopy: @MainActor @Sendable (String) -> Void
let inlinePreview: (@MainActor @Sendable (_ index: Int, _ revision: FileDiffPreviewRevision) -> AnyView)?
var initialScrollRowID: String?
@State var loadState: FileDiffLoadState = .loading
@State var scrollRowID: String?
@State var rowTracker = ScrollRowTracker(topRowID: nil)
/// Row to anchor on the next one-shot restore. Captured explicitly (from
/// the pager at mount, from the live tracker when a refresh re-arms the
/// restore) because the tracker itself is overwritten by every visibility
/// callback, including the ones that fire for the unrestored top of the
/// list before `onAppear` runs.
@State var pendingRestoreRowID: String?
@State private var didRestoreScroll = false
@State private var magnificationStart: Double?
@State var previewRevision: FileDiffPreviewRevision = .current
@State var expansionState = DiffExpansionState()
@@ -40,6 +48,9 @@ public struct FileDiffPageView: View {
}
.onDisappear {
cancelPageTasks()
// Unmount can arrive while a fling is still settling; persist
// the row here since no further idle phase will report it.
onScrollRowIDChanged(rowTracker.topRowID)
}
}
@ViewBuilder
@@ -94,32 +105,49 @@ public struct FileDiffPageView: View {
let gutterWidth = DiffGutterLayout(
maximumLineNumber: presentation.maximumLineNumber
).measuredWidth(fontSize: fontSize)
ScrollView {
let continuation = FileDiffContinuation(
lineBudget: lineBudget,
document: document,
reachedTransportCeiling: reachedTransportCeiling
)
LazyVStack(spacing: 0) {
ForEach(presentation.rows) { row in
diffRow(row, gutterWidth: gutterWidth)
ScrollViewReader { proxy in
ScrollView {
let continuation = FileDiffContinuation(
lineBudget: lineBudget,
document: document,
reachedTransportCeiling: reachedTransportCeiling
)
LazyVStack(spacing: 0) {
ForEach(presentation.rows) { row in
diffRow(row, gutterWidth: gutterWidth)
}
if continuation.shouldShowFooter {
FileDiffContinuationFooter(
continuation: continuation,
state: continuationLoadState,
onShowMore: showMore
)
}
}
if continuation.shouldShowFooter {
FileDiffContinuationFooter(
continuation: continuation,
state: continuationLoadState,
onShowMore: showMore
)
.scrollTargetLayout()
}
.modifier(SettledScrollRowReporter(
tracker: rowTracker,
rowOrderIndex: presentation.rowOrderIndex,
onSettled: onScrollRowIDChanged
))
.refreshable { await load(forceRefresh: true) }
.simultaneousGesture(magnifyGesture)
.onAppear {
// One-shot programmatic restore; after this the scroll
// offset has a single owner (the scroll view's physics).
guard !didRestoreScroll else { return }
didRestoreScroll = true
guard let restoreRowID = pendingRestoreRowID else { return }
proxy.scrollTo(restoreRowID, anchor: .top)
// LazyVStack can only estimate the offset of a row it has
// not realized yet; re-apply once after the first layout
// pass so the anchor lands on the realized row.
Task { @MainActor in
proxy.scrollTo(restoreRowID, anchor: .top)
}
}
.scrollTargetLayout()
}
.scrollPosition(id: $scrollRowID, anchor: .top)
.onChange(of: scrollRowID) {
onScrollRowIDChanged(scrollRowID)
}
.refreshable { await load(forceRefresh: true) }
.simultaneousGesture(magnifyGesture)
}
}
@ViewBuilder
@@ -181,6 +209,15 @@ public struct FileDiffPageView: View {
cancelContinuationTask()
let generation = requestGeneration.begin()
resetExpansion()
switch loadState {
case .loading:
break
case .failed, .loaded(_):
// Refresh keeps the user's place: restore to where they are now,
// not to the row persisted when the page originally mounted.
pendingRestoreRowID = rowTracker.topRowID ?? pendingRestoreRowID
didRestoreScroll = false
}
loadState = .loading
continuationLoadState = .idle
do {
@@ -4,6 +4,11 @@ public struct FileDiffPresentation: Sendable, Equatable {
public let document: FileDiffDocument
let rows: [DiffRowSnapshot]
let maximumLineNumber: Int
/// Document-order position of each row id. `onScrollTargetVisibilityChange`
/// does not guarantee the order of the ids it reports, so the topmost
/// visible row must be resolved against this index rather than taken
/// positionally from the callback array.
let rowOrderIndex: [String: Int]
/// Builds the default row projection away from the caller's actor.
///
@@ -11,6 +16,7 @@ public struct FileDiffPresentation: Sendable, Equatable {
/// - document: Parsed diff document to project.
/// - fileKind: Change kind controlling hidden-context expansion.
/// - Returns: A presentation ready for one atomic UI-state publication.
@concurrent
public nonisolated static func prepareOffMain(
document: FileDiffDocument,
fileKind: FileChangeKind
@@ -23,6 +29,7 @@ public struct FileDiffPresentation: Sendable, Equatable {
)
}
@concurrent
nonisolated static func prepareOffMain(
document: FileDiffDocument,
expansionState: DiffExpansionState,
@@ -38,6 +45,7 @@ public struct FileDiffPresentation: Sendable, Equatable {
}
/// Builds an expansion projection that cooperatively stops when superseded.
@concurrent
nonisolated static func prepareOffMainCancellable(
document: FileDiffDocument,
expansionState: DiffExpansionState,
@@ -86,5 +94,9 @@ public struct FileDiffPresentation: Sendable, Equatable {
self.document = document
self.rows = rows
self.maximumLineNumber = maximumLineNumber
self.rowOrderIndex = Dictionary(
rows.enumerated().map { ($0.element.id, $0.offset) },
uniquingKeysWith: { first, _ in first }
)
}
}
@@ -0,0 +1,71 @@
import SwiftUI
/// Last row seen near the top of the diff scroll view, kept OUTSIDE SwiftUI
/// state on purpose: it changes on every frame of a scroll, and routing it
/// through `@State` or a `scrollPosition` binding makes SwiftUI a second
/// owner of the scroll offset. That ownership fight is what killed fling
/// deceleration, rubber-banding, and pull-to-refresh displacement on real
/// diffs (the offset was re-resolved against the tracked row on every lazy
/// row materialization). UIKit physics own the offset; we only observe.
@MainActor
final class ScrollRowTracker {
var topRowID: String?
nonisolated init(topRowID: String?) {
self.topRowID = topRowID
}
}
/// Resolves the topmost visible row from an unordered set of visible ids.
///
/// `onScrollTargetVisibilityChange` documents no ordering for the ids it
/// reports, so the topmost row is the one earliest in document order, not
/// `visibleIDs.first`. Ids absent from the index (never expected) sort last.
struct TopVisibleRowPolicy {
let rowOrderIndex: [String: Int]
func topRow(among visibleIDs: [String]) -> String? {
visibleIDs.compactMap { id -> (order: Int, id: String)? in
guard let order = rowOrderIndex[id] else { return nil }
return (order, id)
}
.min { lhs, rhs in
if lhs.order == rhs.order { return lhs.id < rhs.id }
return lhs.order < rhs.order
}?
.id
}
}
/// Observes the top visible row and reports it only after scrolling settles.
///
/// Both modifiers are pure observers: neither adds a body dependency nor
/// writes view state during a scroll, so the scroll view is never laid out
/// or repositioned mid-gesture. The row id is read at event time from the
/// tracker and handed to `onSettled` at `.idle` phase for persistence.
struct SettledScrollRowReporter: ViewModifier {
let tracker: ScrollRowTracker
let rowOrderIndex: [String: Int]
let onSettled: @MainActor @Sendable (String?) -> Void
func body(content: Content) -> some View {
if #available(iOS 18.0, macOS 15.0, *) {
content
.onScrollTargetVisibilityChange(idType: String.self, threshold: 0.01) { visibleIDs in
let policy = TopVisibleRowPolicy(rowOrderIndex: rowOrderIndex)
tracker.topRowID = policy.topRow(among: visibleIDs)
}
.onScrollPhaseChange { _, newPhase in
guard newPhase == .idle else { return }
onSettled(tracker.topRowID)
}
} else {
// Both observers are iOS 18 / macOS 15 APIs. The app's iOS floor
// is 18.4, so this branch is reachable only on macOS 14, where
// this package builds for tests alone; no shipping surface
// renders the diff pager there. Scroll-position persistence is
// intentionally absent on that path.
content
}
}
}
@@ -25,6 +25,7 @@ public struct UnifiedDiffParser: Sendable {
/// - isBinary: Whether the file is binary.
/// - totalLineCount: Number of lines in the full raw diff, when reported.
/// - Returns: A display-ready immutable document.
@concurrent
public nonisolated func parseOffMain(
_ unifiedDiff: String,
truncated: Bool = false,
@@ -49,6 +50,7 @@ public struct UnifiedDiffParser: Sendable {
/// - contentFingerprint: Working-file revision fingerprint, when reported.
/// - fileKind: Change kind controlling hidden-context expansion.
/// - Returns: A parsed document and display projection ready for publication.
@concurrent
public nonisolated func parsePresentationOffMain(
_ unifiedDiff: String,
truncated: Bool = false,
@@ -0,0 +1,51 @@
import Testing
@testable import CmuxMobileChanges
@Suite struct TopVisibleRowPolicyTests {
private let policy = TopVisibleRowPolicy(rowOrderIndex: [
"row-0": 0,
"row-1": 1,
"row-2": 2,
"row-3": 3,
])
@Test
func picksDocumentTopmostRegardlessOfCallbackOrder() {
#expect(policy.topRow(among: ["row-2", "row-0", "row-3"]) == "row-0")
#expect(policy.topRow(among: ["row-3", "row-2"]) == "row-2")
#expect(policy.topRow(among: ["row-1"]) == "row-1")
}
@Test
func unknownIDsDoNotOverrideKnownRows() {
#expect(policy.topRow(among: ["ghost", "row-3"]) == "row-3")
#expect(policy.topRow(among: ["ghost-b", "ghost-a"]) == nil)
}
@Test
func emptyVisibleSetResolvesToNil() {
#expect(policy.topRow(among: []) == nil)
}
@Test
func presentationIndexesRowsInDocumentOrder() async {
let diff = """
@@ -1,2 +1,3 @@
let stable = true
+let added = 1
let tail = false
"""
let document = UnifiedDiffParser().parse(diff)
let presentation = await FileDiffPresentation.prepareOffMain(
document: document,
fileKind: .modified
)
let orderedByIndex = presentation.rows
.map(\.id)
.enumerated()
.allSatisfy { presentation.rowOrderIndex[$0.element] == $0.offset }
#expect(orderedByIndex)
#expect(presentation.rowOrderIndex.count == presentation.rows.count)
}
}
@@ -4,9 +4,9 @@ import PackageDescription
// `CmuxMobileCrashReporting` is the iOS crash telemetry leaf package. It owns
// the Sentry startup options for mobile, including watchdog termination,
// app-hang, and MetricKit diagnostics, while depending on `CmuxMobileAnalytics`
// only for the shared telemetry consent seam so crash reporting follows the
// same opt-out as analytics.
// app-hang, and MetricKit diagnostics. It depends on the telemetry consent seam
// in `CMUXMobileCore`, making crash reporting and analytics sibling consumers
// of the same opt-out contract.
let package = Package(
name: "CmuxMobileCrashReporting",
platforms: [
@@ -20,7 +20,7 @@ let package = Package(
),
],
dependencies: [
.package(path: "../CmuxMobileAnalytics"),
.package(path: "../../Shared/CMUXMobileCore"),
.package(path: "../../Shared/CmuxSentryTelemetry"),
.package(
url: "https://github.com/getsentry/sentry-cocoa.git",
@@ -31,7 +31,7 @@ let package = Package(
.target(
name: "CmuxMobileCrashReporting",
dependencies: [
"CmuxMobileAnalytics",
"CMUXMobileCore",
.product(name: "CmuxSentryScrubbing", package: "CmuxSentryTelemetry"),
.product(name: "CmuxSentryReporting", package: "CmuxSentryTelemetry"),
.product(name: "Sentry", package: "sentry-cocoa"),
@@ -44,7 +44,10 @@ let package = Package(
),
.testTarget(
name: "CmuxMobileCrashReportingTests",
dependencies: ["CmuxMobileCrashReporting"],
dependencies: [
"CMUXMobileCore",
"CmuxMobileCrashReporting",
],
swiftSettings: [
.swiftLanguageMode(.v6),
.enableUpcomingFeature("ExistentialAny"),
@@ -1,4 +1,4 @@
public import CmuxMobileAnalytics
public import CMUXMobileCore
import CmuxSentryReporting
import Foundation
public import Sentry
@@ -6,7 +6,7 @@ public import Sentry
/// Starts Sentry-backed crash reporting for the iOS app.
///
/// ``MobileCrashReporter`` intentionally reuses
/// ``CmuxMobileAnalytics/AnalyticsConsentProviding`` so crash telemetry and
/// ``CMUXMobileCore/AnalyticsConsentProviding`` so crash telemetry and
/// analytics obey one opt-out source. `sendDefaultPii` is disabled and every
/// outgoing event, breadcrumb, and structured log is redacted by the shared
/// `SentryEventScrubber` (CmuxSentryReporting) before it leaves the device.
@@ -40,6 +40,8 @@ public struct MobileCrashReporter {
/// - consent: The shared analytics/crash telemetry opt-out gate.
/// - arguments: Process arguments used to gate the DEBUG-only test crash.
/// Defaults to `ProcessInfo.processInfo.arguments`.
/// - prepareLocale: Process-locale initialization performed before Sentry
/// starts any background work.
/// - start: The Sentry start function. Tests inject this closure so they
/// can assert the consent gate without starting the real SDK.
/// - crash: The DEBUG-only test crash function. Tests inject this closure
@@ -51,6 +53,10 @@ public struct MobileCrashReporter {
environment: [String: String] = ProcessInfo.processInfo.environment,
notificationCenter: NotificationCenter = .default,
revocationWatcher: RevocationWatcher,
prepareLocale: () -> Void = {
_ = Locale.current
_ = NSLocale.preferredLanguages
},
start: @escaping (Options) -> Void = { SentrySDK.start(options: $0) },
close: @escaping @Sendable () -> Void = { SentrySDK.close() },
purgeCache: (@Sendable () -> Void)? = nil,
@@ -62,6 +68,12 @@ public struct MobileCrashReporter {
// and CI sessions would all send deliberate crashes and hangs to the
// shared Sentry project.
guard !isTestRun(environment: environment) else { return }
// Foundation lazily initializes process locale through setlocale().
// Sentry also starts a background `sentry-init` thread that reads
// locale environment state. Completing Foundation's initialization on
// the composition-root actor first prevents that thread from racing
// libghostty's own locale initialization when its first surface mounts.
prepareLocale()
let cachePurger = self.cachePurger
let purgeCache = purgeCache ?? { cachePurger.purge() }
@@ -1,4 +1,4 @@
internal import CmuxMobileAnalytics
internal import CMUXMobileCore
internal import Foundation
// Safety: the app composition root is the single owner that calls `arm`.
@@ -1,6 +1,6 @@
import Foundation
import CmuxMobileAnalytics
import CMUXMobileCore
final class CrashTestToggleConsent: AnalyticsConsentProviding, @unchecked Sendable {
private let lock = NSLock()
@@ -1,7 +1,7 @@
import Sentry
import Testing
import CmuxMobileAnalytics
import CMUXMobileCore
@testable import CmuxMobileCrashReporting
private struct FixedConsent: AnalyticsConsentProviding {
@@ -50,6 +50,24 @@ private struct FixedConsent: AnalyticsConsentProviding {
#expect(capturedOptions?.shutdownTimeInterval == 0)
}
@Test func localePreparationPrecedesSentryStartup() {
var sequence: [String] = []
MobileCrashReporter().startIfEnabled(
consent: FixedConsent(isTelemetryEnabled: true),
arguments: ["cmux"],
environment: [:],
revocationWatcher: MobileCrashReporter.RevocationWatcher(),
prepareLocale: { sequence.append("locale") },
start: { _ in sequence.append("sentry") },
close: {},
purgeCache: {},
crash: {}
)
#expect(sequence == ["locale", "sentry"])
}
@Test func optionsFactoryMatchesMobileContract() {
let options = MobileCrashReporter().makeOptions()
@@ -4,6 +4,15 @@ internal import CmuxMobileSupport
public import Foundation
internal import os
/// Controls whether a request carries the connection's attach-ticket context.
/// Stack account authorization is always sent for authorized bearer requests.
public enum MobileCoreRPCAttachTicketPolicy: Sendable, Equatable {
/// Include a current attach token when its route/workspace scope covers the request.
case whenCovered
/// Omit attach-ticket context so it cannot narrow an account-authorized request.
case omit
}
/// A multiplexed RPC client over a single persistent transport to a paired Mac.
///
/// All stored properties are immutable `let`s of `Sendable` types (the session
@@ -159,8 +168,18 @@ public final class MobileCoreRPCClient: MobileSyncing, Sendable {
public func sharesPhysicalTransportRoute(
with otherRoute: CmxAttachRoute
) -> Bool {
MobileRPCConnectAttemptKey(route: route)
== MobileRPCConnectAttemptKey(route: otherRoute)
Self.routesSharePhysicalTransport(route, otherRoute)
}
/// Returns whether two routes compete for the same physical connection
/// lease. Shell ownership arbitration uses this before either route has a
/// live client, including while a background admission is still suspended.
public static func routesSharePhysicalTransport(
_ lhs: CmxAttachRoute,
_ rhs: CmxAttachRoute
) -> Bool {
MobileRPCConnectAttemptKey(route: lhs)
== MobileRPCConnectAttemptKey(route: rhs)
}
/// Synchronously prevent this client from allocating another transport.
@@ -241,10 +260,27 @@ public final class MobileCoreRPCClient: MobileSyncing, Sendable {
///
/// The optional timeout is a hard end-to-end deadline for auth augmentation,
/// connection setup, and response wait, not a per-subphase timeout.
public func sendRequest(_ requestData: Data, timeoutNanoseconds: UInt64? = nil) async throws -> Data {
public func sendRequest(
_ requestData: Data,
timeoutNanoseconds: UInt64? = nil
) async throws -> Data {
try await sendRequest(
requestData,
timeoutNanoseconds: timeoutNanoseconds,
attachTicketPolicy: .whenCovered
)
}
/// Sends one request with explicit control over attach-ticket context.
public func sendRequest(
_ requestData: Data,
timeoutNanoseconds: UInt64? = nil,
attachTicketPolicy: MobileCoreRPCAttachTicketPolicy
) async throws -> Data {
try await sendRequestOperation(
requestData,
timeoutNanoseconds: timeoutNanoseconds
timeoutNanoseconds: timeoutNanoseconds,
attachTicketPolicy: attachTicketPolicy
).response
}
@@ -322,7 +358,8 @@ public final class MobileCoreRPCClient: MobileSyncing, Sendable {
private func sendRequestOperation(
_ requestData: Data,
timeoutNanoseconds: UInt64?,
hostStatusStackToken: String? = nil
hostStatusStackToken: String? = nil,
attachTicketPolicy: MobileCoreRPCAttachTicketPolicy = .whenCovered
) async throws -> AuthenticatedRequestResult {
let deadline = RPCRequestDeadline(
timeoutNanoseconds: timeoutNanoseconds ?? runtime.rpcRequestTimeoutNanoseconds
@@ -336,7 +373,8 @@ public final class MobileCoreRPCClient: MobileSyncing, Sendable {
preparedRequest,
deadline: deadline,
allowAuthRetry: true,
hostStatusStackToken: hostStatusStackToken
hostStatusStackToken: hostStatusStackToken,
attachTicketPolicy: attachTicketPolicy
)
} catch let error as MobileShellConnectionError {
// The host rejected this request on Stack-auth grounds. Before
@@ -358,7 +396,8 @@ public final class MobileCoreRPCClient: MobileSyncing, Sendable {
preparedRequest,
deadline: deadline,
allowAuthRetry: false,
hostStatusStackToken: hostStatusStackToken
hostStatusStackToken: hostStatusStackToken,
attachTicketPolicy: attachTicketPolicy
)
}
}
@@ -423,7 +462,8 @@ public final class MobileCoreRPCClient: MobileSyncing, Sendable {
_ requestData: Data,
deadline: RPCRequestDeadline,
allowAuthRetry: Bool,
hostStatusStackToken: String?
hostStatusStackToken: String?,
attachTicketPolicy: MobileCoreRPCAttachTicketPolicy
) async throws -> AuthenticatedRequestResult {
// Multiplexed over a persistent transport: each request gets a unique
// id, the session's reader task routes the response back here. No
@@ -437,7 +477,8 @@ public final class MobileCoreRPCClient: MobileSyncing, Sendable {
let authenticated = try await requestDataWithAuth(
augmented,
deadline: deadline,
hostStatusStackToken: hostStatusStackToken
hostStatusStackToken: hostStatusStackToken,
attachTicketPolicy: attachTicketPolicy
)
try Task.checkCancellation()
let response = try await session.send(
@@ -472,7 +513,8 @@ public final class MobileCoreRPCClient: MobileSyncing, Sendable {
private func requestDataWithAuth(
_ requestData: Data,
deadline: RPCRequestDeadline,
hostStatusStackToken: String?
hostStatusStackToken: String?,
attachTicketPolicy: MobileCoreRPCAttachTicketPolicy = .whenCovered
) async throws -> AuthenticatedRequestPayload {
guard var request = try JSONSerialization.jsonObject(with: requestData) as? [String: Any] else {
return AuthenticatedRequestPayload(data: requestData, stackAccessToken: nil)
@@ -493,6 +535,7 @@ public final class MobileCoreRPCClient: MobileSyncing, Sendable {
if let attachToken,
requestNeedsAuth,
hasAttachToken,
attachTicketPolicy == .whenCovered,
requestIsCoveredByAttachTicket {
// Expiry is enforced only here, where the RPC-minted attach token
// is actually used. QR-decoded tickets carry no token (and no
@@ -639,9 +682,10 @@ public final class MobileCoreRPCClient: MobileSyncing, Sendable {
switch method {
case "mobile.workspace.list", "workspace.list",
"mobile.task.models.list",
"mobile.directory.list", "mobile.directory.search":
return false
case "workspace.create":
case "workspace.create", "mobile.task.attachment.upload":
return false
case "workspace.action", "workspace.close":
return !ticketCoverage.ticketCoversWorkspaceRequest(
@@ -669,7 +713,8 @@ public final class MobileCoreRPCClient: MobileSyncing, Sendable {
workspaceSelection: workspaceSelection.value,
terminalSelection: terminalSelection.value
)
case "mobile.events.subscribe", "mobile.events.unsubscribe":
case "mobile.events.subscribe", "mobile.events.unsubscribe",
"mobile.events.probe":
return false
case "notification.feed.list", "notification.feed.mark_read", "notification.feed.mark_unread",
"notification.feed.mark_all_read":
@@ -470,7 +470,7 @@ actor MobileCoreRPCSession {
// their cooperative-cancellation retry semantics.
if connectAttemptKey != nil,
!abandonedConnectionCleanupTasks.isEmpty {
throw MobileShellConnectionError.requestTimedOut
throw MobileShellConnectionError.routeCleanupBlocked
}
let waiterID = UUID()
let connectionID: UUID
@@ -502,6 +502,20 @@ actor MobileCoreRPCSession {
let diagnosticTransport = diagnosticTransport
let transportConnectObserver = transportConnectObserver
let initialSessionPurpose = transportSessionPurpose
let reportCancelledConnect: @Sendable () -> Void = {
if let diagnosticTransport, let transportConnectObserver {
transportConnectObserver(
.failed(
attemptID: connectAttemptID,
transport: diagnosticTransport,
failure: .cancelled,
elapsedMilliseconds: Self.elapsedMilliseconds(
since: connectStartedAt
)
)
)
}
}
if let diagnosticTransport, let transportConnectObserver {
transportConnectObserver(
.attempt(
@@ -520,6 +534,7 @@ actor MobileCoreRPCSession {
await rejected.task.value
}
if Task.isCancelled {
reportCancelledConnect()
throw CancellationError()
}
let error = MobileShellConnectionError.connectionClosed
@@ -540,6 +555,7 @@ actor MobileCoreRPCSession {
} catch {
await connectAttemptRegistry.finishConnect(lease: connectLease)
if error is CancellationError || Task.isCancelled {
reportCancelledConnect()
throw CancellationError()
}
if let diagnosticTransport, let transportConnectObserver {
@@ -583,18 +599,20 @@ actor MobileCoreRPCSession {
// A cancellation-ignoring transport must still return its
// late candidate to the existing abandoned-connect cleanup
// path so that path can close it again after completion.
// Suppress the success event without replacing that result
// with `CancellationError`.
if !Task.isCancelled,
let diagnosticTransport,
let transportConnectObserver {
// Report the abandoned attempt as cancelled without
// replacing that result with `CancellationError`.
if Task.isCancelled {
reportCancelledConnect()
} else if let diagnosticTransport,
let transportConnectObserver {
transportConnectObserver(
.connected(
attemptID: connectAttemptID,
transport: diagnosticTransport,
elapsedMilliseconds: Self.elapsedMilliseconds(
since: connectStartedAt
)
elapsedMilliseconds:
Self.elapsedMilliseconds(
since: connectStartedAt
)
)
)
}
@@ -605,14 +623,17 @@ actor MobileCoreRPCSession {
} else {
await cancellationClose.finishWithoutClose()
}
reportCancelledConnect()
throw CancellationError()
} catch {
// Some transports surface their close error instead of
// `CancellationError` after the cancellation handler closes
// them. Treat the task's cancellation bit as authoritative
// so an abandoned dial never becomes a false failure event.
// so an abandoned dial reports cancelled, never a false
// transport failure.
if Task.isCancelled {
_ = await cancellationClose.task()
reportCancelledConnect()
throw CancellationError()
}
await cancellationClose.finishWithoutClose()
@@ -101,6 +101,16 @@ public actor MobileRPCConnectAttemptRegistry {
routeStates[key] = state
}
public func resetRouteHealthForNetworkChange() {
// Current main keeps only active leases and physical cleanup debt. Those
// are ownership facts, not route-health strikes, so a network change must
// not erase them and accidentally admit duplicate dials.
for (key, state) in routeStates
where state.activeLeaseID == nil && state.physicalCleanupTasks.isEmpty {
routeStates[key] = nil
}
}
private func physicalCleanupDidFinish(
key: MobileRPCConnectAttemptKey,
cleanupID: UUID
@@ -105,7 +105,7 @@ public struct MobileSyncWorkspaceListResponse: Decodable, Sendable {
}
/// A workspace group section in the list response. Mirrors the iOS-facing
/// subset the Mac emits (no v2 handle refs, color, or icon). Members are
/// subset the Mac emits (no v2 handle refs or color). Members are
/// listed in the Mac's spatial (`tabs`) order. Absent on Macs old enough not
/// to emit groups.
public struct Group: Decodable, Sendable {
@@ -117,6 +117,8 @@ public struct MobileSyncWorkspaceListResponse: Decodable, Sendable {
public let isCollapsed: Bool
/// Whether the group is pinned on the Mac.
public let isPinned: Bool
/// SF Symbol rendered by the corresponding group row on the Mac.
public let iconSymbol: String?
/// The anchor workspace that owns this group. It is represented by the
/// group header and never rendered as a separate row.
public let anchorWorkspaceID: String
@@ -130,6 +132,7 @@ public struct MobileSyncWorkspaceListResponse: Decodable, Sendable {
case name
case isCollapsed = "is_collapsed"
case isPinned = "is_pinned"
case iconSymbol = "icon_symbol"
case anchorWorkspaceID = "anchor_workspace_id"
}
@@ -139,12 +142,14 @@ public struct MobileSyncWorkspaceListResponse: Decodable, Sendable {
name: String,
isCollapsed: Bool,
isPinned: Bool,
iconSymbol: String? = nil,
anchorWorkspaceID: String
) {
self.id = id
self.name = name
self.isCollapsed = isCollapsed
self.isPinned = isPinned
self.iconSymbol = iconSymbol
self.anchorWorkspaceID = anchorWorkspaceID
}
}
@@ -188,9 +193,13 @@ public struct MobileSyncWorkspaceListResponse: Decodable, Sendable {
/// The full workspace list.
public let workspaces: [Workspace]
/// Group sections, in section order. Empty on Macs old enough not to emit
/// groups (the field is decoded with `decodeIfPresent`).
/// Group sections, in section order. Empty when the Mac reports no groups or
/// when an older payload omits the field.
public let groups: [Group]
/// Whether the decoded payload carried a `groups` field at all. Older or
/// partial responses omit the field, and callers use that to preserve the
/// last authoritative group headers across reconnect churn.
public let groupsFieldWasPresent: Bool
/// Identifier of a workspace created by the request, if any.
public let createdWorkspaceID: String?
/// Identifier of a terminal created by the request, if any.
@@ -211,6 +220,7 @@ public struct MobileSyncWorkspaceListResponse: Decodable, Sendable {
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
workspaces = try container.decode([Workspace].self, forKey: .workspaces)
groupsFieldWasPresent = container.contains(.groups)
groups = try container.decodeIfPresent([Group].self, forKey: .groups) ?? []
createdWorkspaceID = try container.decodeIfPresent(String.self, forKey: .createdWorkspaceID)
createdTerminalID = try container.decodeIfPresent(String.self, forKey: .createdTerminalID)
@@ -234,13 +244,14 @@ extension MobileSyncWorkspaceListResponse {
public init(
workspaces: [Workspace],
groups: [Group],
groupsFieldWasPresent: Bool = true,
createdWorkspaceID: String?,
createdTerminalID: String?
) {
self.workspaces = workspaces
self.groups = groups
self.groupsFieldWasPresent = groupsFieldWasPresent
self.createdWorkspaceID = createdWorkspaceID
self.createdTerminalID = createdTerminalID
}
}
@@ -35,6 +35,7 @@ extension MobileWorkspaceGroupPreview {
name: remote.name,
isCollapsed: remote.isCollapsed,
isPinned: remote.isPinned,
iconSymbol: remote.iconSymbol,
anchorWorkspaceID: MobileWorkspacePreview.ID(rawValue: remote.anchorWorkspaceID)
)
}
@@ -195,7 +195,7 @@ import Testing
allowsStackAuthFallback: true
)
for id in ["stuck-connect-1", "stuck-connect-2", "stuck-connect-3"] {
for (index, id) in ["stuck-connect-1", "stuck-connect-2", "stuck-connect-3"].enumerated() {
let request = try MobileCoreRPCClient.requestData(
method: "terminal.input",
params: [
@@ -207,10 +207,11 @@ import Testing
)
do {
_ = try await client.sendRequest(request)
Issue.record("Expected \(id) to time out")
} catch MobileShellConnectionError.requestTimedOut {
Issue.record("Expected \(id) to fail")
} catch MobileShellConnectionError.requestTimedOut where index == 0 {
} catch MobileShellConnectionError.routeCleanupBlocked where index > 0 {
} catch {
Issue.record("Expected requestTimedOut for \(id), got \(error)")
Issue.record("Expected bounded admission failure for \(id), got \(error)")
}
}
@@ -278,9 +279,9 @@ import Testing
do {
_ = try await client.sendRequest(retryRequest)
Issue.record("Expected \(id) to be rejected while cancelled connect cleanup is stuck")
} catch MobileShellConnectionError.requestTimedOut {
} catch MobileShellConnectionError.routeCleanupBlocked {
} catch {
Issue.record("Expected requestTimedOut for \(id), got \(error)")
Issue.record("Expected routeCleanupBlocked for \(id), got \(error)")
}
}
@@ -270,6 +270,73 @@ import Testing
#expect(mapped.customColorHex == nil)
}
@Test func workspaceListResponseTracksWhetherGroupsFieldWasPresent() throws {
let absentGroupsJSON = Data("""
{
"workspaces": []
}
""".utf8)
let emptyGroupsJSON = Data("""
{
"workspaces": [],
"groups": []
}
""".utf8)
let absentGroups = try MobileSyncWorkspaceListResponse.decode(absentGroupsJSON)
let emptyGroups = try MobileSyncWorkspaceListResponse.decode(emptyGroupsJSON)
#expect(absentGroups.groups.isEmpty)
#expect(!absentGroups.groupsFieldWasPresent)
#expect(emptyGroups.groups.isEmpty)
#expect(emptyGroups.groupsFieldWasPresent)
}
@Test func workspaceListResponseCarriesWorkspaceGroupIcon() throws {
let json = Data("""
{
"workspaces": [],
"groups": [
{
"id": "group-1",
"name": "Release",
"is_collapsed": false,
"is_pinned": true,
"icon_symbol": "shippingbox.fill",
"anchor_workspace_id": "workspace-1"
}
]
}
""".utf8)
let response = try MobileSyncWorkspaceListResponse.decode(json)
let remoteGroup = try #require(response.groups.first)
#expect(remoteGroup.iconSymbol == "shippingbox.fill")
let mappedGroup = MobileWorkspaceGroupPreview(remote: remoteGroup)
#expect(mappedGroup.iconSymbol == "shippingbox.fill")
}
@Test func workspaceListResponseDefaultsMissingWorkspaceGroupIconToNil() throws {
let json = Data("""
{
"workspaces": [],
"groups": [
{
"id": "group-older",
"name": "Older Mac",
"is_collapsed": false,
"is_pinned": false,
"anchor_workspace_id": "workspace-older"
}
]
}
""".utf8)
let response = try MobileSyncWorkspaceListResponse.decode(json)
#expect(response.groups.first?.iconSymbol == nil)
}
/// The Mac emits an optional per-workspace `preview` + `preview_at` (latest
/// notification text + epoch seconds) for the iMessage-style row preview.
/// Both must decode when present and stay `nil` when an older Mac omits them.
@@ -129,6 +129,50 @@ import Testing
#expect(frame.hasAuth)
}
@Test func accountAuthorizedGroupActionCanOmitWorkspaceScopedAttachTicketContext() async throws {
let route = try hostPortRoute(kind: .debugLoopback, host: "127.0.0.1", port: 58465)
let transport = QueuedCancellationProbeTransport()
let runtime = TestMobileSyncRuntime(
transportFactory: QueuedCancellationProbeTransportFactory(transport: transport),
stackAccessToken: "test-stack-token"
)
let ticket = try CmxAttachTicket(
workspaceID: "workspace-main",
terminalID: nil,
macDeviceID: "test-mac",
macDisplayName: "Test Mac",
routes: [route],
expiresAt: Date().addingTimeInterval(60),
authToken: "ticket-secret"
)
let client = MobileCoreRPCClient(
runtime: runtime,
route: route,
ticket: ticket,
allowsStackAuthFallback: true
)
let request = try MobileCoreRPCClient.requestData(
method: "workspace.group.action",
params: [
"group_id": "group-main",
"action": "rename",
"title": "Project Alpha",
]
)
let task = Task {
try await client.sendRequest(request, attachTicketPolicy: .omit)
}
let sent = try await transport.waitForSentRequestCount(1)
task.cancel()
_ = try? await task.value
let frame = try #require(sent.first)
#expect(frame.method == "workspace.group.action")
#expect(frame.attachToken == nil)
#expect(frame.stackAccessToken == "test-stack-token")
#expect(frame.hasAuth)
}
@Test func workspaceGroupCreateCarriesWorkspaceScopedAttachTicketContext() async throws {
let route = try hostPortRoute(kind: .debugLoopback, host: "127.0.0.1", port: 58465)
let transport = QueuedCancellationProbeTransport()
@@ -52,29 +52,31 @@ import Testing
#expect(failure == .unsupportedRoute)
}
@Test func callerCancellationSuppressesCloseInducedFailureAndRetryConnects() async throws {
@Test func abandonedDialEmitsCancelledOutcomeAndRetryConnects() async throws {
let transport = FirstConnectClosedErrorThenSucceedsTransport()
let (events, continuation) = AsyncStream<MobileRPCTransportConnectEvent>.makeStream()
let cancellationSignal = MobileRPCConnectCancellationSignal()
let session = MobileCoreRPCSession(
makeTransport: { transport },
diagnosticTransport: .debugLoopback,
transportConnectObserver: { event in
_ = continuation.yield(event)
Task { await cancellationSignal.record(event) }
}
)
let first = try MobileCoreRPCClient.requestData(
method: "mobile.host.status",
id: "cancelled-closed-connect"
id: "abandoned-connect"
)
let second = try MobileCoreRPCClient.requestData(
method: "mobile.host.status",
id: "retry-after-closed-connect"
id: "retry-after-abandoned-connect"
)
let deadline = DispatchTime.now().uptimeNanoseconds + 60 * 1_000_000_000
let firstTask = Task {
try await session.send(
payload: first,
requestID: "cancelled-closed-connect",
requestID: "abandoned-connect",
deadlineUptimeNanoseconds: deadline
)
}
@@ -89,37 +91,35 @@ import Testing
Issue.record("Expected CancellationError, got \(error)")
}
await transport.waitUntilFirstConnectFinished()
#expect(await cancellationSignal.waitUntilObserved())
let data = try await session.send(
payload: second,
requestID: "retry-after-closed-connect",
requestID: "retry-after-abandoned-connect",
deadlineUptimeNanoseconds: deadline
)
let response = try #require(JSONSerialization.jsonObject(with: data) as? [String: String])
#expect(response["status"] == "ok")
#expect(await transport.connectCount() == 2)
#expect(try await transport.sentRequests().map(\.id) == ["retry-after-closed-connect"])
continuation.finish()
let recorded = await collect(events)
#expect(recorded.count == 3)
guard recorded.count == 3 else {
#expect(recorded.count == 4)
guard recorded.count == 4 else {
await session.tearDown(error: .connectionClosed)
return
}
guard case let .attempt(firstAttemptID, firstTransport) = recorded[0],
case let .attempt(secondAttemptID, secondTransport) = recorded[1],
case let .connected(connectedID, connectedTransport, _) = recorded[2] else {
Issue.record("Expected attempt, attempt, connected with no failure")
guard case let .attempt(firstAttemptID, _) = recorded[0],
case let .failed(abandonedID, abandonedTransport, abandonedFailure, _) = recorded[1],
case let .attempt(secondAttemptID, _) = recorded[2],
case let .connected(connectedID, _, _) = recorded[3] else {
Issue.record("Expected attempt, failed(cancelled), attempt, connected")
await session.tearDown(error: .connectionClosed)
return
}
#expect(firstAttemptID > 0)
#expect(secondAttemptID > 0)
#expect(firstTransport == .debugLoopback)
#expect(secondTransport == .debugLoopback)
#expect(abandonedID == firstAttemptID)
#expect(abandonedTransport == .debugLoopback)
#expect(abandonedFailure == .cancelled)
#expect(connectedID == secondAttemptID)
#expect(connectedTransport == .debugLoopback)
await session.tearDown(error: .connectionClosed)
}
@@ -159,3 +159,26 @@ import Testing
return events
}
}
private actor MobileRPCConnectCancellationSignal {
private var observed = false
func record(_ event: MobileRPCTransportConnectEvent) {
guard case let .failed(_, _, failure, _) = event,
failure == .cancelled else {
return
}
observed = true
}
func waitUntilObserved(
timeout: Duration = .seconds(2)
) async -> Bool {
let clock = ContinuousClock()
let deadline = clock.now.advanced(by: timeout)
while !observed, clock.now < deadline {
await Task.yield()
}
return observed
}
}
@@ -0,0 +1,45 @@
import CMUXMobileCore
import CmuxMobilePairedMac
import CmuxMobileRPC
import Foundation
/// Foreground ownership published before its first transport suspension.
/// Background aggregation consults this reservation so a previously selected
/// control candidate cannot admit a second session on the foreground route.
struct ForegroundConnectionAttemptReservation {
let id: UUID
let requestedMacDeviceID: String?
let instanceTagExpectation: MobileMacInstanceTagExpectation
let routes: [CmxAttachRoute]
func conflicts(with mac: MobilePairedMac) -> Bool {
if targetsSamePairing(as: mac) {
return true
}
return mac.routes.contains { storedRoute in
routes.contains { foregroundRoute in
MobileCoreRPCClient.routesSharePhysicalTransport(
storedRoute,
foregroundRoute
)
}
}
}
private func targetsSamePairing(as mac: MobilePairedMac) -> Bool {
guard let requestedMacDeviceID,
cmxCanonicalDeviceID(requestedMacDeviceID)
== cmxCanonicalDeviceID(mac.macDeviceID) else {
return false
}
switch instanceTagExpectation {
case .adopt:
// Until authentication reports a tag, this attempt can own any
// saved row for the requested logical Mac.
return true
case .preserve(let tag), .require(let tag):
// A legacy nil-tag row aliases the requested tagged instance.
return mac.instanceTag == nil || mac.instanceTag == tag
}
}
}
@@ -91,6 +91,10 @@ public enum MobilePairingFailureCategory: Equatable, Sendable {
/// Two cancellation-ignoring route cleanups are still alive. Retrying in
/// this process cannot start another transport without exceeding the cap.
case routeCleanupBlocked
/// Another connection attempt already owns this route. The Mac did not
/// fail to respond; the active attempt resolves the route on its own, so
/// the user should wait for it rather than treat this as a timeout.
case connectAttemptGated
/// The attempt was cancelled (the user tapped Cancel, or a newer attempt
/// superseded it). Not surfaced as an error.
case cancelled
@@ -123,6 +127,7 @@ extension MobilePairingFailureCategory {
case .unsupportedRoute: return "unsupported_route"
case .noSupportedRoute: return "no_supported_route"
case .routeCleanupBlocked: return "route_cleanup_blocked"
case .connectAttemptGated: return "connect_attempt_gated"
case .cancelled: return "cancelled"
case .unknown: return "other"
}
@@ -252,7 +257,7 @@ extension MobilePairingFailureCategory {
case .invalidCode:
return L10n.string(
"mobile.pairing.invalidCode",
defaultValue: "This isn't a cmux pairing QR. Scan the code shown in the Pair iPhone window on your Mac."
defaultValue: "This isn't a cmux pairing QR. Scan the code shown in Tailscale Pairing on your Mac."
)
case .unrecognizedVersion:
return L10n.string(
@@ -262,7 +267,10 @@ extension MobilePairingFailureCategory {
case .loopbackRejected:
return L10n.string(
"mobile.pairing.loopbackRejected",
defaultValue: "This code points at the Mac itself (localhost), so your iPhone can't use it. Update cmux on the Mac and scan its Iroh code."
defaultValue: """
This code points at the Mac itself (localhost), so your iPhone can't use it. \
Open Tailscale Pairing on the Mac and scan a fresh code.
"""
)
case .macUpdateRequired:
return L10n.string(
@@ -284,6 +292,11 @@ extension MobilePairingFailureCategory {
"mobile.pairing.routeCleanupBlocked",
defaultValue: "cmux paused new connections because earlier connection cleanups are still stuck."
)
case .connectAttemptGated:
return L10n.string(
"mobile.pairing.connectAttemptGated",
defaultValue: "Already reconnecting to this computer."
)
case .cancelled:
return ""
case let .unknown(host, port):
@@ -346,7 +359,7 @@ extension MobilePairingFailureCategory {
case .ticketExpired, .unsupportedRoute, .noSupportedRoute:
return L10n.string(
"mobile.pairing.guidance.rescanFresh",
defaultValue: "Open the pairing window on your Mac and scan a fresh QR or link."
defaultValue: "Open Tailscale Pairing on your Mac and scan a fresh QR or link."
)
case .unrecognizedVersion:
return L10n.string(
@@ -363,6 +376,11 @@ extension MobilePairingFailureCategory {
"mobile.pairing.guidance.routeCleanupBlocked",
defaultValue: "Force-quit and reopen cmux on this iPhone, then reconnect. If this returns, restart cmux on the Mac."
)
case .connectAttemptGated:
return L10n.string(
"mobile.pairing.guidance.connectAttemptGated",
defaultValue: "A connection attempt is already in progress. Give it a moment to finish; retry only if this computer stays disconnected."
)
case .invalidCode, .loopbackRejected, .cancelled, .unknown:
return nil
}
@@ -415,6 +433,11 @@ extension MobilePairingFailureCategory {
switch connectionError {
case .requestTimedOut, .connectAttemptGated:
return .handshakeTimedOut(host: host, port: port)
case .connectAttemptGated:
// Another attempt owns this route: the Mac did not time out,
// so timeout guidance ("No response from ") would misdirect
// the user. Surface the wait-for-active-attempt state instead.
return .connectAttemptGated
case .insecureManualRoute:
return .unsupportedRoute
case .attachTicketExpired:
@@ -38,9 +38,15 @@ extension MobileShellComposite {
/// Whether the Mac supports workspace group mutation requests.
public var supportsWorkspaceGroupActions: Bool { supportedHostCapabilities.contains(Self.workspaceGroupActionsCapability) && allowsMacScopedWorkspaceMutations }
/// Whether the Mac supports creating a workspace directly inside a group.
public var supportsWorkspaceCreateInGroup: Bool { supportedHostCapabilities.contains(Self.workspaceCreateInGroupCapability) && allowsMacScopedWorkspaceMutations }
public var supportsWorkspaceCreateInGroup: Bool {
supportedHostCapabilities.contains(Self.workspaceCreateInGroupCapability)
&& discoversMacScopedWorkspaceMutations
}
/// Whether the Mac supports creating workspace groups from iOS.
public var supportsWorkspaceGroupCreate: Bool { supportedHostCapabilities.contains(Self.workspaceGroupCreateCapability) && allowsMacScopedWorkspaceMutations }
public var supportsWorkspaceGroupCreate: Bool {
supportedHostCapabilities.contains(Self.workspaceGroupCreateCapability)
&& discoversMacScopedWorkspaceMutations
}
/// Whether the Mac supports dogfood feedback submission.
public var supportsDogfoodFeedback: Bool { supportedHostCapabilities.contains(Self.dogfoodFeedbackCapability) }
/// Whether the Mac supports chat artifact stat/fetch/thumbnail/list RPCs.
@@ -40,6 +40,11 @@ extension MobileShellComposite {
.reachabilityChanged,
a: isOnline ? 1 : 0
))
// Route strikes and hard gates accumulated on the old path
// predict nothing about the new one; drop them before this
// recovery pass so it is not refused by stale poisoning.
await self.connectAttemptRegistry.resetRouteHealthForNetworkChange()
guard !Task.isCancelled else { return }
self.recoverMobileConnection(trigger: .networkChange)
}
}
@@ -97,7 +102,14 @@ extension MobileShellComposite {
probeCurrentConnection: connectionState == .connected && remoteClient != nil,
resyncAfterHealthy: true
)
if multiMacAggregationEnabled, trigger.reschedulesSecondaryAggregation {
// A disconnected redial has cleared its foreground identity. Starting
// aggregation then would classify that same stored Mac as secondary and
// race the foreground attempt for one physical route lease.
if multiMacAggregationEnabled,
trigger.reschedulesSecondaryAggregation,
connectionState == .connected,
remoteClient != nil,
!connectionRecoveryOwner.isRedialingOrValidating {
scheduleSecondaryAggregation()
}
}
@@ -269,9 +281,17 @@ extension MobileShellComposite {
self.macConnectionStatus = .unavailable
self.clearRemoteConnectionContext()
self.applyConnectionRecoveryOwnerState()
await expectedClient.disconnect()
MobileDebugLog.anchormux(
"connection.recovery waiting for physical transport drain "
+ "attempt=\(attempt.id.uuidString)"
)
await expectedClient.disconnectAndWaitForTransportDrain()
guard !Task.isCancelled,
self.connectionRecoveryOwner.isCurrent(attempt) else { return }
MobileDebugLog.anchormux(
"connection.recovery physical transport drained "
+ "attempt=\(attempt.id.uuidString)"
)
}
if self.connectionState == .connected {
self.connectionState = .disconnected
@@ -321,7 +341,8 @@ extension MobileShellComposite {
_ attempt: MobileConnectionRecoveryOwner.Attempt,
connectionGeneration: UUID
) -> Bool {
if lastSuccessfulTerminalSubscriptionGeneration == connectionGeneration {
if lastSuccessfulTerminalSubscription?.connectionGeneration
== connectionGeneration {
return completeConnectionRecovery(attempt)
}
return connectionRecoveryOwner.transitionToValidation(
@@ -385,8 +406,15 @@ extension MobileShellComposite {
))
}
func recordSuccessfulTerminalSubscription() {
lastSuccessfulTerminalSubscriptionGeneration = connectionGeneration
func recordSuccessfulTerminalSubscription(
connectionGeneration: UUID,
listenerID: UUID? = nil
) {
lastSuccessfulTerminalSubscription =
MobileTerminalSubscriptionValidation(
connectionGeneration: connectionGeneration,
listenerID: listenerID
)
if connectionRecoveryOwner.completeValidation(connectionGeneration: connectionGeneration) {
recordConnectionRecoverySucceeded()
applyConnectionRecoveryOwnerState()
@@ -325,7 +325,9 @@ extension MobileShellComposite {
// but the other Macs are a read-only snapshot. Re-aggregate them on
// foreground so workspaces created on another Mac while backgrounded
// appear without a manual pull-to-refresh.
if multiMacAggregationEnabled, connectionState == .connected {
if multiMacAggregationEnabled,
connectionState == .connected,
remoteClient != nil {
self.scheduleSecondaryAggregation()
}
}
@@ -374,6 +374,7 @@ extension MobileShellComposite {
name: record.name,
isCollapsed: record.isCollapsed,
isPinned: record.isPinned,
iconSymbol: record.iconSymbol,
anchorWorkspaceID: record.anchorWorkspaceID
)
}
@@ -0,0 +1,187 @@
internal import CmuxMobileRPC
public import CmuxMobileShellModel
public import Foundation
extension MobileShellComposite {
/// Whether the selected Mac instance currently advertises task attachments.
///
/// - Parameters:
/// - macDeviceID: Physical Mac selected in the task composer.
/// - instanceTag: Exact paired app instance, when known.
/// - Returns: `true` only for a matching host capability announcement.
public func supportsTaskAttachments(
macDeviceID: String,
instanceTag: String?
) -> Bool {
if matchesForegroundPairing(
macDeviceID: macDeviceID,
instanceTag: instanceTag
) {
return supportedHostCapabilities.contains(Self.taskAttachmentCapability)
}
if let subscription = controlSubscriptionMatching(
macDeviceID: macDeviceID,
instanceTag: instanceTag
) {
return subscription.supportedHostCapabilities.contains(
Self.taskAttachmentCapability
)
}
let aliases = pairedMacAliasIDs(
for: macDeviceID,
instanceTag: instanceTag
)
if let instanceTag {
return aliases.contains {
presenceMap.instance(deviceId: $0, tag: instanceTag)?
.capabilities.contains(Self.taskAttachmentCapability) == true
}
}
return aliases.contains {
presenceMap.soleRouteAdvertisingInstance(deviceId: $0)?
.capabilities.contains(Self.taskAttachmentCapability) == true
}
}
/// Uploads one staged task attachment to the selected Mac in 3 MiB chunks.
///
/// - Parameters:
/// - attachment: App-owned staged attachment file.
/// - operationID: Task submission idempotency key.
/// - macDeviceID: Target Mac device id.
/// - instanceTag: Exact paired app instance, when known.
/// - Returns: The final absolute Mac path, or a user-actionable failure.
public func uploadTaskAttachment(
_ attachment: TaskComposerAttachment,
operationID: UUID,
macDeviceID: String,
instanceTag: String?
) async -> Result<String, MobileWorkspaceMutationFailure> {
if !matchesForegroundPairing(
macDeviceID: macDeviceID,
instanceTag: instanceTag
) || remoteClient == nil {
guard await switchToMac(
macDeviceID: macDeviceID,
instanceTag: instanceTag
) else {
return .failure(.notConnected(
hostDisplayName: taskComposerTargetName(
macDeviceID: macDeviceID,
instanceTag: instanceTag
)
))
}
}
guard !Task.isCancelled,
let context = captureWorkspaceCreateContext(),
context.macDeviceID == macDeviceID,
instanceTag == nil || context.instanceTag == instanceTag else {
return .failure(.notConnected(
hostDisplayName: taskComposerTargetName(
macDeviceID: macDeviceID,
instanceTag: instanceTag
)
))
}
guard context.supportedHostCapabilities.contains(
Self.taskAttachmentCapability
) else {
return .failure(.unsupported(hostDisplayName: context.hostDisplayName))
}
let data: Data
do {
data = try await loadTaskAttachmentData(
from: attachment.localStagedFileURL
)
} catch {
return .failure(.rejected(hostDisplayName: context.hostDisplayName))
}
guard data.count == attachment.byteCount,
data.count <= TaskComposerAttachment.maximumFileBytes else {
return .failure(.rejected(hostDisplayName: context.hostDisplayName))
}
let plan = MobileTaskAttachmentChunkPlan(totalByteCount: data.count)
do {
var finalPath: String?
for (index, range) in plan.ranges.enumerated() {
try Task.checkCancellation()
let isLast = index == plan.ranges.count - 1
let params: [String: Any] = [
"operation_id": operationID.uuidString,
"upload_id": attachment.id.uuidString,
"file_name": attachment.displayName,
"total_bytes": data.count,
"offset": range.lowerBound,
"data_b64": data.subdata(in: range).base64EncodedString(),
"last": isLast,
]
let response = try await context.client.sendRequest(
MobileCoreRPCClient.requestData(
method: "mobile.task.attachment.upload",
params: params
)
)
guard context.isCurrent(
macDeviceID: foregroundMacDeviceID,
instanceTag: activeMacInstanceTag,
client: remoteClient,
generation: connectionGeneration
), isSignedIn else {
return .failure(.notConnected(
hostDisplayName: context.hostDisplayName
))
}
guard let object = try JSONSerialization.jsonObject(with: response)
as? [String: Any] else {
throw MobileShellConnectionError.invalidResponse
}
if isLast {
guard let path = object["path"] as? String,
path.hasPrefix("/") else {
throw MobileShellConnectionError.invalidResponse
}
finalPath = path
}
}
guard let finalPath else {
return .failure(.rejected(hostDisplayName: context.hostDisplayName))
}
return .success(finalPath)
} catch {
if context.isCurrent(
macDeviceID: foregroundMacDeviceID,
instanceTag: activeMacInstanceTag,
client: remoteClient,
generation: connectionGeneration
) {
handleMacAvailabilityFailureIfCurrent(
after: error,
expectedClient: context.client,
expectedGeneration: context.generation
)
}
return .failure(
workspaceMutationFailure(
error,
hostDisplayName: context.hostDisplayName
)
)
}
}
private func loadTaskAttachmentData(from url: URL) async throws -> Data {
try await withThrowingTaskGroup(of: Data.self) { group in
group.addTask(priority: .utility) {
try Task.checkCancellation()
return try Data(contentsOf: url, options: .mappedIfSafe)
}
guard let data = try await group.next() else {
throw CancellationError()
}
return data
}
}
}
@@ -247,7 +247,7 @@ extension MobileShellComposite {
)
}
private func taskComposerTargetName(macDeviceID: String, instanceTag: String?) -> String {
func taskComposerTargetName(macDeviceID: String, instanceTag: String?) -> String {
displayPairedMacs.first {
$0.macDeviceID == macDeviceID
&& (instanceTag == nil || $0.instanceTag == instanceTag)
@@ -0,0 +1,235 @@
internal import CmuxMobileRPC
public import CmuxMobileShellModel
import Foundation
extension MobileShellComposite {
/// Resolves a secondary control subscription for a physical Mac: the
/// exact pairing when a tag is given, otherwise any same-device pairing.
/// Mirrors the pre-MacPairingKey device-id lookup these capability
/// checks were written against.
func controlSubscriptionMatching(
macDeviceID: String,
instanceTag: String?
) -> SecondaryMacSubscription? {
let probe = MacPairingKey(macDeviceID: macDeviceID, instanceTag: instanceTag)
if probe.normalizedInstanceTag != nil,
let exact = secondaryMacSubscriptions[probe] {
return exact
}
for key in secondaryMacSubscriptions.keys
where key.canonicalMacDeviceID == probe.canonicalMacDeviceID {
guard let subscription = secondaryMacSubscriptions[key] else { continue }
if instanceTag == nil
|| key.normalizedInstanceTag == probe.normalizedInstanceTag
|| subscription.authenticatedInstanceTag == instanceTag
|| subscription.storedInstanceTag == instanceTag {
return subscription
}
}
return nil
}
/// Whether the selected Mac instance advertises task model discovery.
///
/// - Parameters:
/// - macDeviceID: Physical Mac selected in the task composer.
/// - instanceTag: Exact paired app instance, when known.
/// - Returns: `true` only for a matching host capability announcement.
public func supportsTaskModels(
macDeviceID: String,
instanceTag: String?
) -> Bool {
if matchesForegroundPairing(
macDeviceID: macDeviceID,
instanceTag: instanceTag
) {
return supportedHostCapabilities.contains(Self.taskModelsCapability)
}
if let subscription = controlSubscriptionMatching(
macDeviceID: macDeviceID,
instanceTag: instanceTag
) {
return subscription.supportedHostCapabilities.contains(
Self.taskModelsCapability
)
}
let aliases = pairedMacAliasIDs(
for: macDeviceID,
instanceTag: instanceTag
)
if let instanceTag {
return aliases.contains {
presenceMap.instance(deviceId: $0, tag: instanceTag)?
.capabilities.contains(Self.taskModelsCapability) == true
}
}
return aliases.contains {
presenceMap.soleRouteAdvertisingInstance(deviceId: $0)?
.capabilities.contains(Self.taskModelsCapability) == true
}
}
/// Fetches one provider's models from the selected Mac.
///
/// - Parameters:
/// - provider: Coding-agent provider to query.
/// - macDeviceID: Physical Mac selected in the task composer.
/// - instanceTag: Exact paired app instance, when known.
/// - Returns: Models plus their discovery source.
/// - Throws: A connection or response error when discovery cannot complete.
public func fetchTaskModels(
provider: MobileTaskAgentProvider,
macDeviceID: String,
instanceTag: String?
) async throws -> MobileTaskModelListResult {
if !matchesForegroundPairing(
macDeviceID: macDeviceID,
instanceTag: instanceTag
) || remoteClient == nil {
guard await switchToMac(
macDeviceID: macDeviceID,
instanceTag: instanceTag
) else {
throw MobileShellConnectionError.connectionClosed
}
}
guard !Task.isCancelled,
let context = captureWorkspaceCreateContext(),
context.macDeviceID == macDeviceID,
instanceTag == nil || context.instanceTag == instanceTag,
context.supportedHostCapabilities.contains(
Self.taskModelsCapability
) else {
throw MobileShellConnectionError.invalidResponse
}
do {
let response = try await context.client.sendRequest(
MobileCoreRPCClient.requestData(
method: "mobile.task.models.list",
params: ["provider": provider.rawValue]
)
)
guard context.isCurrent(
macDeviceID: foregroundMacDeviceID,
instanceTag: activeMacInstanceTag,
client: remoteClient,
generation: connectionGeneration
), isSignedIn,
let object = try JSONSerialization.jsonObject(with: response)
as? [String: Any],
let rawSource = object["source"] as? String,
let source = MobileTaskModelListSource(rawValue: rawSource),
let rawModels = object["models"] as? [[String: Any]]
else {
throw MobileShellConnectionError.invalidResponse
}
var models: [MobileTaskAgentModel] = []
models.reserveCapacity(rawModels.count)
var seenIDs: Set<String> = []
for rawModel in rawModels {
guard let id = rawModel["id"] as? String,
!id.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
let displayName = rawModel["display_name"] as? String,
!displayName.isEmpty,
seenIDs.insert(id).inserted else {
throw MobileShellConnectionError.invalidResponse
}
models.append(MobileTaskAgentModel(
id: id,
displayName: displayName
))
}
guard !models.isEmpty else {
throw MobileShellConnectionError.invalidResponse
}
return MobileTaskModelListResult(models: models, source: source)
} catch {
if context.isCurrent(
macDeviceID: foregroundMacDeviceID,
instanceTag: activeMacInstanceTag,
client: remoteClient,
generation: connectionGeneration
) {
handleMacAvailabilityFailureIfCurrent(
after: error,
expectedClient: context.client,
expectedGeneration: context.generation
)
}
throw error
}
}
/// Returns cached models synchronously for composer rendering and restore.
///
/// - Parameters:
/// - provider: Coding-agent provider to resolve.
/// - macDeviceID: Physical Mac selected in the task composer.
/// - instanceTag: Exact paired instance; the cache intentionally remains
/// device/provider scoped so app rebuilds on one Mac share discovery.
/// - Returns: Previously fetched models, or `nil`.
public func discoveredTaskModels(
provider: MobileTaskAgentProvider,
macDeviceID: String,
instanceTag _: String?
) -> [MobileTaskAgentModel]? {
taskModelCache[
MobileTaskModelCacheKey(
macDeviceID: macDeviceID,
provider: provider
)
]?.result.models
}
/// Refreshes and caches one provider's models when the Mac supports it.
///
/// Failed refreshes leave an earlier valid cache entry intact.
///
/// - Parameters:
/// - provider: Coding-agent provider to query.
/// - macDeviceID: Physical Mac selected in the task composer.
/// - instanceTag: Exact paired app instance, when known.
public func refreshTaskModels(
provider: MobileTaskAgentProvider,
macDeviceID: String,
instanceTag: String?
) async {
guard supportsTaskModels(
macDeviceID: macDeviceID,
instanceTag: instanceTag
) else {
return
}
guard let result = try? await fetchTaskModels(
provider: provider,
macDeviceID: macDeviceID,
instanceTag: instanceTag
) else {
return
}
taskModelCache[
MobileTaskModelCacheKey(
macDeviceID: macDeviceID,
provider: provider
)
] = MobileTaskModelCacheEntry(
result: result,
fetchedAt: runtime?.now() ?? Date()
)
}
/// Fetch timestamp used by package tests and cache diagnostics.
func taskModelsFetchedAt(
provider: MobileTaskAgentProvider,
macDeviceID: String,
instanceTag _: String?
) -> Date? {
taskModelCache[
MobileTaskModelCacheKey(
macDeviceID: macDeviceID,
provider: provider
)
]?.fetchedAt
}
}
@@ -241,7 +241,8 @@ extension MobileShellComposite {
target: target,
hostDisplayName: hostDisplayName,
logID: id.rawValue,
actionName: "move"
actionName: "move",
isMacScoped: true
)
switch result {
case .success:
@@ -347,7 +348,8 @@ extension MobileShellComposite {
target: target,
hostDisplayName: hostDisplayName,
logID: "foreground",
actionName: "create_group"
actionName: "create_group",
isMacScoped: true
)
}
@@ -381,6 +383,14 @@ extension MobileShellComposite {
)
}
private func hostAuthorizesAccountScopedMutations(target: WorkspaceMutationTarget) -> Bool {
if target.isForeground {
return hostAuthorizesAccountScopedMutations
}
return target.ownerKey.flatMap { secondaryMacSubscriptions[$0] }?
.supportedHostCapabilities.contains(Self.workspaceMutationAccountAuthCapability) ?? false
}
private func sendWorkspaceMutation(
method: String,
params: [String: Any],
@@ -412,9 +422,11 @@ extension MobileShellComposite {
let target = workspaceGroupMutationTarget(for: id)
let hostDisplayName = workspaceGroupHostDisplayName(for: id, target: target)
guard workspaceGroupActionCapabilities(for: id).supportsGroupActions else {
MobileDebugLog.anchormux("workspace.mutation blocked action=\(actionName) id=\(id.rawValue) reason=capability")
return .failure(.unsupported(hostDisplayName: hostDisplayName))
}
guard macScopedWorkspaceMutationIsAuthorized(target: target) else {
MobileDebugLog.anchormux("workspace.mutation blocked action=\(actionName) id=\(id.rawValue) reason=scope")
return .failure(.authorizationFailed(hostDisplayName: hostDisplayName))
}
var params: [String: Any] = ["group_id": id.rawValue, "action": action]
@@ -427,7 +439,8 @@ extension MobileShellComposite {
target: target,
hostDisplayName: hostDisplayName,
logID: id.rawValue,
actionName: actionName
actionName: actionName,
isMacScoped: true
)
}
@@ -438,7 +451,8 @@ extension MobileShellComposite {
hostDisplayName: String?,
logID: String,
actionName: String,
refreshAfterMutation: Bool = true
refreshAfterMutation: Bool = true,
isMacScoped: Bool = false
) async -> Result<Void, MobileWorkspaceMutationFailure> {
// Route the mutation to the Mac that actually OWNS this workspace. The
// aggregated list can include rows from secondary Macs, whose connection is
@@ -447,6 +461,7 @@ extension MobileShellComposite {
// mutate a foreground workspace). The foreground path is unchanged for
// foreground-owned (or single-Mac / anonymous) rows.
guard let client = target.client else {
MobileDebugLog.anchormux("workspace.mutation blocked action=\(actionName) id=\(logID) reason=no_route")
// Owner is a known non-foreground Mac with no live connection: can't
// deliver. Snap the row back to the authoritative state instead of
// misrouting to the foreground Mac.
@@ -456,10 +471,27 @@ extension MobileShellComposite {
return .failure(.notConnected(hostDisplayName: hostDisplayName))
}
let generation = connectionGeneration
MobileDebugLog.anchormux("workspace.mutation sending action=\(actionName) id=\(logID) foreground=\(target.isForeground)")
do {
let request = try MobileCoreRPCClient.requestData(method: method, params: params)
_ = try await client.sendRequest(request)
let attachTicketPolicy: MobileCoreRPCAttachTicketPolicy =
isMacScoped && hostAuthorizesAccountScopedMutations(target: target)
? .omit
: .whenCovered
_ = try await client.sendRequest(
request,
attachTicketPolicy: attachTicketPolicy
)
} catch {
// Diagnostics carry only the bounded failure vocabulary plus the
// short RPC code: an rpcError message is an arbitrary host string
// and must never be retained in exported diagnostics.
let failureKind = DiagnosticFailureKind.classify(error)
var rpcCode = "none"
if case let MobileShellConnectionError.rpcError(code, _) = error {
rpcCode = code ?? "unknown"
}
MobileDebugLog.anchormux("workspace.mutation failed action=\(actionName) id=\(logID) kind=\(failureKind) code=\(rpcCode)")
if disconnectForAuthorizationFailureIfNeeded(error) {
return .failure(.authorizationFailed(hostDisplayName: hostDisplayName))
}
@@ -473,7 +505,7 @@ extension MobileShellComposite {
expectedGeneration: generation
)
}
mobileShellLog.error("workspace mutation failed action=\(actionName, privacy: .public) id=\(logID, privacy: .public) error=\(String(describing: error), privacy: .public)")
mobileShellLog.error("workspace mutation failed action=\(actionName, privacy: .public) id=\(logID, privacy: .public) kind=\(String(describing: failureKind), privacy: .public) error=\(String(describing: error), privacy: .private)")
if refreshAfterMutation {
await refreshAfterWorkspaceMutation(target)
}
@@ -483,6 +515,7 @@ extension MobileShellComposite {
if refreshAfterMutation {
await refreshAfterWorkspaceMutation(target)
}
MobileDebugLog.anchormux("workspace.mutation accepted action=\(actionName) id=\(logID)")
return .success(())
}
@@ -99,7 +99,11 @@ extension MobileShellComposite {
return .failure(.notConnected(hostDisplayName: context.hostDisplayName))
}
let resultData = try await client.sendRequest(
MobileCoreRPCClient.requestData(method: "workspace.create", params: params)
MobileCoreRPCClient.requestData(method: "workspace.create", params: params),
attachTicketPolicy: groupID != nil
&& context.supportedHostCapabilities.contains(Self.workspaceMutationAccountAuthCapability)
? .omit
: .whenCovered
)
let response = try MobileSyncWorkspaceListResponse.decode(resultData)
let createdWorkspace: MobileWorkspacePreview.ID?
@@ -3,10 +3,25 @@ internal import CmuxMobileRPC
internal import Foundation
extension MobileShellComposite {
/// Whether the active ticket was issued with Mac-wide mutation scope.
///
/// Menu discovery depends on stable scope, not the ticket's short-lived
/// expiry. New hosts authorize by signed-in account after expiry; legacy
/// hosts keep the existing visible action and surface a failure on use.
var hasMacScopedWorkspaceMutationTicketScope: Bool {
let ticket = activeTicket ?? remoteClient?.attachTicket
return MobileShellWorkspaceMutationTicketPolicy(now: runtime?.now() ?? Date())
.hasMacScopedWorkspaceMutationScope(ticket)
}
var allowsMacScopedWorkspaceMutations: Bool {
allowsMacScopedWorkspaceMutations(targetClient: nil)
}
var discoversMacScopedWorkspaceMutations: Bool {
hasMacScopedWorkspaceMutationTicketScope || allowsMacScopedWorkspaceMutations
}
func allowsMacScopedWorkspaceMutations(targetClient: MobileCoreRPCClient?) -> Bool {
let ticket = activeTicket ?? targetClient?.attachTicket
return MobileShellWorkspaceMutationTicketPolicy(now: runtime?.now() ?? Date())
@@ -17,7 +32,8 @@ extension MobileShellComposite {
}
/// Whether the foreground Mac authorizes Mac-scoped workspace mutations by
/// the signed-in account (attach tickets only narrow while current).
/// the signed-in account. Requests using this capability omit attach-ticket
/// context so a saved workspace route cannot narrow the account authority.
var hostAuthorizesAccountScopedMutations: Bool {
supportedHostCapabilities.contains(Self.workspaceMutationAccountAuthCapability)
}
@@ -121,6 +121,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
static let workspaceGroupActionsCapability = "workspace.group_actions.v1"
static let workspaceCreateInGroupCapability = "workspace.create_in_group.v1", workspaceGroupCreateCapability = "workspace.group_create.v1"
static let taskCreateCapability = "workspace.task_create.v1"
static let taskAttachmentCapability = "task.attachments.v1"
static let taskModelsCapability = "task.models.v1"
static let chatArtifactCapability = "chat.artifact.v1"
static let chatArtifactGalleryCapability = "chat.artifact.gallery.v1"
static let terminalArtifactCapability = "terminal.artifact.v1"
@@ -429,6 +431,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
@ObservationIgnored var groupCollapseStore: MobileWorkspaceGroupCollapseStore
/// Device-local task templates used by the iOS task composer.
@ObservationIgnored public let taskTemplateStore: (any MobileTaskTemplateStoring)?
/// Mac/provider model responses observed by the task composer.
var taskModelCache: [MobileTaskModelCacheKey: MobileTaskModelCacheEntry] = [:]
/// The connected Mac's `mobile.host.status` capabilities. Feature gates are
/// computed from this set so version-skew checks cannot drift from the raw
/// host payload.
@@ -526,14 +530,11 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
private var composerDismissedTerminalIDs: Set<String> = []
/// Monotonic focus-request token for the iMessage-style composer field.
///
/// The composer's text field owns its first responder via SwiftUI `@FocusState`,
/// which neither the terminal surface nor the representable coordinator can set
/// directly. When the surface needs the field re-focused without re-presenting the
/// composer the reveal-after-hide case, where the chrome and draft are already
/// back but the terminal proxy stole first responder it bumps this token through
/// ``presentAndFocusComposer()``. ``TerminalComposerView`` observes the change and
/// drives `isFieldFocused = true`, keeping `@FocusState` the single source of truth
/// for who holds the keyboard.
/// The surface input session owns first-responder commands; SwiftUI `@FocusState`
/// mirrors their result. When the surface needs the field re-focused without
/// re-presenting the composer, it bumps this token through
/// ``presentAndFocusComposer()``. ``TerminalComposerView`` consumes the keyed
/// intent and forwards it to that session owner.
public private(set) var composerFocusRequest: Int = 0
/// True while a ``composerFocusRequest`` has been issued but not yet consumed
/// by the composer field. The field's `onChange` of the token only observes
@@ -853,9 +854,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
/// consumed, not buffered invisibly behind the await.
private var terminalSubscriptionStartTask: Task<Void, Never>?
/// Subscription success is the final validation edge for a replacement
/// connection. This generation record closes the race where the ack arrives
/// before the stored-Mac redial task returns from `connect`.
var lastSuccessfulTerminalSubscriptionGeneration: UUID?
/// connection or listener. This snapshot closes the race where an old
/// acknowledgement arrives after a newer listener has taken ownership.
var lastSuccessfulTerminalSubscription: MobileTerminalSubscriptionValidation?
/// The focused client whose terminal subscribe/reassert operations are
/// fenced while its final unsubscribe is prepared. Existing wire requests
/// drain before unsubscribe; new ones cannot start.
@@ -1617,6 +1618,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
enqueueDraftOperation { await draftStore.clearAllDrafts() }
}
taskTemplateStore?.clearAllUserData()
taskModelCache.removeAll()
// Drop unflushed keystroke snapshots too: an armed flush that runs
// before the wipe would only write text the wipe then deletes, but the
// buffer itself must not carry one account's text into the next.
@@ -2449,36 +2451,6 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
setHasKnownPairedMac(true, generation: generation)
}
let irohReconnectIsBlocked = automaticIrohReconnectIsBlocked(accountID: scope.userID)
let zeroTouchCandidates: [MobilePairedMac] = if irohReconnectIsBlocked {
[]
} else {
await discoverZeroTouchIrohCandidates(
scope: scope,
generation: generation,
excluding: Set(candidates.map {
MobilePairedMac.pairingID(
macDeviceID: $0.macDeviceID,
instanceTag: $0.instanceTag
)
})
)
}
guard generation == storedMacReconnectGeneration else {
return .superseded
}
guard await isScopeCurrent(scope) else {
finishStoredMacReconnectAttempt(generation: generation)
return .superseded
}
candidates.append(contentsOf: zeroTouchCandidates)
let zeroTouchCandidateIDs = Set(zeroTouchCandidates.map(\.id))
guard !candidates.isEmpty else {
if !hasKnownStoredMac, !irohReconnectIsBlocked {
setHasKnownPairedMac(false, generation: generation)
}
finishStoredMacReconnectAttempt(generation: generation)
return .failed(.noRoute)
}
// Capture one coherent post-request view of the registry and paired-Mac
// store. The store read happens after the registry await, so an
// authenticated Presence write that lands during the request wins. The
@@ -2543,8 +2515,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
)
}
if connectionState != .connected,
!automaticIrohReconnectIsBlocked(accountID: scope.userID),
!zeroTouchCandidateIDs.contains(mac.id) {
!automaticIrohReconnectIsBlocked(accountID: scope.userID) {
switch await freshReconnectRoutesAfterLocalFailure(
for: mac,
scope: scope,
@@ -2576,6 +2547,63 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
}
if connectionState == .connected { break }
}
// A saved authenticated route is the cheapest and most authoritative
// recovery path. Broker discovery can be slow for accounts with a large
// development fleet, so only ask for zero-touch candidates after every
// saved candidate failed. This keeps a healthy saved Mac from sitting
// behind an unrelated account-wide discovery request.
var zeroTouchCandidates: [MobilePairedMac] = []
if connectionState != .connected,
!automaticIrohReconnectIsBlocked(accountID: scope.userID) {
zeroTouchCandidates = await discoverZeroTouchIrohCandidates(
scope: scope,
generation: generation,
excluding: Set(candidates.map {
MobilePairedMac.pairingID(
macDeviceID: $0.macDeviceID,
instanceTag: $0.instanceTag
)
})
)
guard generation == storedMacReconnectGeneration else {
return .superseded
}
guard await isScopeCurrent(scope) else {
finishStoredMacReconnectAttempt(generation: generation)
return .superseded
}
for mac in zeroTouchCandidates {
guard generation == storedMacReconnectGeneration,
await isScopeCurrent(scope),
await !isHiddenMacDeviceID(
mac.macDeviceID,
instanceTag: mac.instanceTag,
scope: scope
) else { break }
let routes = storedReconnectRoutes(mac)
attemptedAutomaticIroh = attemptedAutomaticIroh
|| routes.contains { $0.kind == .iroh }
lastDialOutcome = await connectStoredMacOutcome(
name: mac.displayName ?? mac.macDeviceID,
routes: routes,
pairedMacDeviceID: mac.macDeviceID,
instanceTag: mac.instanceTag,
legacyTailscaleRoutes: mac.legacyTailscaleRoutes ?? [],
automaticReconnectAccountID: scope.userID,
ifStillCurrent: { [weak self] in
self?.storedMacReconnectGeneration == generation
}
)
if connectionState == .connected { break }
}
}
if candidates.isEmpty, zeroTouchCandidates.isEmpty {
if !hasKnownStoredMac, !irohReconnectIsBlocked {
setHasKnownPairedMac(false, generation: generation)
}
finishStoredMacReconnectAttempt(generation: generation)
return .failed(.noRoute)
}
// A newer attempt may have started during the connect; it now owns the flags.
guard generation == storedMacReconnectGeneration else { return .superseded }
guard await isScopeCurrent(scope) else {
@@ -4994,9 +5022,29 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
supportedKinds: supportedRouteKinds,
preferNonLoopback: Self.prefersNonLoopbackRoutes
)
let foregroundIDSet: Set<String>
// During a bounded foreground redial, `clearRemoteConnectionContext()`
// has already nil'd `foregroundMacDeviceID`, which would make the very
// Mac being redialed eligible as a "secondary". That opens a duplicate
// background-control session the redial must then drain one wasted
// QUIC dial plus up to the handoff-drain timeout of added reconnect
// latency. Exclude the in-flight recovery target exactly like a live
// foreground; once recovery settles the normal exclusion (success) or
// eligibility (terminal failure) resumes.
let exclusionMacDeviceID: String?
let exclusionTag: String?
if let foregroundMacDeviceID {
let canonicalID = cmxCanonicalDeviceID(foregroundMacDeviceID)
exclusionMacDeviceID = foregroundMacDeviceID
exclusionTag = activeMacInstanceTag
} else if isReconnectingStoredMac || connectionRecoveryOwner.isActive {
exclusionMacDeviceID = recoveryTargetMacDeviceID
exclusionTag = recoveryTargetInstanceTag
} else {
exclusionMacDeviceID = nil
exclusionTag = nil
}
let foregroundIDSet: Set<String>
if let exclusionMacDeviceID {
let canonicalID = cmxCanonicalDeviceID(exclusionMacDeviceID)
foregroundIDSet = physicalAliasIDsByCanonicalID[canonicalID]
?? Set([canonicalID])
} else {
@@ -5006,9 +5054,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
if case let .peer(identity, _)? = activeRoute?.endpoint {
foregroundIrohEndpointIDs.insert(identity.endpointID)
}
let activeTag = activeMacInstanceTag
if let foregroundMacDeviceID {
let canonicalForegroundID = cmxCanonicalDeviceID(foregroundMacDeviceID)
let activeTag = exclusionTag
if let exclusionMacDeviceID {
let canonicalForegroundID = cmxCanonicalDeviceID(exclusionMacDeviceID)
// With no authenticated tag the foreground could be any of the
// device's rows, so every row's endpoint is treated as the
// foreground's own; with a tag, only the exact pairing's endpoints
@@ -5160,7 +5208,11 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
)
}
secondaryMacEstablishmentFlights[flightKey] =
SecondaryMacEstablishmentFlight(id: flightID, task: task)
SecondaryMacEstablishmentFlight(
id: flightID,
mac: mac,
task: task
)
let outcome = await task.value
if secondaryMacEstablishmentFlights[flightKey]?.id == flightID {
secondaryMacEstablishmentFlights[flightKey] = nil
@@ -9278,12 +9330,55 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
macConnectionStatus = .unavailable
return
}
let subscriptionIsValidated =
terminalEventListenerID.map { listenerID in
lastSuccessfulTerminalSubscription
== MobileTerminalSubscriptionValidation(
connectionGeneration: connectionGeneration,
listenerID: listenerID
)
} ?? false
let requiresSubscriptionValidation =
runtime?.supportsServerPushEvents == true
|| terminalEventListenerID != nil
guard !requiresSubscriptionValidation
|| subscriptionIsValidated else {
macConnectionStatus = .reconnecting
connectionRecoveryFailed = false
return
}
let foregroundKey = foregroundMacKey
if var foregroundState = workspacesByMac[foregroundKey],
foregroundState.status != .connected {
foregroundState.status = .connected
workspacesByMac[foregroundKey] = foregroundState
}
macConnectionStatus = .connected
isRecoveringConnection = false
connectionRecoveryFailed = false
connectionRequiresReauth = false
}
@discardableResult
func recordUsableTerminalSubscription(
client: MobileCoreRPCClient,
connectionGeneration: UUID,
listenerID: UUID
) -> Bool {
guard isCurrentRemoteOperation(
client: client,
generation: connectionGeneration
) else {
return false
}
recordSuccessfulTerminalSubscription(
connectionGeneration: connectionGeneration,
listenerID: listenerID
)
markMacConnectionHealthy()
return true
}
func markMacConnectionReconnecting() {
guard connectionState == .connected, remoteClient != nil else {
macConnectionStatus = .unavailable
@@ -10010,6 +10105,13 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
}
}
private enum TerminalEventSubscriptionProbeResult {
case active
case missing
case unsupported
case failed
}
private func requestTerminalEventSubscription(
client: MobileCoreRPCClient,
reason: String,
@@ -10019,6 +10121,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
let requestData: Data
do {
var params: [String: Any] = [
"client_id": clientID,
"stream_id": terminalEventStreamID,
"topics": topics,
]
@@ -10233,7 +10336,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
terminalSubscriptionHandoffFenceClientID = nil
}
let listenerID = UUID()
let listenerConnectionGeneration = connectionGeneration
terminalEventListenerID = listenerID
markMacConnectionHealthy()
// Arm the liveness watchdog for this subscription generation. Done only
// inside the push-events path (after the guard above) so scripted
// transport tests, which set `supportsServerPushEvents = false`, never
@@ -10286,6 +10391,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
self?.beginTerminalEventSubscriptionStart(
client: client,
listenerID: listenerID,
connectionGeneration: listenerConnectionGeneration,
topics: topics,
transport: outputTransport,
subscriptionReadiness: subscriptionReadiness,
@@ -10296,7 +10402,12 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
for await event in stream {
guard !Task.isCancelled else { return }
guard let self else { return }
guard self.remoteClient === client, self.connectionState == .connected else { return }
guard self.isCurrentRemoteOperation(
client: client,
generation: listenerConnectionGeneration
) else {
return
}
// Any yielded envelope proves the transport is still pushing, so
// it resets the liveness window (not just render_grid events).
self.cancelTerminalInputAckResubscribeRetry()
@@ -10367,6 +10478,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
private func beginTerminalEventSubscriptionStart(
client: MobileCoreRPCClient,
listenerID: UUID,
connectionGeneration: UUID,
topics: [String],
transport: TerminalOutputTransport,
subscriptionReadiness: MobileTerminalEventSubscriptionReadiness? = nil,
@@ -10401,7 +10513,14 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
topics: topics
) ?? .failed
guard let self else { return }
guard !Task.isCancelled, self.terminalEventListenerID == listenerID else { return }
guard !Task.isCancelled,
self.terminalEventListenerID == listenerID,
self.isCurrentRemoteOperation(
client: client,
generation: connectionGeneration
) else {
return
}
self.terminalSubscriptionStartTask = nil
guard ack.isSubscribed else {
MobileDebugLog.anchormux("sync.subscribe_failed reason=start")
@@ -10418,8 +10537,13 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
// cross-actor readiness hop can admit a cancellation or newer
// listener, and a stale acknowledgement must never mutate that
// replacement connection after it resumes.
self.recordSuccessfulTerminalSubscription()
self.markMacConnectionHealthy()
guard self.recordUsableTerminalSubscription(
client: client,
connectionGeneration: connectionGeneration,
listenerID: listenerID
) else {
return
}
didSubscribe = true
MobileDebugLog.anchormux("sync.subscribe_ok topics=\(topics.count) transport=\(transport)")
// Negotiate state sync v2 only from the subscription
@@ -10579,15 +10703,12 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
/// every healthy idle subscription every ~10.5s, forever (the 2026-06-10
/// Release-sim bisect finding).
///
/// The probe is an idempotent `mobile.events.subscribe` for the SAME
/// stream id and current topics, not a generic ping: a completed
/// round-trip proves the transport the events ride on is alive AND that
/// the server-side registration is (re)installed, and the host's
/// subscription tracker re-evaluates producer demand on every replace. A
/// generic `mobile.host.status` answer could mask a dropped registration
/// behind a live RPC channel forever. Unlike the resync recovery, the
/// probe restarts nothing: no listener teardown, no replay, no stream
/// interruption.
/// The read-only `mobile.events.probe` checks the SAME stream id: a
/// completed response proves the control channel is alive and reports
/// whether the host still owns the registration without replacing it or
/// churning producer demand. A missing registration is repaired with one
/// subscribe and replay; an older host that lacks the probe verb falls
/// back to the former idempotent subscribe behavior.
private func checkRenderGridLiveness(listenerID: UUID) {
guard renderGridLivenessListenerID == listenerID else { return }
guard let client = remoteClient, connectionState == .connected else { return }
@@ -10681,10 +10802,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
}
}
/// Bounded positive-liveness probe: re-assert the event subscription and
/// only count a completed round-trip as alive. Any failure (timeout,
/// closed connection, rpc rejection) reports dead and lets the watchdog
/// run its recovery.
/// Bounded positive-liveness probe: inspect the existing event
/// registration without mutating it, repairing it only when missing.
///
/// The deadline bounds the WHOLE attempt, including any Stack token work
/// that precedes the wire write inside `sendRequest`; an unbounded hang
@@ -10696,12 +10815,23 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
timeoutNanoseconds: UInt64
) async -> TerminalEventSubscriptionAck {
let probe = Task { @MainActor [weak self] in
await self?.requestTerminalEventSubscription(
guard let self else { return TerminalEventSubscriptionAck.failed }
switch await self.requestTerminalEventSubscriptionProbe(
client: client,
reason: "liveness_probe",
topics: topics,
timeoutNanoseconds: timeoutNanoseconds
) ?? .failed
) {
case .active:
return .subscribed(alreadySubscribed: true)
case .missing, .unsupported:
return await self.requestTerminalEventSubscription(
client: client,
reason: "liveness_probe_repair",
topics: topics,
timeoutNanoseconds: timeoutNanoseconds
)
case .failed:
return .failed
}
}
// Bounded deadline via a one-shot DispatchSourceTimer the same
// sanctioned primitive the watchdog tick uses with cancellation
@@ -10717,6 +10847,45 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
return ack
}
private func requestTerminalEventSubscriptionProbe(
client: MobileCoreRPCClient,
timeoutNanoseconds: UInt64
) async -> TerminalEventSubscriptionProbeResult {
let requestData: Data
do {
requestData = try MobileCoreRPCClient.requestData(
method: "mobile.events.probe",
params: [
"client_id": clientID,
"stream_id": terminalEventStreamID,
]
)
} catch {
return .failed
}
do {
let data = try await client.sendRequest(
requestData,
timeoutNanoseconds: timeoutNanoseconds
)
guard let object = try JSONSerialization.jsonObject(with: data)
as? [String: Any],
object["stream_id"] as? String == terminalEventStreamID,
let subscribed = object["subscribed"] as? Bool else {
return .failed
}
return subscribed ? .active : .missing
} catch MobileShellConnectionError.rpcError(let code, _)
where code == "method_not_found" {
return .unsupported
} catch {
if remoteClient === client {
_ = disconnectForAuthorizationFailureIfNeeded(error)
}
return .failed
}
}
func resyncTerminalOutput(
reason: String,
restartEventStream: Bool,
@@ -11816,12 +11985,13 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
// to leave the existing sections intact. Authoritative groups are passed
// through the device-local collapse store before entering the per-Mac
// source of truth, so derived groups keep this phone's collapse choices.
let groups: [MobileWorkspaceGroupPreview]? =
(mergeExistingWorkspaces || !groupsAreAuthoritative)
? nil
: groupCollapseStore.apply(
to: response.groups.map { MobileWorkspaceGroupPreview(remote: $0) }
)
// Empty or missing group metadata during reconnect/rebind is not enough to
// remove sections; only a healthy, complete ungrouped snapshot can do that.
let groups = remoteWorkspaceGroups(
from: response,
mergeExistingWorkspaces: mergeExistingWorkspaces,
groupsAreAuthoritative: groupsAreAuthoritative
)
setForegroundWorkspaceState(
workspaces: remoteWorkspaces, groups: groups, merge: mergeExistingWorkspaces)
#if DEBUG
@@ -11865,6 +12035,34 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
syncSelectedTerminalForWorkspace()
}
private func remoteWorkspaceGroups(
from response: MobileSyncWorkspaceListResponse,
mergeExistingWorkspaces: Bool,
groupsAreAuthoritative: Bool
) -> [MobileWorkspaceGroupPreview]? {
guard !mergeExistingWorkspaces, groupsAreAuthoritative else { return nil }
guard response.groupsFieldWasPresent else { return nil }
let groups = response.groups.map { MobileWorkspaceGroupPreview(remote: $0) }
guard groups.isEmpty else {
return groupCollapseStore.apply(to: groups)
}
guard canAcceptEmptyGroupSnapshot(from: response) else { return nil }
return []
}
private func canAcceptEmptyGroupSnapshot(
from response: MobileSyncWorkspaceListResponse
) -> Bool {
guard connectionState == .connected, macConnectionStatus == .connected else {
return false
}
let responseStillReferencesGroups = response.workspaces.contains { workspace in
workspace.groupID?.isEmpty == false
}
guard !responseStillReferencesGroups else { return false }
return true
}
private func remoteWorkspacesPreservingSnapshots(
from response: MobileSyncWorkspaceListResponse
) -> [MobileWorkspacePreview] {
@@ -5,17 +5,29 @@ internal import Foundation
struct MobileShellWorkspaceMutationTicketPolicy {
let now: Date
func hasMacScopedWorkspaceMutationScope(_ ticket: CmxAttachTicket?) -> Bool {
guard let ticket,
ticket.authToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else {
return false
}
return ticket.workspaceID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
/// - Parameters:
/// - ticket: The connection's attach ticket, if any.
/// - hostAuthorizesByAccount: Whether the host advertises
/// `workspace.mutations.account_auth.v1`: the signed-in Stack account
/// authorizes Mac-scoped mutations and an attach ticket only narrows
/// scope while current. Legacy hosts reject these verbs without a
/// current mac-scoped ticket, so the client fails closed for them.
/// authorizes Mac-scoped mutations. Callers omit attach-ticket context
/// for these requests so an older saved workspace route cannot narrow
/// them. Legacy hosts reject these verbs without a current mac-scoped
/// ticket, so the client fails closed for them.
func allowsMacScopedWorkspaceMutations(
_ ticket: CmxAttachTicket?,
hostAuthorizesByAccount: Bool
) -> Bool {
if hostAuthorizesByAccount {
return true
}
guard let ticket,
ticket.authToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false,
!ticket.isExpired(at: now) else {
@@ -26,4 +38,8 @@ struct MobileShellWorkspaceMutationTicketPolicy {
}
return ticket.workspaceID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
func allowsMacScopedWorkspaceMutations(_ ticket: CmxAttachTicket?) -> Bool {
allowsMacScopedWorkspaceMutations(ticket, hostAuthorizesByAccount: false)
}
}

Some files were not shown because too many files have changed in this diff Show More