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
Lawrence Chen 053ba0291c Publish four SDKs without taking over cmux CLI packages (#9376)
* Test isolated four-language SDK publishing

* Isolate and coordinate four SDK publishers

* Harden SDK release orchestration

* Make SDK publishing explicitly dispatched

* Enforce SDK release provenance

* Serialize coordinated SDK releases

* Test resumable SDK publishing

* Make SDK releases safely resumable

* Test ambiguous registry publish recovery

* Reconcile ambiguous registry publishes

* Test fully reproducible SDK preflights

* Complete reproducible SDK preflights

* Test SDK publisher security boundaries

* Secure reproducible SDK publishing

* Test usable registry release state

* Require usable registry release state

* Test pre-tag registry and Go gates

* Gate SDK tags on consumable releases

* Test final SDK release race guards

* Close final SDK release race windows

* Test SDK bootstrap and propagation recovery

* Make SDK bootstrap and propagation resilient

* Test release bootstrap and public Go verification

* Fail closed before coordinated SDK releases

* Run SDK surface gate after main validation

* Test Go probe polling without pipe reuse

* Poll Go verification without pipe reuse

* Test attested PyPI project bootstrap

* Reserve PyPI SDK name before release tags

* Test non-UTF-8 Go probe output

* Decode Go probe output defensively

* Test SDK registry ownership gates

* Require SDK registry ownership before tags

* Test registry ownership and monotonic recovery

* Reconcile registry ownership and release history

* Test publisher identity and reproducible recovery

* Test reproducible Python source archives

* Bind publisher identity and reproduce SDK artifacts

* Test registry error privacy and recovery placement

* Sanitize registry transport failures

* Test monotonic and attested release recovery

* Enforce monotonic attested release recovery

* Test current provenance and post-publish reconciliation

* Verify registry state after every publish

* Test prerelease recovery and registry index skew

* Recover prerelease and index propagation safely

* Test external SDK release authority

* Gate SDK release authority outside branch workflows

* Test repository-dispatched npm provenance

* Verify repository-dispatched npm attestations

* Test approval-fresh commit-bound release checks

* Revalidate release authority at tag creation

* Test least-exposure release credentials

* Limit SDK tag credentials to the atomic push

* Test credential-locked SDK bootstraps

* Harden SDK registry bootstraps

* Test isolated release authority and convergence

* Isolate SDK release credentials

* Test fresh recoverable SDK tag retries

* Make SDK tag retries fresh and recoverable

* Test isolated registry bootstrap credentials

* Isolate registry bootstrap credentials

* Test registry recovery identity binding

* Bind registry recovery to publisher identity

* Test publishing tool cancellation and Python pinning

* Harden publishing tool runtime behavior

* Test bounded registry publisher execution

* Bound registry publisher subprocesses

* Test multi-entry npm integrity metadata

* Verify multi-entry npm integrity metadata

* Test tag recovery after main advances

* Recover tag push after main advances

* Test rerun snapshot tag normalization

* Normalize rerun release tag snapshots

* Test crates.io access policy compliance

* Honor crates.io data access policy

* Test cross-process crates.io pacing

* Pace crates checks between processes

* Test PyPI bootstrap source revalidation

* Revalidate PyPI bootstrap source

* Test published SDK source identity

* Bind published SDKs to typed source

* Test multi-entry npm provenance SRI

* Accept multi-entry npm integrity metadata

* Test publisher artifact identity binding

* Bind publishers to validated artifacts

* Scope release artifacts to workflow attempts

* Test release artifact rerun identity

* Bind reruns to attempt artifacts

* Test publisher authority revalidation

* Revalidate publisher registry authority

* Test publisher verifier isolation

* Isolate PyPI publisher authority checks

* Route SDK jobs through runner controls

* Test npm provenance runner isolation

* Keep npm provenance on GitHub runner
2026-08-03 00:18:11 -07:00
Austin Wang ddd4a01bc5 Bump version to 0.64.22 (#9442) 2026-08-03 00:14:42 -07:00
Austin Wang 67d4fc12e1 Merge pull request #9436 from manaflow-ai/issue-9431-intel-sentry-init-crash
Disable Ghostty native Sentry in embedded builds
2026-08-02 23:06:45 -07:00
austinpower1258 b0b96e7b34 Fix Swift Testing diagnostic type 2026-08-02 22:53:38 -07:00
Austin Wang 42d4f04126 Merge pull request #9422 from manaflow-ai/fix-close-surface-nonexistent-ref-fallthrough
Fail closed for stale destructive surface targets
2026-08-02 22:42:52 -07:00
austinpower1258 63c8c28288 fix: complete explicit surface review coverage 2026-08-02 22:21:24 -07:00
austinpower1258 1d48844494 Disable Ghostty native Sentry in cmux builds 2026-08-02 22:01:52 -07:00
Abdulaziz Albahar 06bc29603c Fail iOS workflow when selected filter runs zero tests (#9404)
* Test selected iOS execution guard

* Fail selected iOS runs that execute zero tests

* Test selected-test diagnostic safety

* Sanitize selected-test diagnostics

* Tighten selected-test log classification
2026-08-02 23:52:21 -05:00
austinpower1258 2d709e87b7 Test embedded GhosttyKit excludes native Sentry 2026-08-02 21:46:21 -07:00
austinpower1258 5e35ff0c4a fix: harden explicit destructive surface targeting 2026-08-02 21:16:31 -07:00
Austin Wang 84f5755b56 Fix cmux ssh startup script syntax error in no-progress retry loop (#9425)
* Add failing test that cmux ssh startup scripts parse under /bin/sh

Regression coverage for #9423.

* Fix cmux ssh startup script syntax error in no-progress retry loop

The reusable foreground-auth + SSH PTY attach path passed a compound 'if'
as the no-progress retry loop's attach command. That loop prefixes the
command with environment assignments, which POSIX only allows before a
simple command, so /bin/sh rejected the generated cmux-ssh-startup script
with 'syntax error near unexpected token then' and cmux ssh failed
immediately.

Wrap the attempt registration and the attach command in a shell function
and pass its name, matching SSHPTYAttachStartupCommandBuilder.

Fixes #9423

* Move #9423 regression coverage to Swift Testing

Cover the defect where it lives, in the shell generator, with a Swift
Testing case instead of an XCTest addition to the CLI integration suite.
Reverts the call-site-only workaround so the next commit fixes the
generator for every caller.

* Export the no-progress attach budget instead of prefixing the command

SSHPTYAttachExitCode.noProgressRetryLoopLines prefixed the caller's
command with environment assignments. POSIX only allows an assignment
prefix before a simple command, so the reusable foreground-auth attach
path, which passes a compound 'if ...; then ...; fi', generated a
cmux-ssh-startup script that /bin/sh rejected with 'syntax error near
unexpected token then'. cmux ssh failed immediately on 0.64.21.

Assign and export the budget on their own lines so any command shape is
legal and children still see the values.

Fixes #9423
2026-08-02 21:11:17 -07:00
Austin Wang 7dff5ec471 Clear Dock notifications when focused (#9418)
* test: cover Dock notification dismissal on focus

* fix: dismiss Dock notifications on focus

* fix: preserve focus history host conformance
2026-08-02 19:53:16 -07:00
Austin WangandClaude Opus 5 4de871173e Preserve CLAUDE_SECURESTORAGE_CONFIG_DIR across agent restore (#9419)
* Add failing test for CLAUDE_SECURESTORAGE_CONFIG_DIR capture

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

* Allowlist CLAUDE_SECURESTORAGE_CONFIG_DIR in agent launch env capture

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

* Move Claude secure storage env tests to their own file

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

---------

Co-authored-by: Claude Opus 5 <[email protected]>
2026-08-02 19:17:28 -07:00
Austin WangandClaude Opus 5 b4c2163a37 Fix noclobber 'cannot overwrite existing file' error from bash shell integration (#9420)
* Add failing regression test for bash shim noclobber error

Repros https://github.com/manaflow-ai/cmux/issues/9356: with `set -o noclobber`,
the bash integration's second shim write prints "cannot overwrite existing file"
and leaves the shim stale.

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

* Force-clobber cmux-owned generated files in bash/zsh shell integration

Under `set -o noclobber` the bash integration's per-surface CLI shim write
(`} >"$shim_path"`) is refused by the shell, printing
"cannot overwrite existing file" on every prompt and leaving the shim stale.
`2>/dev/null` cannot suppress it: the shell reports the redirect failure before
the compound command's stderr redirection applies.

Switch that write, and the remaining plain-`>` writes to cmux-owned generated
files in the bash integration (bg pid file, gh stderr capture, history temp
file, history-last marker) plus the zsh gh stderr capture, to the explicit
clobber operator `>|`, matching what the rest of both integrations already use.

Fixes #9356

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

---------

Co-authored-by: Claude Opus 5 <[email protected]>
2026-08-02 19:16:26 -07:00
austinpower1258 bd89d1c16c fix: fail closed for stale destructive surface targets 2026-08-02 19:09:51 -07:00
austinpower1258 786a077bc3 test: cover stale destructive surface targets 2026-08-02 19:09:36 -07:00
Austin Wang 33ac210ab4 Bump version to 0.64.21 (#9414) 2026-08-02 17:24:10 -07:00
Austin Wang ff3b4aa3cd Fix registry kind test fixture compilation (#9413)
* test: fix registry kind fixture compilation

* test: respect agent cwd policy in kind matrix
2026-08-02 15:06:45 -07:00
Austin Wang e7ca40e6e1 Preserve Pi resume identity across repeated restores (#9399)
* test: cover repeated Pi restore identity

* fix: preserve Pi identity across repeated restores
2026-08-02 14:52:07 -07:00
Austin Wang 9fc3212e72 Fix Kimi restore-of-restore binding kind decoding (#9397)
* Add restore binding kind regression tests

* Fix registry-owned restore binding kind decoding
2026-08-02 14:05:17 -07:00
Lawrence Chen afe629534f Keep sidebar icon until avatar URL exists (#9386)
* test: require avatar URL before photo mode

* fix: keep sidebar icon until avatar URL exists
2026-08-02 01:00:36 -07:00
Lawrence Chen e49777b6c1 Harden remote daemon transports and workspace RPC (#9390)
* Add remote daemon security regressions

* test(cmux-tui): reject multiline Cargo paths

* fix(cmux-tui): harden package build metadata

* fix(remote): harden workspace file mutations

* Harden remote network admission

* Harden remote runtime state handling

* fix(remote): serialize identity persistence safely

* fix(remote): persist one logical connection attempt

* Harden remote CLI secret handling

* Silence release-only remote CLI warning

* Fix remote CLI test lint

* docs(cmux-tui): remove unsafe credential examples

* test(cmux-tui): expose transient approval authorization

* fix(cmux-tui): close remote review blockers

* test(cmux-tui): expose unbounded admin request read

* fix(remote): bound diagnostics and lifecycle cleanup

* test(remote): expose admin frame boundary mismatch

* fix(remote): align relay MSRV and admin framing

* test(remote): expose replayed workspace cursors

* fix(remote): retain workspace query continuations

* test(remote): expose identity and socket durability gaps

* Harden client socket and trust persistence

* Add regressions for remote review findings

* Fix remote review findings

* test(remote): expose intermediate symlink traversal

* Harden remote directory creation against symlinks

* test(remote): expose authorization commit gaps

* Fix committed identity state and relay ticket expiry

* test(remote): expose blocking Iroh secret reads

* Harden persisted Iroh secret reads

* test(remote): expose mux reassembly budget release

* Retain ingress budgets through mux reassembly

* test(remote): specify owned client socket handoff

* Own client socket cleanup through bridge shutdown

* test(remote): expose final review races

* Fix final remote review races

* test(remote): expose cross-process ownership gaps

* Fix cross-process remote ownership races

* test(remote): expose final lifecycle leaks

* Bound remote startup and dropped request cleanup

* test(remote): expose shared auth state race

* Fix shared authorization state ownership

* test(remote): expose shutdown state lease gap

* Retain auth lease through blocking writes

* test(remote): expose daemon handoff contention

* Retry authorization state during daemon handoff

* test(remote): expose queued auth loss on exit

* fix(remote): drain auth persistence on shutdown

* test(remote): expose shutdown ownership races

* fix(remote): preserve daemon shutdown ownership

* test(remote): expose shutdown queue and hook races

* fix(remote): coalesce auth persistence snapshots

* test(remote): expose auth finalization gaps

* fix(remote): finalize auth before lifecycle cleanup

* test(remote): isolate concurrent cleanup pauses

* test(remote): expose stale metadata after auth failure

* fix(remote): clear lifecycle metadata after auth failure

* test(remote): expose legacy sidecar handoff race

* fix(remote): fence legacy sidecar process exit

* test(remote): satisfy cleanup clippy gate

* test(remote): expose failed finalization handoff

* test(remote): expose unavailable pidfd upgrade

* fix(remote): fall back when pidfd is unavailable

* fix(remote): authenticate shutdown finalization

* test(remote): expose unsafe shutdown recovery

* fix(remote): bind shutdown to daemon lifecycle

* test(remote): expose stale shutdown evidence

* fix(remote): close shutdown evidence gaps

* test(remote): expose unclean shutdown recovery

* fix(remote): recover unclean daemon shutdowns

* test(remote): expose unfenced legacy restart

* fix(remote): fence legacy automatic restarts

* test(remote): expose lifecycle fence dead ends

* fix(remote): make lifecycle fencing recoverable

* test(remote): expose rollback and malformed runtime gaps

* fix(remote): fence authorization state across rollbacks

* test(remote): expose unconfirmed auth rollback fence

* fix(remote): reconfirm auth fence durability

* test(remote): expose preflight and fence durability gaps

* fix(remote): preflight recovery before auth mutation

* test(remote): expose lifecycle startup retry gaps

* fix(remote): make fenced startup retries durable

* test(remote): expose active lifecycle durability gaps

* fix(remote): durably own active daemon lifecycle

* test(remote): expose unlocalized recovery guidance

* fix(remote): localize recovery guidance

* test(remote): expose final lifecycle review gaps

* fix(remote): fence authorization before lifecycle state

* test(remote): cover review regressions

* fix(remote): pin workspace operations to descriptors

* fix(tui): use shared pty child abstraction

* test(remote): expose delayed enrollment timeout

* fix(remote): preserve invitation approval window

* test(remote): cover replaced workspace roots

* fix(remote): preserve pinned workspace identity

* fix(tui): call renameat2 through the Linux syscall

* test(remote): cover pinned query and approval windows

* fix(remote): preserve pinned query and approval windows

* test(remote): cover resume expiry task lifecycle

* fix(remote): cancel obsolete resume expiry tasks

* test(remote): cover background task shutdown

* fix(remote): bound background task lifetimes

* fix(sdks): preserve Rust 1.88 support

* test(remote): cover transient Unix dial failures

* fix(remote): retry transient Unix dial failures

* test(remote): cover terminal reconnect failures

* fix(remote): retry only carrier failures

* test(remote): cover review regressions

* fix(remote): close reviewed lifecycle gaps

* Clarify relay routing key in TUI help

* Keep terminal provider failures out of reconnect

* test(remote): match control-character build diagnostic

* fix(relay): bound Durable Object outbound queues

* test(tui): expose acknowledged stream close race

* fix(tui): preserve completed Go stream opens

* test(tui): cover carrier and Git environment isolation

* test(tui): cap authenticated Iroh carrier fixture

* fix(tui): bound carriers and isolate Git RPCs

* test(tui): cover workspace HTTP raw admission

* fix(tui): admit workspace HTTP before parsing

* test(tui): cover Unix accept recovery and daemon locale

* fix(tui): recover Unix listeners and localize daemon output

* test(tui): cover autoreview regressions

* fix(tui): close autoreview regressions

* test(tui): cover final remote daemon review findings

* fix(tui): bound final remote daemon resources

* fix(tui): close final autoreview findings

* test(remote): preserve pagination cursor after deadline

* fix(remote): commit pagination cursors after delivery

* fix(remote): acknowledge delivered pagination pages

* test(remote): cover final transport review regressions

* fix(remote): close final transport review findings

* fix(tui): preserve Rust 1.91 SQLite compatibility

* test(remote): cover custom recovery socket selection

* fix(remote): close final latency and recovery findings

* test(remote): cover final socket hardening regressions

* fix(remote): preserve socket directory ownership boundaries

* test(remote): distinguish managed and caller-owned directories

* fix(remote): separate managed and caller-owned directories

* test(remote): cover final autoreview findings

* fix(remote): close final autoreview findings

* test(pty): bound hardened descriptor fallback

* fix(pty): fail fast on oversized fallback scans
2026-08-01 19:29:55 -07:00
Abdulaziz AlbaharandClaude Fable 5 175127ea59 Preserve live peer sessions across equivalent route revision bumps (#9342)
* Add failing tests: live sessions must survive equivalent route revisions

Two regressions captured from foreground telemetry on build 20260801001626:

1. equivalentRouteRevisionBumpKeepsTheLivePeerSession: a broker
   connectivity sync that bumps the account route revision without
   changing the peer's material route content (only last_seen_at moved)
   tears down the live admitted session with runtimeReconfigured.

2. concurrentRedialCannotDisplaceAnInstalledLiveSession: two concurrent
   connectedSession callers can both pass the installed-slot check across
   the dead-on-arrival probe suspension, so the second install displaces
   the first admitted session without closing it and records a second
   established lifecycle event.

Both tests fail on current code; the fix lands in the next commit.

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

* Preserve live peer sessions across equivalent route revision bumps

The connectivity engine tore down every peer session whenever the
account route revision changed, even when the peer's route content was
identical. Broker registration heartbeats bump the revision while only
moving last_seen_at and path-hint freshness, so a foreground iOS client
lost its live control session every 10-90 seconds to a
runtimeReconfigured close followed by a full rediscover-dial-pair cycle.

The engine now derives CmxConnectivityRouteContent from each installed
snapshot: per-peer admission material (binding id, app instance, tag,
platform, identity generation, pairing flag, capabilities) plus
account-wide trust material (relay fleet, LAN rendezvous, grant
verification keys). On a revision change it invalidates only peers whose
material content differs. A changed endpoint identity keys the peer out
of the new content, a removed binding leaves it unrouted, and any
account-material change tears down all peers, so every security-relevant
change still invalidates. A missing baseline or a revision bump without
a replacement snapshot fails closed and keeps the old invalidate-all
behavior.

Also close the double-establish race in CmxConnectivityPeerSession: the
dead-on-arrival probe suspends the actor between clearing the pending
dial and installing it, so a concurrent caller could install its own
dial in that window and the late installer silently displaced the live
session while double-recording an established lifecycle. The installer
now rechecks the installed slot after the probe and adopts the winner,
closing its own redundant session.

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

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-08-01 19:41:59 -05:00
Austin Wang 2f4059bd32 Keep explicit agent restore records across shell preexec (#9391)
* test: cover restore binding across shell preexec

* fix: retain manual agent restore bindings

* test: cover Grok restore generations and Dock replacement

* fix: preserve replacement Dock resume bindings

* fix: construct Vault presenter on main actor
2026-08-01 15:00:57 -07:00
Austin Wang 6f79836228 Fix Vault popover crashes in recycled rows (#9392)
* test: keep Vault presentation out of hosted row content

* fix: own Vault popovers outside recycled rows

* fix: harden Vault popover table lifecycle
2026-08-01 14:42:01 -07:00
Austin Wang 6d47bf38c3 Resolve restore targets from live process identity (#9384)
* test: cover stale surface restore routing

* Prefer live terminal routing for restore

* test: reject ambiguous restore TTY routing

* Reject ambiguous TTY restore routing

* test: cover live restore target resolution

* Resolve restore target from live process

* test: cover authoritative restore routing failure

* Fail closed on missing live restore target

* test: cover restored TTY registration race

* Wait for fresh restore TTY registration

* test: scope relay restore TTY routing

* test: constrain relay TTY resolution

* test: preserve relay restore workspace aliases

* Scope relay restore to authenticated terminal

* test: cover live and Dock TTY routing

* fix: complete live TTY restore routing

* test: cover ended and cached TTY routing

* fix: retire stale TTY lifecycle evidence

* test: cover fail-closed restore and reconnect routing

* fix: make restore routing readiness authoritative

* test: cover transferred and persistent TTY routing

* fix: preserve live TTY proof across bridge retries

* test: cover relay restore after new workspace move

* fix: preserve relay routing across workspace moves

* test: cover relay ownership after surface moves

* fix: keep relay provenance scoped through moves

* test: cover stale relay TTY lifecycle

* fix: retire relay TTY provenance on terminal end

* test: cover durable relay identity after moves

* fix: keep relay identity durable across moves

* test: cover authoritative TTY trust boundaries

* fix: bind TTY reports to terminal runtime

* test: cover Grok restore routing

* test: cover relay TTY readiness gaps

* fix: wait for relay TTY readiness
2026-08-01 13:42:40 -07:00
Lawrence Chenandaustinpower1258 80f40831da cmux-tui: render inline Kitty images through libghostty (#8811)
* test: cover kitty placement frame reuse

* fix: cache kitty placement frames

* test: cover pixel-accurate kitty clipping

* fix: clip kitty placements in pixel space

* test: cover pixel-accurate kitty replay clipping

* fix: clip kitty replay in pixel space

* test: cover number-only kitty image attach

* test: cover both numbered kitty image aliases

* fix: preserve kitty number aliases across attach

* test: cover inflight kitty replay across resize

* fix: preserve inflight kitty replay

* test: cover anonymous kitty replay collisions

* fix: preserve anonymous kitty placements in replay

* test: cover kitty object count limits

* fix: bound kitty graphics object counts

* test: cover Kitty graphics in web render mode

* test: cover host kitty scene invalidation

* fix: restore kitty graphics after host resize

* feat: render Kitty graphics in web terminal

* test: cover graphics writer shutdown quiescence

* fix: layer Kitty graphics above cell backgrounds

* fix: draw web graphics from callback ref

* fix: quiesce graphics before terminal restore

* test: cover kitty replay allocation order

* fix: preserve kitty replay allocation order

* test: cover incremental render graphics deltas

* test(tui): cover linear graphics state maintenance

* fix: send incremental render graphics deltas

* test: cover bounded kitty replay semantics

* test(tui): cover late Kitty image ordering

* test: cover render transport size boundaries

* fix(tui): maintain Kitty graphic IDs linearly

* test: cover atomic cell geometry updates

* test(tui): cover Kitty PNG compatibility

* test: cover full render metadata budget

* test: preserve measured cell pixels across resize

* test: bound kitty pixel cache lookups

* fix: make cell geometry updates atomic

* test: bound kitty placement grouping

* fix: align render transport size budgets

* test: bound render taps and resize replay

* fix: preserve kitty images in bounded vt replay

* fix: bound render taps and skip unused replay

* docs(tui): document inline Kitty image support

* style(tui): format merged changes

* test: cover UTF-8 before kitty replay

* test: cover large kitty resize replay

* fix: distinguish UTF-8 from C1 kitty APC

* fix: preserve kitty upload across resize

* test(tui-sdk): cover retained render metadata overflow

* fix(tui-sdk): bound retained render events

* test(tui-web): expose placement canvas memory blowup

* fix(tui-web): bound graphic canvas backing

* test: preserve hosted Kitty image aliases

* fix: preserve hosted Kitty image aliases

* test(tui): cover stale Kitty write after resize clear

* fix(tui): discard stale Kitty writes after resize clear

* test(browser): expose terminal host alias protocol gap

* fix(browser): support terminal host Kitty aliases

* test(cmux-tui): expect resize alias sidecars

* test(tui): preserve sparse viewport across replay

* fix(tui): preserve sparse rows in terminal replay

* fix(tui): align replayed scrollback rows

* test(tui): await terminal host process exit

* test(tui): cover Kitty alias history and sparse replay

* fix(tui): preserve Kitty alias and sparse row history

* fix(tui): address Kitty graphics review findings

* fix(tui): harden Kitty graphics integration

* fix(tui): resolve final Kitty autoreview findings

* fix(tui): close Kitty autoreview findings

* test(tui): cover final Kitty review regressions

* fix(tui): close final Kitty autoreview findings

* test(tui): reject overflowing PTY pixel geometry

* fix(tui): reject invalid PTY pixel geometry

* test(tui): cover remaining Kitty review regressions

* fix(tui): close remaining Kitty review findings

* fix(cmux-tui): close graphics review gaps

* fix(cmux-tui): preserve attach and startup progress

* fix(tui): reconcile image render geometry

* fix(tui): align attach wire progress

* fix(tui): bound inline image rendering resources

* fix(tui): close inline image review gaps

* fix(tui): bound inline graphics hot paths

* test(tui): cover graphics attachment memory regressions

* fix(tui): bound graphics attachment allocations

* test(tui): budget retained render capacity

* test(tui): cover graphics review regressions

* fix(tui): close graphics autoreview gaps

* test(tui): cover second graphics review regressions

* fix(tui): close remaining graphics review gaps

* test(tui): cover remaining graphics review regressions

* fix(tui): close graphics review findings

* test(tui): cover final graphics review regressions

* fix(tui): close final graphics review findings

* test(tui): cover graphics admission regressions

* fix(tui): make graphics admission lazy and refillable

* test(tui): cover final host lifecycle findings

* fix(tui): bound host lifecycle work

* test(tui): cover final protocol review findings

* fix(tui): close final protocol review gaps

* test(tui): cover bounded graphics writer failure

* fix(tui): bound graphics output failure lifecycle

* test(tui): cover final remote graphics review gaps

* fix(tui): validate and localize remote attach data

* test(tui): cover final graphics ownership findings

* fix(tui): scope graphics output ownership

* test(tui): cover graphics resource safety gaps

* fix(tui): bound graphics resource lifecycles

* test(tui): cover graphics budget scan fanout

* fix(tui): make graphics admission single-pass

* test(tui): cover review resource safety gaps

* fix(tui): bound graphics attachment resources

* test(tui): cover enhanced input adapter compatibility

* fix(tui): reconcile shortcut merge with graphics input

* test(tui): reconcile merged attach fixtures

* test(tui): bound inline surface state

* fix(tui): keep libghostty state out of line

* test(tui): cover cell pixel fanout retry gap

* fix(tui): reconcile skipped cell pixel fanout

* test(tui): cover aggregate graphics ownership gaps

* fix(tui): bound aggregate graphics ownership

* test(tui): cover graphics teardown ownership

* fix(tui): rebalance graphics ownership on teardown

* test(tui): cover aggregate graphics recovery

* fix(tui): recover aggregate graphics capacity

* test(tui): isolate graphics counters per thread

* test(tui): cover graphics resource ownership gaps

* fix(tui): close graphics resource ownership gaps

* test(tui): cover Kitty replay state divergence

* fix(tui): preserve Kitty replay state across mirrors

* test(tui): cover terminal resource lifecycle stalls

* fix(tui): decouple terminal resource lifecycle work

* test(tui): cover review lifecycle regressions

* fix(tui): close review lifecycle gaps

* test(tui): cover graphics review regressions

* fix(tui): reconcile graphics lifecycle under load

* test(tui): cover graphics baseline and quota exhaustion

* fix(tui): reconcile graphics baselines and quota overflow

* test(tui): make graphics backpressure deterministic

* test(tui): cover exited quota and reset replay ordering

* fix(tui): preserve graphics state across resets and exits

* test(tui): cover scrolled Kitty placement alignment

* fix(tui): align Kitty graphics with scrolled viewports

* test(tui): bound stalled renderer output

* fix(tui): preserve renderer output backpressure

* test(tui): cover relabel and retry bounds

* fix(tui): bound graphics recovery work

* fix(ci): isolate fork-agent singleton default

* test(tui): bound persistent graphics recovery

* fix(tui): bound persistent graphics recovery

* test(tui): drain stalled quota worker

* test(tui): cover panic and fanout lifecycles

* fix(tui): bound graphics worker lifecycles

* test(web): cover exhausted graphics decode queue

* fix(web): retire exhausted graphics decode jobs

* test(tui): cover final Kitty review findings

* fix(tui): close final Kitty replay gaps

* test(tui): cover encoded Kitty quota

* fix(tui): budget encoded Kitty uploads

* test(browser): sync Kitty replay ceilings

* fix(browser): match Kitty replay ceilings

* test(tui): cover unsupported Kitty grayscale

* fix(tui): bound Kitty snapshot formats

* test(tui): cover Kitty quota recovery

* fix(tui): reconcile Kitty quota recovery

* test(tui): cover reconnect completion retry

* fix(tui): retry failed host reconnect completion

* test(tui): cover attach priority and replay cursor state

* fix(tui): preserve attach priority and replay state

* test(tui): cover superseded attach resize failure

* fix(tui): settle the latest promoted resize

* chore(tui): satisfy strict attach lifecycle lint

* test(tui): cover numeric Kitty final chunks

* fix(tui): parse Kitty chunk flags numerically

* test(tui): cover Kitty images in web scrollback

* fix(tui): render Kitty images in web scrollback

* test(tui): admit terminals after graphics quota failure

* fix(tui): degrade graphics after quota failure

* test(tui): finish graphics probe at DA1 marker

* fix(tui): end graphics probe at DA1 marker

* test(tui): reject stale scrollback image epochs

* fix(tui): version scrollback image anchors

* test(tui): refresh active scrollback epochs

* fix(tui): refresh active scrollback epochs

* test(tui): ignore screen-only history epochs

* fix(tui): scope history epochs to retained rows

* test(tui): keep image frames out of history epochs

* fix(tui): keep image frames out of history epochs

* test(tui): assert stable screen-only history epochs

* test(tui): bound deferred work and reserve attaches

* fix(tui): bound deferred graphics coordination

* test(tui): exhaust saturated Kitty quota retries

* fix(tui): exhaust saturated Kitty quota retries

* test(tui): retain overlapping Kitty replay placements

* fix(tui): clip Kitty placements at replay boundaries

* fix(tui): preserve merged attach invariants

* test(ci): cover Ghostty path metadata

* fix(ci): inspect executable Ghostty consumers

* test(ios): replace wall-clock synchronization

* chore(xcode): normalize project ordering

* fix(ssh): simplify retry script assembly

* test(app): update detached transfer fixture

* test: update remote PTY lifecycle fake

* test: require explicit app-host test mode

* fix: declare app-host test launch mode

* fix: clear Xcode 26.3 warning gate

* test: detect embedded app-host test bundle

* fix: detect app-host tests from embedded bundle

* chore: drop unreliable app-host scheme marker

* test: avoid async ARC lifetime assertion

* test: require app-host build identity

* fix: stamp app-host test builds before launch

* test: require test-runner app-host marker

* fix: forward app-host test identity through xcodebuild

* fix: keep completed iroh dial single-flight through install

* test: re-report lifecycle after status clear

* Fix cmux-tui merge integration

* test(web): cover render attach WebSocket budget

* fix(web): admit full render attach frames

---------

Co-authored-by: austinpower1258 <[email protected]>
2026-08-01 07:54:34 -07:00
Austin Wang b813ab9a25 Discover fresh Grok sessions from disk (#9382)
* test: cover Grok session discovery

* test: cover Grok timestamp fallbacks

* fix: discover fresh Grok sessions
2026-08-01 05:03:27 -07:00
Lawrence Chen f560731d26 Merge pull request #8667 from manaflow-ai/feat-cmux-tui-remote-daemon
Add authenticated remote daemon and clients to cmux-tui
2026-08-01 04:34:02 -07:00
Lawrence Chen 367f2ef891 Fix sidebar avatar loading fallback (#9375)
* fix: refine sidebar avatar loading fallback

* refactor: simplify sidebar avatar fallback

* test: cover sidebar avatar launch restoration

* fix: keep sidebar avatar icon during session restore
2026-08-01 04:26:09 -07:00
Lawrence Chen 9a7e4032d9 Disclose yearly billing in compact pricing labels (#9378)
* Simplify annual pricing labels

* Tighten pricing card layout

* Align pricing numerals and app grid

* Remove annual pricing totals

* Add annual billing cadence regression coverage

* Disclose yearly billing in compact labels
2026-08-01 04:04:34 -07:00
Lawrence Chen fdd8a1b7a2 Show signed-out state on app pricing page with in-app sign-in (#7821)
* Show signed-out state on app pricing page with in-app sign-in

When the embedded /app-pricing webview has no authenticated session, the
page used to claim "Current plan: Free" (misleading for signed-out Pro
users, invites duplicate purchase) and offered no way to sign in.

Now a banner at the top says the user is not signed in and links to
sign-in, the current-plan badge is suppressed while unauthenticated, and
the Free card CTA becomes Sign in. The sign-in link runs the existing
native-sign-in handler flow inside the webview, so Stack cookies land in
the webview session and /handler/after-sign-in hands tokens to the app
via its <scheme>://auth-callback URL. BrowserNavigationDelegate now opens
the app's own auth-callback scheme via NSWorkspace (user-activated
main-frame links only) since WKWebView cannot open native schemes.

* Scope auth-callback intercept to the app web origin and split it into its own file

Two review-driven fixes to the new native auth-callback intercept:

1. Security (Codex/Greptile P1): the intercept accepted a user-clicked
   <scheme>://auth-callback link from ANY page in the embedded browser.
   Because HostBrowserSignInFlow accepts stateless callbacks, a malicious
   page could hand attacker-chosen tokens to the app and swap the
   signed-in account on one click. The predicate now also requires the
   navigation's SOURCE frame origin to match AuthEnvironment.appWebOrigin
   (the origin serving /handler/after-sign-in), reusing the normalized
   BrowserWebAuthnSecurityOrigin comparison. Links from any other origin
   fall through to the regular external-navigation handling.

2. workflow-guard-tests: BrowserNavigationDelegate.swift grew +36 lines,
   past the 25-line incidental allowance over its 635-line budget. The
   predicate and router now live in a dedicated collaborator,
   BrowserAuthCallbackNavigationPolicy, matching the delegate's existing
   pattern of small policy objects; the delegate is back to +20.

* Pin auth-callback intercept to this build's own callback scheme

Structured-review P1: AuthCallbackRouter accepts the built-in cmux,
cmux-nightly, and cmux-dev schemes plus the extra one, and the trusted
/handler/after-sign-in page can legitimately emit any allowed scheme as
native_app_return_to. Stable cmux would therefore auto-open a
token-bearing cmux-nightly://auth-callback link, handing this session's
tokens to whatever app registered that scheme (attacker-registerable
when Nightly is absent). The predicate now requires the destination
scheme to equal AuthEnvironment.callbackScheme before NSWorkspace.open;
other schemes fall through to regular external-navigation handling.

* Fail-closed auth-callback dispositions and in-process delivery

Two structured-review P1s on the intercept:

1. Not fail-closed: a rejected cmux://auth-callback link fell through to
   the generic external-app prompt, so an untrusted page's attacker-token
   link could still reach the app after one confirming click, and a
   crafted cmux-nightly link from the trusted page could reach that
   scheme's handler. The policy now returns a disposition: user-activated
   main-frame auth-callback-shaped links that fail the scheme/origin
   checks are cancelled outright (.block). Non-link-activated navigations
   keep the browser's regular handling, same as every other custom scheme.

2. Token egress through LaunchServices: NSWorkspace.open routes the
   token-bearing URL to whatever app currently claims the scheme. Accepted
   callbacks are now delivered in-process through the app delegate's
   application(_:open:) entrypoint (the exact path LaunchServices would
   invoke), so the URL never leaves this process.

The disposition handling lives in a BrowserNavigationDelegate extension in
the policy file, keeping the delegate at +6 lines over its budget base.

* Fail closed on every auth-callback-shaped navigation; return webview to pricing after delivery

Extends 6c71e6b91d on review findings:

1. disposition() now blocks ALL auth-callback-shaped navigations that are
   not the exact trusted flow (user-activated main-frame link, own scheme,
   trusted source origin). JS redirects and subframe navigations previously
   passed through to the generic external-app prompt, where one confirming
   click could hand attacker-chosen tokens to the stateless callback path.

2. The popup/new-window path (BrowserPanel.createWebViewWith) applies the
   same rule via shouldBlockExternalNavigation: auth-callback-shaped URLs
   from window.open never reach the external-app prompt.

3. After a delivered callback, the embedded flow no longer strands the
   webview on the 'Signed in to cmux' page: /app-pricing passes
   web_return_to on the after-sign-in URL and the navigation delegate
   navigates the webview back to it (same-origin relative path only), so
   the pricing page reloads with the authenticated session and shows the
   restored plan. The switch-account flow preserves the param.

* Add signed-out pricing regression coverage

* Complete embedded pricing sign-in safely

* Fail closed on targetless auth callbacks

* Split auth callback disposition policy

* Add auth callback recovery regression tests

* Complete auth callbacks across browser surfaces
2026-08-01 03:48:27 -07:00
lawrencecchen 7dabcc417d Stabilize Go stream cleanup race test 2026-08-01 03:30:32 -07:00
Austin Wang a65e552e38 Fix Grok session discovery for fresh launches (#9379)
* test: cover Grok session discovery

* test: cover Grok timestamp fallbacks
2026-08-01 03:27:14 -07:00
lawrencecchen 0f264122bf Retry Unix carrier failures during reconnect 2026-08-01 03:24:03 -07:00
Lawrence Chen 43d1d3c8e9 Simplify annual pricing labels (#9373)
* Simplify annual pricing labels

* Tighten pricing card layout

* Align pricing numerals and app grid

* Remove annual pricing totals
2026-08-01 03:02:49 -07:00
Abdulaziz Albahar 03b33ab399 Merge pull request #9329 from manaflow-ai/feat-cmd-bracket-workspace-history-2
Cmd+[ / Cmd+] traverse global workspace focus history; pane cycling becomes rebindable
2026-08-01 04:50:28 -05:00
lawrencecchen bf889a5ae9 Unblock saturated PTY writers during cleanup 2026-08-01 02:50:19 -07:00
lawrencecchen 82a0cc1a54 Merge remote-tracking branch 'origin/main' into codex/remote-codex-patch-http 2026-08-01 02:36:55 -07:00
lawrencecchen 55fe60de70 Preserve completed Go streams across EOF 2026-08-01 02:36:40 -07:00
Lawrence Chen bbbf411686 Keep Pi hooks and unread updates off UI-critical paths (#9289)
* Test Pi managed extension refresh

* Refresh managed Pi extension on session start

* Test Pi lifecycle hook responsiveness

* Detach Pi lifecycle hooks from UI events

* Test titlebar layout invalidation

* Cache titlebar layout inputs

* Test unread invalidation scope

* Scope unread invalidation to leaf views

* Test targeted sidebar unread updates

* Route unread updates to affected sidebar rows

* Test heartbeat identity write suppression

* Suppress redundant heartbeat defaults writes

* Localize unread and titlebar observation ownership

* Fix restore argument helper compilation

* Address unread ownership review findings

* Publish refreshed extension membership snapshots

* Align projected-surface dismissal expectation

* Add regressions for Pi lifecycle isolation

* Serialize Pi lifecycle work by session

* Tighten Pi and unread regression coverage

* Scope remaining Pi and sidebar refreshes

* Add remaining Pi latency regressions

* Avoid blocking Pi refresh and stale unread delivery

* Add manual unread action regression

* Keep unread actions and group refreshes scoped

* Add extension snapshot sequence regression

* Preserve extension sequence for identical snapshots

* Add Pi lock symlink regression

* Index extension unread updates and harden Pi lock

* Fix sidebar scale test construction

* Import titlebar test dependency

* Fix Pi regression fixtures

* Stabilize sidebar projection scale gate

* Fix titlebar observer isolation warning

* Fix sidebar extension snapshot warning

* Fix restore CLI contract probe
2026-08-01 02:33:08 -07:00
lawrencecchen 1179bfbd9a Merge remote-tracking branch 'origin/main' into codex/remote-codex-patch-http 2026-08-01 02:12:33 -07:00
lawrencecchen 51d4b413e4 Harden remote recovery and child reaping 2026-08-01 02:12:28 -07:00
Lawrence Chen a1026956d3 docs: fix restore CLI help probe format (#9365) 2026-08-01 01:56:16 -07:00
lawrencecchen de99cb6503 Merge remote-tracking branch 'origin/main' into codex/remote-codex-patch-http 2026-08-01 01:24:02 -07:00
lawrencecchen 4f24258a03 Keep terminal provider failures out of carrier retries 2026-08-01 01:23:47 -07:00
Lawrence Chen 4d1a40fbe4 fix: close browser handoff cleanup races (#9323)
* test: cover final browser handoff cleanup races

* fix: close final browser handoff cleanup races

* test: isolate browser availability revocation

* test: cover popup cleanup on app sign-out

* fix: close browser popups on app sign-out

* test: reproduce cleanup resetting closing browser panel

* fix: keep sign-out cleanup out of closing panels

* test: retain closing browser panel cleanup ownership

* fix: retain authenticated browser cleanup ownership

* test: reset browser cleanup retries for new ownership

* fix: scope browser cleanup retries to ownership

* refactor: keep browser ownership record private

* test: avoid popup window detachment timing
2026-08-01 01:22:58 -07:00
lawrencecchen 70b8b04348 Merge remote-tracking branch 'origin/main' into codex/remote-codex-patch-http 2026-08-01 01:05:57 -07:00
lawrencecchen 127f72ca06 Fix Linux clippy lint in provider authority command 2026-08-01 01:05:32 -07:00
Lawrence Chen 8d18ffc893 Harden resource SDK cancellation and transport lifecycles (#9366)
* Harden SDK cancellation and deadline boundaries

* Test WebSocket pairing dispatch boundaries

* Cancel WebSocket frames before pairing dispatch

* Test Zig dispatch and admission races

* Fix Zig dispatch uncertainty and admission handoff

* Use public-safe Zig admission terminology

* Test queued mutation and rejection races

* Test reusable Zig pre-write timeouts

* Preserve WebSocket dispatch certainty

* Keep Zig pre-write timeouts reusable

* Test Zig EOF payload dispatch boundary

* Test Unix connect queue cancellation

* Treat complete Zig payloads as dispatched

* Cancel Unix frames before connect dispatch

* Test transport dispatch resource release

* Test Zig custom transport uncertainty

* Release transport handles at dispatch

* Classify all Zig post-dispatch failures

* Test transport lifecycle hardening

* Harden transport dispatch lifecycle

* Test Zig transport lifetime hardening

* Harden Zig transport deadlines and teardown

* Test WebSocket preamble failure cleanup

* Close WebSocket on preamble failure

* Test Zig nonreading peer write deadline

* Test Zig nonblocking read readiness races

* Keep Zig Unix transport nonblocking

* Test WebSocket closing-state isolation

* Seal WebSocket while closing

* Test Zig close against descriptor reuse

* Hold Zig socket fd through active IO

* Test WebSocket lifecycle parity

* Test Zig stream envelopes before open ack

* Test Rust Unix socket creation hardening

* Buffer Zig stream envelopes before open ack

* Harden Rust Unix socket creation

* Share WebSocket authentication lifecycle

* Test timely Zig Unix descriptor release

* Order Rust socket setup before connect

* Release idle Zig Unix connections promptly

* Test deferred TypeScript request cancellation

* Test dispatch cancellation ordering

* Handle starved deadline rejection eagerly

* Test Zig deadlines across poll interruptions

* Cancel deferred TypeScript requests safely

* Preserve Zig deadlines across poll interruptions

* Test raw deferred dispatch deadlines

* Veto expired raw transport frames

* Test pairing denial classification

* Classify WebSocket handshake rejection

* Test raw request timeout bounds

* Validate raw request timeout bounds

* Test Rust connect socket reuse

* Test C++ request admission lock retries

* Retry C++ request admission lock attempts

* Reuse Rust socket while polling connect

* Test raw zero-timeout dispatch parity

* Preserve raw zero-timeout dispatch

* Test raw Zig client connect timeout

* Bound raw Zig client socket connects

* Test TypeScript transport review regressions

* Harden TypeScript request dispatch contracts

* Fail WebSocket handshakes closed

* Contain Unix connect observer failures

* Reject delayed Unix fixture connection failures

* Test Zig stream control overflow ownership

* Fix Zig stream pending ownership
2026-08-01 00:39:19 -07:00
lawrencecchen b6869edb7d Merge remote-tracking branch 'origin/main' into codex/remote-codex-patch-http
# Conflicts:
#	cmux-tui/crates/cmux-tui/Cargo.toml
#	cmux-tui/crates/cmux-tui/src/main.rs
2026-08-01 00:36:14 -07:00
lawrencecchen f4d5919cee Fix Linux clippy portability 2026-08-01 00:32:22 -07:00
Lawrence Chen 3543d836b7 Make workspace schema errors actionable (#9320)
* test: require actionable workspace schema errors

* Improve newer workspace schema recovery error

* test: distinguish stale schema socket recovery

* Only suggest shutdown for a live schema socket

* test: keep schema errors free of state paths

* Hide state paths from schema recovery errors

* test: fence actionable schema recovery

* Fence schema recovery to the owning daemon

* test: fence schema recovery fallbacks

* Fence schema recovery shutdowns

* test: preserve force in fenced schema shutdown

* Unify forced daemon handoff policy
2026-08-01 00:23:49 -07:00
lawrencecchen 4b84d8f717 Fix remote daemon review findings 2026-08-01 00:17:31 -07:00
lawrencecchen ad6c16575c Allow relay transport slot in resource boundary check 2026-07-31 23:55:20 -07:00
lawrencecchen d376697337 Merge remote-tracking branch 'origin/main' into codex/remote-codex-patch-http 2026-07-31 23:54:20 -07:00
lawrencecchen 115c522b15 Merge remote-tracking branch 'origin/main' into codex/remote-codex-patch-http
# Conflicts:
#	cmux-tui/Cargo.lock
#	cmux-tui/crates/cmux-tui-core/src/surface.rs
#	cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime.rs
#	cmux-tui/crates/cmux-tui/src/cli.rs
#	cmux-tui/crates/cmux-tui/src/main.rs
#	cmux-tui/crates/cmux-tui/src/session/remote.rs
#	cmux-tui/crates/cmux-tui/tests/terminal_host_recovery.rs
#	cmux-tui/spec/README.md
2026-07-31 23:50:59 -07:00
Austin Wang 89c52b8066 Fix restoring Codex sessions across relaunch generations (#9370)
* test: cover restoring restored Codex sessions

* fix: preserve restored agent binding generations
2026-07-31 23:48:19 -07:00
Lawrence Chen 2aef381d48 Add annual Pro pricing (#9234)
* feat(web): add annual Pro pricing

* fix(web): toggle pricing without navigation

* feat(web): refine annual pricing presentation

* fix(web): use cmux product blue for annual savings

* fix: include working cmux theme picker

* chore: update Ghostty theme picker base

* fix(web): adapt product blue for theme contrast

* chore: update Ghostty theme picker base

* test(web): reproduce stale Pagefind trace

* fix(web): build Pagefind before Next tracing

* test(web): cover annual Team pricing

* feat(web): add annual Team pricing

* test: reject external browser intent from untrusted sites

* fix: trust-gate external browser handoffs

* test(web): reject invalid billing intervals

* fix(web): reject invalid billing intervals

* Pin GhosttyKit for pricing theme picker

* Harden annual pricing integration

* Pin app theme injection to trusted origin

* test: cover final annual pricing regressions

* fix: close annual pricing review gaps

* test(web): exercise Stripe catalog behavior

* test: cover cross-origin annual checkout handoff

* fix: trust cross-origin checkout from app pricing

* test: reject arbitrary external pricing destinations

* fix: pin external pricing handoff destination

* test: require same-origin app checkout relay

* fix: relay app checkout through trusted origin

* test: cover app origin and Stripe pagination guards

* fix: close final pricing safety gaps

* test: reject invalid pricing relay parameters

* fix: validate app pricing relay parameters

* test: cover app web loopback aliases

* fix: share browser loopback host validation

* refactor: keep app origin helpers scoped

* test: keep unrelated Pagefind coverage unchanged

* test: preserve tagged callbacks through pricing relay

* fix: preserve trusted tagged pricing callbacks

* fix: clean up embedded pricing layout

* test: cover annual Team labels without lookup keys

* fix: label annual Team subscriptions from metadata

* test: cover annual pricing review regressions

* fix: close annual pricing review gaps

* fix: return validated restore arguments

* fix: align annual pricing interaction contracts

* fix: authenticate pricing callbacks and catalog retries

* test: cover final annual pricing review regressions

* fix: close final annual pricing review gaps

* test: keep app theme policy in package suite

* Record combined GhosttyKit artifact
2026-07-31 23:41:19 -07:00
Abdulaziz AlbaharandClaude Fable 5 798aa345e5 Port reconnect residuals onto connectivity v2 (#9347)
* Test: recovery triggers fired while inactive must wait for the foreground probe

A recovery trigger arriving while the iOS scene is inactive or mid-
backgrounding must not dial: the dial suspends with the process (field
traces on the reconnect incident showed ~9.5s stalls) and then competes
with the foreground recovery pass. Expect no probe until
resumeForegroundRefresh(), then exactly one.

Red on current main; the parking fix lands in the next commit.
Ports the regression from https://github.com/manaflow-ai/cmux/pull/9256
onto connectivity v2.

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

* Park inactive-phase recovery triggers and replay them on foreground

Recovery triggers (network change, presence push, liveness, dead event
stream) that arrive while the iOS scene is inactive or mid-backgrounding
used to dial immediately. The dial suspends with the process (field
traces on the reconnect incident showed ~9.5s stalls) and later competes
with the foreground recovery pass. Park the trigger in
pendingInactiveRecoveryTrigger while foregroundRefreshIsActive is false
and replay the most recent one exactly once in resumeForegroundRefresh(),
after the foreground passes, so the replay coalesces into any attempt
they already started.

An explicit pairing connect and the account boundary clear the parked
trigger, matching how they supersede live recovery.

Green for the regression added in the previous commit. Ports the
inactive-parking piece of https://github.com/manaflow-ai/cmux/pull/9256
onto connectivity v2.

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

* Classify connect-registry gate refusals as connectAttemptGated, not timedOut

When the connect-attempt registry refuses a dial because the exact route
already has a connect attempt in flight (.busy), the session threw
requestTimedOut. The refusal is instantaneous and never reached the
network, so diagnostics recorded fabricated sub-30ms "timedOut"
failures that poisoned lastFailureEvent and made exports look like the
network was timing out during recovery storms.

Add MobileShellConnectionError.connectAttemptGated with a dedicated
DiagnosticFailureKind.routeGated (raw value 25, append-only) and throw
it for the .busy gate. Callers keep their previous user-facing behavior
(retryable timeout category); only the diagnostic taxonomy and the
settings diagnostics rows distinguish the gate refusal. New localized
strings (en/ja) for the error and both diagnostics surfaces.

Single commit: the regression tests reference the new enum cases, so a
tests-first commit cannot compile against main. Tests:
- activeRouteAdmissionReportsRouteGatedInsteadOfTimedOut (RPC): a second
  session on a route with an in-flight dial gets connectAttemptGated and
  never allocates a transport.
- gatedDialRefusalsReportRouteGatedNotTimedOut (CMUXMobileCore): a gated
  refusal surfaces as routeGated in lastFailureKind, never timedOut.
- Taxonomy raw-value and diagnosticFailureKind mapping expectations.

Ports the truth-telling piece of
https://github.com/manaflow-ai/cmux/pull/9256 onto connectivity v2.

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

* Throttle unchanged-evidence presence-push recovery restarts to one per 45s

The iOS presence subscription delivers ~15s heartbeats about online
Macs. While the phone is disconnected, every heartbeat restarted
connection recovery through recoverFromPushedRouteBatch, so during a
persistent outage the phone kept abandoning its own in-flight dials on
the heartbeat cadence and each abandoned dial fed the connect-registry
gate (https://github.com/manaflow-ai/cmux/issues/9177).

Connectivity v2 did not absorb this: CmxConnectivityInvalidationSubscriber
and ConnectivityInvalidationSubscriberCoordinator replaced the Mac-side
PresenceNudgeSubscriber, while the phone-side presence path
(PresenceClient -> syncPushedRoutes -> recoverFromPushedRouteBatch ->
recoverMobileConnection(.presencePush)) survives unthrottled on main.

MobilePresencePushRecoveryThrottle passes changed evidence (new routes,
a Mac coming online) unconditionally and unchanged heartbeats at most
once per 45s, above the heartbeat cadence and a recovery pass's dial
budget, below the 30-60s automatic backoff ladder. Clock is injected
per call (runtime?.now()); a rewound wall clock re-admits instead of
freezing. Account boundary resets the throttle.

Single commit: the tests reference the new type, so a tests-first
commit cannot compile against main. Ports the throttle piece of
https://github.com/manaflow-ai/cmux/pull/9256 onto connectivity v2.

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

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-08-01 01:39:57 -05:00
lawrencecchen d69e5b214d feat(remote): add Codex patch and authenticated HTTP RPC 2026-07-31 23:20:13 -07:00
Abdulaziz AlbaharandClaude Fable 5 4c62139cc6 tests: fileprivate helpers using file-private StoredShortcut alias
First unit-target compile of this file (gate run) rejected internal methods
whose signatures use the private AppStoredShortcut typealias.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-31 19:44:19 -07:00
Abdulaziz AlbaharandClaude Fable 5 4093b1bf1b tests_v2: replace sleep-then-assert with condition polls in focus-history e2e
check-test-determinism.py --strict flagged the post-close sleep; poll for
selection and close instead.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-31 19:19:54 -07:00
Abdulaziz AlbaharandClaude Fable 5 81fe1ddd71 Cmd+[ / Cmd+] traverse global workspace focus history; pane cycling becomes rebindable
The Ghostty goto_split:previous/next mirror in the shortcut dispatch now
yields to a bound Focus Back/Forward shortcut (matchConfiguredShortcut,
including shortcuts.when gating), so ⌘[ / ⌘] reach the focus-history branch
and drive the exact same TabManager.navigateBack()/navigateForward() path as
the titlebar arrow buttons: same history model, same closed-workspace
pruning, same enable conditions. Unbinding Focus Back/Forward hands the keys
back to the mirror, as the keyboard-shortcuts docs already promised.

Pane cycling stays available two ways: the Ghostty goto_split trigger on any
non-colliding key, and new cmux-owned rebindable actions focusPreviousPane /
focusNextPane (default unbound) that share the same cyclePaneFocus body, per
the shared-entrypoint policy. The window key-equivalent fallback route gets
the same yield so both dispatch layers agree.

The new actions follow the full shortcut policy: KeyboardShortcutSettings +
CmuxSettings ShortcutAction (defaults, display names, panes group), Settings
recorder rows, cmux.json shortcuts.bindings support, schema enum, web
keyboard-shortcuts page (en+ja), and the shortcut-actions reference. Labels
localized in Localizable.xcstrings for all catalog languages.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-31 10:46:58 -07:00
Abdulaziz AlbaharandClaude Fable 5 e81d4fcfbe Add failing coverage: Cmd+[ / Cmd+] must traverse global workspace focus history
Ghostty's macOS defaults bind goto_split:previous/next to cmd+[ / cmd+], the
same keys as Focus Back/Forward. The shortcut dispatch mirrors those triggers
to cycle pane focus and checks the mirror before the focus-history branch, so
the keys cycle panes inside the current workspace (or do nothing) while the
titlebar arrow buttons navigate across workspaces.

Coverage added ahead of the fix so CI shows red then green:
- cmuxTests/FocusHistoryBracketShortcutRoutingTests: dispatches real ⌘[ / ⌘]
  events through debugHandleCustomShortcut with the Ghostty mirror installed
  via a new DEBUG seam; expects workspace focus-history navigation.
- cmuxUITests/FocusHistoryShortcutUITests: end-to-end over the control socket
  (simulate_shortcut uses the same matcher as the app-level monitor); walks
  back/forward across three workspaces and checks closed-workspace skipping.
- tests_v2/test_focus_history_shortcut_cross_workspace.py: local socket
  verification against a tagged build.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-31 09:33:42 -07:00
lawrencecchen f907a8d910 Merge remote-tracking branch 'origin/main' into feat-cmux-tui-remote-daemon-finalize
# Conflicts:
#	.github/workflows/cmux-tui-build-package.yml
2026-07-26 07:23:49 -07:00
lawrencecchen 79e48f18aa test(tui): await terminal reconnect lifecycle 2026-07-26 02:38:38 -07:00
lawrencecchen 60c4422680 Merge remote-tracking branch 'origin/main' into feat-cmux-tui-remote-daemon-finalize
# Conflicts:
#	cmux-tui/Cargo.toml
#	cmux-tui/crates/cmux-tui/src/cli.rs
#	cmux-tui/crates/cmux-tui/src/session/remote.rs
#	cmux-tui/docs/README.md
2026-07-26 02:34:39 -07:00
lawrencecchen e7b0ca28fc fix(tui): detach streams when hosted terminals exit 2026-07-25 23:27:28 -07:00
lawrencecchen 8fd59f3028 test(tui): reproduce hosted attach exit leak 2026-07-25 23:25:56 -07:00
lawrencecchen 0dd345825c fix(tui): preserve fast terminal exit handshakes 2026-07-25 22:54:21 -07:00
lawrencecchen eaf8f616fe test(tui): reproduce short-lived host launch race 2026-07-25 22:46:20 -07:00
lawrencecchen 675385e4d1 fix(tui): size scoped attaches atomically 2026-07-25 22:33:33 -07:00
lawrencecchen 740ba35388 fix(tui): normalize local OSC 7 working directories 2026-07-25 22:29:48 -07:00
lawrencecchen 30a443ab0f test(tui): reproduce OSC 7 cwd launch failure 2026-07-25 22:27:56 -07:00
lawrencecchen 0a7e6020fc Merge remote-tracking branch 'origin/main' into feat-cmux-tui-remote-daemon-finalize
# Conflicts:
#	cmux-tui/Cargo.lock
#	cmux-tui/Cargo.toml
#	cmux-tui/crates/cmux-tui-core/src/surface.rs
#	cmux-tui/crates/cmux-tui/Cargo.toml
#	cmux-tui/crates/cmux-tui/src/main.rs
#	cmux-tui/crates/cmux-tui/src/session/remote.rs
2026-07-25 22:27:17 -07:00
lawrencecchen 1a125aefbd fix(remote): terminate revoked RPC clients promptly 2026-07-25 19:05:29 -07:00
lawrencecchen 47b6b7afac test(remote): reproduce delayed revocation termination 2026-07-25 18:58:26 -07:00
lawrencecchen 6f455e9e59 fix(pty): preserve macOS child exec failures 2026-07-25 17:21:07 -07:00
lawrencecchen 88e1ad1794 test(pty): reproduce hidden macOS exec failure 2026-07-25 17:17:53 -07:00
lawrencecchen cce59bbbde fix(remote): terminate owner-disconnected sessions 2026-07-25 17:05:50 -07:00
lawrencecchen 8b36f74982 test(remote): reproduce endless owner disconnect reconnect 2026-07-25 17:02:51 -07:00
lawrencecchen 71a1df0b12 fix(pty): share nonblocking macOS allocation 2026-07-24 22:18:22 -07:00
lawrencecchen 0f3e7bfb0e test(tui): reproduce blocked macOS surface spawn 2026-07-24 22:09:43 -07:00
lawrencecchen 58bea4c80f fix(remote): avoid blocking macOS PTY metadata lookup 2026-07-24 22:00:39 -07:00
lawrencecchen 26da34a37c test(remote): reproduce blocked macOS PTY spawn 2026-07-24 21:54:10 -07:00
lawrencecchen 86f0733415 fix(remote): bootstrap Iroh auto through relay 2026-07-24 21:03:52 -07:00
lawrencecchen f6c17f062a test(remote): reproduce Iroh auto direct starvation 2026-07-24 21:01:33 -07:00
lawrencecchen 328a2bed2e fix(remote): bound initial carrier attempts 2026-07-24 19:07:23 -07:00
lawrencecchen e493e1ec72 test(remote): reproduce wedged initial route attempts 2026-07-24 19:02:21 -07:00
lawrencecchen 8d07c458c9 fix(remote): retry failed Iroh carrier dials 2026-07-24 18:05:05 -07:00
lawrencecchen 0f9c76cd80 test(remote): reproduce terminal Iroh dial failures 2026-07-24 18:02:16 -07:00
lawrencecchen 0b8c52171d fix(remote): preserve dialable Iroh runtime hints 2026-07-24 16:24:53 -07:00
lawrencecchen ac29568837 test(remote): reproduce stripped Iroh runtime hints 2026-07-24 16:21:46 -07:00
lawrencecchen 9fa20c02f2 fix(remote): order surface lifecycle behind bulk output 2026-07-24 03:51:21 -07:00
lawrencecchen e331424745 test(remote): reproduce surface tail overtaking 2026-07-24 03:38:00 -07:00
lawrencecchen 025663aac8 fix(remote): batch durable object relay frames 2026-07-24 02:46:03 -07:00
lawrencecchen 592f6552c7 test(remote): reproduce durable object message amplification 2026-07-24 02:32:16 -07:00
lawrencecchen b869f0b5bd fix(remote): bound unacknowledged delivery per lane 2026-07-24 02:16:57 -07:00
lawrencecchen 304b5ccb65 test(remote): reproduce unbounded tunnel delivery window 2026-07-24 02:11:47 -07:00
lawrencecchen 41dde29de3 fix(remote): avoid admin half-close race 2026-07-24 01:53:03 -07:00
lawrencecchen f449d15164 test(remote): reproduce admin socket close race 2026-07-24 01:51:11 -07:00
lawrencecchen 4411616ce0 fix(remote): decouple mux lanes under backpressure 2026-07-24 00:20:47 -07:00
lawrencecchen 22ede7a5e3 test(remote): reproduce mux backpressure coupling 2026-07-24 00:14:49 -07:00
lawrencecchen 20aee413bf fix(remote): route render traffic over bulk lane 2026-07-24 00:04:11 -07:00
lawrencecchen 035364d661 test(remote): reproduce render traffic lane inversion 2026-07-24 00:02:51 -07:00
lawrencecchen 8768253b20 fix(remote): isolate partial physical link attempts 2026-07-23 23:18:27 -07:00
lawrencecchen 79d9e3bef4 test(remote): reproduce stale partial link poisoning 2026-07-23 23:13:00 -07:00
lawrencecchen 8164bbe2c8 fix(remote): backpressure replay window saturation 2026-07-23 22:54:04 -07:00
lawrencecchen 1c19277640 test(remote): reproduce replay pressure stream loss 2026-07-23 22:49:53 -07:00
lawrencecchen 065e6d4e70 fix(remote): ignore closing relay sockets for capacity 2026-07-23 22:21:46 -07:00
lawrencecchen ece0a29cf7 test(remote): reproduce closing relay socket capacity 2026-07-23 22:19:37 -07:00
lawrencecchen 6aaa6e25d1 fix(remote): retry link-ready carrier loss 2026-07-23 21:46:03 -07:00
lawrencecchen 45f5789677 test(remote): reproduce link-ready carrier loss 2026-07-23 21:44:07 -07:00
lawrencecchen 167e0f9215 fix(remote): retry transient relay startup loss 2026-07-23 20:48:29 -07:00
lawrencecchen 2e5c9b3bd4 test(remote): reproduce relay startup carrier loss 2026-07-23 20:45:41 -07:00
lawrencecchen c167aea705 fix(remote): retry transient startup carrier loss 2026-07-23 19:59:59 -07:00
lawrencecchen fa9becff1d test(remote): reproduce transient startup carrier loss 2026-07-23 19:49:54 -07:00
lawrencecchen 8af0adb710 fix(remote): survive public websocket carrier loss 2026-07-23 19:15:48 -07:00
lawrencecchen 74dd151186 test(remote): reproduce public websocket failures 2026-07-23 19:14:10 -07:00
lawrencecchen 6934c1b84c test(remote): make SSH cancellation carrier loss deterministic 2026-07-23 18:17:17 -07:00
lawrencecchen b870844399 fix(relay): flush queued frames before disconnect 2026-07-23 18:08:27 -07:00
lawrencecchen 01252f5ead test(relay): reproduce queued frame loss on peer close 2026-07-23 18:06:58 -07:00
lawrencecchen 36ffa781e0 fix(remote): preserve sessions across relay peer loss 2026-07-23 17:49:07 -07:00
lawrencecchen 0cbf897f3c test(remote): reproduce relay control reconnect failure 2026-07-23 17:46:40 -07:00
lawrencecchen 1436099fe6 fix(remote): stabilize PTY process lifecycle 2026-07-23 16:34:25 -07:00
lawrencecchen 87c2dd2bca fix(remote): cancel reconnect bootstrap on client shutdown 2026-07-23 16:23:24 -07:00
lawrencecchen e094871b95 test(remote): exercise unprepared no-install fallback 2026-07-23 16:22:05 -07:00
lawrencecchen 163cd84294 test(remote): reproduce reconnect shutdown stalls 2026-07-23 16:17:36 -07:00
lawrencecchen 2a7e5f6c4b test(remote): reproduce PTY lifecycle races 2026-07-23 16:13:40 -07:00
lawrencecchen 8a99426baf fix(tui): publish final render frame before pty removal 2026-07-23 15:45:38 -07:00
lawrencecchen 78a5b6ac45 test(tui): reproduce final render loss on pty exit 2026-07-23 15:43:12 -07:00
lawrencecchen eccb7f6876 fix(remote): preserve resume state after tunnel loss 2026-07-23 14:44:54 -07:00
lawrencecchen e56989d062 test(remote): reproduce relay tunnel resume loss 2026-07-23 14:41:11 -07:00
lawrencecchen 144a5ce3f0 test(remote): preserve session after tunnel carrier loss 2026-07-23 14:34:20 -07:00
lawrencecchen 7a02cb5d83 Merge origin/main into remote daemon transport 2026-07-23 09:38:18 -07:00
lawrencecchen 9128a509a7 fix(remote): integrate process snapshot protocol 2026-07-23 05:39:06 -07:00
lawrencecchen 42daac3c68 test(remote): cover scoped multi-client lifecycle 2026-07-23 05:31:26 -07:00
lawrencecchen 728bdc9666 feat(remote): add process catalog and terminal snapshots 2026-07-23 05:26:52 -07:00
lawrencecchen 552145cea1 fix(remote): clear failed remove recovery state 2026-07-23 05:26:01 -07:00
lawrencecchen cae4086c6d test(remote): reject phantom remove recovery paths 2026-07-23 05:25:46 -07:00
lawrencecchen 5d5f957adc fix(remote): close restored-mtime remove bypass 2026-07-23 05:22:21 -07:00
lawrencecchen 0869925356 test(remote): expose process discovery and terminal snapshot gaps 2026-07-23 05:20:44 -07:00
lawrencecchen 09e0e72dc6 test(remote): expose restored-mtime remove bypass 2026-07-23 05:20:10 -07:00
lawrencecchen b1bfbf2618 test(remote): measure interactive latency under bulk 2026-07-23 05:19:14 -07:00
lawrencecchen 3762c8259b fix(remote): close restored-mtime CAS bypass 2026-07-23 05:18:31 -07:00
lawrencecchen 15759ed779 test(remote): require complete reservation cleanup 2026-07-23 05:17:49 -07:00
lawrencecchen 48100e471e fix(remote): keep protocol UUIDs wasm-safe 2026-07-23 05:14:51 -07:00
lawrencecchen 0632d9a6c7 fix(remote): stabilize request and stream errors 2026-07-23 05:12:45 -07:00
lawrencecchen 338e52df23 test(remote): expose restored-mtime CAS bypass 2026-07-23 05:12:34 -07:00
lawrencecchen 111038174f fix(remote): track partial patch mutation outcomes 2026-07-23 05:11:16 -07:00
lawrencecchen 2980315f01 test(remote): cover request identity and stream rejection 2026-07-23 05:08:09 -07:00
lawrencecchen b1b9640c9f fix(remote): harden process replay lifecycle 2026-07-23 05:01:41 -07:00
lawrencecchen 0a331fb9ed fix(remote): sanitize persisted runtime routes 2026-07-23 05:00:56 -07:00
lawrencecchen 1fcd8a645c test(remote): expose persisted route credentials 2026-07-23 05:00:27 -07:00
lawrencecchen 361d2598fa fix(remote): preserve non-route daemon names 2026-07-23 05:00:24 -07:00
lawrencecchen 279fc181bb test(remote): cover partial patch mutation outcomes 2026-07-23 04:59:32 -07:00
lawrencecchen 1a81031035 fix(remote): sanitize route-shaped daemon labels 2026-07-23 04:59:07 -07:00
lawrencecchen ca023f1b26 refactor(remote): normalize route query ownership 2026-07-23 04:58:35 -07:00
lawrencecchen 2ad739bc4b fix(remote): redact remaining route-shaped diagnostics 2026-07-23 04:57:38 -07:00
lawrencecchen 8be11efc3e fix(remote): validate staged guarded-write snapshot 2026-07-23 04:57:06 -07:00
lawrencecchen 545bfb6618 fix(remote): parse daemon stop arguments strictly 2026-07-23 04:55:52 -07:00
lawrencecchen 7cb076bddc test(remote): make route redaction assertions deterministic 2026-07-23 04:55:05 -07:00
lawrencecchen 1cc8f43fc4 test(remote): expose staged guarded-write mutation 2026-07-23 04:54:34 -07:00
lawrencecchen 542a5edfe8 fix(remote): enforce CLI daemon identity semantics 2026-07-23 04:54:19 -07:00
lawrencecchen 261ca9d845 fix(remote): redact route diagnostic state 2026-07-23 04:50:23 -07:00
lawrencecchen 5a5e7f0a19 fix(remote): fingerprint dirty build sources 2026-07-23 04:49:11 -07:00
lawrencecchen 34d8ab8e70 fix(remote): preserve mutation recovery state 2026-07-23 04:47:39 -07:00
lawrencecchen 6dbc729fd9 test(remote): expose route credential diagnostics 2026-07-23 04:45:23 -07:00
lawrencecchen 11a5779420 test(remote): expose CLI identity correctness gaps 2026-07-23 04:45:16 -07:00
lawrencecchen a73e0654de test(remote): distinguish dirty build identities 2026-07-23 04:44:35 -07:00
lawrencecchen 08b5013f09 test(remote): cover guarded mutation recovery failures 2026-07-23 04:40:50 -07:00
lawrencecchen 2b78a791ed fix(remote): preserve secure terminal drain state 2026-07-23 04:34:10 -07:00
lawrencecchen 613a431784 fix(remote): offload oversized RPC errors 2026-07-23 04:31:51 -07:00
lawrencecchen 34d9a472e6 test(remote): expose secure terminal drain masking 2026-07-23 04:29:34 -07:00
lawrencecchen 276f77bec3 fix(remote): validate guarded write snapshots 2026-07-23 04:25:38 -07:00
lawrencecchen 0fd09c87cd test(remote): expose same-inode guarded write race 2026-07-23 04:25:13 -07:00
lawrencecchen 2b99af976a fix(remote): drain admitted control on terminal 2026-07-23 04:23:41 -07:00
lawrencecchen bbbfa4a5d8 chore(remote): gate platform mutation helpers 2026-07-23 04:23:39 -07:00
lawrencecchen 13afdfb79f fix(remote): retain raw stat during identity conversion 2026-07-23 04:20:32 -07:00
lawrencecchen e277ab5083 test(remote): expose process identity lifecycle gaps 2026-07-23 04:20:05 -07:00
lawrencecchen 8cdf8ebd20 fix(remote): harden guarded workspace mutations 2026-07-23 04:19:28 -07:00
lawrencecchen 5e4b0fc969 test(remote): preserve oversized RPC retries 2026-07-23 04:17:54 -07:00
lawrencecchen d887dc37b3 test(remote): expose terminal drain lifecycle gaps 2026-07-23 04:16:56 -07:00
lawrencecchen a811c07f77 fix(remote): bound RPC response encoding 2026-07-23 04:08:21 -07:00
lawrencecchen 136d8e385b test(remote): expose guarded mutation edge races 2026-07-23 04:06:37 -07:00
lawrencecchen e885842a56 test(remote): bound oversized RPC responses 2026-07-23 04:04:03 -07:00
lawrencecchen affc58e00b fix(remote): bound SSH bootstrap output 2026-07-23 04:03:36 -07:00
lawrencecchen 16245c3633 fix(remote): remove unused SSH ingress constructor 2026-07-23 04:02:26 -07:00
lawrencecchen 7d4025927c fix(remote): exhaustively classify client auth 2026-07-23 04:02:23 -07:00
lawrencecchen f78563e84c fix(remote): bound relay websocket ingress 2026-07-23 04:01:58 -07:00
lawrencecchen 394bb9d214 fix(tui): skip unsupported fallback routes 2026-07-23 04:01:39 -07:00
lawrencecchen ff5f616887 test(tui): preserve supported route fallback 2026-07-23 04:01:00 -07:00
lawrencecchen 28fc744379 fix(remote): pin verified workspace root identity 2026-07-23 04:00:02 -07:00
lawrencecchen 3e2e6d956a fix(remote): ignore rename timestamp updates in CAS 2026-07-23 03:59:13 -07:00
lawrencecchen e346cb86a0 fix(remote): bound websocket reassembly 2026-07-23 03:57:59 -07:00
lawrencecchen 8be1bbe982 fix(remote): make process output loss explicit 2026-07-23 03:57:53 -07:00
lawrencecchen cfbf88283c fix(tui): use enrolled auth for reconnect routes 2026-07-23 03:57:08 -07:00
lawrencecchen b9a313ca5e test(tui): label invitation reconnect as enrolled 2026-07-23 03:56:35 -07:00
lawrencecchen 4095cc37ea fix(remote): classify raced parents as conflicts 2026-07-23 03:55:09 -07:00
lawrencecchen a08f11fbc0 fix(remote): make workspace mutations race resistant 2026-07-23 03:54:22 -07:00
lawrencecchen 7b1c011bc5 refactor(remote): carry typed ingress through authorization 2026-07-23 03:53:51 -07:00
lawrencecchen acfb3ba622 test(remote): bound SSH bootstrap output 2026-07-23 03:51:36 -07:00
lawrencecchen bb86f925d8 test(remote): bound fragmented websocket messages 2026-07-23 03:51:30 -07:00
lawrencecchen bbd940af7f fix(tui): resolve client transports through registry 2026-07-23 03:51:03 -07:00
lawrencecchen 1689f628e6 fix(remote): type inbound carrier authentication 2026-07-23 03:49:11 -07:00
lawrencecchen bf8ffdaf39 test(remote): handle provider rejection without Debug 2026-07-23 03:47:58 -07:00
lawrencecchen c4605b33b4 fix(remote): type client transport auth capabilities 2026-07-23 03:44:13 -07:00
lawrencecchen 13255e0352 test(remote): expose silent process output loss 2026-07-23 03:43:44 -07:00
lawrencecchen 63f967baa7 fix(remote): make lane mux failures terminal 2026-07-23 03:43:00 -07:00
lawrencecchen b8a6b88757 test(remote): expose workspace mutation path races 2026-07-23 03:40:30 -07:00
lawrencecchen 2e143f3fe9 test(remote): reject carrier auth on network routes 2026-07-23 03:40:21 -07:00
lawrencecchen d4127ed406 test(remote): require typed inbound carrier evidence 2026-07-23 03:39:44 -07:00
lawrencecchen d92a8c0786 fix(remote): fold reconnect source selection 2026-07-23 03:33:07 -07:00
lawrencecchen e3f9014de7 fix(tui): authenticate stdio proxy responder 2026-07-23 03:33:02 -07:00
lawrencecchen 830b3d5928 fix(tui): redact route failure endpoints 2026-07-23 03:32:57 -07:00
lawrencecchen 3048ee2d22 test(tui): require peer auth before stdio proxying 2026-07-23 03:31:22 -07:00
lawrencecchen 4c57e49752 test(tui): verify packaged SSH build identity 2026-07-23 03:31:11 -07:00
lawrencecchen 81f9dd7319 test(tui): expose endpoint secrets in route failures 2026-07-23 03:30:58 -07:00
lawrencecchen 5c7fbe77a7 test(remote): cover PTY continuity across reconnect 2026-07-23 03:30:00 -07:00
lawrencecchen caefb767b3 fix(remote): redact endpoint diagnostics 2026-07-23 03:28:59 -07:00
lawrencecchen 8b6aa4f485 test(remote): cover TCP forwarding end to end 2026-07-23 03:28:48 -07:00
lawrencecchen 353c9c95fc fix(tui): reserve tunnel receive capacity 2026-07-23 03:26:53 -07:00
lawrencecchen 458336e7e9 fix(tui): report SSH build identity 2026-07-23 03:26:19 -07:00
lawrencecchen 20ada3e9e6 fix(remote): bind raw SSH bootstrap to source revision 2026-07-23 03:25:58 -07:00
lawrencecchen 21f98e8e92 test(remote): reserve tunnel receive capacity 2026-07-23 03:25:40 -07:00
lawrencecchen 8cf1125101 test(remote): cover WSS certificate verification 2026-07-23 03:24:38 -07:00
lawrencecchen 3a712f7d72 test(remote): preserve admitted frames before lane EOF 2026-07-23 03:23:45 -07:00
lawrencecchen e6c9cd5989 fix(tui): bootstrap SSH fallback routes 2026-07-23 03:23:30 -07:00
lawrencecchen ffeb8ac02e test(remote): expose endpoint secret diagnostics 2026-07-23 03:21:44 -07:00
lawrencecchen abec330a8a fix(remote): route process events over bulk lane 2026-07-23 03:21:08 -07:00
lawrencecchen 066c6e0ff7 test(tui): expose Bulk starving Tunnel budget 2026-07-23 03:21:02 -07:00
lawrencecchen 295cf736c7 test(remote): expose lane mux terminal lifecycle gaps 2026-07-23 03:19:51 -07:00
lawrencecchen c152179d95 fix(relay): bound signed ticket lifetime 2026-07-23 03:15:55 -07:00
lawrencecchen 09ad1fa46f test(remote): expose same-version SSH bootstrap reuse 2026-07-23 03:14:16 -07:00
lawrencecchen 6b24921341 fix(remote): derive structured diff paths from Git metadata 2026-07-23 03:04:44 -07:00
lawrencecchen 75a49d1173 test(tui): expose SSH bootstrap route regressions 2026-07-23 03:04:00 -07:00
lawrencecchen 360bdad324 fix(remote): prioritize shared physical writers 2026-07-23 02:58:29 -07:00
lawrencecchen 8612aca4f2 test(remote): require bulk process event lane 2026-07-23 02:56:53 -07:00
lawrencecchen dc537e85bf test(relay): expose replayable ticket lifetimes 2026-07-23 02:56:45 -07:00
lawrencecchen 7eb2915e11 fix(remote): isolate process control requests 2026-07-23 02:56:22 -07:00
lawrencecchen cdc1c03479 test(remote): expose quoted Git diff paths 2026-07-23 02:55:55 -07:00
lawrencecchen bb9ac6ce10 fix(tui): reserve remote receive budget by priority 2026-07-23 02:54:29 -07:00
lawrencecchen e305fb5c10 test(remote): expose blocked process control lane 2026-07-23 02:54:02 -07:00
lawrencecchen e37d632ea9 test(remote): expose shared writer priority inversion 2026-07-23 02:52:18 -07:00
lawrencecchen 7089c27f62 test(remote): expose blocked process control lane 2026-07-23 02:52:18 -07:00
lawrencecchen b4afe063cb fix(tui): surface fatal SSH bootstrap cause 2026-07-23 02:52:18 -07:00
lawrencecchen 3f0f453cdd fix(remote): isolate lane mux ingress queues 2026-07-23 02:45:57 -07:00
lawrencecchen 4c6891ac2a fix(tui): keep remote route attempts isolated 2026-07-22 19:24:43 -07:00
lawrencecchen 995605530b test(tui): cover isolated remote route attempts 2026-07-22 19:19:57 -07:00
lawrencecchen bceafd389f fix(remote): authenticate Unix socket responders 2026-07-22 19:18:28 -07:00
lawrencecchen 41dd6cb523 test(remote): expose lane mux ingress blocking 2026-07-22 19:15:24 -07:00
lawrencecchen fb3ad110e5 test(remote): reject wrong-uid Unix responders 2026-07-22 19:15:06 -07:00
lawrencecchen 341fcb783f test(tui): expose missing transport priority reserves 2026-07-22 19:11:22 -07:00
lawrencecchen 519ae8d891 fix(tui): bound remote stream backlogs by bytes 2026-07-22 19:06:02 -07:00
lawrencecchen 035a67b67a test(tui): expose remote stream mailbox mismatch 2026-07-22 19:01:55 -07:00
lawrencecchen ee186a8dba test(tui): stress concurrent relay resumes 2026-07-22 18:49:21 -07:00
lawrencecchen 26e74c0f10 fix(tui): recover relay control after cancellation 2026-07-22 18:48:38 -07:00
lawrencecchen 1ff6d5c573 test(tui): cover cancelled relay reconnects 2026-07-22 18:44:15 -07:00
lawrencecchen 1d3a1c29d2 fix(tui): harden remote transport publication and diagnostics 2026-07-22 18:02:28 -07:00
lawrencecchen 42df658797 test(tui): run rollback regression in release mode 2026-07-22 17:49:32 -07:00
lawrencecchen 1659380e60 test(tui): cover production remote transport failures 2026-07-22 17:05:30 -07:00
lawrencecchen 1f2a96803d Merge remote-tracking branch 'origin/main' into feat-cmux-tui-remote-daemon 2026-07-22 12:46:09 -07:00
lawrencecchen 8c6c1fcb80 feat(tui): add authenticated remote daemon and clients 2026-07-22 12:46:04 -07:00
1056 changed files with 182522 additions and 10580 deletions
+14 -7
View File
@@ -10,10 +10,10 @@ 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-v1
GHOSTTYKIT_BUILD_FLAVOR: crashsubdir-cmux-crash-sentry-off-v1
steps:
- name: Clear stale git locks (self-hosted reused workspace)
shell: bash
@@ -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,16 +84,25 @@ 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: |
set -euo pipefail
cd ghostty && zig build -Dcrash-report-subdir="$GHOSTTYKIT_CRASH_REPORT_SUBDIR" -Demit-xcframework=true -Demit-macos-app=false -Dxcframework-target=universal -Doptimize=ReleaseFast
cd ghostty && zig build -Dcrash-report-subdir="$GHOSTTYKIT_CRASH_REPORT_SUBDIR" -Dsentry=false -Demit-xcframework=true -Demit-macos-app=false -Dxcframework-target=universal -Doptimize=ReleaseFast
- name: Package xcframework
if: steps.check-release.outputs.exists == 'false'
@@ -121,6 +128,6 @@ jobs:
--repo manaflow-ai/ghostty \
--target "${{ steps.ghostty-sha.outputs.sha }}" \
--title "GhosttyKit xcframework (${{ steps.ghostty-sha.outputs.sha }}, ${GHOSTTYKIT_BUILD_FLAVOR})" \
--notes "Pre-built GhosttyKit.xcframework for commit ${{ steps.ghostty-sha.outputs.sha }} with crash-report-subdir=${GHOSTTYKIT_CRASH_REPORT_SUBDIR}" \
--notes "Pre-built GhosttyKit.xcframework for commit ${{ steps.ghostty-sha.outputs.sha }} with crash-report-subdir=${GHOSTTYKIT_CRASH_REPORT_SUBDIR} and sentry=false" \
GhosttyKit.xcframework.tar.gz
echo "Published release $TAG"
+1 -1
View File
@@ -95,7 +95,7 @@ jobs:
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: GhosttyKit.xcframework
key: ghosttykit-${{ steps.ghostty-revision.outputs.sha }}
key: ghosttykit-sentry-off-v1-${{ steps.ghostty-revision.outputs.sha }}
- name: Download pre-built GhosttyKit.xcframework
if: steps.cache-ghosttykit.outputs.cache-hit != 'true'
+12 -5
View File
@@ -175,6 +175,9 @@ jobs:
- name: Validate cmux scheme test configuration
run: ./tests/test_ci_scheme_testaction_debug.sh
- name: Validate selected iOS test execution guard
run: python3 tests/test_ios_selected_test_execution.py
- name: Validate cmuxTests sharding
run: |
python3 scripts/ci/cmux_unit_test_shard.py --validate
@@ -255,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
@@ -510,7 +515,7 @@ jobs:
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: GhosttyKit.xcframework
key: ghosttykit-${{ steps.ghostty-revision.outputs.sha }}
key: ghosttykit-sentry-off-v1-${{ steps.ghostty-revision.outputs.sha }}
- name: Download pre-built GhosttyKit.xcframework
if: steps.cache-ghosttykit.outputs.cache-hit != 'true'
@@ -1059,9 +1064,11 @@ 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
python3 tests/test_issue_9356_bash_shim_noclobber.py
python3 tests/test_issue_8953_zsh_prompt_wrap_guard.py
python3 tests/test_shell_git_branch_stale_cwd.py
python3 tests/test_shell_git_config_remote_url_parsing.py
@@ -1246,7 +1253,7 @@ jobs:
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: GhosttyKit.xcframework
key: ghosttykit-${{ steps.ghostty-revision.outputs.sha }}
key: ghosttykit-sentry-off-v1-${{ steps.ghostty-revision.outputs.sha }}
- name: Validate cached GhosttyKit.xcframework
id: validate-ghosttykit-package-tests
@@ -1558,7 +1565,7 @@ jobs:
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: GhosttyKit.xcframework
key: ghosttykit-${{ steps.ghostty-revision.outputs.sha }}
key: ghosttykit-sentry-off-v1-${{ steps.ghostty-revision.outputs.sha }}
- name: Download pre-built GhosttyKit.xcframework
if: steps.cache-ghosttykit-lag.outputs.cache-hit != 'true'
@@ -1879,7 +1886,7 @@ jobs:
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: GhosttyKit.xcframework
key: ghosttykit-${{ steps.ghostty-revision.outputs.sha }}
key: ghosttykit-sentry-off-v1-${{ steps.ghostty-revision.outputs.sha }}
- name: Download pre-built GhosttyKit.xcframework
if: steps.cache-ghosttykit-release.outputs.cache-hit != 'true'
+51 -21
View File
@@ -1,16 +1,21 @@
name: cmux-tui artifacts
# Publishes raw cmux-tui (the Rust TUI multiplexer) binaries to the
# cmux-binaries R2 bucket (public at https://files.cmux.com/cmux-tui/...) so cloud
# VM snapshot builders and install scripts can curl a binary directly, without
# npm or PyPI. Binary building is shared with the npm/uvx distribution lane
# (cmux-tui-build-package.yml); this workflow only adds the R2 raw-binary publish.
# Publishes raw cmux-tui (the Rust TUI multiplexer) and cmux-relay binaries to the
# cmux-binaries R2 bucket (public under https://files.cmux.com/cmux-tui/... and
# https://files.cmux.com/cmux-relay/...) so cloud VM snapshot builders and install
# scripts can curl a binary directly, without npm or PyPI. Binary building is
# shared with the npm/uvx distribution lane (cmux-tui-build-package.yml); this
# workflow only adds the R2 raw-binary publish.
#
# Layout in R2:
# cmux-tui/<commit-sha>/cmux-tui-<rust-target> immutable, commit-addressed
# cmux-tui/<commit-sha>/manifest.json
# cmux-tui/latest/cmux-tui-<rust-target> rolling, manual publishes only
# cmux-tui/latest/manifest.json
# cmux-relay/<commit-sha>/cmux-relay-<rust-target> immutable, commit-addressed
# cmux-relay/<commit-sha>/manifest.json
# cmux-relay/latest/cmux-relay-<rust-target> rolling, manual publishes only
# cmux-relay/latest/manifest.json
on:
# Temporarily manual-only beginning 2026-07-13 to pause automatic CI/CD.
@@ -28,8 +33,9 @@ jobs:
contents: read
uses: ./.github/workflows/cmux-tui-build-package.yml
with:
# Binaries only; the version input is unused when packaging is off.
version: "0.0.0"
# Raw R2 artifacts are not npm releases and must never claim that an
# older published package can reproduce their source contents.
version: 0.0.0-r2.${{ github.sha }}
package_npm: false
package_pypi: false
include_windows: true
@@ -49,21 +55,37 @@ jobs:
with:
persist-credentials: false
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
- name: Download cmux-tui binaries
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
pattern: cmux-tui-*
path: assets
path: assets/cmux-tui
merge-multiple: true
- name: Download cmux-relay binaries
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
pattern: cmux-relay-*
path: assets/cmux-relay
merge-multiple: true
- name: Build manifest and checksums
run: |
cd assets
chmod 0755 cmux-tui-*
sha256sum cmux-tui-* > cmux-tui-checksums.txt
python3 - "$GITHUB_SHA" <<'PY'
build_manifest() {
local directory="$1"
local binary_prefix="$2"
local checksums="$3"
(
cd "$directory"
chmod 0755 "${binary_prefix}"*
sha256sum "${binary_prefix}"* > "$checksums"
python3 - "$GITHUB_SHA" "$binary_prefix" <<'PY'
import hashlib, json, os, sys
from datetime import datetime, timezone
files = sorted(f for f in os.listdir(".") if f.startswith("cmux-tui-") and not f.endswith(".txt"))
files = sorted(
f for f in os.listdir(".")
if f.startswith(sys.argv[2]) and not f.endswith(".txt")
)
manifest = {
"commit": sys.argv[1],
"builtAt": datetime.now(timezone.utc).isoformat(),
@@ -75,6 +97,10 @@ jobs:
json.dump(manifest, out, indent=2)
print(json.dumps(manifest, indent=2))
PY
)
}
build_manifest assets/cmux-tui cmux-tui- cmux-tui-checksums.txt
build_manifest assets/cmux-relay cmux-relay- cmux-relay-checksums.txt
- name: Upload to R2
env:
@@ -85,9 +111,10 @@ jobs:
run: |
set -euo pipefail
publish_prefix() {
local prefix="$1"
local cache="$2"
for file in assets/cmux-tui-* assets/manifest.json; do
local directory="$1"
local prefix="$2"
local cache="$3"
for file in "$directory"/*; do
python3 scripts/ci/upload-r2-object.py \
--file "$file" \
--endpoint-url "$R2_ENDPOINT" \
@@ -96,11 +123,14 @@ jobs:
--cache-control "$cache"
done
}
publish_prefix "cmux-tui/$GITHUB_SHA" "public, max-age=31536000, immutable"
publish_prefix assets/cmux-tui "cmux-tui/$GITHUB_SHA" "public, max-age=31536000, immutable"
publish_prefix assets/cmux-relay "cmux-relay/$GITHUB_SHA" "public, max-age=31536000, immutable"
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
publish_prefix "cmux-tui/latest" "no-cache, no-store, must-revalidate"
publish_prefix assets/cmux-tui "cmux-tui/latest" "no-cache, no-store, must-revalidate"
publish_prefix assets/cmux-relay "cmux-relay/latest" "no-cache, no-store, must-revalidate"
fi
echo "Published: https://files.cmux.com/cmux-tui/$GITHUB_SHA/manifest.json"
echo "Published: https://files.cmux.com/cmux-relay/$GITHUB_SHA/manifest.json"
# Transitional double-publish under the pre-rename prefix and binary
# names: out-of-repo consumers (cmux-cloud bootstrap/install-mux.sh
@@ -109,10 +139,10 @@ jobs:
# (https://github.com/manaflow-ai/cmux-cloud/pull/2).
legacy_assets="assets-legacy"
mkdir -p "$legacy_assets"
for file in assets/cmux-tui-*; do
for file in assets/cmux-tui/cmux-tui-*; do
cp "$file" "$legacy_assets/$(basename "$file" | sed 's/^cmux-tui-/cmux-mux-/')"
done
cp assets/manifest.json "$legacy_assets/manifest.json"
cp assets/cmux-tui/manifest.json "$legacy_assets/manifest.json"
publish_legacy_prefix() {
local prefix="$1"
local cache="$2"
+171 -6
View File
@@ -50,21 +50,25 @@ jobs:
matrix:
include:
- target: aarch64-apple-darwin
build_target: aarch64-apple-darwin
runner: ${{ vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }}
cross: false
ext: ""
compatibility_target: ""
- target: x86_64-apple-darwin
build_target: x86_64-apple-darwin
runner: ${{ vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }}
cross: true
ext: ""
compatibility_target: ""
- target: x86_64-unknown-linux-musl
build_target: x86_64-unknown-linux-musl
runner: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
cross: true
ext: ""
compatibility_target: x86_64-unknown-linux-gnu
- target: aarch64-unknown-linux-musl
build_target: aarch64-unknown-linux-musl
runner: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
cross: true
ext: ""
@@ -90,7 +94,7 @@ jobs:
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y clang libclang-dev pkg-config
sudo apt-get install -y binutils clang libclang-dev pkg-config
- name: Resolve Ghostty Zig version
id: ghostty-zig-version
@@ -122,6 +126,9 @@ jobs:
- name: Build cmux-tui (native)
if: matrix.cross == false
env:
CMUX_TUI_DISTRIBUTION_VERSION: ${{ inputs.version }}
PACKAGE_NPM: ${{ inputs.package_npm }}
working-directory: cmux-tui
shell: bash
run: |
@@ -130,11 +137,19 @@ jobs:
unset CMUX_GHOSTTY_SRC
CMUX_TUI_BUILD_COMMIT="$(git -C .. rev-parse HEAD)"
CMUX_TUI_GHOSTTY_COMMIT="$(git -C ../ghostty rev-parse HEAD)"
export CMUX_TUI_BUILD_COMMIT CMUX_TUI_GHOSTTY_COMMIT
cargo build -p cmux-tui --bin cmux-tui --release --locked --target ${{ matrix.target }}
export CMUX_TUI_BUILD_COMMIT CMUX_TUI_GHOSTTY_COMMIT CMUX_TUI_DISTRIBUTION_VERSION
if [[ "$PACKAGE_NPM" == "true" ]]; then
CMUX_TUI_NPM_BOOTSTRAP_VERSION="$CMUX_TUI_DISTRIBUTION_VERSION"
export CMUX_TUI_NPM_BOOTSTRAP_VERSION
fi
cargo build -p cmux-tui --bin cmux-tui --release --locked --target ${{ matrix.build_target }}
cargo build -p cmux-relay --bin cmux-relay --release --locked --target ${{ matrix.build_target }}
- name: Build cmux-tui (cross)
if: matrix.cross == true
env:
CMUX_TUI_DISTRIBUTION_VERSION: ${{ inputs.version }}
PACKAGE_NPM: ${{ inputs.package_npm }}
working-directory: cmux-tui
shell: bash
run: |
@@ -143,20 +158,66 @@ jobs:
unset CMUX_GHOSTTY_SRC
CMUX_TUI_BUILD_COMMIT="$(git -C .. rev-parse HEAD)"
CMUX_TUI_GHOSTTY_COMMIT="$(git -C ../ghostty rev-parse HEAD)"
export CMUX_TUI_BUILD_COMMIT CMUX_TUI_GHOSTTY_COMMIT
cargo zigbuild -p cmux-tui --bin cmux-tui --release --locked --target ${{ matrix.target }}
export CMUX_TUI_BUILD_COMMIT CMUX_TUI_GHOSTTY_COMMIT CMUX_TUI_DISTRIBUTION_VERSION
if [[ "$PACKAGE_NPM" == "true" ]]; then
CMUX_TUI_NPM_BOOTSTRAP_VERSION="$CMUX_TUI_DISTRIBUTION_VERSION"
export CMUX_TUI_NPM_BOOTSTRAP_VERSION
fi
cargo zigbuild -p cmux-tui --bin cmux-tui --release --locked --target ${{ matrix.build_target }}
cargo zigbuild -p cmux-relay --bin cmux-relay --release --locked --target ${{ matrix.build_target }}
- name: Stage binary
shell: bash
run: |
mkdir -p dist
binary="dist/cmux-tui-${{ matrix.target }}${{ matrix.ext }}"
relay_binary="dist/cmux-relay-${{ matrix.target }}${{ matrix.ext }}"
cp "cmux-tui/target/${{ matrix.target }}/release/cmux-tui${{ matrix.ext }}" "$binary"
cp "cmux-tui/target/${{ matrix.target }}/release/cmux-relay${{ matrix.ext }}" "$relay_binary"
if [[ -n "${{ matrix.compatibility_target }}" ]]; then
cp "$binary" "dist/cmux-tui-${{ matrix.compatibility_target }}${{ matrix.ext }}"
cp "$relay_binary" "dist/cmux-relay-${{ matrix.compatibility_target }}${{ matrix.ext }}"
fi
ls -la dist
- name: Verify non-npm binaries disable SSH auto-install
if: runner.os == 'Linux' && matrix.target == 'x86_64-unknown-linux-musl' && inputs.package_npm == false
shell: bash
run: |
CMUX_TUI_PROBE="$(dist/cmux-tui-${{ matrix.target }} remote-probe --json)"
CMUX_TUI_EXPECTED_BUILD_IDENTITY="$(git rev-parse HEAD)"
export CMUX_TUI_PROBE CMUX_TUI_EXPECTED_BUILD_IDENTITY
python3 - <<'PY'
import json
import os
probe = json.loads(os.environ["CMUX_TUI_PROBE"])
expected_identity = os.environ["CMUX_TUI_EXPECTED_BUILD_IDENTITY"]
if probe.get("build_identity") != expected_identity:
raise SystemExit(
f"binary build identity {probe.get('build_identity')!r} "
f"!= {expected_identity!r}"
)
if probe.get("npm_bootstrap_version") is not None:
raise SystemExit(
"non-npm binary unexpectedly advertises npm bootstrap version "
f"{probe.get('npm_bootstrap_version')!r}"
)
PY
- name: Smoke-test release remote sessions
if: matrix.target == 'x86_64-unknown-linux-musl'
shell: bash
run: cmux-tui/scripts/smoke-remote-release.sh "dist/cmux-tui-${{ matrix.target }}"
- name: Test release remote sequence rollback
if: matrix.target == 'x86_64-unknown-linux-musl'
working-directory: cmux-tui
shell: bash
run: >-
cargo test --release --locked --target ${{ matrix.target }}
-p cmux-remote queue_admission_failure_rolls_back_sequence_and_replay
- name: Upload binary artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
@@ -164,6 +225,68 @@ jobs:
path: dist/cmux-tui-*${{ matrix.ext }}
if-no-files-found: error
- name: Upload relay binary artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: cmux-relay-${{ matrix.target }}
path: dist/cmux-relay-*${{ matrix.ext }}
if-no-files-found: error
cloudflare-relay:
name: Cloudflare Durable Object relay
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 30
env:
RUSTUP_TOOLCHAIN: "1.91.0"
permissions:
contents: read
defaults:
run:
working-directory: cmux-tui/relays/cloudflare-do
steps:
- name: Checkout caller ref
if: inputs.checkout_ref == ''
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Checkout requested ref
if: inputs.checkout_ref != ''
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
ref: ${{ inputs.checkout_ref }}
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22.14.0"
cache: npm
cache-dependency-path: cmux-tui/relays/cloudflare-do/package-lock.json
- name: Install pinned Rust toolchain and Worker builder
run: |
rustup toolchain install "$RUSTUP_TOOLCHAIN" --profile minimal --component clippy --target wasm32-unknown-unknown
cargo install --locked [email protected]
- name: Install pinned npm dependencies
run: npm ci --no-audit --no-fund
- name: Test and lint relay
run: |
python3 tests/validate_wrangler_config.py
cargo test --locked
cargo clippy --locked --all-targets -- -D warnings
- name: Audit npm dependencies
run: npm audit --audit-level=high
- name: Build Worker
run: npm run build
- name: Validate Wrangler deployment bundle
run: npx --no-install wrangler deploy --dry-run --outdir "$RUNNER_TEMP/cmux-cloudflare-relay"
build-windows:
name: build x86_64-pc-windows-gnu
if: inputs.include_windows
@@ -214,6 +337,9 @@ jobs:
printf '%s\n' 'C:\msys64\mingw64\bin' >> "$GITHUB_PATH"
- name: Build libghostty-vt + cmux-tui (Windows GNU)
env:
CMUX_TUI_DISTRIBUTION_VERSION: ${{ inputs.version }}
PACKAGE_NPM: ${{ inputs.package_npm }}
shell: bash
run: |
# Keep the manual Zig build and Cargo's build script on the same
@@ -221,12 +347,27 @@ jobs:
unset CMUX_GHOSTTY_SRC
CMUX_TUI_BUILD_COMMIT="$(git rev-parse HEAD)"
CMUX_TUI_GHOSTTY_COMMIT="$(git -C ghostty rev-parse HEAD)"
export CMUX_TUI_BUILD_COMMIT CMUX_TUI_GHOSTTY_COMMIT
export CMUX_TUI_BUILD_COMMIT CMUX_TUI_GHOSTTY_COMMIT CMUX_TUI_DISTRIBUTION_VERSION
if [[ "$PACKAGE_NPM" == "true" ]]; then
CMUX_TUI_NPM_BOOTSTRAP_VERSION="$CMUX_TUI_DISTRIBUTION_VERSION"
export CMUX_TUI_NPM_BOOTSTRAP_VERSION
fi
cd ghostty
zig build -Demit-lib-vt=true -Demit-xcframework=false -Doptimize=ReleaseFast -Dtarget=x86_64-windows-gnu --prefix "$RUNNER_TEMP/ghostty-vt-win-gnu"
cd ../cmux-tui
cargo build -p cmux-tui --bin cmux-tui --release --locked --target x86_64-pc-windows-gnu
- name: Verify remote commands fail clearly on Windows
shell: bash
run: |
set +e
output="$(cmux-tui/target/x86_64-pc-windows-gnu/release/cmux-tui.exe remote-probe --json 2>&1)"
status=$?
set -e
printf '%s\n' "$output"
test "$status" -eq 1
grep -F "remote daemon commands require Unix sockets" <<<"$output"
- name: Stage binary
shell: bash
run: |
@@ -343,6 +484,30 @@ jobs:
chmod +x dist/binaries/cmux-tui-x86_64-unknown-linux-musl
dist/binaries/cmux-tui-x86_64-unknown-linux-musl --version >/tmp/cmux-tui-version.txt 2>&1 || \
dist/binaries/cmux-tui-x86_64-unknown-linux-musl --help >/tmp/cmux-tui-version.txt 2>&1
CMUX_TUI_PROBE="$(dist/binaries/cmux-tui-x86_64-unknown-linux-musl remote-probe --json)"
CMUX_TUI_EXPECTED_BUILD_IDENTITY="$(git rev-parse HEAD)"
export CMUX_TUI_PROBE CMUX_TUI_EXPECTED_BUILD_IDENTITY
python3 - <<'PY'
import json
import os
probe = json.loads(os.environ["CMUX_TUI_PROBE"])
expected = os.environ["NPM_VERSION"]
expected_identity = os.environ["CMUX_TUI_EXPECTED_BUILD_IDENTITY"]
if probe.get("build_identity") != expected_identity:
raise SystemExit(
f"binary build identity {probe.get('build_identity')!r} "
f"!= {expected_identity!r}"
)
if probe.get("distribution_version") != expected:
raise SystemExit(
f"binary distribution version {probe.get('distribution_version')!r} != {expected!r}"
)
if probe.get("npm_bootstrap_version") != expected:
raise SystemExit(
f"binary npm bootstrap version {probe.get('npm_bootstrap_version')!r} != {expected!r}"
)
PY
- name: Archive npm package directories with executable modes
if: inputs.package_npm
+49 -10
View File
@@ -6,13 +6,43 @@ on:
- main
paths:
- "cmux-tui/**"
- ".github/workflows/cmux-tui-nightly.yml"
- ".github/workflows/cmux-tui-release-cut.yml"
- ".github/workflows/cmux-tui-release.yml"
- ".github/workflows/cmux-tui-sdks.yml"
- ".github/workflows/cmux-tui-spec.yml"
- ".github/workflows/sdk-bootstrap-crates.yml"
- ".github/workflows/sdk-bootstrap-npm.yml"
- ".github/workflows/sdk-bootstrap-pypi.yml"
- ".github/workflows/sdk-publish-crates.yml"
- ".github/workflows/sdk-publish-go.yml"
- ".github/workflows/sdk-publish-java.yml"
- ".github/workflows/sdk-publish-npm.yml"
- ".github/workflows/sdk-publish-python.yml"
- ".github/workflows/sdk-release-cut.yml"
- ".github/workflows/tui-publish-npm.yml"
- ".github/workflows/tui-publish-pypi.yml"
- "tests/test_tui_publish_workflow_security.py"
pull_request:
paths:
- "cmux-tui/**"
- ".github/workflows/cmux-tui-nightly.yml"
- ".github/workflows/cmux-tui-release-cut.yml"
- ".github/workflows/cmux-tui-release.yml"
- ".github/workflows/cmux-tui-sdks.yml"
- ".github/workflows/cmux-tui-spec.yml"
- ".github/workflows/sdk-bootstrap-crates.yml"
- ".github/workflows/sdk-bootstrap-npm.yml"
- ".github/workflows/sdk-bootstrap-pypi.yml"
- ".github/workflows/sdk-publish-crates.yml"
- ".github/workflows/sdk-publish-go.yml"
- ".github/workflows/sdk-publish-java.yml"
- ".github/workflows/sdk-publish-npm.yml"
- ".github/workflows/sdk-publish-python.yml"
- ".github/workflows/sdk-release-cut.yml"
- ".github/workflows/tui-publish-npm.yml"
- ".github/workflows/tui-publish-pypi.yml"
- "tests/test_tui_publish_workflow_security.py"
workflow_dispatch:
concurrency:
@@ -37,6 +67,12 @@ jobs:
with:
python-version: "3.12.8"
- name: Install workflow guard dependencies
run: |
python3 -m pip install \
--disable-pip-version-check \
"PyYAML==6.0.3"
- name: Test protocol inventory
run: python3 cmux-tui/scripts/test_check_spec_inventory.py
@@ -70,8 +106,11 @@ jobs:
-p 'test_*.py' \
-v
- name: Test SDK publishing workflow guards
run: python3 tests/test_tui_publish_workflow_security.py -v
- name: Check package versions
run: python3 cmux-tui/bindings/check-versions.py
run: python3 cmux-tui/bindings/check-versions.py --published-only
- name: Test shared conformance runner
run: |
@@ -153,10 +192,10 @@ jobs:
env:
PYTHONPATH: cmux-tui/bindings/python
run: |
python3 -m unittest discover -s cmux-tui/bindings/python/tests -v
python3 -m pip install \
--disable-pip-version-check \
"setuptools==80.9.0"
python3 -m unittest discover -s cmux-tui/bindings/python/tests -v
python3 -m pip install \
--no-build-isolation \
--no-deps \
@@ -176,7 +215,7 @@ jobs:
distribution = next(
item
for item in importlib.metadata.distributions(path=[str(package)])
if item.metadata["Name"] == "cmux"
if item.metadata["Name"] == "cmux-sdk"
)
assert not distribution.requires
PY
@@ -192,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
+626
View File
@@ -0,0 +1,626 @@
name: sdk bootstrap crates
on:
repository_dispatch:
types: [sdk-bootstrap-crates]
permissions: {}
concurrency:
group: sdk-bootstrap-crates
cancel-in-progress: false
env:
BOOTSTRAP_VERSION: "0.0.0-bootstrap.0"
RUST_TOOLCHAIN: "1.95.0"
jobs:
build:
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 15
permissions:
contents: read
outputs:
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 the Rust SDK crates without confirm_bootstrap=true." >&2
exit 1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
ref: ${{ github.sha }}
fetch-depth: 0
- name: Require current protected main
run: |
set -euo pipefail
[[ "$GITHUB_REF" == "refs/heads/main" ]] || {
echo "Dispatch sdk-bootstrap-crates.yml from main, found $GITHUB_REF." >&2
exit 1
}
git fetch --force origin main
main_sha="$(git rev-parse origin/main)"
[[ "$GITHUB_SHA" == "$main_sha" ]] || {
echo "workflow commit $GITHUB_SHA is not current main $main_sha" >&2
exit 1
}
- name: Install pinned Rust toolchain
run: |
rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal
rustup default "$RUST_TOOLCHAIN"
cargo --version
rustc --version
- name: Build and test the ownership bootstrap
id: package
run: |
set -euo pipefail
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
- 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-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
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
ref: ${{ github.sha }}
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
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/$PACKAGE-bootstrap-registry.json"
status="$(
curl \
--silent \
--show-error \
--location \
--retry 5 \
--retry-delay 1 \
--retry-all-errors \
--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/$PACKAGE"
)"
case "$status" in
404)
echo "$PACKAGE is unclaimed; bootstrap may continue."
project_status=missing
;;
200)
echo "$PACKAGE exists; bootstrap bytes must match."
project_status=exists
;;
*)
echo "crates.io returned HTTP $status; refusing to infer availability." >&2
exit 1
;;
esac
echo "status=$project_status" >> "$GITHUB_OUTPUT"
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
artifacts=(bootstrap-crate/*.crate)
[[ "${#artifacts[@]}" == 1 ]] || {
echo "expected one tested crate, found ${#artifacts[@]}" >&2
exit 1
}
python3 cmux-tui/bindings/reconcile_registry_artifact.py check \
--registry crates \
--package "$PACKAGE" \
--version "$BOOTSTRAP_VERSION" \
--artifact "${artifacts[0]}" \
--require-match
sleep 1
python3 cmux-tui/bindings/verify_crates_ownership.py \
--package "$PACKAGE" \
--repository https://github.com/manaflow-ai/cmux \
--owner-id 431397 \
--owner-login lawrencecchen \
--bootstrap-ownership-only
- 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"
- 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
- 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-sdk
steps:
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: cmux-sdk-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.sdk_sha256 }}
PACKAGE: cmux-sdk
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
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
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
verify:
needs:
- build
- preflight
- decisions
- publish-sdk
- publish-sidebar
if: >-
always() &&
needs.build.result == 'success' &&
needs.preflight.result == 'success' &&
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:
contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
ref: ${{ github.sha }}
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
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
artifacts=(bootstrap-crate/*.crate)
[[ "${#artifacts[@]}" == 1 ]] || {
echo "expected one tested crate, found ${#artifacts[@]}" >&2
exit 1
}
python3 cmux-tui/bindings/reconcile_registry_artifact.py check \
--registry crates \
--package "$PACKAGE" \
--version "$BOOTSTRAP_VERSION" \
--artifact "${artifacts[0]}" \
--retry-missing-project \
--wait-seconds 300 \
--require-match
sleep 1
python3 cmux-tui/bindings/verify_crates_ownership.py \
--package "$PACKAGE" \
--repository https://github.com/manaflow-ai/cmux \
--owner-id 431397 \
--owner-login lawrencecchen \
--bootstrap-ownership-only
+340
View File
@@ -0,0 +1,340 @@
name: sdk bootstrap npm
on:
repository_dispatch:
types: [sdk-bootstrap-npm]
permissions: {}
concurrency:
group: sdk-bootstrap-npm
cancel-in-progress: false
env:
BOOTSTRAP_VERSION: "0.0.0-bootstrap.0"
jobs:
build:
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 15
permissions:
contents: read
outputs:
artifact_sha256: ${{ steps.package.outputs.artifact_sha256 }}
steps:
- name: Require explicit bootstrap confirmation
if: github.event.client_payload.confirm_bootstrap != true
run: |
echo "Refusing to reserve cmux-sdk without confirm_bootstrap=true." >&2
exit 1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
ref: ${{ github.sha }}
fetch-depth: 0
- name: Require current protected main
run: |
set -euo pipefail
[[ "$GITHUB_REF" == "refs/heads/main" ]] || {
echo "Dispatch sdk-bootstrap-npm.yml from main, found $GITHUB_REF." >&2
exit 1
}
git fetch --force origin main
main_sha="$(git rev-parse origin/main)"
[[ "$GITHUB_SHA" == "$main_sha" ]] || {
echo "workflow commit $GITHUB_SHA is not current main $main_sha" >&2
exit 1
}
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22.14.0"
package-manager-cache: false
- name: Install pinned npm
run: npm install --global --ignore-scripts [email protected]
- name: Build, test, and pack the bootstrap prerelease
id: package
working-directory: cmux-tui/bindings/typescript
run: |
set -euo pipefail
npm ci --no-audit --no-fund
npm version "$BOOTSTRAP_VERSION" --no-git-tag-version
npm test
mkdir -p "$RUNNER_TEMP/cmux-npm-bootstrap"
npm pack --pack-destination "$RUNNER_TEMP/cmux-npm-bootstrap"
shopt -s nullglob
packages=("$RUNNER_TEMP"/cmux-npm-bootstrap/*.tgz)
[[ "${#packages[@]}" == 1 ]] || {
echo "expected one bootstrap artifact, found ${#packages[@]}" >&2
exit 1
}
CMUX_NPM_PACKAGE="${packages[0]}" \
node scripts/verify-packaged-consumer.mjs
artifact_sha256="$(sha256sum "${packages[0]}" | cut -d ' ' -f 1)"
[[ "$artifact_sha256" =~ ^[0-9a-f]{64}$ ]] || {
echo "bootstrap package digest is malformed" >&2
exit 1
}
echo "artifact_sha256=$artifact_sha256" >> "$GITHUB_OUTPUT"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: cmux-npm-bootstrap-package
path: ${{ runner.temp }}/cmux-npm-bootstrap/*.tgz
if-no-files-found: error
overwrite: true
preflight:
needs: build
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:
persist-credentials: false
ref: ${{ github.sha }}
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: cmux-npm-bootstrap-package
path: bootstrap-package
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22.14.0"
package-manager-cache: false
- name: Install pinned npm
run: npm install --global --ignore-scripts [email protected]
- name: Inspect the npm bootstrap state
id: project
run: |
set -euo pipefail
metadata="$RUNNER_TEMP/cmux-sdk-bootstrap-registry.json"
status="$(
curl \
--silent \
--show-error \
--location \
--retry 5 \
--retry-all-errors \
--output "$metadata" \
--write-out '%{http_code}' \
https://registry.npmjs.org/cmux-sdk
)"
case "$status" in
404)
echo "cmux-sdk is unclaimed; bootstrap may continue."
project_status=missing
;;
200)
echo "cmux-sdk exists; bootstrap bytes and provenance must match."
project_status=exists
;;
*)
echo "npm registry returned HTTP $status; refusing to infer availability." >&2
exit 1
;;
esac
echo "status=$project_status" >> "$GITHUB_OUTPUT"
- name: Reconcile an existing npm ownership bootstrap
if: steps.project.outputs.status == 'exists'
run: |
set -euo pipefail
shopt -s nullglob
packages=(bootstrap-package/*.tgz)
[[ "${#packages[@]}" == 1 ]] || {
echo "expected one tested npm artifact, found ${#packages[@]}" >&2
exit 1
}
python3 cmux-tui/bindings/verify_npm_provenance.py \
--package cmux-sdk \
--version "$BOOTSTRAP_VERSION" \
--repository-url git+https://github.com/manaflow-ai/cmux.git \
--repository-directory cmux-tui/bindings/typescript \
--owner lawrencechen \
--workflow .github/workflows/sdk-bootstrap-npm.yml \
--workflow-ref refs/heads/main \
--dist-tag bootstrap \
--publisher owner \
--artifact "${packages[0]}"
- name: Request publication for an unclaimed project
id: decision
if: steps.project.outputs.status == 'missing'
run: echo "need_publish=true" >> "$GITHUB_OUTPUT"
publish:
needs:
- build
- preflight
if: needs.preflight.outputs.need_publish == 'true'
runs-on: ubuntu-latest # github-hosted-required: npm provenance publishing
timeout-minutes: 10
permissions:
id-token: write
environment:
name: npm-bootstrap
url: https://www.npmjs.com/package/cmux-sdk
steps:
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: cmux-npm-bootstrap-package
path: bootstrap-package
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22.14.0"
registry-url: https://registry.npmjs.org
package-manager-cache: false
- name: Install pinned npm
run: npm install --global --ignore-scripts [email protected]
- name: Verify protected source and the exact tested package
env:
EXPECTED_SHA256: ${{ needs.build.outputs.artifact_sha256 }}
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 npm artifact digest is malformed" >&2
exit 1
}
shopt -s nullglob
packages=(bootstrap-package/*.tgz)
[[ "${#packages[@]}" == 1 ]] || {
echo "expected one tested npm artifact, found ${#packages[@]}" >&2
exit 1
}
actual_sha256="$(sha256sum "${packages[0]}" | cut -d ' ' -f 1)"
[[ "$actual_sha256" == "$EXPECTED_SHA256" ]] || {
echo "downloaded npm bootstrap artifact digest mismatch" >&2
exit 1
}
- name: Publish the exact tested prerelease artifact
continue-on-error: true
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_BOOTSTRAP_TOKEN }}
run: |
set -euo pipefail
[[ -n "$NODE_AUTH_TOKEN" ]] || {
echo "npm-bootstrap environment secret NPM_BOOTSTRAP_TOKEN is required." >&2
exit 1
}
shopt -s nullglob
packages=(bootstrap-package/*.tgz)
[[ "${#packages[@]}" == 1 ]] || {
echo "expected one tested npm artifact, found ${#packages[@]}" >&2
exit 1
}
echo "npm lifecycle scripts are disabled in the credentialed publisher"
npm publish "${packages[0]}" \
--ignore-scripts \
--tag bootstrap \
--provenance \
--access public
verify:
needs:
- build
- preflight
- publish
if: >-
always() &&
needs.build.result == 'success' &&
needs.preflight.result == 'success' &&
(needs.publish.result == 'success' || needs.publish.result == 'skipped')
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 10
permissions:
contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
ref: ${{ github.sha }}
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: cmux-npm-bootstrap-package
path: bootstrap-package
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22.14.0"
package-manager-cache: false
- name: Install pinned npm
run: npm install --global --ignore-scripts [email protected]
- name: Verify the prerelease did not claim latest
run: |
set -euo pipefail
tags="$RUNNER_TEMP/cmux-sdk-bootstrap-tags.json"
deadline=$((SECONDS + 300))
until npm view cmux-sdk dist-tags --json > "$tags"; do
(( SECONDS < deadline )) || {
echo "cmux-sdk bootstrap tags did not become visible within 300 seconds." >&2
exit 1
}
sleep 15
done
node - "$tags" "$BOOTSTRAP_VERSION" <<'NODE'
const fs = require("node:fs");
const [path, expected] = process.argv.slice(2);
const tags = JSON.parse(fs.readFileSync(path, "utf8"));
if (tags.bootstrap !== expected || Object.hasOwn(tags, "latest")) {
throw new Error(`unexpected cmux-sdk dist-tags: ${JSON.stringify(tags)}`);
}
NODE
- name: Verify the npm ownership bootstrap
run: |
set -euo pipefail
shopt -s nullglob
packages=(bootstrap-package/*.tgz)
[[ "${#packages[@]}" == 1 ]] || {
echo "expected one tested npm artifact, found ${#packages[@]}" >&2
exit 1
}
python3 cmux-tui/bindings/verify_npm_provenance.py \
--package cmux-sdk \
--version "$BOOTSTRAP_VERSION" \
--repository-url git+https://github.com/manaflow-ai/cmux.git \
--repository-directory cmux-tui/bindings/typescript \
--owner lawrencechen \
--workflow .github/workflows/sdk-bootstrap-npm.yml \
--workflow-ref refs/heads/main \
--dist-tag bootstrap \
--publisher owner \
--artifact "${packages[0]}"
+413
View File
@@ -0,0 +1,413 @@
name: sdk bootstrap pypi
on:
repository_dispatch:
types: [sdk-bootstrap-pypi]
permissions: {}
env:
BOOTSTRAP_VERSION: "0.0.0a0"
concurrency:
group: sdk-bootstrap-pypi
cancel-in-progress: false
jobs:
build:
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
permissions:
contents: read
outputs:
artifact_id: ${{ steps.upload.outputs.artifact-id }}
artifact_sha256: ${{ steps.package.outputs.artifact_sha256 }}
steps:
- name: Require explicit bootstrap confirmation
if: github.event.client_payload.confirm_bootstrap != true
run: |
echo "Refusing to reserve cmux-sdk without confirm_bootstrap=true." >&2
exit 1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
ref: ${{ github.sha }}
fetch-depth: 0
- name: Require current protected main
run: |
set -euo pipefail
[[ "$GITHUB_REF" == "refs/heads/main" ]] || {
echo "Dispatch sdk-bootstrap-pypi.yml from main." >&2
exit 1
}
git fetch --force origin main
main_sha="$(git rev-parse origin/main)"
[[ "$GITHUB_SHA" == "$main_sha" ]] || {
echo "workflow commit is not current main" >&2
exit 1
}
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.12.8"
- name: Install pinned packaging tools
run: |
python3 -m pip install \
--disable-pip-version-check \
"build==1.3.0" \
"setuptools==80.9.0" \
"wheel==0.45.1"
- name: Prepare the prerelease source tree
env:
CMUX_BOOTSTRAP_VERSION: ${{ env.BOOTSTRAP_VERSION }}
run: |
python3 - <<'PY'
import os
from pathlib import Path
import re
import shutil
source = Path("cmux-tui/bindings/python")
target = Path(os.environ["RUNNER_TEMP"]) / "cmux-python-bootstrap"
shutil.copytree(source, target)
manifest = target / "pyproject.toml"
contents = manifest.read_text(encoding="utf-8")
contents, count = re.subn(
r'(?m)^version = "[^"]+"$',
f'version = "{os.environ["CMUX_BOOTSTRAP_VERSION"]}"',
contents,
)
if count != 1:
raise SystemExit("expected one static project version")
manifest.write_text(contents, encoding="utf-8")
PY
- name: Test the prerelease source tree
run: |
cd "$RUNNER_TEMP/cmux-python-bootstrap"
PYTHONPATH=. python3 -m unittest discover -s tests -v
- name: Build deterministic bootstrap distributions
run: |
export SOURCE_DATE_EPOCH
SOURCE_DATE_EPOCH="$(git show -s --format=%ct "$GITHUB_SHA")"
python3 -m build --no-isolation --sdist --wheel \
--outdir "$GITHUB_WORKSPACE/bootstrap-dist" \
"$RUNNER_TEMP/cmux-python-bootstrap"
python3 cmux-tui/bindings/normalize_python_sdist.py \
--archive bootstrap-dist/*.tar.gz \
--epoch "$SOURCE_DATE_EPOCH"
- name: Test the exact bootstrap distributions
env:
CMUX_PYTHON_DIST_DIR: ${{ github.workspace }}/bootstrap-dist
run: |
cd "$RUNNER_TEMP/cmux-python-bootstrap"
PYTHONPATH=. python3 -m unittest tests.test_package_consumer -v
- name: Fingerprint the bootstrap distributions
id: package
run: |
set -euo pipefail
shopt -s nullglob
wheels=(bootstrap-dist/*.whl)
sdists=(bootstrap-dist/*.tar.gz)
[[ "${#wheels[@]}" == 1 && "${#sdists[@]}" == 1 ]] || {
echo "expected one bootstrap wheel and one source distribution" >&2
exit 1
}
artifact_sha256="$(
cd bootstrap-dist
sha256sum *.whl *.tar.gz | sort -k2 | sha256sum | cut -d ' ' -f 1
)"
[[ "$artifact_sha256" =~ ^[0-9a-f]{64}$ ]] || exit 1
echo "artifact_sha256=$artifact_sha256" >> "$GITHUB_OUTPUT"
- id: upload
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: cmux-python-bootstrap-dist-${{ github.run_attempt }}
path: bootstrap-dist/*
if-no-files-found: error
preflight:
needs: build
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
permissions:
contents: read
outputs:
need_publish: ${{ steps.decision.outputs.need_publish }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
ref: ${{ github.sha }}
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
artifact-ids: ${{ needs.build.outputs.artifact_id }}
path: bootstrap-dist
- name: Check whether the PyPI project exists
id: project
run: |
python3 - <<'PY'
import json
import os
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
request = Request(
"https://pypi.org/pypi/cmux-sdk/json",
headers={"Accept": "application/json"},
)
try:
with urlopen(request, timeout=20) as response:
metadata = json.loads(response.read())
except HTTPError as error:
if error.code != 404:
raise SystemExit("PyPI project lookup failed") from error
status = "missing"
except (OSError, URLError, json.JSONDecodeError) as error:
raise SystemExit("PyPI project lookup failed") from error
else:
if not isinstance(metadata, dict) or not isinstance(
metadata.get("info"), dict
):
raise SystemExit("PyPI project metadata is malformed")
status = "exists"
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
output.write(f"status={status}\n")
PY
- name: Check the existing bootstrap wheel
if: steps.project.outputs.status == 'exists'
id: wheel_state
run: |
python3 cmux-tui/bindings/reconcile_registry_artifact.py check \
--registry pypi \
--package cmux-sdk \
--version "$BOOTSTRAP_VERSION" \
--artifact bootstrap-dist/*.whl \
--allowed-artifact bootstrap-dist/*.whl \
--allowed-artifact bootstrap-dist/*.tar.gz \
--write-github-output
- name: Check the existing bootstrap source distribution
if: steps.project.outputs.status == 'exists'
id: sdist_state
run: |
python3 cmux-tui/bindings/reconcile_registry_artifact.py check \
--registry pypi \
--package cmux-sdk \
--version "$BOOTSTRAP_VERSION" \
--artifact bootstrap-dist/*.tar.gz \
--allowed-artifact bootstrap-dist/*.whl \
--allowed-artifact bootstrap-dist/*.tar.gz \
--write-github-output
- name: Decide whether publishing is required
id: decision
env:
PROJECT_STATUS: ${{ steps.project.outputs.status }}
WHEEL_STATUS: ${{ steps.wheel_state.outputs.status }}
SDIST_STATUS: ${{ steps.sdist_state.outputs.status }}
run: |
set -euo pipefail
if [[ "$PROJECT_STATUS" == "missing" ]]; then
need_publish=true
elif [[ "$WHEEL_STATUS" == "match" && "$SDIST_STATUS" == "match" ]]; then
need_publish=false
elif [[ "$WHEEL_STATUS" == "missing" && "$SDIST_STATUS" == "missing" ]]; then
echo "cmux-sdk exists without the expected bootstrap release" >&2
exit 1
elif { [[ "$WHEEL_STATUS" == "match" && "$SDIST_STATUS" == "missing" ]] ||
[[ "$WHEEL_STATUS" == "missing" && "$SDIST_STATUS" == "match" ]]; }; then
need_publish=true
else
echo "unexpected bootstrap registry state" >&2
exit 1
fi
echo "need_publish=$need_publish" >> "$GITHUB_OUTPUT"
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
if: steps.project.outputs.status == 'exists'
with:
python-version: "3.12.8"
- name: Install the pinned provenance verifier
if: steps.project.outputs.status == 'exists'
run: |
python3 -m pip install \
--disable-pip-version-check \
"pypi-attestations==0.0.29"
- name: Verify existing bootstrap with pypi-attestations verify pypi
if: steps.project.outputs.status == 'exists'
env:
WHEEL_STATUS: ${{ steps.wheel_state.outputs.status }}
SDIST_STATUS: ${{ steps.sdist_state.outputs.status }}
run: |
set -euo pipefail
shopt -s nullglob
filenames=()
if [[ "$WHEEL_STATUS" == "match" ]]; then
wheels=(bootstrap-dist/*.whl)
filenames+=(--filename "$(basename "${wheels[0]}")")
fi
if [[ "$SDIST_STATUS" == "match" ]]; then
sdists=(bootstrap-dist/*.tar.gz)
filenames+=(--filename "$(basename "${sdists[0]}")")
fi
python3 cmux-tui/bindings/verify_pypi_provenance.py \
--package cmux-sdk \
--version "$BOOTSTRAP_VERSION" \
--repository https://github.com/manaflow-ai/cmux \
--owner lawrencecchen \
--workflow sdk-bootstrap-pypi.yml \
--environment pypi-bootstrap \
"${filenames[@]}"
publish:
needs:
- build
- preflight
if: needs.preflight.outputs.need_publish == 'true'
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
permissions:
id-token: write
environment:
name: pypi-bootstrap
url: https://pypi.org/p/cmux-sdk
outputs:
outcome: ${{ steps.publish.outcome }}
steps:
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
artifact-ids: ${{ needs.build.outputs.artifact_id }}
path: bootstrap-dist
- name: Verify the immutable bootstrap distributions
env:
EXPECTED_ARTIFACT_SHA256: ${{ needs.build.outputs.artifact_sha256 }}
run: |
set -euo pipefail
[[ "$EXPECTED_ARTIFACT_SHA256" =~ ^[0-9a-f]{64}$ ]] || exit 1
actual_sha256="$(
cd bootstrap-dist
sha256sum *.whl *.tar.gz | sort -k2 | sha256sum | cut -d ' ' -f 1
)"
[[ "$actual_sha256" == "$EXPECTED_ARTIFACT_SHA256" ]] || {
echo "downloaded Python bootstrap artifact digest mismatch" >&2
exit 1
}
- name: Revalidate protected source before bootstrap publication
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
}
[[ "$GITHUB_SHA" =~ ^[0-9a-f]{40}$ ]] || {
echo "bootstrap commit is malformed" >&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
}
- name: Publish the attested bootstrap distributions
id: publish
continue-on-error: true
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
with:
packages-dir: bootstrap-dist
attestations: true
skip-existing: true
verify:
needs:
- build
- preflight
- publish
if: >-
always() &&
needs.build.result == 'success' &&
needs.preflight.result == 'success' &&
(needs.publish.result == 'success' || needs.publish.result == 'skipped')
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
permissions:
contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
ref: ${{ github.sha }}
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.12.8"
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
artifact-ids: ${{ needs.build.outputs.artifact_id }}
path: bootstrap-dist
- name: Install the pinned provenance verifier
run: |
python3 -m pip install \
--disable-pip-version-check \
"pypi-attestations==0.0.29"
- name: Reconcile exact bootstrap distributions
run: |
set -euo pipefail
python3 cmux-tui/bindings/reconcile_registry_artifact.py check \
--registry pypi \
--package cmux-sdk \
--version "$BOOTSTRAP_VERSION" \
--artifact bootstrap-dist/*.whl \
--allowed-artifact bootstrap-dist/*.whl \
--allowed-artifact bootstrap-dist/*.tar.gz \
--wait-seconds 300 \
--require-match
python3 cmux-tui/bindings/reconcile_registry_artifact.py check \
--registry pypi \
--package cmux-sdk \
--version "$BOOTSTRAP_VERSION" \
--artifact bootstrap-dist/*.tar.gz \
--allowed-artifact bootstrap-dist/*.whl \
--allowed-artifact bootstrap-dist/*.tar.gz \
--wait-seconds 300 \
--require-match
- name: Verify trusted-publisher provenance with pypi-attestations verify pypi
run: |
set -euo pipefail
shopt -s nullglob
wheels=(bootstrap-dist/*.whl)
sdists=(bootstrap-dist/*.tar.gz)
python3 cmux-tui/bindings/verify_pypi_provenance.py \
--package cmux-sdk \
--version "$BOOTSTRAP_VERSION" \
--filename "$(basename "${wheels[0]}")" \
--filename "$(basename "${sdists[0]}")" \
--repository https://github.com/manaflow-ai/cmux \
--owner lawrencecchen \
--workflow sdk-bootstrap-pypi.yml \
--environment pypi-bootstrap
+75 -68
View File
@@ -1,19 +1,24 @@
name: sdk publish crates
name: sdk preflight crates
on:
push:
tags:
- "mux-sdk-v*"
- "cmux-sdk-v*"
workflow_call:
inputs:
version:
description: "SDK version to validate"
required: true
type: string
workflow_dispatch:
inputs:
version:
description: "SDK version to validate/publish, for example 0.1.0"
description: "SDK version to validate, for example 0.1.0"
required: true
type: string
permissions: {}
env:
RUST_TOOLCHAIN: "1.95.0"
concurrency:
group: sdk-publish-crates-${{ github.ref }}
cancel-in-progress: false
@@ -29,6 +34,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
fetch-depth: 0
- name: Validate tag and package versions
id: version
@@ -37,13 +43,15 @@ jobs:
run: |
set -euo pipefail
if [[ "${GITHUB_REF_TYPE:-}" == "tag" ]]; then
[[ "$GITHUB_REF_NAME" =~ ^(mux|cmux)-sdk-v[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
echo "tag must match mux-sdk-vX.Y.Z or cmux-sdk-vX.Y.Z" >&2
[[ "$GITHUB_REF_NAME" =~ ^cmux-sdk-v[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
echo "tag must match cmux-sdk-vX.Y.Z" >&2
exit 1
}
version="${GITHUB_REF_NAME#cmux-sdk-v}"
[[ "$DISPATCH_VERSION" == "$version" ]] || {
echo "workflow_dispatch version $DISPATCH_VERSION does not match tag version $version" >&2
exit 1
}
version="$GITHUB_REF_NAME"
version="${version#mux-sdk-v}"
version="${version#cmux-sdk-v}"
else
version="$DISPATCH_VERSION"
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
@@ -51,6 +59,8 @@ jobs:
exit 1
}
fi
python3 cmux-tui/bindings/validate_release_version.py \
--version "$version"
python3 - "$version" <<'PY'
import json
import pathlib
@@ -72,7 +82,9 @@ jobs:
raise SystemExit(1)
print(f"All package versions match {expected}")
PY
python3 cmux-tui/bindings/check-versions.py --expected "$version"
python3 cmux-tui/bindings/check-versions.py \
--published-only \
--expected "$version"
echo "version=$version" >> "$GITHUB_OUTPUT"
bindings-e2e-rust:
@@ -97,18 +109,63 @@ jobs:
- name: Install zig
run: ./scripts/install-zig-ci.sh
- name: Rust version
- name: Install pinned Rust toolchain
run: |
rustc --version || true
if ! command -v cargo >/dev/null; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
fi
rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal
rustup default "$RUST_TOOLCHAIN"
cargo --version
rustc --version
- name: Build cmux-tui server
working-directory: cmux-tui
run: cargo build -p cmux-tui --bin cmux-tui --locked
- name: Test Rust SDK packages
working-directory: cmux-tui
env:
CMUX_SDK_VERSION: ${{ needs.version.outputs.version }}
run: |
set -euo pipefail
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-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-sdk-$CMUX_SDK_VERSION.crate" \
-C "$verify_root"
tar -xzf \
"target/package/cmux-sidebar-$CMUX_SDK_VERSION.crate" \
-C "$verify_root"
cargo test \
--manifest-path \
"$verify_root/cmux-sidebar-$CMUX_SDK_VERSION/Cargo.toml" \
--config \
"patch.crates-io.cmux-sdk.path='$verify_root/cmux-sdk-$CMUX_SDK_VERSION'" \
--all-targets
- name: Upload validated cmux-sdk crate
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: cmux-rust-sdk-crate
path: cmux-tui/target/package/cmux-sdk-${{ needs.version.outputs.version }}.crate
if-no-files-found: error
overwrite: true
- name: Upload validated cmux-sidebar crate
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: cmux-rust-sidebar-crate
path: cmux-tui/target/package/cmux-sidebar-${{ needs.version.outputs.version }}.crate
if-no-files-found: error
overwrite: true
- name: Rust SDK conformance
run: |
report="$RUNNER_TEMP/cmux-sdk-conformance-rust.txt"
@@ -118,53 +175,3 @@ jobs:
--cmux-tui-bin "$GITHUB_WORKSPACE/cmux-tui/target/debug/cmux-tui" |
tee "$report"
grep -Eq '^PASS +rust +live-creation-exit-restart-unix$' "$report"
publish:
needs:
- version
- bindings-e2e-rust
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
permissions:
contents: read
id-token: write
environment:
name: crates-io
url: https://crates.io/crates/cmux-client
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Authenticate cmux-client with crates.io trusted publishing
id: auth_client
uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1.0.5
- name: Publish cmux-client
working-directory: cmux-tui
env:
CARGO_REGISTRY_TOKEN: ${{ steps.auth_client.outputs.token }}
run: cargo publish -p cmux-client --locked
- name: Wait for cmux-client to reach the crates.io index
env:
CMUX_SDK_VERSION: ${{ needs.version.outputs.version }}
run: |
set -euo pipefail
for _ in $(seq 1 30); do
if cargo info "cmux-client@$CMUX_SDK_VERSION" >/dev/null 2>&1; then
exit 0
fi
sleep 10
done
echo "cmux-client@$CMUX_SDK_VERSION did not reach the crates.io index" >&2
exit 1
- name: Authenticate cmux-sidebar with crates.io trusted publishing
id: auth_sidebar
uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1.0.5
- name: Publish cmux-sidebar
working-directory: cmux-tui
env:
CARGO_REGISTRY_TOKEN: ${{ steps.auth_sidebar.outputs.token }}
run: cargo publish -p cmux-sidebar --locked
+146 -12
View File
@@ -1,10 +1,22 @@
name: sdk publish go
name: sdk validate go
on:
push:
tags:
- "mux-sdk-v*"
- "cmux-sdk-v*"
workflow_call:
inputs:
version:
description: "SDK version to validate or verify"
required: true
type: string
verify_tag:
description: "Resolve the coordinated public Go module tag"
required: false
default: false
type: boolean
release_ref:
description: "Exact coordinated Go module tag ref"
required: false
default: ""
type: string
workflow_dispatch:
inputs:
version:
@@ -29,28 +41,66 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
fetch-depth: 0
- name: Validate tag and package versions
id: version
env:
CALLER_WORKFLOW_REF: ${{ github.workflow_ref }}
DISPATCH_VERSION: ${{ inputs.version }}
RELEASE_REF: ${{ inputs.release_ref }}
VERIFY_TAG: ${{ inputs.verify_tag }}
run: |
set -euo pipefail
if [[ "${GITHUB_REF_TYPE:-}" == "tag" ]]; then
[[ "$GITHUB_REF_NAME" =~ ^(mux|cmux)-sdk-v[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
echo "tag must match mux-sdk-vX.Y.Z or cmux-sdk-vX.Y.Z" >&2
[[ "$GITHUB_REF_NAME" =~ ^cmux-tui/bindings/go/v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || {
echo "tag must match cmux-tui/bindings/go/vX.Y.Z" >&2
exit 1
}
version="${GITHUB_REF_NAME#cmux-tui/bindings/go/v}"
[[ "$DISPATCH_VERSION" == "$version" ]] || {
echo "requested version $DISPATCH_VERSION does not match tag version $version" >&2
exit 1
}
version="$GITHUB_REF_NAME"
version="${version#mux-sdk-v}"
version="${version#cmux-sdk-v}"
else
version="$DISPATCH_VERSION"
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
[[ "$version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || {
echo "workflow_dispatch version must match X.Y.Z" >&2
exit 1
}
fi
python3 cmux-tui/bindings/validate_release_version.py \
--version "$version"
if [[ "$VERIFY_TAG" == "true" ]]; then
expected_caller="$GITHUB_REPOSITORY/.github/workflows/sdk-release-cut.yml@$GITHUB_REF"
[[ "$CALLER_WORKFLOW_REF" == "$expected_caller" ]] || {
echo "Public Go tag verification is only available through sdk-release-cut.yml." >&2
exit 1
}
tag="cmux-tui/bindings/go/v$version"
expected_ref="refs/tags/$tag"
[[ "$RELEASE_REF" == "$expected_ref" ]] || {
echo "Refusing to verify Go ref $RELEASE_REF; expected $expected_ref." >&2
exit 1
}
git fetch --force origin main --tags
git tag --list 'cmux-sdk-v*' | \
python3 cmux-tui/bindings/validate_release_version.py \
--version "$version" \
--require-latest-tag
release_sha="$(git rev-parse "refs/tags/$tag^{commit}")" || {
echo "release tag does not exist: $tag" >&2
exit 1
}
git merge-base --is-ancestor "$release_sha" origin/main || {
echo "release tag $tag is not an ancestor of protected main" >&2
exit 1
}
[[ "$release_sha" == "$GITHUB_SHA" ]] || {
echo "release tag $tag resolves to $release_sha, expected workflow commit $GITHUB_SHA" >&2
exit 1
}
fi
python3 - "$version" <<'PY'
import json
import pathlib
@@ -71,10 +121,13 @@ jobs:
raise SystemExit(1)
print(f"All package versions match {expected}")
PY
python3 cmux-tui/bindings/check-versions.py --expected "$version"
python3 cmux-tui/bindings/check-versions.py \
--published-only \
--expected "$version"
echo "version=$version" >> "$GITHUB_OUTPUT"
bindings-e2e-go:
if: inputs.verify_tag != true
needs: version
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 40
@@ -124,6 +177,7 @@ jobs:
grep -Eq '^PASS +go +live-creation-exit-restart-unix$' "$report"
validate-go-module:
if: inputs.verify_tag != true
needs: bindings-e2e-go
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
permissions:
@@ -141,5 +195,85 @@ jobs:
- name: Validate Go module
working-directory: cmux-tui/bindings/go
run: |
go test ./...
go build ./...
go vet ./...
verify-versioned-go-module:
if: inputs.verify_tag == true
needs: version
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 35
permissions:
contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
ref: ${{ github.sha }}
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: "1.22.x"
cache: false
- name: Resolve the public module tag from a clean consumer
env:
CMUX_SDK_VERSION: ${{ needs.version.outputs.version }}
run: |
set -euo pipefail
module="github.com/manaflow-ai/cmux/cmux-tui/bindings/go"
expected="v$CMUX_SDK_VERSION"
scratch="$(mktemp -d)"
trap 'rm -rf "$scratch"' EXIT
export GOENV=off
export GOFLAGS=""
export GOINSECURE=""
export GOPROXY=https://proxy.golang.org
export GOSUMDB=sum.golang.org
export GOPRIVATE=""
export GONOPROXY=none
export GONOSUMDB=none
export GOMODCACHE="$scratch/modcache"
export GOCACHE="$scratch/buildcache"
export GOWORK=off
mkdir "$scratch/consumer"
cd "$scratch/consumer"
go mod init cmux-release-consumer
python3 "$GITHUB_WORKSPACE/cmux-tui/bindings/wait_for_go_module.py" \
--module "$module" \
--version "$expected" \
--wait-seconds 1800 \
--retry-seconds 30
go get "$module@$expected"
go mod download "$module@$expected"
go mod verify
resolved="$(go list -m -f '{{.Version}}' "$module")"
[[ "$resolved" == "$expected" ]] || {
echo "resolved $module@$resolved, expected $expected" >&2
exit 1
}
module_dir="$(go list -m -f '{{.Dir}}' "$module")"
python3 "$GITHUB_WORKSPACE/cmux-tui/bindings/verify_go_module_source.py" \
--repository "$GITHUB_WORKSPACE" \
--commit "$GITHUB_SHA" \
--module-subdir cmux-tui/bindings/go \
--downloaded-root "$module_dir"
cat > release_test.go <<EOF
package consumer
import (
"testing"
cmux "$module"
raw "$module/raw"
)
func TestReleasedPackagesCompile(t *testing.T) {
_ = cmux.ClientOptions{}
_ = raw.Options{}
}
EOF
gofmt -w release_test.go
go test -mod=readonly ./...
+5 -19
View File
@@ -1,10 +1,6 @@
name: sdk publish java
on:
push:
tags:
- "mux-sdk-v*"
- "cmux-sdk-v*"
workflow_dispatch:
inputs:
version:
@@ -36,21 +32,11 @@ jobs:
DISPATCH_VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
if [[ "${GITHUB_REF_TYPE:-}" == "tag" ]]; then
[[ "$GITHUB_REF_NAME" =~ ^(mux|cmux)-sdk-v[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
echo "tag must match mux-sdk-vX.Y.Z or cmux-sdk-vX.Y.Z" >&2
exit 1
}
version="$GITHUB_REF_NAME"
version="${version#mux-sdk-v}"
version="${version#cmux-sdk-v}"
else
version="$DISPATCH_VERSION"
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
echo "workflow_dispatch version must match X.Y.Z" >&2
exit 1
}
fi
version="$DISPATCH_VERSION"
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
echo "workflow_dispatch version must match X.Y.Z" >&2
exit 1
}
python3 - "$version" <<'PY'
import json
import pathlib
+55 -67
View File
@@ -1,21 +1,25 @@
name: sdk publish npm
name: sdk preflight npm
on:
push:
tags:
- "mux-sdk-v*"
- "cmux-sdk-v*"
workflow_call:
inputs:
version:
description: "SDK version to validate"
required: true
type: string
outputs:
artifact_id:
description: "Immutable ID of the validated npm artifact"
value: ${{ jobs.bindings-e2e-typescript.outputs.artifact_id }}
artifact_sha256:
description: "SHA-256 of the validated npm tarball"
value: ${{ jobs.bindings-e2e-typescript.outputs.artifact_sha256 }}
workflow_dispatch:
inputs:
version:
description: "SDK version to validate/publish, for example 0.1.0"
description: "SDK version to validate, for example 0.1.0"
required: true
type: string
confirm_npm_cmux:
description: "Set true only for the coordinated npm cmux SDK publish"
required: true
default: false
type: boolean
permissions: {}
@@ -37,6 +41,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
fetch-depth: 0
- name: Validate tag and package versions
id: version
@@ -45,13 +50,15 @@ jobs:
run: |
set -euo pipefail
if [[ "${GITHUB_REF_TYPE:-}" == "tag" ]]; then
[[ "$GITHUB_REF_NAME" =~ ^(mux|cmux)-sdk-v[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
echo "tag must match mux-sdk-vX.Y.Z or cmux-sdk-vX.Y.Z" >&2
[[ "$GITHUB_REF_NAME" =~ ^cmux-sdk-v[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
echo "tag must match cmux-sdk-vX.Y.Z" >&2
exit 1
}
version="${GITHUB_REF_NAME#cmux-sdk-v}"
[[ "$DISPATCH_VERSION" == "$version" ]] || {
echo "workflow_dispatch version $DISPATCH_VERSION does not match tag version $version" >&2
exit 1
}
version="$GITHUB_REF_NAME"
version="${version#mux-sdk-v}"
version="${version#cmux-sdk-v}"
else
version="$DISPATCH_VERSION"
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
@@ -59,6 +66,8 @@ jobs:
exit 1
}
fi
python3 cmux-tui/bindings/validate_release_version.py \
--version "$version"
python3 - "$version" <<'PY'
import json
import pathlib
@@ -79,7 +88,9 @@ jobs:
raise SystemExit(1)
print(f"All package versions match {expected}")
PY
python3 cmux-tui/bindings/check-versions.py --expected "$version"
python3 cmux-tui/bindings/check-versions.py \
--published-only \
--expected "$version"
echo "version=$version" >> "$GITHUB_OUTPUT"
bindings-e2e-typescript:
@@ -88,6 +99,9 @@ jobs:
timeout-minutes: 40
permissions:
contents: read
outputs:
artifact_id: ${{ steps.upload.outputs.artifact-id }}
artifact_sha256: ${{ steps.package.outputs.artifact_sha256 }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -119,7 +133,9 @@ jobs:
- name: Install TypeScript adapter dependencies
working-directory: cmux-tui/bindings/typescript
run: npm ci --no-audit --no-fund
run: |
npm ci --no-audit --no-fund
npm test
- name: Build cmux-tui server
working-directory: cmux-tui
@@ -137,55 +153,27 @@ jobs:
grep -Eq '^PASS +typescript +live-creation-exit-restart-unix$' "$report"
grep -Eq '^PASS +typescript +live-creation-exit-restart-websocket$' "$report"
publish:
# The npm package name "cmux" is currently a different live package
# (the cloud-VM CLI). Publishing the SDK there is a coordinated breaking
# action, so tag pushes never publish to npm and manual runs must opt in.
if: github.event_name == 'workflow_dispatch'
needs: bindings-e2e-typescript
# npm --provenance rejects self-hosted runners; the attestation is only
# verifiable from a GitHub-hosted runner. This one publish job must stay on
# ubuntu-latest (github-hosted), unlike the routed self-hosted jobs above.
runs-on: ubuntu-latest # github-hosted-required: npm provenance needs a github-hosted runner
permissions:
contents: read
id-token: write
environment:
name: npm
url: https://www.npmjs.com/package/cmux
steps:
- name: Require npm cmux confirmation
if: inputs.confirm_npm_cmux != true
run: |
echo "Refusing to publish npm package cmux without confirm_npm_cmux=true." >&2
exit 1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22.14.0"
registry-url: https://registry.npmjs.org
- name: Upgrade npm for OIDC trusted publishing
# Node 22 bundles npm 10, which signs provenance but cannot
# authenticate the publish via OIDC trusted publishing (the PUT is
# unauthenticated and 404s). npm >= 11.5.1 performs the OIDC token
# exchange for the publish itself.
run: npm install -g npm@^11.5.1
- name: Build package
- name: Pack the validated npm artifact
id: package
working-directory: cmux-tui/bindings/typescript
run: |
npm ci --no-audit --no-fund
npm run build
set -euo pipefail
mkdir -p "$RUNNER_TEMP/cmux-npm-dist"
npm pack --pack-destination "$RUNNER_TEMP/cmux-npm-dist"
shopt -s nullglob
packages=("$RUNNER_TEMP"/cmux-npm-dist/*.tgz)
[[ "${#packages[@]}" == 1 ]] || {
echo "expected one validated npm artifact" >&2
exit 1
}
artifact_sha256="$(sha256sum "${packages[0]}" | cut -d ' ' -f 1)"
[[ "$artifact_sha256" =~ ^[0-9a-f]{64}$ ]] || exit 1
echo "artifact_sha256=$artifact_sha256" >> "$GITHUB_OUTPUT"
- name: Publish package to npm
working-directory: cmux-tui/bindings/typescript
# The npm `cmux` name still serves the cloud-VM CLI on the `latest`
# dist-tag (0.8.3). The SDK ships on its own `sdk` tag so installing
# bare `cmux` keeps resolving the CLI; use `npm i cmux@sdk` for the SDK.
run: npm publish --provenance --tag sdk
- name: Upload the validated npm artifact
id: upload
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: cmux-npm-dist-${{ github.run_attempt }}
path: ${{ runner.temp }}/cmux-npm-dist/*.tgz
if-no-files-found: error
+87 -37
View File
@@ -1,14 +1,23 @@
name: sdk publish python
name: sdk preflight python
on:
push:
tags:
- "mux-sdk-v*"
- "cmux-sdk-v*"
workflow_call:
inputs:
version:
description: "SDK version to validate"
required: true
type: string
outputs:
artifact_id:
description: "Immutable ID of the validated Python distributions"
value: ${{ jobs.build.outputs.artifact_id }}
artifact_sha256:
description: "SHA-256 of the validated distribution digest manifest"
value: ${{ jobs.build.outputs.artifact_sha256 }}
workflow_dispatch:
inputs:
version:
description: "SDK version to validate/publish, for example 0.1.0"
description: "SDK version to validate, for example 0.1.0"
required: true
type: string
@@ -32,6 +41,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
fetch-depth: 0
- name: Validate tag and package versions
id: version
@@ -40,13 +50,15 @@ jobs:
run: |
set -euo pipefail
if [[ "${GITHUB_REF_TYPE:-}" == "tag" ]]; then
[[ "$GITHUB_REF_NAME" =~ ^(mux|cmux)-sdk-v[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
echo "tag must match mux-sdk-vX.Y.Z or cmux-sdk-vX.Y.Z" >&2
[[ "$GITHUB_REF_NAME" =~ ^cmux-sdk-v[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
echo "tag must match cmux-sdk-vX.Y.Z" >&2
exit 1
}
version="${GITHUB_REF_NAME#cmux-sdk-v}"
[[ "$DISPATCH_VERSION" == "$version" ]] || {
echo "workflow_dispatch version $DISPATCH_VERSION does not match tag version $version" >&2
exit 1
}
version="$GITHUB_REF_NAME"
version="${version#mux-sdk-v}"
version="${version#cmux-sdk-v}"
else
version="$DISPATCH_VERSION"
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
@@ -54,6 +66,8 @@ jobs:
exit 1
}
fi
python3 cmux-tui/bindings/validate_release_version.py \
--version "$version"
python3 - "$version" <<'PY'
import json
import pathlib
@@ -74,7 +88,9 @@ jobs:
raise SystemExit(1)
print(f"All package versions match {expected}")
PY
python3 cmux-tui/bindings/check-versions.py --expected "$version"
python3 cmux-tui/bindings/check-versions.py \
--published-only \
--expected "$version"
echo "version=$version" >> "$GITHUB_OUTPUT"
bindings-e2e-python:
@@ -88,6 +104,10 @@ jobs:
with:
persist-credentials: false
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.12.8"
- name: Init ghostty submodule
run: git submodule update --init --depth 1 ghostty
@@ -109,6 +129,16 @@ jobs:
working-directory: cmux-tui
run: cargo build -p cmux-tui --bin cmux-tui --locked
- name: Install declared Python build backend
run: |
python3 -m pip install \
--disable-pip-version-check \
"setuptools==80.9.0"
- name: Test Python SDK package
working-directory: cmux-tui/bindings/python
run: PYTHONPATH=. python3 -m unittest discover -s tests -v
- name: Python SDK conformance
run: |
report="$RUNNER_TEMP/cmux-sdk-conformance-python.txt"
@@ -124,42 +154,62 @@ jobs:
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
permissions:
contents: read
outputs:
artifact_id: ${{ steps.upload.outputs.artifact-id }}
artifact_sha256: ${{ steps.package.outputs.artifact_sha256 }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.12.8"
- name: Install pinned Python packaging tools
run: |
python3 -m pip install \
--disable-pip-version-check \
"build==1.3.0" \
"setuptools==80.9.0" \
"wheel==0.45.1"
- name: Build sdist and wheel
working-directory: cmux-tui/bindings/python
run: |
python3 -m pip install --upgrade build
python3 -m build --sdist --wheel
set -euo pipefail
SOURCE_DATE_EPOCH="$(git show -s --format=%ct "$GITHUB_SHA")"
export SOURCE_DATE_EPOCH
python3 -m build --no-isolation --sdist --wheel
python3 ../normalize_python_sdist.py \
--archive dist/*.tar.gz \
--epoch "$SOURCE_DATE_EPOCH"
- name: Test the exact Python distributions
working-directory: cmux-tui/bindings/python
env:
CMUX_PYTHON_DIST_DIR: ${{ github.workspace }}/cmux-tui/bindings/python/dist
run: PYTHONPATH=. python3 -m unittest tests.test_package_consumer -v
- name: Fingerprint the validated Python distributions
id: package
run: |
set -euo pipefail
cd cmux-tui/bindings/python/dist
shopt -s nullglob
files=(*.whl *.tar.gz)
[[ "${#files[@]}" == 2 ]] || {
echo "expected one wheel and one source distribution" >&2
exit 1
}
artifact_sha256="$(sha256sum "${files[@]}" | sort -k2 | sha256sum | cut -d ' ' -f 1)"
[[ "$artifact_sha256" =~ ^[0-9a-f]{64}$ ]] || exit 1
echo "artifact_sha256=$artifact_sha256" >> "$GITHUB_OUTPUT"
- name: Upload distributions
id: upload
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: cmux-python-dist
name: cmux-python-dist-${{ github.run_attempt }}
path: cmux-tui/bindings/python/dist/*
if-no-files-found: error
publish:
needs: build
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
permissions:
contents: read
id-token: write
environment:
name: pypi
url: https://pypi.org/p/cmux
steps:
- name: Download distributions
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: cmux-python-dist
path: dist
- name: Publish package distributions to PyPI
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
with:
packages-dir: dist
attestations: true
File diff suppressed because it is too large Load Diff
+1 -22
View File
@@ -59,28 +59,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
GHOSTTY_SHA=$(git -C ghostty rev-parse HEAD)
BUILD_FLAVOR="crashsubdir-cmux-crash-v1"
TAG="xcframework-$GHOSTTY_SHA-$BUILD_FLAVOR"
URL="https://github.com/manaflow-ai/ghostty/releases/download/$TAG/GhosttyKit.xcframework.tar.gz"
echo "Downloading xcframework for ghostty $GHOSTTY_SHA"
MAX_RETRIES=30
RETRY_DELAY=20
for i in $(seq 1 $MAX_RETRIES); do
if curl -fSL -o GhosttyKit.xcframework.tar.gz "$URL"; then
echo "Download succeeded on attempt $i"
break
fi
if [ "$i" -eq "$MAX_RETRIES" ]; then
echo "Failed to download xcframework after $MAX_RETRIES attempts" >&2
exit 1
fi
echo "Attempt $i/$MAX_RETRIES failed, retrying in ${RETRY_DELAY}s..."
sleep $RETRY_DELAY
done
tar xzf GhosttyKit.xcframework.tar.gz
rm GhosttyKit.xcframework.tar.gz
test -d GhosttyKit.xcframework
./scripts/download-prebuilt-ghosttykit.sh
- name: Install zig
run: |
+1 -22
View File
@@ -180,28 +180,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
GHOSTTY_SHA=$(git -C ghostty rev-parse HEAD)
BUILD_FLAVOR="crashsubdir-cmux-crash-v1"
TAG="xcframework-$GHOSTTY_SHA-$BUILD_FLAVOR"
URL="https://github.com/manaflow-ai/ghostty/releases/download/$TAG/GhosttyKit.xcframework.tar.gz"
echo "Downloading xcframework for ghostty $GHOSTTY_SHA"
MAX_RETRIES=30
RETRY_DELAY=20
for i in $(seq 1 $MAX_RETRIES); do
if curl -fSL -o GhosttyKit.xcframework.tar.gz "$URL"; then
echo "Download succeeded on attempt $i"
break
fi
if [ "$i" -eq "$MAX_RETRIES" ]; then
echo "Failed to download xcframework after $MAX_RETRIES attempts" >&2
exit 1
fi
echo "Attempt $i/$MAX_RETRIES failed, retrying in ${RETRY_DELAY}s..."
sleep $RETRY_DELAY
done
tar xzf GhosttyKit.xcframework.tar.gz
rm GhosttyKit.xcframework.tar.gz
test -d GhosttyKit.xcframework
./scripts/download-prebuilt-ghosttykit.sh
- name: Install zig
run: |
+7
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.
@@ -401,6 +405,9 @@ jobs:
xcrun simctl boot "$SIMULATOR_ID" >/dev/null 2>&1 || true
xcrun simctl bootstatus "$SIMULATOR_ID" -b
if xcodebuild "${XCODEBUILD_ARGS[@]}" 2>&1 | tee "$LOG_PATH"; then
./scripts/ci/require_selected_test_execution.sh \
"$LOG_PATH" \
"${TEST_FILTER:-}"
exit 0
fi
status="${PIPESTATUS[0]}"
+1 -1
View File
@@ -92,7 +92,7 @@ jobs:
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: GhosttyKit.xcframework
key: ghosttykit-${{ hashFiles('.gitmodules', 'ghostty/**') }}
key: ghosttykit-sentry-off-v1-${{ hashFiles('.gitmodules', 'ghostty/**') }}
- name: Download pre-built GhosttyKit.xcframework
if: steps.cache-ghosttykit.outputs.cache-hit != 'true'
+1 -164
View File
@@ -3,36 +3,19 @@ name: cmux-tui publish npm
on:
workflow_dispatch:
inputs:
publish_target:
description: "Package contents to publish under the shared npm cmux name"
required: true
default: tui
type: choice
options:
- tui
- sdk
version:
description: "Package version to publish, for example 0.1.0"
required: true
type: string
artifact_run_id:
description: "Successful cmux-tui release run containing verified packages"
required: false
type: string
sdk_verification_run_id:
description: "SDK npm workflow run whose TypeScript end-to-end job passed"
required: false
required: true
type: string
confirm_tui_cmux:
description: "Set true only for the coordinated npm cmux TUI publish"
required: true
default: false
type: boolean
confirm_sdk_cmux:
description: "Set true only for the coordinated npm cmux SDK publish"
required: true
default: false
type: boolean
permissions: {}
@@ -42,7 +25,6 @@ concurrency:
jobs:
validate-version:
if: inputs.publish_target == 'tui'
# This workflow's launcher publish deliberately omits --tag so the version
# becomes npm `latest`. Only strict stable X.Y.Z may go through here; a
# nightly-form version on latest would put a nightly in front of every
@@ -153,109 +135,7 @@ jobs:
exit 1
fi
validate-sdk-version:
if: inputs.publish_target == 'sdk'
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 5
permissions:
actions: read
contents: read
outputs:
release_sha: ${{ steps.release.outputs.release_sha }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
ref: ${{ github.sha }}
fetch-depth: 0
- name: Require protected main and verified SDK run
id: release
env:
GH_TOKEN: ${{ github.token }}
DISPATCH_VERSION: ${{ inputs.version }}
SDK_VERIFICATION_RUN_ID: ${{ inputs.sdk_verification_run_id }}
run: |
set -euo pipefail
[[ "$GITHUB_REF" == "refs/heads/main" ]] || {
echo "Refusing to publish the SDK from $GITHUB_REF; dispatch this workflow on main." >&2
exit 1
}
[[ "$DISPATCH_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
echo "workflow_dispatch version must match X.Y.Z" >&2
exit 1
}
[[ "$SDK_VERIFICATION_RUN_ID" =~ ^[0-9]+$ ]] || {
echo "sdk_verification_run_id must be a GitHub Actions run ID" >&2
exit 1
}
git fetch --force origin main
current_main="$(git rev-parse origin/main)"
if ! git merge-base --is-ancestor "$GITHUB_SHA" "$current_main"; then
echo "workflow commit $GITHUB_SHA is not contained in protected main $current_main" >&2
exit 1
fi
python3 - "$DISPATCH_VERSION" <<'PY'
import json
import pathlib
import sys
import tomllib
expected = sys.argv[1]
root = pathlib.Path.cwd()
versions = {
"typescript package.json": json.loads((root / "cmux-tui/bindings/typescript/package.json").read_text())["version"],
"python pyproject.toml": tomllib.loads((root / "cmux-tui/bindings/python/pyproject.toml").read_text())["project"]["version"],
"rust Cargo.toml": tomllib.loads((root / "cmux-tui/bindings/rust/Cargo.toml").read_text())["package"]["version"],
}
mismatches = {name: got for name, got in versions.items() if got != expected}
if mismatches:
for name, got in mismatches.items():
print(f"{name}: expected {expected}, got {got}", file=sys.stderr)
raise SystemExit(1)
print(f"All package versions match {expected}")
PY
IFS=$'\t' read -r actual_path verified_sha event status <<<"$(
gh api "repos/$GITHUB_REPOSITORY/actions/runs/$SDK_VERIFICATION_RUN_ID" \
--jq '[.path, .head_sha, .event, .status] | @tsv'
)"
if [[ "$actual_path" != ".github/workflows/sdk-publish-npm.yml" ]]; then
echo "verification run $SDK_VERIFICATION_RUN_ID came from $actual_path" >&2
exit 1
fi
if [[ "$event" != "workflow_dispatch" || "$status" != "completed" ]]; then
echo "verification run must be a completed workflow_dispatch run; got $event/$status" >&2
exit 1
fi
IFS=$'\t' read -r job_count job_status job_conclusion <<<"$(
gh api "repos/$GITHUB_REPOSITORY/actions/runs/$SDK_VERIFICATION_RUN_ID/jobs" \
--jq '[.jobs[] | select(.name == "bindings-e2e-typescript")] as $jobs |
[($jobs | length), ($jobs[0].status // ""), ($jobs[0].conclusion // "")] | @tsv'
)"
if [[ "$job_count" != "1" || "$job_status" != "completed" || "$job_conclusion" != "success" ]]; then
echo "verification run TypeScript end-to-end job is not a single completed success" >&2
exit 1
fi
git merge-base --is-ancestor "$verified_sha" "$GITHUB_SHA" || {
echo "verification commit $verified_sha is not an ancestor of $GITHUB_SHA" >&2
exit 1
}
if ! git diff --quiet "$verified_sha" "$GITHUB_SHA" -- \
cmux-tui \
.github/workflows/sdk-publish-npm.yml \
':(exclude)cmux-tui/bindings/RELEASING.md'; then
echo "SDK sources or verification workflow changed after run $SDK_VERIFICATION_RUN_ID" >&2
exit 1
fi
echo "release_sha=$GITHUB_SHA" >> "$GITHUB_OUTPUT"
publish:
if: inputs.publish_target == 'tui'
needs: validate-version
runs-on: ubuntu-latest # github-hosted-required: npm provenance needs a github-hosted runner
permissions:
@@ -336,46 +216,3 @@ jobs:
# Deliberately do not pass --tag: this coordinated TUI publish takes
# over the cmux latest dist-tag from the old 0.8.3 CLI when version > 0.8.3.
npm publish --provenance dist/npm-packages/cmux
publish-sdk:
if: inputs.publish_target == 'sdk'
needs: validate-sdk-version
runs-on: ubuntu-latest # github-hosted-required: npm provenance needs a github-hosted runner
permissions:
contents: read
id-token: write
environment:
name: npm-tui
url: https://www.npmjs.com/package/cmux
steps:
- name: Require npm cmux SDK confirmation
if: inputs.confirm_sdk_cmux != true
run: |
echo "Refusing to publish npm package cmux for the SDK without confirm_sdk_cmux=true." >&2
exit 1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
ref: ${{ needs.validate-sdk-version.outputs.release_sha }}
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22.14.0"
registry-url: https://registry.npmjs.org
- name: Install npm with OIDC support
run: npm install -g [email protected]
- name: Build and test SDK package
working-directory: cmux-tui/bindings/typescript
run: |
npm ci --no-audit --no-fund
npm test
- name: Publish SDK package
working-directory: cmux-tui/bindings/typescript
# Keep the TUI launcher on `latest`; SDK consumers opt in with
# `npm install cmux@sdk`.
run: npm publish --provenance --tag sdk
+93
View File
@@ -2,6 +2,99 @@
All notable changes to cmux are documented here.
## [0.64.22] - 2026-08-03
### Fixed
- Fix a crash seconds after launch on Intel Macs; cmux is now the only process-wide crash handler, and embedded GhosttyKit no longer links Ghostty's native Sentry initializer ([#9436](https://github.com/manaflow-ai/cmux/pull/9436))
- Fix `cmux ssh <host>` failing immediately with a shell syntax error from the generated startup script ([#9425](https://github.com/manaflow-ai/cmux/pull/9425)) -- thanks @KousukeUchiyama for the report!
- Clear Dock notifications when you focus the pane that raised them ([#9418](https://github.com/manaflow-ai/cmux/pull/9418))
- Keep a restored Claude agent on its own account instead of falling back to the ambient one ([#9419](https://github.com/manaflow-ai/cmux/pull/9419)) -- thanks @seanyoungberg for the report!
- Stop bash shell integration printing `cannot overwrite existing file` on every prompt under `set -o noclobber` ([#9420](https://github.com/manaflow-ai/cmux/pull/9420)) -- thanks @8bit-void for the report!
- Fail closed when `close` or `respawn-pane` is given an explicit `--surface` that no longer exists, instead of acting on a different live surface ([#9422](https://github.com/manaflow-ai/cmux/pull/9422)) -- thanks @PhilipPinckaers for the report!
### Thanks to 5 contributors!
- [@8bit-void](https://github.com/8bit-void)
- [@austinywang](https://github.com/austinywang)
- [@KousukeUchiyama](https://github.com/KousukeUchiyama)
- [@PhilipPinckaers](https://github.com/PhilipPinckaers)
- [@seanyoungberg](https://github.com/seanyoungberg)
## [0.64.21] - 2026-08-02
### Added
- Native iPhone and iPad Simulator panes, with their own commands and automation ([#7857](https://github.com/manaflow-ai/cmux/pull/7857))
- First-class Mosh transport for remote workspaces ([#8442](https://github.com/manaflow-ai/cmux/pull/8442))
- Workspace-wide terminal font zoom on Cmd+Ctrl+= / Cmd+Ctrl+- / Cmd+Ctrl+0 ([#8791](https://github.com/manaflow-ai/cmux/pull/8791)), and per-tab zoom now persists across restarts ([#8543](https://github.com/manaflow-ai/cmux/pull/8543))
- Cmd+Shift+T reopens the last closed item ([#9132](https://github.com/manaflow-ai/cmux/pull/9132))
- Cmd+[ and Cmd+] traverse global workspace focus history, and pane cycling becomes rebindable ([#9329](https://github.com/manaflow-ai/cmux/pull/9329)) -- thanks @azooz2003-bit! -- alongside a workspace-only focus history setting ([#8654](https://github.com/manaflow-ai/cmux/pull/8654))
- Move active surfaces between panes with automatic directional splits ([#8764](https://github.com/manaflow-ai/cmux/pull/8764)); `goto_split:previous` and `goto_split:next` cycle through every pane with wrapping ([#2639](https://github.com/manaflow-ai/cmux/pull/2639)) -- thanks @mykmelez!
- Dock panes persist across session restore ([#8690](https://github.com/manaflow-ai/cmux/pull/8690)), with full Dock surface runtime parity ([#8782](https://github.com/manaflow-ai/cmux/pull/8782))
- Reopen closed workspaces with sticky repo identity ([#8841](https://github.com/manaflow-ai/cmux/pull/8841))
- Target browser profiles from the CLI ([#8874](https://github.com/manaflow-ai/cmux/pull/8874)), and Command-clicked HTML files render in browser panes ([#9096](https://github.com/manaflow-ai/cmux/pull/9096))
- Sidebar account and mobile pairing controls ([#8354](https://github.com/manaflow-ai/cmux/pull/8354)); sidebar metadata renders Markdown links ([#8663](https://github.com/manaflow-ai/cmux/pull/8663)) -- thanks @djova!
- Notification feed read state is a leading swipe with mark-unread ([#8868](https://github.com/manaflow-ai/cmux/pull/8868)) -- thanks @azooz2003-bit!
- Idle background agents hibernate under critical memory pressure even when routine Agent Hibernation is off ([#9090](https://github.com/manaflow-ai/cmux/pull/9090))
- `cmux restore` runs without a shell ([#9265](https://github.com/manaflow-ai/cmux/pull/9265))
- iOS (beta): stream Mac browser panes to the phone, interactive and pixel-perfect, with dialogs mirrored ([#8298](https://github.com/manaflow-ai/cmux/pull/8298)) -- thanks @azooz2003-bit!
- iOS (beta): chronological notification feed ([#8210](https://github.com/manaflow-ai/cmux/pull/8210)) -- thanks @azooz2003-bit!
- iOS (beta): launch agent workspaces straight from the task composer ([#7670](https://github.com/manaflow-ai/cmux/pull/7670))
- iOS (beta): Tailscale connection method opt-in with QR-authorized pairing ([#9247](https://github.com/manaflow-ai/cmux/pull/9247)) -- thanks @azooz2003-bit!
- iOS (beta): haptic feedback setting ([#8797](https://github.com/manaflow-ai/cmux/pull/8797)), Open Folders on Tap ([#8524](https://github.com/manaflow-ai/cmux/pull/8524)), unified animated toasts ([#8376](https://github.com/manaflow-ai/cmux/pull/8376)), and workspace identity customization ([#8636](https://github.com/manaflow-ai/cmux/pull/8636)) -- thanks @azooz2003-bit!
### Changed
- Workspace initial commands launch through your login shell ([#8801](https://github.com/manaflow-ai/cmux/pull/8801)) -- thanks @azooz2003-bit! -- and auto-resume uses the normal terminal shell ([#8837](https://github.com/manaflow-ai/cmux/pull/8837))
- iOS (beta): the phone-to-Mac transport is rebuilt on one connectivity authority, with authenticated discovery, named disconnect reasons, and relay-credential rollover ([#9284](https://github.com/manaflow-ai/cmux/pull/9284), [#8840](https://github.com/manaflow-ai/cmux/pull/8840), [#8716](https://github.com/manaflow-ai/cmux/pull/8716), [#8494](https://github.com/manaflow-ai/cmux/pull/8494)) -- thanks @azooz2003-bit!
- iOS (beta): terminal scrolling is local and smooth on screen-anchored render grids ([#8860](https://github.com/manaflow-ai/cmux/pull/8860)) -- thanks @azooz2003-bit!
- iOS (beta): state sync v2 replaces the invalidate-and-refetch loop with per-record deltas ([#8284](https://github.com/manaflow-ai/cmux/pull/8284)) -- thanks @azooz2003-bit!
- iOS (beta): onboarding is rebuilt around a live agent handoff ([#8418](https://github.com/manaflow-ai/cmux/pull/8418)), as a swipeable tour ([#9158](https://github.com/manaflow-ai/cmux/pull/9158)) with a Game of Life backdrop on every page ([#8880](https://github.com/manaflow-ai/cmux/pull/8880)) -- thanks @azooz2003-bit!
- iOS (beta): removing a Mac from a phone hides it for that phone only, instead of deleting it everywhere ([#8760](https://github.com/manaflow-ai/cmux/pull/8760), [#8778](https://github.com/manaflow-ai/cmux/pull/8778)) -- thanks @azooz2003-bit!
### Fixed
- Fix leaked `openThread` loops burning ~90% of cmux idle CPU ([#8851](https://github.com/manaflow-ai/cmux/pull/8851))
- Fix workspace-switch renderer freezes ([#8793](https://github.com/manaflow-ai/cmux/pull/8793)), reclaim hidden Ghostty renderer memory ([#8998](https://github.com/manaflow-ai/cmux/pull/8998)), and fix the Vault sidebar beachball at large session counts ([#8680](https://github.com/manaflow-ai/cmux/pull/8680))
- Fix Vim Mode cursor and selection rendering ([#8995](https://github.com/manaflow-ai/cmux/pull/8995))
- Fix TextBox IME composition rendering ([#8688](https://github.com/manaflow-ai/cmux/pull/8688))
- Fix zsh prompt wrap spacer lines by letting Ghostty own prompt layout ([#8964](https://github.com/manaflow-ai/cmux/pull/8964))
- Fix Settings and main window zombies under AeroSpace ([#8513](https://github.com/manaflow-ai/cmux/pull/8513)) -- thanks @fml09!
- Fix a Debug-build crash on macOS 26.5 from non-finite sidebar divider coordinates ([#9156](https://github.com/manaflow-ai/cmux/pull/9156)) -- thanks @oscarbrey!
- Fix Mermaid diagrams double-scaling under viewer zoom ([#8914](https://github.com/manaflow-ai/cmux/pull/8914)), restore the focused-read indicator after a surface-scoped mark-read ([#8927](https://github.com/manaflow-ai/cmux/pull/8927)), keep Pi launch arguments when resuming a restored session ([#8912](https://github.com/manaflow-ai/cmux/pull/8912)), and import appearance at Settings store init instead of live-applying it ([#8913](https://github.com/manaflow-ai/cmux/pull/8913)) -- thanks @ejc3!
- Notify only after the Pi agent settles ([#8574](https://github.com/manaflow-ai/cmux/pull/8574)) -- thanks @mrohan-sq!
- Tear down remote daemon PTY sessions once ([#8643](https://github.com/manaflow-ai/cmux/pull/8643)) -- thanks @ejc3! -- and support `respawn-pane` in the Go relay tmux compatibility layer ([#8660](https://github.com/manaflow-ai/cmux/pull/8660)) -- thanks @bencollins2!
- Exclude `.attrib` from watched filesystem events ([#8659](https://github.com/manaflow-ai/cmux/pull/8659)) -- thanks @varomorf!
- Preserve surface IDs in workstream events ([#8703](https://github.com/manaflow-ai/cmux/pull/8703)) -- thanks @revanthreddy-hai!
- Stop the sidebar PR poller from re-downloading every repo's full PR list on each poll ([#8521](https://github.com/manaflow-ai/cmux/pull/8521)) -- thanks @joshfree!
- Restore Codex ([#9370](https://github.com/manaflow-ai/cmux/pull/9370)), Kimi Code ([#8584](https://github.com/manaflow-ai/cmux/pull/8584)), Grok ([#9382](https://github.com/manaflow-ai/cmux/pull/9382)), and Pi ([#9399](https://github.com/manaflow-ai/cmux/pull/9399)) sessions across relaunch, and stop duplicate agent resumes ([#8619](https://github.com/manaflow-ai/cmux/pull/8619))
- ssh-tmux: fix focus after single-pane promotion ([#9020](https://github.com/manaflow-ai/cmux/pull/9020)), named-key encoding for the remote `TERM` ([#9273](https://github.com/manaflow-ai/cmux/pull/9273)), and terminal replies leaking into reattached panes ([#9272](https://github.com/manaflow-ai/cmux/pull/9272)); fix workspace shortcuts from hosted tmux terminals ([#8621](https://github.com/manaflow-ai/cmux/pull/8621))
- Fix SSH relay deadlock after app restart ([#9105](https://github.com/manaflow-ai/cmux/pull/9105)), stale SSH workspace connection status ([#9085](https://github.com/manaflow-ai/cmux/pull/9085)), remote PTY `PATH` inherited from cmuxd ([#8677](https://github.com/manaflow-ai/cmux/pull/8677)), and login-shell resolution before terminal spawn ([#8681](https://github.com/manaflow-ai/cmux/pull/8681))
- Fix sidebar reopen cutoff render ([#8626](https://github.com/manaflow-ai/cmux/pull/8626)), row clipping during height-changing reorder ([#9189](https://github.com/manaflow-ai/cmux/pull/9189)), idle layout livelock ([#8532](https://github.com/manaflow-ai/cmux/pull/8532)), and status URL clicks ([#8528](https://github.com/manaflow-ai/cmux/pull/8528))
- Fix Dock paste routing to the selected terminal ([#9112](https://github.com/manaflow-ai/cmux/pull/9112)), Dock terminal working-directory inheritance ([#8691](https://github.com/manaflow-ai/cmux/pull/8691)), and Cmd-click link opening in Dock terminals ([#8594](https://github.com/manaflow-ai/cmux/pull/8594))
- Browser: fix navigation for terminal-wrapped URL pastes ([#8601](https://github.com/manaflow-ai/cmux/pull/8601)), automation recovery after load failures ([#8548](https://github.com/manaflow-ai/cmux/pull/8548)), partial blank screenshots ([#9281](https://github.com/manaflow-ai/cmux/pull/9281)), and blurred Google Sheets canvas rendering ([#8697](https://github.com/manaflow-ai/cmux/pull/8697))
- Fix inline code escaping in the Markdown viewer ([#9274](https://github.com/manaflow-ai/cmux/pull/9274)) and composer attachment thumbnail re-rasterization ([#8817](https://github.com/manaflow-ai/cmux/pull/8817))
- Fix renderer presentation for background-created surfaces ([#8540](https://github.com/manaflow-ai/cmux/pull/8540)) and stale semantic prompts duplicating inline TUI frames ([#9275](https://github.com/manaflow-ai/cmux/pull/9275))
- Fix workspace group anchor numbering ([#9176](https://github.com/manaflow-ai/cmux/pull/9176)); closing a group's anchor keeps the group instead of scattering its members to the root ([#8925](https://github.com/manaflow-ai/cmux/pull/8925))
- Preserve workspace IDs across session restore ([#8695](https://github.com/manaflow-ai/cmux/pull/8695)) and restored resume workspace titles ([#8687](https://github.com/manaflow-ai/cmux/pull/8687)); fit same-display restored windows to visible bounds ([#8675](https://github.com/manaflow-ai/cmux/pull/8675))
- Fix a `DispatchWorkItem` chain stack overflow ([#8615](https://github.com/manaflow-ai/cmux/pull/8615)) and subprocess pipe descriptor leaks ([#9187](https://github.com/manaflow-ai/cmux/pull/9187))
- iOS (beta): preserve terminal input ordering under fast typing ([#8682](https://github.com/manaflow-ai/cmux/pull/8682)), scroll position across mid-stream verified replays ([#9032](https://github.com/manaflow-ai/cmux/pull/9032)), and keyboard focus after the photo picker ([#9287](https://github.com/manaflow-ai/cmux/pull/9287)) -- thanks @azooz2003-bit!
- iOS (beta): fix a startup crash from sentry-init racing environ mutation ([#9238](https://github.com/manaflow-ai/cmux/pull/9238)) and TestFlight crash paths ([#9034](https://github.com/manaflow-ai/cmux/pull/9034))
- iOS (beta): fix workspace-list scroll stutter from live updates ([#9139](https://github.com/manaflow-ai/cmux/pull/9139)), and make the notification feed scroll fast with thousands of items ([#9141](https://github.com/manaflow-ai/cmux/pull/9141)) -- thanks @azooz2003-bit!
### Thanks to 13 contributors!
- [@austinywang](https://github.com/austinywang)
- [@azooz2003-bit](https://github.com/azooz2003-bit)
- [@bencollins2](https://github.com/bencollins2)
- [@djova](https://github.com/djova)
- [@ejc3](https://github.com/ejc3)
- [@fml09](https://github.com/fml09)
- [@joshfree](https://github.com/joshfree)
- [@lawrencecchen](https://github.com/lawrencecchen)
- [@mrohan-sq](https://github.com/mrohan-sq)
- [@mykmelez](https://github.com/mykmelez)
- [@oscarbrey](https://github.com/oscarbrey)
- [@revanthreddy-hai](https://github.com/revanthreddy-hai)
- [@varomorf](https://github.com/varomorf)
## [0.64.20] - 2026-07-19
### Added
+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
+12 -2
View File
@@ -76,15 +76,25 @@ extension CMUXCLI {
client: SocketClient,
includeAmbientTTY: Bool = true
) -> CallerTerminalBinding? {
guard let ttyName = resolveCallerTTYName(includeAmbientTTY: includeAmbientTTY),
let payload = try? client.sendV2(method: "debug.terminals") else {
guard let ttyName = resolveCallerTTYName(includeAmbientTTY: includeAmbientTTY) else {
return nil
}
return uniqueCallerTerminalBindingByTTY(ttyName: ttyName, client: client)
}
func uniqueCallerTerminalBindingByTTY(
ttyName: String,
client: SocketClient,
workspaceId: String? = nil
) -> CallerTerminalBinding? {
guard let payload = try? client.sendV2(method: "debug.terminals") else { return nil }
let terminals = payload["terminals"] as? [[String: Any]] ?? []
let scopedWorkspaceId = normalizedHandleValue(workspaceId)
var matched: [CallerTerminalBinding] = []
for terminal in terminals {
guard normalizedTTYName(terminal["tty"] as? String) == ttyName,
let workspaceId = normalizedHandleValue(terminal["workspace_id"] as? String),
scopedWorkspaceId == nil || workspaceId == scopedWorkspaceId,
let surfaceId = normalizedHandleValue(terminal["surface_id"] as? String) else {
continue
}
+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
+130 -8
View File
@@ -1,4 +1,5 @@
import Foundation
import Darwin
extension CMUXCLI {
private static let piExtensionMarker = "cmux-pi-session-extension-marker"
@@ -26,6 +27,89 @@ extension CMUXCLI {
}
}
@discardableResult
private func withPiExtensionMutationLock<T>(
at extensionURL: URL,
createParentDirectory: Bool,
acquireNonBlocking: Bool = false,
fileManager: FileManager = .default,
_ operation: () throws -> T
) throws -> T? {
let directoryURL = extensionURL.deletingLastPathComponent()
if createParentDirectory {
try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true)
}
let lockURL = directoryURL.appendingPathComponent(".cmux-session.lock", isDirectory: false)
let descriptor = Darwin.open(
lockURL.path,
O_CREAT | O_RDWR | O_CLOEXEC | O_NOFOLLOW,
mode_t(S_IRUSR | S_IWUSR)
)
guard descriptor >= 0 else {
throw piExtensionReadError(at: extensionURL)
}
defer { Darwin.close(descriptor) }
var metadata = stat()
guard Darwin.fstat(descriptor, &metadata) == 0,
metadata.st_mode & mode_t(S_IFMT) == mode_t(S_IFREG) else {
throw piExtensionReadError(at: extensionURL)
}
let lockOperation = LOCK_EX | (acquireNonBlocking ? LOCK_NB : 0)
guard flock(descriptor, lockOperation) == 0 else {
if acquireNonBlocking, errno == EWOULDBLOCK || errno == EAGAIN {
return nil
}
throw piExtensionReadError(at: extensionURL)
}
defer { flock(descriptor, LOCK_UN) }
return try operation()
}
private func piExtensionReadError(at url: URL) -> CLIError {
CLIError(message: String.localizedStringWithFormat(
String(
localized: "cli.hooks.pi.error.readFailed",
defaultValue: "Failed to read %@"
),
url.path
))
}
func refreshManagedPiExtensionIfNeeded(_ def: AgentHookDef) {
let extensionURL = piExtensionURL(for: def)
let fileManager = FileManager.default
guard fileManager.fileExists(atPath: extensionURL.path) else { return }
do {
try withPiExtensionMutationLock(
at: extensionURL,
createParentDirectory: false,
acquireNonBlocking: true,
fileManager: fileManager
) {
guard fileManager.fileExists(atPath: extensionURL.path) else { return }
let existing = try existingPiExtensionContents(at: extensionURL, fileManager: fileManager)
if existing.isEmpty {
try Self.piExtensionSource.write(to: extensionURL, atomically: true, encoding: .utf8)
return
}
guard existing.contains(Self.piExtensionMarker),
existing != Self.piExtensionSource
else {
return
}
// Revalidate immediately before replacement. All cmux install, refresh,
// and uninstall mutations share this lock, so an in-flight refresh
// cannot recreate an extension that another cmux process removed.
guard try existingPiExtensionContents(at: extensionURL, fileManager: fileManager) == existing else {
return
}
try Self.piExtensionSource.write(to: extensionURL, atomically: true, encoding: .utf8)
}
} catch {
// Hook delivery must continue when a managed extension cannot be refreshed.
}
}
func installPiExtensionHooks(_ def: AgentHookDef) throws {
let extensionURL = piExtensionURL(for: def)
let fileManager = FileManager.default
@@ -64,11 +148,25 @@ extension CMUXCLI {
return
}
}
try fileManager.createDirectory(
at: extensionURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try Self.piExtensionSource.write(to: extensionURL, atomically: true, encoding: .utf8)
try withPiExtensionMutationLock(
at: extensionURL,
createParentDirectory: true,
fileManager: fileManager
) {
let current = try existingPiExtensionContents(at: extensionURL, fileManager: fileManager)
if !current.isEmpty, !current.contains(Self.piExtensionMarker) {
throw CLIError(message: String.localizedStringWithFormat(
String(
localized: "cli.hooks.pi.error.notCmuxExtension",
defaultValue: "%@ exists and is not a cmux extension; leaving it alone"
),
extensionURL.path
))
}
if current != Self.piExtensionSource {
try Self.piExtensionSource.write(to: extensionURL, atomically: true, encoding: .utf8)
}
}
print(String.localizedStringWithFormat(
String(
localized: "cli.hooks.pi.installed",
@@ -91,8 +189,23 @@ extension CMUXCLI {
))
return
}
let existing = try existingPiExtensionContents(at: extensionURL, fileManager: fm)
guard existing.contains(Self.piExtensionMarker) else {
var removed = false
var refused = false
try withPiExtensionMutationLock(
at: extensionURL,
createParentDirectory: false,
fileManager: fm
) {
let existing = try existingPiExtensionContents(at: extensionURL, fileManager: fm)
guard !existing.isEmpty else { return }
guard existing.contains(Self.piExtensionMarker) else {
refused = true
return
}
try fm.removeItem(at: extensionURL)
removed = true
}
if refused {
print(String.localizedStringWithFormat(
String(
localized: "cli.hooks.pi.refuseRemoveMissingMarker",
@@ -102,7 +215,16 @@ extension CMUXCLI {
))
return
}
try fm.removeItem(at: extensionURL)
guard removed else {
print(String.localizedStringWithFormat(
String(
localized: "cli.hooks.pi.noneFound",
defaultValue: "No Pi cmux extension found at %@"
),
extensionURL.path
))
return
}
print(String.localizedStringWithFormat(
String(
localized: "cli.hooks.pi.removed",
+11 -3
View File
@@ -31,7 +31,7 @@ class PiCmuxCommandDispatcher {
// CLI owns a four-second end-to-end deadline. Observe that outcome before the
// extension classifies a terminal delivery as failed.
private static readonly feedDrainDeadlineMs = 4500;
private controlQueue: Promise<void> = Promise.resolve();
private controlQueues = new Map<string | null, Promise<void>>();
private pendingFeedCommands = new Map<string, PiFeedCommand>();
private pendingFeedKeysBySession = new Map<string | null, string[]>();
private priorityFeedCommands = new Map<string | null, PiFeedCommand[]>();
@@ -56,8 +56,16 @@ class PiCmuxCommandDispatcher {
input: string | undefined,
context: PiExtensionContextSnapshot,
): Promise<CommandResult> {
const scheduled = this.controlQueue.then(() => this.execute(args, cwd, input, context));
this.controlQueue = scheduled.then(() => undefined, () => undefined);
const sessionId = context.sessionId;
const previous = this.controlQueues.get(sessionId) || Promise.resolve();
const scheduled = previous.then(() => this.execute(args, cwd, input, context));
let tail: Promise<void>;
tail = scheduled
.then(() => undefined, () => undefined)
.finally(() => {
if (this.controlQueues.get(sessionId) === tail) this.controlQueues.delete(sessionId);
});
this.controlQueues.set(sessionId, tail);
return scheduled;
}
enqueueFeed(key: string, command: PiFeedCommand): void {
+39 -8
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 {
@@ -466,16 +487,26 @@ function settleTurn(sessionStates: Map<string, SessionState>, sessionId: string)
return completion;
}
function warn(ctx: PiExtensionContextSnapshot | null, message: string, details: Record<string, unknown> = {}): void {
function warn(
ctx: PiExtensionContextSnapshot | null,
message: string,
details: Record<string, unknown> = {},
notifyUser = false,
): void {
const payload = { source: "cmux-pi-extension", level: "warning", message, ...details };
try {
console.warn(JSON.stringify(payload));
} catch (_) {
console.warn(`[cmux-pi-extension] ${message}`);
}
try {
ctx?.notifyWarning?.();
} catch (_) {}
// Hook transport is best-effort telemetry. Keep routine command failures in
// the terminal instead of interrupting Pi with a generic toast; reserve the
// UI warning for an unexpected extension-task exception.
if (notifyUser) {
try {
ctx?.notifyWarning?.();
} catch (_) {}
}
}
function cmuxExecutable(): string {
+110 -54
View File
@@ -283,21 +283,19 @@ function isTerminalFeedEvent(eventName: PiFeedEventName): boolean {
return eventName === "PostToolUse" || eventName === "SubagentStop";
}
function sendFeed(
function prepareFeedDispatch(
dispatcher: PiCmuxCommandDispatcher,
sessionStates: Map<string, SessionState>,
eventName: PiFeedEventName,
context: PiExtensionContextSnapshot,
event: unknown,
): void {
if (process.env.CMUX_PI_HOOKS_DISABLED === "1") return;
): (() => void) | undefined {
if (process.env.CMUX_PI_HOOKS_DISABLED === "1") return undefined;
const sessionId = context.sessionId;
if (!sessionId) return;
if (!dispatcher.canDispatch(sessionId)) return;
const target = surfaceTargetArgs(dispatcher, sessionId);
if (!target) return;
if (!sessionId) return undefined;
if (!dispatcher.canDispatch(sessionId)) return undefined;
const state = stateFor(sessionStates, sessionId);
if (state.stopped) return;
if (state.stopped) return undefined;
const cwd = context.cwd;
const toolCallId = firstString(objectValue(event, ["toolCallId", "tool_call_id", "id"]));
const toolName = firstString(objectValue(event, ["toolName", "tool_name", "name"]));
@@ -323,14 +321,18 @@ function sendFeed(
const isError = objectValue(event, ["isError", "is_error"]);
if (isError !== undefined) payload.is_error = projectPiFeedValue(isError, projectionState);
}
dispatcher.enqueueFeed(`${sessionId}:${toolCallId || toolName || "unknown"}`, {
args: ["hooks", "feed", "--source", "pi", "--event", eventName, ...target],
cwd,
payload,
context,
terminal: isTerminalFeedEvent(eventName),
onFailure: () => { state.feedDeliveryFailed = true; },
});
return () => {
const target = surfaceTargetArgs(dispatcher, sessionId);
if (!target) return;
dispatcher.enqueueFeed(`${sessionId}:${toolCallId || toolName || "unknown"}`, {
args: ["hooks", "feed", "--source", "pi", "--event", eventName, ...target],
cwd,
payload,
context,
terminal: isTerminalFeedEvent(eventName),
onFailure: () => { state.feedDeliveryFailed = true; },
});
};
}
async function publishPendingCompletion(
@@ -338,9 +340,8 @@ async function publishPendingCompletion(
sessionStates: Map<string, SessionState>,
context: PiExtensionContextSnapshot,
sessionId: string,
completion: PendingCompletion,
): Promise<void> {
const completion = settleTurn(sessionStates, sessionId);
if (!completion) return;
await dispatcher.finishFeedForSession(sessionId);
const state = stateFor(sessionStates, sessionId);
const feedDelivered = !state.feedDeliveryFailed;
@@ -352,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,
@@ -366,8 +371,33 @@ async function publishPendingCompletion(
export default function cmuxPiSessionExtension(pi: ExtensionAPI) {
const dispatcher = new PiCmuxCommandDispatcher();
const sessionStates = new Map<string, SessionState>();
const lifecycleTails = new Map<string, Promise<void>>();
pi.on("session_start", async (_event, ctx) => {
const enqueueLifecycleTask = (
sessionId: string,
context: PiExtensionContextSnapshot,
operation: () => Promise<unknown> | unknown,
): Promise<void> => {
const previous = lifecycleTails.get(sessionId) || Promise.resolve();
let tracked: Promise<void>;
tracked = previous
.then(operation)
.then(() => undefined)
.catch((error) => {
const errorMessage = error instanceof Error ? error.message : undefined;
warn(context, "cmux lifecycle task failed", {
error_available: error !== undefined,
error_message: utf8Prefix(errorMessage, 512),
}, true);
})
.finally(() => {
if (lifecycleTails.get(sessionId) === tracked) lifecycleTails.delete(sessionId);
});
lifecycleTails.set(sessionId, tracked);
return tracked;
};
pi.on("session_start", (_event, ctx) => {
const context = snapshotContext(ctx);
const sessionId = context.sessionId;
if (sessionId) {
@@ -376,64 +406,88 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) {
state.feedDeliveryFailed = false;
state.stopped = false;
}
const ok = await sendHook(dispatcher, "session-start", context);
if (ok && sessionId) await ensureResumeBinding(dispatcher, context, sessionId);
if (!sessionId) return;
enqueueLifecycleTask(sessionId, context, async () => {
const ok = await sendHook(dispatcher, "session-start", context);
if (ok) await ensureResumeBinding(dispatcher, context, sessionId);
});
});
pi.on("before_agent_start", async (event, ctx) => {
pi.on("before_agent_start", (event, ctx) => {
const context = snapshotContext(ctx);
const sessionId = context.sessionId;
const turnId = sessionId ? beginTurn(sessionStates, sessionId, event) : undefined;
await sendHook(dispatcher, "prompt-submit", context, { prompt: event.prompt, turn_id: turnId });
if (!sessionId) return;
const turnId = beginTurn(sessionStates, sessionId, event);
enqueueLifecycleTask(sessionId, context, () => (
sendHook(dispatcher, "prompt-submit", context, { prompt: event.prompt, turn_id: turnId })
));
});
pi.on("tool_execution_start", async (event, ctx) => {
const enqueueFeed = (
eventName: PiFeedEventName,
event: unknown,
ctx: ExtensionContext,
): void => {
const context = snapshotContext(ctx);
const eventName = isSubagentTool(event) ? "SubagentStart" : "PreToolUse";
sendFeed(dispatcher, sessionStates, eventName, context, event);
const sessionId = context.sessionId;
if (!sessionId) return;
const dispatch = prepareFeedDispatch(dispatcher, sessionStates, eventName, context, event);
if (!dispatch) return;
enqueueLifecycleTask(sessionId, context, dispatch);
};
pi.on("tool_execution_start", (event, ctx) => {
enqueueFeed(isSubagentTool(event) ? "SubagentStart" : "PreToolUse", event, ctx);
});
pi.on("tool_execution_end", async (event, ctx) => {
const context = snapshotContext(ctx);
const eventName = isSubagentTool(event) ? "SubagentStop" : "PostToolUse";
sendFeed(dispatcher, sessionStates, eventName, context, event);
pi.on("tool_execution_end", (event, ctx) => {
enqueueFeed(isSubagentTool(event) ? "SubagentStop" : "PostToolUse", event, ctx);
});
pi.on("session_before_compact", async (event, ctx) => {
const context = snapshotContext(ctx);
sendFeed(dispatcher, sessionStates, "PreCompact", context, event);
pi.on("session_before_compact", (event, ctx) => {
enqueueFeed("PreCompact", event, ctx);
});
pi.on("session_compact", async (event, ctx) => {
const context = snapshotContext(ctx);
sendFeed(dispatcher, sessionStates, "PostCompact", context, event);
pi.on("session_compact", (event, ctx) => {
enqueueFeed("PostCompact", event, ctx);
});
pi.on("agent_end", async (event, ctx) => {
pi.on("agent_end", (event, ctx) => {
const context = snapshotContext(ctx);
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()) {
await publishPendingCompletion(dispatcher, sessionStates, context, sessionId);
const completion = settleTurn(sessionStates, sessionId);
if (completion) {
enqueueLifecycleTask(sessionId, context, () => (
publishPendingCompletion(dispatcher, sessionStates, context, sessionId, completion)
));
}
}
});
pi.on("agent_settled", async (_event, ctx) => {
pi.on("agent_settled", (_event, ctx) => {
const context = snapshotContext(ctx);
const isIdle = ctx.isIdle();
const sessionId = context.sessionId;
if (!sessionId || !isIdle) return;
// Consume pending completion before subprocess calls so duplicate settlement cannot notify twice.
await publishPendingCompletion(dispatcher, sessionStates, context, sessionId);
const completion = settleTurn(sessionStates, sessionId);
if (completion) {
enqueueLifecycleTask(sessionId, context, () => (
publishPendingCompletion(dispatcher, sessionStates, context, sessionId, completion)
));
}
});
pi.on("session_shutdown", async (event, ctx) => {
@@ -449,16 +503,18 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) {
terminationReason: firstString(objectValue(event, ["reason"])) || "session_shutdown",
};
}
await dispatcher.finishFeedForSession(sessionId);
const feedDelivered = !state.feedDeliveryFailed;
state.feedDeliveryFailed = false;
if (!feedDelivered) warn(context, "cmux hook command failed", { session_id: sessionId });
if (stopPayload) await sendHook(dispatcher, "stop", context, stopPayload);
try {
await clearResumeBinding(dispatcher, context, sessionId);
} finally {
releaseSessionRuntime(dispatcher, sessionStates, sessionId);
}
await enqueueLifecycleTask(sessionId, context, async () => {
await dispatcher.finishFeedForSession(sessionId);
const feedDelivered = !state.feedDeliveryFailed;
state.feedDeliveryFailed = false;
if (!feedDelivered) warn(context, "cmux hook command failed", { session_id: sessionId });
if (stopPayload) await sendHook(dispatcher, "stop", context, stopPayload);
try {
await clearResumeBinding(dispatcher, context, sessionId);
} finally {
releaseSessionRuntime(dispatcher, sessionStates, sessionId);
}
});
});
}
"""#
+155 -15
View File
@@ -1,4 +1,5 @@
import CMUXAgentLaunch
import Darwin
import Foundation
extension CMUXCLI {
@@ -53,23 +54,13 @@ extension CMUXCLI {
}
params["surface_id"] = surfaceID
} else if selector.usesCurrentSurface,
let surfaceID = processEnvironment["CMUX_SURFACE_ID"],
!surfaceID.isEmpty {
params["surface_id"] = surfaceID
} else if selector.usesCurrentSurface,
let ttyName = resolveCallerTTYName(),
let caller = resolveTerminalBinding(
ttyName: ttyName,
client: client
let surfaceID = try currentRestoreSurfaceID(
client: client,
processEnvironment: processEnvironment
) {
params["surface_id"] = caller.surfaceId
params["surface_id"] = surfaceID
} else {
throw CLIError(
message: String(
localized: "cli.restore.error.currentSurfaceUnknown",
defaultValue: "restore: the current cmux surface could not be identified. Retry from this terminal or pass --surface <id|ref>."
)
)
throw currentRestoreSurfaceUnknownError()
}
let payload = try client.sendV2(method: "surface.resume.get", params: params)
@@ -190,6 +181,155 @@ extension CMUXCLI {
)
}
private func currentRestoreSurfaceID(
client: SocketClient,
processEnvironment: [String: String]
) throws -> String? {
// The remote relay and the local CLI do not share a PID namespace.
if client.isRelayBacked {
return try relayRestoreSurfaceID(
client: client,
processEnvironment: processEnvironment
)
}
let resolution = AgentProcessBindingResolution.controllingTTY.rawValue
do {
let payload = try client.sendV2(
method: "agent.resolve_delivery_target",
params: [
"pid": Int(ProcessInfo.processInfo.processIdentifier),
"pid_resolution": resolution,
]
)
guard payload["source"] as? String == "pid",
payload["pid_resolution"] as? String == resolution,
let workspaceID = normalizedHandleValue(payload["workspace_id"] as? String),
isUUID(workspaceID),
let surfaceID = normalizedHandleValue(payload["surface_id"] as? String),
isUUID(surfaceID) else {
throw currentRestoreSurfaceUnknownError()
}
return surfaceID
} catch let error as CLIError {
switch error.v2Code {
case "not_found":
client.close()
throw currentRestoreSurfaceUnknownError()
case "method_not_found", "unrecognized_method":
// These protocol replies were consumed in full, so the socket
// remains synchronized for the legacy discovery request.
return legacyRestoreSurfaceID(
client: client,
workspaceID: nil
)
default:
client.close()
throw error
}
} catch {
client.close()
throw error
}
}
private func relayRestoreSurfaceID(
client: SocketClient,
processEnvironment: [String: String]
) throws -> String? {
let ttyName = resolveCallerDescriptorTTYName()
?? resolveCallerTTYName(includeAmbientTTY: false)
guard let ttyName else { return nil }
let resolution = AgentTTYBindingResolution.reportedTTY.rawValue
let workspaceID = normalizedHandleValue(processEnvironment["CMUX_WORKSPACE_ID"])
var params: [String: Any] = [
"tty_name": ttyName,
"tty_resolution": resolution,
]
if let workspaceID {
// Lets an older app identify this probe as an unsupported
// workspace-only resolution. The authenticated relay rewrites
// aliases and separately stamps its authoritative owner id.
params["workspace_id"] = workspaceID
}
do {
let payload = try client.sendV2(
method: "agent.resolve_delivery_target",
params: params
)
if payload["source"] as? String == "workspace",
payload["surface_id"] == nil || payload["surface_id"] is NSNull,
let resolvedWorkspaceID = normalizedHandleValue(payload["workspace_id"] as? String),
isUUID(resolvedWorkspaceID) {
// Previous app versions ignore the TTY probe and resolve
// only workspace_id. Use their alias-rewritten result to
// scope the legacy terminal list, not the stale remote
// shell environment value that produced the request.
return legacyRestoreSurfaceID(
client: client,
workspaceID: resolvedWorkspaceID
)
}
guard payload["source"] as? String == "tty",
payload["tty_resolution"] as? String == resolution,
let resolvedWorkspaceID = normalizedHandleValue(payload["workspace_id"] as? String),
isUUID(resolvedWorkspaceID),
let surfaceID = normalizedHandleValue(payload["surface_id"] as? String),
isUUID(surfaceID) else {
throw currentRestoreSurfaceUnknownError()
}
return surfaceID
} catch let error as CLIError {
switch error.v2Code {
case "not_found":
client.close()
throw currentRestoreSurfaceUnknownError()
case "method_not_found", "unrecognized_method":
guard let workspaceID, isUUID(workspaceID) else { return nil }
return legacyRestoreSurfaceID(
client: client,
workspaceID: workspaceID
)
default:
client.close()
throw error
}
} catch {
client.close()
throw error
}
}
private func legacyRestoreSurfaceID(
client: SocketClient,
workspaceID: String?
) -> String? {
// Prefer the live descriptors. Generic TTY variables can be inherited
// across nested shells, so only dedicated cmux hints are a fallback.
let ttyName = resolveCallerDescriptorTTYName()
?? resolveCallerTTYName(includeAmbientTTY: false)
guard let ttyName,
let binding = uniqueCallerTerminalBindingByTTY(
ttyName: ttyName,
client: client,
workspaceId: workspaceID
) else {
return nil
}
return binding.surfaceId
}
private func currentRestoreSurfaceUnknownError() -> CLIError {
CLIError(
message: String(
localized: "cli.restore.error.currentSurfaceUnknown",
defaultValue: "restore: the current cmux surface could not be identified. Retry from this terminal or pass --surface <id|ref>."
)
)
}
private func restoreSelector(_ arguments: [String]) throws -> RestoreSelector {
if arguments.first == "--surface" {
if arguments.count == 1 {
+201 -36
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")
}
}
@@ -4947,13 +5005,35 @@ struct CMUXCLI {
let csWsFlag = optionValue(commandArgs, name: "--workspace")
let windowRaw = windowFromArgsOrOverride(commandArgs, windowOverride: windowId)
let workspaceArg = csWsFlag ?? (windowRaw == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
let surfaceRaw = optionValue(commandArgs, name: "--surface") ?? optionValue(commandArgs, name: "--panel") ?? (csWsFlag == nil && windowRaw == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil)
let explicitSurfaceRaw = optionValue(commandArgs, name: "--surface") ?? optionValue(commandArgs, name: "--panel")
let surfaceRaw = explicitSurfaceRaw ?? (csWsFlag == nil && windowRaw == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil)
var params: [String: Any] = [:]
let winId = try normalizeWindowHandle(windowRaw, client: client)
if let winId { params["window_id"] = winId }
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client, windowHandle: winId)
if let wsId { params["workspace_id"] = wsId }
let sfId = try normalizeSurfaceHandle(surfaceRaw, client: client, workspaceHandle: wsId, windowHandle: winId)
let sfId: String?
if let explicitSurfaceRaw {
let explicitSurfaceHandle = explicitSurfaceRaw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !explicitSurfaceHandle.isEmpty else {
throw CLIError(message: String(
localized: "cli.surface.error.handleBlank",
defaultValue: "Surface handle is blank"
))
}
if let wsId {
sfId = try resolveSurfaceId(explicitSurfaceHandle, workspaceId: wsId, client: client)
} else if let winId {
sfId = try normalizeSurfaceHandle(explicitSurfaceHandle, client: client, workspaceHandle: nil, windowHandle: winId)
} else {
throw CLIError(message: String(
localized: "cli.closeSurface.error.explicitSurfaceRequiresWorkspaceOrWindow",
defaultValue: "close-surface requires --workspace or --window with explicit --surface"
))
}
} else {
sfId = try normalizeSurfaceHandle(surfaceRaw, client: client, workspaceHandle: wsId, windowHandle: winId)
}
if let sfId { params["surface_id"] = sfId }
let payload = try client.sendV2(method: "surface.close", params: params)
if let closedWorkspaceId = (payload["workspace_id"] as? String) ?? wsId,
@@ -7071,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 --")
}
@@ -10234,10 +10325,10 @@ struct CMUXCLI {
" cmux_relay_cli=\"$HOME/.cmux/bin/cmux\"",
" if [ ! -x \"$cmux_relay_cli\" ]; then cmux_relay_cli=\"$(command -v cmux 2>/dev/null || true)\"; fi",
" if [ -n \"$cmux_relay_cli\" ]; then",
" ( cmux_relay_report_tty='{\"workspace_id\":\"__CMUX_WORKSPACE_ID__\",\"tty_name\":\"'$cmux_bootstrap_tty'\"}'",
" ( cmux_relay_report_tty='{\"workspace_id\":\"__CMUX_WORKSPACE_ID__\",\"tty_name\":\"'$cmux_bootstrap_tty'\",\"terminal_lifecycle_id\":\"__CMUX_TERMINAL_LIFECYCLE_ID__\",\"attempt_id\":\"__CMUX_SSH_ATTEMPT_ID__\"}'",
" cmux_relay_ports_kick='{\"workspace_id\":\"__CMUX_WORKSPACE_ID__\",\"reason\":\"command\"}'",
" if [ -n \"__CMUX_SURFACE_ID__\" ]; then",
" cmux_relay_report_tty='{\"workspace_id\":\"__CMUX_WORKSPACE_ID__\",\"surface_id\":\"__CMUX_SURFACE_ID__\",\"tty_name\":\"'$cmux_bootstrap_tty'\"}'",
" cmux_relay_report_tty='{\"workspace_id\":\"__CMUX_WORKSPACE_ID__\",\"surface_id\":\"__CMUX_SURFACE_ID__\",\"tty_name\":\"'$cmux_bootstrap_tty'\",\"terminal_lifecycle_id\":\"__CMUX_TERMINAL_LIFECYCLE_ID__\",\"attempt_id\":\"__CMUX_SSH_ATTEMPT_ID__\"}'",
" cmux_relay_ports_kick='{\"workspace_id\":\"__CMUX_WORKSPACE_ID__\",\"surface_id\":\"__CMUX_SURFACE_ID__\",\"reason\":\"command\"}'",
" fi",
" env -u CMUX_SOCKET CMUX_SOCKET_PATH=\"127.0.0.1:\(remoteRelayPort)\" \"$cmux_relay_cli\" rpc surface.report_tty \"$cmux_relay_report_tty\" >/dev/null 2>&1 || true",
@@ -15370,26 +15461,51 @@ struct CMUXCLI {
}
private func resolveSurfaceId(_ raw: String?, workspaceId: String, client: SocketClient) throws -> String {
if let raw, isUUID(raw) {
return raw
}
if let raw, isHandleRef(raw) {
let listed = try client.sendV2(method: "surface.list", params: ["workspace_id": workspaceId])
let items = listed["surfaces"] as? [[String: Any]] ?? []
for item in items where (item["ref"] as? String) == raw {
if let id = item["id"] as? String { return id }
}
throw CLIError(message: "Surface ref not found: \(raw)")
}
let listed = try client.sendV2(method: "surface.list", params: ["workspace_id": workspaceId])
let items = listed["surfaces"] as? [[String: Any]] ?? []
if let raw, let index = Int(raw) {
if let raw {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
throw CLIError(message: String(
localized: "cli.surface.error.handleBlank",
defaultValue: "Surface handle is blank"
))
}
if isUUID(trimmed) {
for item in items where surfaceHandleMatches(trimmed, item: item) {
if let id = item["id"] as? String { return id }
}
throw CLIError(message: localizedFormat(
"cli.surface.error.notFound",
defaultValue: "Surface not found: %@",
trimmed
))
}
if isHandleRef(trimmed) {
for item in items where surfaceHandleMatches(trimmed, item: item) {
if let id = item["id"] as? String { return id }
}
throw CLIError(message: localizedFormat(
"cli.surface.error.refNotFound",
defaultValue: "Surface ref not found: %@",
trimmed
))
}
guard let index = Int(trimmed) else {
throw CLIError(message: localizedFormat(
"cli.surface.error.invalidHandle",
defaultValue: "Invalid surface handle: %@ (expected UUID, ref like surface:1, or index)",
trimmed
))
}
for item in items where intFromAny(item["index"]) == index {
if let id = item["id"] as? String { return id }
}
throw CLIError(message: "Surface index not found")
throw CLIError(message: String(
localized: "cli.surface.error.indexNotFound",
defaultValue: "Surface index not found"
))
}
if let focused = items.first(where: { ($0["focused"] as? Bool) == true }) {
@@ -15528,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]
@@ -15540,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
@@ -22424,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
}
}
@@ -30976,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
}
@@ -31856,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(
@@ -32255,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(
@@ -35274,6 +35436,9 @@ export default CMUXSessionRestore;
print(subcommandUsage("hooks") ?? "Usage: cmux hooks <setup|uninstall|agent>")
return true
}
if def.name == "pi", action == "session-start" {
refreshManagedPiExtensionIfNeeded(def)
}
let actionArgs = Array(rest.dropFirst())
switch action {
case "inject-args" where def.name == "codex":
+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
})
}
@@ -71,6 +71,11 @@ public enum DiagnosticFailureKind: Int, Sendable, Codable, CaseIterable {
/// event queue overflowed while the transport stopped draining (for
/// example the peer's network path died mid-write).
case sendQueueOverflow = 24
/// The connect-attempt registry refused a dial because the exact route is
/// held by an in-flight connect attempt. Distinguishes gate refusals from
/// genuine dial timeouts in exports; a gated attempt never reached the
/// network.
case routeGated = 25
case unknown = 255
/// Reduces a typed or system error to the bounded diagnostic vocabulary.
@@ -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
}
}
@@ -272,6 +272,7 @@ import os
#expect(DiagnosticFailureKind.admissionLeaseExpired.rawValue == 22)
#expect(DiagnosticFailureKind.admissionRevalidationFailed.rawValue == 23)
#expect(DiagnosticFailureKind.sendQueueOverflow.rawValue == 24)
#expect(DiagnosticFailureKind.routeGated.rawValue == 25)
#expect(
Set(DiagnosticFailureKind.allCases.map(\.rawValue)).count
== DiagnosticFailureKind.allCases.count
@@ -288,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)
@@ -441,6 +441,59 @@ 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
// sub-30ms timeout failures that poisoned `lastFailureEvent`.
let gatedRefusal = DiagnosticEvent(
code: .transportDialFailed,
tNanos: 2,
a: DiagnosticTransportKind.iroh.rawValue,
b: DiagnosticFailureKind.routeGated.rawValue,
c: 7
)
let report = DiagnosticReport(
anchorWallNanos: 1_000_000_000,
anchorMonotonicNanos: 1,
events: [gatedRefusal]
)
#expect(report.lastFailureKind == .routeGated)
#expect(report.lastFailureKind != .timedOut)
#expect(report.lastFailureEvent == gatedRefusal)
}
@Test func clearStartsFreshBoundedSessionAndResetsAnchors() async {
let log = DiagnosticLog(
capacity: 2,
@@ -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
@@ -25,7 +31,9 @@ public actor CmxConnectivityEngine {
private var endpointGeneration: UInt64?
private var localIdentity: CmxIrohPeerIdentity?
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] = [:]
@@ -198,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 {
@@ -224,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
@@ -240,36 +251,52 @@ public actor CmxConnectivityEngine {
}
/// Records the last route revision installed atomically by the composition root.
public func didInstallRouteRevision(_ revision: UInt64) async {
guard routeRevision != revision else { return }
await invalidateAllPeers(failure: .superseded)
///
/// Peers whose material route content is unchanged keep their live
/// sessions; every other peer is invalidated before the new revision
/// 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 {
// 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)
routeRevision = revision
routeContent = content
publishSnapshot()
}
/// 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()
}
@@ -283,6 +310,7 @@ public actor CmxConnectivityEngine {
public func waitForUsableHomeRelay(
timeout: Duration = .seconds(15)
) async throws {
try await ensureEndpointReady()
try await supervisor.waitForUsableHomeRelay(timeout: timeout)
}
@@ -407,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,
@@ -419,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)
}
@@ -439,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)
}
@@ -539,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 {
@@ -612,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()
@@ -638,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 {
@@ -678,10 +781,36 @@ public actor CmxConnectivityEngine {
throw CmxConnectivityEngineError.superseded
}
}
let content = response.snapshot.map(CmxConnectivityRouteContent.init)
if routeRevision != response.revision {
await invalidateAllPeers(failure: .superseded)
await invalidatePeersSuperseded(by: content)
routeRevision = response.revision
routeContent = content
publishSnapshot()
} else if let content {
routeContent = content
}
}
/// Invalidates peers whose authoritative route material changed.
///
/// A missing baseline or replacement fails closed and tears down every
/// peer, preserving the pre-content-tracking behavior.
private func invalidatePeersSuperseded(
by content: CmxConnectivityRouteContent?
) async {
guard let previous = routeContent,
let content,
previous.account == content.account else {
await invalidateAllPeers(failure: .superseded)
return
}
for (peerID, peer) in peers {
guard let previousRoute = previous.peerRoute(for: peerID),
previousRoute == content.peerRoute(for: peerID) else {
await peer.invalidate(failure: .superseded)
continue
}
}
}
@@ -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()
@@ -200,6 +206,22 @@ actor CmxConnectivityPeerSession {
continue redial
}
// The dead-on-arrival probe suspends this actor. A concurrent
// caller that dialed in that window may have installed first;
// 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 {
return installed.session
}
if let winner = await settleRedundantDial(
connected,
installedID: installed.id
) {
return winner
}
continue redial
}
install(
connected,
id: pending.id,
@@ -268,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,
@@ -0,0 +1,80 @@
/// Authoritative route material whose change requires live session teardown.
///
/// Volatile freshness fields are excluded on purpose: `last_seen_at`, path
/// hints, direct ports, and display names move on every registration
/// heartbeat and shape only the next dial, never the trust of an already
/// admitted connection. Comparing this content lets a route revision bump
/// 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.
struct BindingMaterial: Equatable, Sendable {
let bindingID: String
let appInstanceID: String
let tag: String
let platform: CmxIrohPlatform
let identityGeneration: Int
let pairingEnabled: Bool
let capabilities: [String]
init(binding: CmxIrohBrokerBinding) {
bindingID = binding.bindingID
appInstanceID = binding.appInstanceID
tag = binding.tag
platform = binding.platform
identityGeneration = binding.identityGeneration
pairingEnabled = binding.pairingEnabled
// The admission policy reads capabilities with set semantics.
capabilities = binding.capabilities.sorted()
}
}
let account: AccountMaterial
private let peerRoutes: [CmxConnectivityPeerID: [BindingMaterial]]
init(snapshot: CmxIrohDiscoveryResponse) {
account = AccountMaterial(snapshot: snapshot)
var routes: [CmxConnectivityPeerID: [BindingMaterial]] = [:]
for binding in snapshot.bindings {
let peerID = CmxConnectivityPeerID(
identity: binding.endpointID,
deviceID: binding.deviceID
)
routes[peerID, default: []].append(BindingMaterial(binding: binding))
}
peerRoutes = routes.mapValues { bindings in
bindings.sorted { $0.bindingID < $1.bindingID }
}
}
/// Returns the material route for one peer, or nil when unrouted.
func peerRoute(
for peerID: CmxConnectivityPeerID
) -> [BindingMaterial]? {
peerRoutes[peerID]
}
}
@@ -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
@@ -88,7 +88,10 @@ extension CmxIrohClientRuntime {
try requireCurrent(revision)
guard published else { return .failed(.superseded) }
if let routeRevision = discovery.revision {
await connectivityEngine.didInstallRouteRevision(routeRevision)
await connectivityEngine.didInstallRouteRevision(
routeRevision,
routes: discovery
)
}
liveDiscoveryGeneration &+= 1
return .refreshed
@@ -376,7 +376,10 @@ public actor CmxIrohClientRuntime {
guard published else {
return .failed(.superseded)
}
await connectivityEngine.didInstallRouteRevision(discoveredRevision)
await connectivityEngine.didInstallRouteRevision(
discoveredRevision,
routes: discovery
)
liveDiscoveryGeneration &+= 1
return .refreshed
} catch {
@@ -520,7 +523,8 @@ public actor CmxIrohClientRuntime {
if published {
if let routeRevision = discovery.revision {
await connectivityEngine.didInstallRouteRevision(
routeRevision
routeRevision,
routes: discovery
)
}
liveDiscoveryGeneration &+= 1
@@ -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
}
@@ -459,7 +480,10 @@ extension CmxIrohHostRuntime {
await handleRoute(policy.binding, policy.routePathHints)
try requireCurrent(revision)
if let routeRevision = discovery.revision {
await connectivityEngine.didInstallRouteRevision(routeRevision)
await connectivityEngine.didInstallRouteRevision(
routeRevision,
routes: discovery
)
}
scheduleLANPublication(
binding: policy.binding,
@@ -149,7 +149,10 @@ extension CmxIrohHostRuntime {
try requireCurrent(revision)
await handleRoute(metadata, discovered.pathHints)
try requireCurrent(revision)
await connectivityEngine.didInstallRouteRevision(discoveredRevision)
await connectivityEngine.didInstallRouteRevision(
discoveredRevision,
routes: discovery
)
scheduleLANPublication(
binding: metadata,
rendezvous: discovery.lanRendezvous,
@@ -321,7 +321,10 @@ public actor CmxIrohHostRuntime {
await handleBinding(registration, discovery, publishedPolicy.attestation)
try requireCurrent(revision)
if let routeRevision = discovery.revision {
await connectivityEngine.didInstallRouteRevision(routeRevision)
await connectivityEngine.didInstallRouteRevision(
routeRevision,
routes: discovery
)
}
scheduleRegistrationRenewal(
binding: registration.binding,
@@ -416,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,
@@ -482,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,389 @@ 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: [
Self.peerRouteResponse(
revision: 9,
lastSeenAt: "2026-07-30T00:00:00Z"
),
Self.peerRouteResponse(
revision: 10,
lastSeenAt: "2026-07-30T00:00:45Z"
),
])
let session = try await rig.engine.acquireControl(
for: rig.request,
ownerID: UUID()
)
#expect(await session.isClosed() == false)
try await rig.engine.reconcileRoutes()
let snapshot = await rig.engine.snapshot()
#expect(snapshot.routeRevision == 10)
#expect(await rig.connection.observedCloseCallCount() == 0)
#expect(await session.isClosed() == false)
#expect(snapshot.peers.first?.phase == .connected)
await rig.engine.stop()
}
@Test
func changedIdentityGenerationOnRevisionBumpStillInvalidatesTheSession() async throws {
let rig = try await Self.admittedPeerRig(responses: [
Self.peerRouteResponse(
revision: 9,
lastSeenAt: "2026-07-30T00:00:00Z"
),
Self.peerRouteResponse(
revision: 10,
lastSeenAt: "2026-07-30T00:00:45Z",
identityGeneration: 2
),
])
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() == 1)
#expect(await session.isClosed())
await rig.engine.stop()
}
@Test
func removedPeerBindingOnRevisionBumpStillInvalidatesTheSession() async throws {
let rig = try await Self.admittedPeerRig(responses: [
Self.peerRouteResponse(
revision: 9,
lastSeenAt: "2026-07-30T00:00:00Z"
),
Self.peerRouteResponse(
revision: 10,
lastSeenAt: "2026-07-30T00:00:45Z",
includesPeerBinding: false
),
])
let session = try await rig.engine.acquireControl(
for: rig.request,
ownerID: UUID()
)
try await rig.engine.reconcileRoutes()
#expect(await rig.connection.observedCloseCallCount() == 1)
#expect(await session.isClosed())
await rig.engine.stop()
}
@Test
func changedRelayFleetOnRevisionBumpStillInvalidatesTheSession() async throws {
let rig = try await Self.admittedPeerRig(responses: [
Self.peerRouteResponse(
revision: 9,
lastSeenAt: "2026-07-30T00:00:00Z"
),
Self.peerRouteResponse(
revision: 10,
lastSeenAt: "2026-07-30T00:00:45Z",
relayFleet: ["https://replacement.relay.example/"]
),
])
let session = try await rig.engine.acquireControl(
for: rig.request,
ownerID: UUID()
)
try await rig.engine.reconcileRoutes()
#expect(await rig.connection.observedCloseCallCount() == 1)
#expect(await session.isClosed())
await rig.engine.stop()
}
@Test
func revisionBumpWithoutReplacementContentFailsClosed() async throws {
let rig = try await Self.admittedPeerRig(responses: [
Self.peerRouteResponse(
revision: 9,
lastSeenAt: "2026-07-30T00:00:00Z"
),
Self.unchangedResponse(revision: 12),
])
let session = try await rig.engine.acquireControl(
for: rig.request,
ownerID: UUID()
)
try await rig.engine.reconcileRoutes()
#expect(await rig.engine.snapshot().routeRevision == 12)
#expect(await rig.connection.observedCloseCallCount() == 1)
#expect(await session.isClosed())
await rig.engine.stop()
}
@Test
func installedRouteRevisionUsesRouteContentEquivalence() 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 equivalent = try #require(Self.peerRouteResponse(
revision: 10,
lastSeenAt: "2026-07-30T00:00:45Z"
).snapshot)
let changed = try #require(Self.peerRouteResponse(
revision: 11,
lastSeenAt: "2026-07-30T00:01:30Z",
identityGeneration: 2
).snapshot)
await rig.engine.didInstallRouteRevision(10, routes: equivalent)
#expect(await rig.engine.snapshot().routeRevision == 10)
#expect(await rig.connection.observedCloseCallCount() == 0)
#expect(await session.isClosed() == false)
await rig.engine.didInstallRouteRevision(11, routes: changed)
#expect(await rig.engine.snapshot().routeRevision == 11)
#expect(await rig.connection.observedCloseCallCount() == 1)
#expect(await session.isClosed())
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(
@@ -150,6 +533,141 @@ struct CmxConnectivityEngineTests {
try await Self.waitUntil { await finished.value() }
}
private static let peerEndpointID = String(repeating: "f", count: 64)
private static let peerDeviceID = "123e4567-e89b-42d3-a456-426614174999"
private struct AdmittedPeerRig {
let engine: CmxConnectivityEngine
let connections: [TestIrohConnection]
let authority: ScriptedConnectivityAuthority
let request: CmxByteTransportRequest
var connection: TestIrohConnection { connections[0] }
}
private static func admittedPeerRig(
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 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: connections.map { .connection($0) }
)
let supervisor = CmxIrohEndpointSupervisor(
factory: TestIrohEndpointFactory(endpoints: [endpoint]),
configuration: try endpointConfiguration()
)
let authority = ScriptedConnectivityAuthority(responses: responses)
let context = CmxIrohClientContext(
dialPlan: try testIrohDialPlan(),
credential: try .pairGrant("e30.e30.AA")
)
let engine = CmxConnectivityEngine(
supervisor: supervisor,
contextProvider: TestIrohClientContextProvider(context: context),
authority: authority,
installRouteSnapshot: { _ in }
)
try await engine.start()
let request = CmxByteTransportRequest(
route: try CmxAttachRoute(
id: "iroh-v2",
kind: .iroh,
endpoint: .peer(identity: peerIdentity, pathHints: [])
),
expectedPeerDeviceID: peerDeviceID,
authorizationMode: .transportAdmission
)
return AdmittedPeerRig(
engine: engine,
connections: connections,
authority: authority,
request: request
)
}
private static func peerRouteResponse(
revision: UInt64,
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",
"device_id": "\(peerDeviceID)",
"app_instance_id": "0a0a0a0a-0000-4000-8000-000000000002",
"tag": "default",
"platform": "mac",
"endpoint_id": "\(peerEndpointID)",
"identity_generation": \(identityGeneration),
"pairing_enabled": true,
"capabilities": [\(capabilityList)],
"path_hints": [],
"last_seen_at": "\(lastSeenAt)"
}
"""
let fleet = relayFleet
.map { "\"\($0)\"" }
.joined(separator: ", ")
let keys = grantVerificationKeyIDs
.map {
"""
{"kid": "\($0)", "alg": "ed25519", "spki_der_base64": "QUJD"}
"""
}
.joined(separator: ", ")
return try decodeResponse(
"""
{
"protocol_version": 2,
"revision": \(revision),
"changed": true,
"reset": false,
"snapshot": {
"route_contract_version": 1,
"revision": \(revision),
"bindings": [\(includesPeerBinding ? binding : "")],
"relay_fleet": [\(fleet)],
"lan_rendezvous": {
"generation": 1,
"key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
},
"grant_verification_keys": {
"version": 1,
"current_kid": "current",
"keys": [\(keys)]
}
}
}
"""
)
}
private static func endpointConfiguration() throws -> CmxIrohEndpointConfiguration {
CmxIrohEndpointConfiguration(
secretKey: try CmxIrohSecretKey(bytes: Data(repeating: 5, count: 32)),
@@ -224,6 +742,27 @@ struct CmxConnectivityEngineTests {
}
}
private actor ScriptedConnectivityAuthority: CmxConnectivityAuthorityServing {
private var responses: [CmxConnectivitySyncResponse]
private var observedKnownRevisions: [UInt64?] = []
init(responses: [CmxConnectivitySyncResponse]) {
self.responses = responses
}
func syncConnectivity(
knownRevision: UInt64?
) async throws -> CmxConnectivitySyncResponse {
observedKnownRevisions.append(knownRevision)
guard !responses.isEmpty else {
throw CmxIrohTrustBrokerClientError.connectivity
}
return responses.removeFirst()
}
func knownRevisions() -> [UInt64?] { observedKnownRevisions }
}
private actor InitialThenFailingConnectivityAuthority: CmxConnectivityAuthorityServing {
private let initial: CmxConnectivitySyncResponse
private var calls = 0
@@ -255,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
@@ -222,6 +222,155 @@ struct CmxConnectivityPeerSessionTests {
await peer.releaseControl(ownerID: ownerID)
}
@Test
func concurrentRedialCannotDisplaceAnInstalledLiveSession() async throws {
let request = try Self.request()
let peerID = try CmxConnectivityPeerID(request: request)
let winner = TestConnectivitySession(
continuityID: 81,
gatesFirstIsClosedCheck: true
)
let loser = TestConnectivitySession(
continuityID: 82,
gatesFirstIsClosedCheck: true
)
let builder = OrderedGatedConnectivitySessionBuilder(
sessions: [winner, loser]
)
let peer = CmxConnectivityPeerSession(
peerID: peerID,
buildSession: { request in
try await builder.build(request)
}
)
// Park both callers past their pre-dial installed-slot checks so the
// first install lands while the second caller is still in flight.
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 secondCaller.value
#expect(await peer.connectionContinuityID() == 81)
#expect(await winner.closeCount() == 0)
#expect(await loser.closeCount() == 1)
#expect(await peer.snapshot().phase == .connected)
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()
@@ -516,6 +665,12 @@ private actor TestConnectivitySession: CmxConnectivitySession {
private var closureWaiters: [CheckedContinuation<Void, Never>] = []
private var closeAttributionWaiter: CheckedContinuation<Void, Never>?
private var closeAttributionWaiting = false
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:
@@ -524,11 +679,15 @@ private actor TestConnectivitySession: CmxConnectivitySession {
init(
continuityID: UInt64,
gatesCloseAttribution: Bool = false,
keepsSelectedPathStreamOpen: Bool = false
keepsSelectedPathStreamOpen: 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? {
@@ -575,7 +734,26 @@ private actor TestConnectivitySession: CmxConnectivitySession {
)
}
func isClosed() -> Bool { closed }
func isClosed() async -> Bool {
if isClosedGatePending {
isClosedGatePending = false
isClosedGateWaiting = true
await withCheckedContinuation { continuation in
isClosedGateWaiter = continuation
}
isClosedGateWaiting = false
}
return closed
}
func isClosedGateIsWaiting() -> Bool {
isClosedGateWaiting
}
func releaseIsClosedGate() {
isClosedGateWaiter?.resume()
isClosedGateWaiter = nil
}
func connectionContinuityID() -> UInt64? {
closed ? nil : continuityID
@@ -613,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":

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