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 649cb7f673 Add missing return in restoreCLIArgument (#9338)
PR 9265 landed restoreCLIArgument with a guard statement plus a bare final
expression; Swift only allows implicit return in single-expression bodies, so
every app build on main fails with 'missing return in static method expected
to return String?'. CI is paused (workflow_dispatch only), which is how the
break reached main.

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-31 23:58:07 -05:00
Abdulaziz AlbaharandClaude Fable 5 6846070817 Fix missing return in restoreCLIArgument (main compile break) (#9334)
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]>
2026-07-31 23:52:03 -05:00
Austin Wang 51a95f8dc2 Fix missing return in restoreCLIArgument breaking nightly Release build (#9343) 2026-07-31 21:39:44 -07:00
Austin Wangandcmux reload-cloud cdb35d72e1 Fix sender-relative key-window routing for restored windows (#9282)
* test: reproduce sender-relative window key routing

* fix: key sender-relative window actions

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-31 21:24:27 -07:00
Austin Wangandcmux reload-cloud 5e83b4ec18 Fix ssh-tmux named-key encoding for remote TERM (#9273)
* test: cover tmux named-key forwarding

* fix: delegate remote tmux named keys to tmux

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-31 21:06:16 -07:00
Austin Wangandlawrencecchen 5d1cea3927 Fix stale semantic prompts duplicating inline TUI frames (#9275)
* terminal: log every applied surface size

* ghostty: add prompt overwrite regression coverage

* ghostty: clear stale prompt marks on output overwrite

* test: add dSYMs to iOS upload fixtures

* ghostty: pin prompt lifecycle GhosttyKit

* test: re-report lifecycle after status clear

* test: bypass quit path in remote tmux cleanup

(cherry picked from commit de565ada4d)

* test: retire remote tmux fixture surfaces

(cherry picked from commit 756cc69b57)

* test: isolate socket policy from renderer observers

* ci: route TUI spec through runner variable

* test: signal Python SDK handler teardown reliably

---------

Co-authored-by: lawrencecchen <[email protected]>
2026-07-31 21:05:51 -07:00
Abdulaziz AlbaharandClaude Fable 5 c3cdbd6044 Bound iOS iroh client retries with one shared backoff policy (#9301)
* Add failing coverage for unbounded iOS iroh activation retries

A failing activation currently re-runs registration, discovery, and
relay-policy against the broker on every dial or preparation, with no
client-side spacing: field phones wedged in this loop issued broker
mutations every 2-10 seconds for 40+ hours. These tests pin the intended
bounds: a failed activation arms a client backoff visible as a
retryScheduled diagnostic no longer than the 30 s foreground cap, dials
inside the window stay broker-silent with the unchanged inactive error
shape, and a scenePhase-active transition clears the window immediately.

Tests-first commit: red until the client backoff lands.

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

* Bound iOS iroh client retries with one shared backoff policy

PR 9269 removed every server-side broker quota, so nothing bounded a
runaway client: wedged phones re-ran registration/discovery/relay-policy
every 2-10 s for 40+ hours, while single transient blips overshot the
other way (32-36 s retryScheduled naps, 33-65 s idle gaps) because the
default CmxIrohRetrySchedule is a host profile (30 s first retry, 1 h cap).

CmxIrohReconnectBackoff is the one shared, injectable ladder: decorrelated
jitter drawn from [floor, min(cap, previous*3)] with a 1 s floor and 30 s
foreground cap, seedable SplitMix64 for exact-schedule tests, reset() to
the floor, and server Retry-After honored as a bounded lower bound.

Wired without changing success paths: a failed broker-bound activation
arms the ladder (emits retryScheduled) and reconciles inside the window
skip broker work, cleared on scenePhase-active, network-path change,
account switch, and success; the relay-policy refresh loop draws its
failure delay from the same ladder; the client runtime builds its relay
credential coordinator with CmxIrohRetrySchedule.foregroundClient; and
CmxIrohBrokerBackpressureGate paces registration mutations to 2 s-spaced
slots via an injected sleep, so a wedged phone cannot exceed ~30
challenge/register attempts per minute even with no server rate limit.

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

* Unify connectivity v2 invalidation resubscribes onto the shared backoff

Connectivity v2 landed with its own private retry ladder inside
CmxConnectivityInvalidationSubscriber.run(): unjittered exponential
1,2,4...60 s with a hardcoded clock, no reset semantics, and a cap that
exceeds the 30 s foreground bound. The shared policy wins: failures now
draw decorrelated-jittered delays from the injected
CmxIrohReconnectBackoff (1 s floor, 30 s cap), a served stream resets the
ladder to its floor window, and the jittered draw spreads a fleet's
re-subscribes when a service deploy closes every socket at once. The
backoff and sleep are injectable with source-compatible defaults, and a
seeded twin-ladder test pins the exact schedule.

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

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-31 23:01:25 -05:00
Austin Wang 515a3119f5 Fix ssh-tmux terminal replies leaking into reattached panes (#9272)
* test(remote-tmux): require mirror protocol ownership

* fix(remote-tmux): make Ghostty a protocol mirror
2026-07-31 20:49:52 -07:00
Abdulaziz AlbaharandClaude Fable 5 3a77d9a102 Classify transient token misses as connectivity so launch activation stops failing closed (#9259)
* 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]>

* Serve the persisted token pair from the keychain before any connection attempt

authenticatedSessionSnapshot() queued behind launch restore and foreground
revalidation (network /users/me round trips bounded by the sessionRestore
timeout), then threw a retryable error for the transition window, so endpoint
activation waited out network latency plus a backoff nap to obtain tokens that
were sitting in the keychain the whole time.

Keychain reads are microseconds, so the snapshot now tries a stored fast path
first: read refresh + stored access (never network-refreshing), bracket with a
refresh re-read so a rotation crossing the window is detected, and pin
generation and account id across the reads. Backend calls send both tokens and
the server refreshes a stale access token itself, so the stored pair is
sufficient to dial with. The fast path declines (falls back to the full
bootstrap-awaiting path) on an auth-environment-switch launch, while a sign-in
exchange or sign-out capture owns the store, or when no complete pair exists;
the transient classification from the previous commits still covers those.

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

* Propagate cancellation from the broker token read instead of connectivity

CodeRabbit finding on https://github.com/manaflow-ai/cmux/pull/9259: the
blanket transient mapping converted a caller's CancellationError into
.connectivity, letting retry and cached-policy fallbacks keep working on a
cancelled task. Cancellation now rethrows as CancellationError, with a
regression test pinning it.

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

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-31 22:38:17 -05:00
Abdulaziz AlbaharandClaude Fable 5 4f4a4d1156 iOS: fix Hidden Computers Forget swipe crash (destructive role on confirm-first button) (#9291)
* iOS: regression test for Forget swipe crash on Hidden Computers rows

Adds CMUX_UITEST_HIDDEN_COMPUTERS_PREVIEW (fixture Hidden Computers list
with production closure semantics) and a UI test that swipes a row, taps
Forget, and requires the app to survive with the confirmation dialog shown
and the row still listed. Fails on current main: the destructive-role swipe
button makes SwiftUI batch-delete the row while the model keeps it, which
is the UICollectionView item-count abort reported on TestFlight build
20260731052644 (iOS 27.0).

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

* iOS: fix Forget swipe crash by dropping destructive role from confirm-first button

SwiftUI's list coordinator treats a destructive-role swipe button as "this
tap removes the row" and eagerly runs a collection-view batch delete. The
Forget tap only presents the confirmation dialog, so the model count never
changed and UIKit aborted with the invalid-item-count assertion. Keep the
red appearance with .tint(.red) and leave the dialog flow (whose own
destructive confirm does remove the row) untouched, matching
WorkspaceNavigationRow's confirm-first Delete. The context-menu Forget
keeps its destructive role: menus don't drive row removal.

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

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-31 22:25:37 -05:00
Austin Wang 4ae648f956 Close the remaining ContentView release-chain gap (#9082)
* Test bounded deferred UI replacement bursts

* Strengthen deferred replacement lifetime coverage

* Cover active sleeper replacement

* Synchronize deferred closure lifetime assertions

* Bound cursor scheduler regression test

* Make sidebar test clock waits cancellable

* Test ContentView stored task ownership

* Break workspace handoff task release chain
2026-07-31 20:24:46 -07:00
Abdulaziz AlbaharandClaude Fable 5 7a6b63343e iOS: replace disconnect chrome with Mail-style status line under the computers picker (#9276)
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]>
2026-07-31 22:19:36 -05:00
Lawrence Chenandcmux-lawrence a45c38f596 Keep company information page low key (#9335)
Co-authored-by: cmux-lawrence <[email protected]>
2026-07-31 20:11:02 -07:00
e0604ca175 iOS: restore workspace drag-and-drop + group create after ticket expiry; native drop-into-group signifiers (#8602)
* Add failing tests: mac-scoped workspace mutations must survive attach-ticket expiry

The iOS workspace list's drag-and-drop and the +-button's New Workspace
Group item vanish ten minutes after pairing: both are gated on
allowsMacScopedWorkspaceMutations, which requires a current mac-scoped
attach ticket, and minted tickets default to a 600s TTL. The host already
treats Stack same-account auth as the sole authorization gate for every
other mobile verb; these tests pin the expected behavior that
workspace.move / workspace.group.* / create-in-group survive ticket
expiry on hosts that advertise workspace.mutations.account_auth.v1.

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

* 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]>

* Fix CmuxMobileShellUI test target compile: pass terminalFolderTapEnabled to Coordinator

The folder-tap change added a required terminalFolderTapEnabled parameter
to GhosttySurfaceRepresentable.Coordinator but the package test target is
not run by CI, so TerminalSurfaceMountOwnershipTests landed uncompilable.

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

* iOS: drop workspaces onto group headers natively and show end-of-group boundaries while dragging

Two drag-and-drop UX gaps in the workspace list: empty (anchor-only) and
collapsed groups had no drop slot at all, and the invisible 16pt
end-of-group footer made drops near a group's end ambiguous between
in-group and root placement.

Drop-into: dropSessionDidUpdate hit-tests the session location; a drag
hovering the vertical middle band of a group header row (8pt edge bands
still produce plain insertion gaps) returns
UITableViewDropProposal(.move, .insertIntoDestinationIndexPath), so
UIKit's native row highlight signals join-the-group. performDropWith maps
that to a join-at-end intent (groupID + nil beforeWorkspaceID, already
supported by MobileWorkspaceMovePolicy.applyingWorkspaceReorderToGroupEnd)
through the same optimistic-order + chained-send path grouped index moves
use, factored into one applyGroupedWorkspaceMove helper. Eligibility runs
through normalizedIntent, so anchors, unknown groups, and no-op joins
never highlight. The band decision lives in a pure
WorkspaceListDropProposalPolicy with unit tests.

Boundary signifier: the coordinator tracks drag-session lifetime
(dragSessionWillBegin/DidEnd) and reconfigures footer rows, which render
a 2pt separator capsule inside their unchanged 16pt slot only while a
drag is active — drop above the rule joins the group, below lands at
root.

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

* Test the native drop-into-group flow through the real coordinator delegates

Drives dropSessionDidUpdate and performDropWith on a laid-out UITableView
with protocol-mocked UIDropSession/UITableViewDropCoordinator: middle-band
header hover proposes insert-into, edge bands keep insertion gaps,
ineligible joins fall back, a completed into-drop calls dropIntoGroup with
the native intoRowAt animation and no index move, a stale into proposal
without a recorded target never joins, and drag-session lifetime toggles
the footer boundary state. isDragSessionActive becomes private(set) for
the assertion.

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

* Instrument the mobile workspace.move path end to end (DEBUG only)

Every decision point that could silently swallow a phone drag now logs:
coordinator drop rejection reasons, resolver no-intent, chain aborts,
client gates, send outcome (anchormux container log), and on the host the
requested params, every rejection, and the Bool each reorder actually
returned (cmuxDebugLog). A drop that reverts is now attributable from
either side's log instead of indistinguishable from success.

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

* Add failing tests: drops must survive nil sourceIndexPath

UIKit nils UITableViewDropItem.sourceIndexPath once the data source
applies any snapshot during the drag session. The footer-boundary
reconfigure does that on every drag, so every real phone drop arrived
with source=nil, failed the performDrop guard, and silently snapped
back (evidence: move.performDrop REJECTED ... source=nil in the device
log). Mocked drops always supplied a source index, which is how this
escaped.

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

* Resolve drop source from dragged-item identity, not UIKit's snapshot-bound index path

UITableViewDropItem.sourceIndexPath goes nil the moment the data source
applies any snapshot mid-drag — the footer-boundary reconfigure does on
every drag session, so every real drop was silently cancelled and flew
back. Both drop branches now find the dragged WorkspaceListTableItem in
the current configuration items by identity, which stays valid across
mid-drag snapshot applies and live list updates.

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

* Adopt main's WorkspaceListTable shape in the drop-test fixture

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

* test(ios): cover workspace drag transitions

* fix(ios): preserve native workspace drag transitions

* test(ios): stabilize workspace drag gesture

* test(ios): cover workspace drag transition matrix

* fix(ios): scope drag fixture data to preview view

* test(ios): update workspace drop fixture initializer

* test(ios): keep workspace drags above tab chrome

* test(ios): cover collapsed workspace drop snapshot

* fix(ios): settle collapsed workspace drops synchronously

* test(ios): cover workspace drop animation lifecycle

* fix(ios): complete workspace drop animations natively

* test(ios): require geometry targets for all drops

* fix(ios): unify workspace drop geometry transactions

* test(ios): require UIKit-owned drop completion

* fix(ios): let UIKit own workspace drop completion

* test(ios): require synchronous table drop batches

* fix(ios): coordinate workspace drops with table batches

* test(ios): distinguish workspace group drop boundaries

* fix(ios): identify each workspace group drop boundary

* refactor(ios): align workspace table apply diagnostics

* test(ios): preserve native drop ownership

* fix(ios): keep drag lifecycle under drop delegate

* test(ios): name legacy move-path invariant

* test: deduplicate host mutation authorization matrix

---------

Co-authored-by: cmux reload-cloud <[email protected]>
Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-31 22:05:17 -05:00
Austin Wangandcmux reload-cloud 6e2edb1b62 Fix Dock paste routing to selected terminal (#9112)
* Test Dock selection first-responder handoff (#9097)

* Restore selected Dock terminal focus

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-31 19:57:43 -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
Austin Wang fa11c0398d Fix inline code escaping in markdown viewer (#9274)
* test(markdown): cover codespan escaping

* fix(markdown): escape codespans exactly once

* test(markdown): make injection proof deterministic

* fix(markdown): contain malformed codespan tokens

* fix(markdown): diagnose invalid codespans

* test(markdown): avoid hook-order coupling

* fix(markdown): use neutral invalid-span marker

* fix(markdown): keep codespan normalization linear
2026-07-31 19:37:18 -07:00
Austin Wangandcmux reload-cloud 5bf9595804 Add shell-free cmux restore verb (#9265)
* test: require short CLI restore startup input

* feat: restore processes from structured launch data

* fix: prefer live restore binding identity

* fix: route restore through bundled CLI

* fix: harden restore startup dispatch

* fix: reconcile structured restore overrides

* fix: harden restore binding and cwd fallback

* fix: recover restore context without shell env

* fix: secure restore transport failures

* fix: harden structured restore boundaries

* test: cover restore startup compatibility gaps

* fix: type restore cwd fallback explicitly

* fix: call restore launch mapper explicitly

* fix: name restore socket responder distinctly

* fix: preserve restore startup compatibility

* fix: ignore empty restore PATH components

* fix: bound restore provider preflights

* refactor: isolate restore preflight execution

* fix: harden restore launch boundaries

* test: require readable restore startup verb

* fix: keep restore startup input readable

* test: cover restore review edge cases

* fix: address restore review edge cases

* refactor: align restore types with package policy

* test: cover restore compatibility review gaps

* fix: close restore review edge cases

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-31 19:34:35 -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 Albahar b2db1c4551 Fix iOS keyboard focus after photo picker (#9287)
* test: cover keyboard recovery after photo picker

* fix: release terminal focus before photo picker
2026-07-31 21:08:27 -05:00
Austin Wangandcmux reload-cloud 4d27e944a8 Fix partial blank browser screenshots (#9281)
* test: cover partial blank browser screenshots

* fix: verify browser screenshot frames

* fix: harden screenshot verification

* fix: make screenshot probes conservative

* fix: bound screenshot verifier work

* fix: address screenshot review feedback

* fix: reduce screenshot verifier false positives

* fix: keep screenshot verification effective

* fix: make screenshot retries cancellable

* fix: make screenshot capture policy testable

* fix: tighten screenshot attestation policy

* test: exercise browser screenshot DOM probes

* fix: exclude inconclusive screenshot probes

* fix: bound screenshot verifier resources

* refactor: isolate screenshot capture types

* fix: defer screenshot bridge terminal state

* fix: tighten screenshot coordinate attestation

* docs: clarify screenshot mismatch threshold

* fix: bound screenshot WebKit evaluation

* fix: return straight screenshot pixel colors

* test: use ordinary screenshot web view configuration

* fix: preserve screenshot synchronization timeouts

* fix: classify screenshot synchronization uncertainty

* fix: fail open when screenshot preparation is unavailable

* fix: ignore text behind passive overlays

* fix: preserve screenshot synchronization ordering

* perf: sample screenshot pixels on demand

* perf: normalize only screenshot probe regions

* test: assert pending fonts through probes

* fix: share screenshot continuation completion gate

* fix: reject screenshot request reuse safely

* fix: budget verified browser screenshots

* fix: bound browser snapshot attempts

* fix: preserve screenshot scanline order

* test: cover browser screenshot timeout nesting

* fix: preserve snapshot helper defaults

* fix: share browser screenshot timing budget

* refactor: make screenshot pixel values explicit

* fix: make overlapping screenshots retryable

* fix: export screenshot timing dependency

* fix: attest text across scripts safely

* fix: detect transparent screenshot gaps

* fix: bound screenshot recovery timing

* fix: await screenshot lease teardown

* fix: serialize screenshot lease completion

* test: eliminate screenshot suite warnings

* fix: preserve screenshot deadline nesting

* fix: eliminate screenshot capture warnings

* test: cover screenshot probes on complex DOMs

* fix: bound screenshot probe work in WebKit

* fix: avoid retaining browser during frame sync

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-31 19:06:08 -07:00
Lawrence Chen 387061d163 test: update mobile host route cache calls (#9328) 2026-07-31 19:03:12 -07:00
Abdulaziz Albahar 08164fda77 Fix live agent prose streaming ownership
Replaces timer polling with demand-owned render/tick coalescing, binds transcript settlement to active turn ownership, and clears/replays previews on stream lifecycle changes.
2026-07-31 20:53:53 -05:00
Abdulaziz Albahar bf5e03eb15 Merge pull request #9284 from manaflow-ai/feat-connectivity-v2
Rebuild Iroh connectivity authority and Apple transport
2026-07-31 19:09:33 -05:00
Lawrence ChenandAbdulaziz Albahar 820aa65e60 Fix iOS terminal scrolling during edge swipe back (#6659)
* Add failing iOS terminal edge swipe test

* Reserve iOS back swipe edge from terminal scroll

* Fix iOS edge reservation lint

* Extract terminal scroll mechanics view

* Retire terminal-specific edge reservation

* Add failing iOS edge swipe scroll regression

* Model edge swipe gesture hierarchy in test

* Prioritize iOS back swipe over surface pans

* Scope swipe precedence to navigation content

* Fix swipe-back unit test hierarchy

---------

Co-authored-by: Abdulaziz Albahar <[email protected]>
2026-07-31 18:58:08 -05:00
Abdulaziz AlbaharandClaude Fable 5 cb468e974d Comprehensive Sentry telemetry for iroh/transport failures (iOS + macOS) (#9305)
* Move Sentry scrubbing layer to shared CmuxSentryTelemetry package

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

* Transport diagnostics core: DiagnosticLog tap, presentation, incident policy

DiagnosticLog gains a single settable event tap delivered on the drain task
(after ring retention, so selected-path dedup is respected and the hot-path
record() stays untouched). DiagnosticEventPresentation decodes events into
stable case names and per-code fields for telemetry sinks. Pure
TransportIncidentPolicy turns the failure stream into a bounded set of
reportable incidents: per-signature cooldown with coalesced counts, hourly
capture budget, sustained-streak outage escalation, and suppression of
attributable noise (cancelled/superseded churn, offline-while-unreachable,
idle timeout while backgrounded). pairFail now records the classified
DiagnosticFailureKind in its b slot so pairing failures group by cause.

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

* Bridge transport diagnostics into Sentry on iOS and macOS

TransportSentryReporter (CmuxSentryReporting) consumes the DiagnosticLog tap:
every retained event becomes a scrubbed breadcrumb and a budget-limited
structured log line, and failures that cross TransportIncidentPolicy's gates
become Sentry events fingerprinted by code/failure/transport signature with
the compact diagnostic ring export attached, so one issue carries the full
connection timeline that previously had to be pulled off the device by hand.

iOS gains the shared last-mile scrubber it was waiting on: beforeSend now
scrubs (in addition to the consent gate), beforeBreadcrumb and beforeSendLog
are installed, and enableLogs is on; swizzling and automatic network capture
stay off. macOS enables logs, scrubs them, and taps the Mac host's
hostDiagnosticLog with role macHost after SentrySDK.start.

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

* Regenerate SwiftPM lockfiles for the CmuxSentryTelemetry dependency

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

* Address review: tap admission floor, cooldown-on-drop bug, scrub arrays, single pairFail

The event tap now gates on an ingress admission sequence: installing an
observer while recorded events are still queued on the drain task no longer
delivers those pre-installation events (regression test records a 500-event
burst and installs the tap with no drain sync). A budget-dropped capture no
longer stamps lastCaptureTNanos, so a brand-new failure signature arriving
during budget exhaustion captures as soon as the window slides instead of
serving a phantom cooldown. The structured-log scrubber now handles
string-array attributes (previously bypassed) and writes back via
SentryLog.Attribute. One exhausted connect now records a single pairFail
carrying transport (a) and failure (b) instead of a pairFail+rpcFailed pair
that double-counted the outage streak; pairFail and routeUnavailable decode
their transport slot in presentation. The iOS workspace lockfile aligns
sentry-cocoa to 9.24.0, matching the package-local pins (fixes the SwiftPM
lockfile policy guard). Doc states coverage is policy-shaped, not per-event.

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

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-31 18:41:53 -05:00
Abdulaziz Albahar c8c7edf090 Merge remote-tracking branch 'origin/main' into feat-connectivity-v2 2026-07-31 16:41:22 -07:00
Abdulaziz Albahar da0db1e71d Merge pull request #9318 from manaflow-ai/feat-connectivity-v2-closeout
Harden authoritative Iroh route reconciliation
2026-07-31 18:40:08 -05:00
Lawrence Chen a1fcec5a7f Fix Pro TestFlight fulfillment and welcome flow (#8859)
* test: expose broken Pro fulfillment

* Fix Pro TestFlight purchase fulfillment

* Show current Pro benefits in welcome portal

* Avoid duplicate TestFlight invitations

* Fix Dock lifecycle module import

* Fix Dock snapshot type inference

* Make Dock resume policy return type explicit

* test: expose Pro welcome hydration failure

* Fix Pro welcome page hydration

* Add TestFlight link to Pro welcome

* test: catch Pro external assignment overlap

* Fix external TestFlight group handoff

* test: require TestFlight signup from Pro email

* Require Pro users to join TestFlight from email

* Use jovial Pro signup email copy

* Polish Pro welcome and isolate Founder email

* test: cover TestFlight invitation retry

* Retry TestFlight invitation after group assignment

* test: cover external TestFlight signing profile

* Select beta profile for external TestFlight overrides

* Test Pro welcome HTML escaping

* test: reproduce Pro TestFlight browser routing

* Fix authenticated Pro browser handoff

* Fix responsive dashboard navigation

* Fix repeat Stripe dev stack starts

* test: reproduce Pro handoff review findings

* Fix Pro handoff security and invite delivery

* test: preserve internal TestFlight job identity

* Fix external TestFlight profile selection

* Refine browser session handoff ownership

* test: cover handoff tokens and TestFlight identity

* Fix final Pro workflow review findings

* test: require localized Pro welcome surfaces

* Localize Pro welcome across web locales

* test: cover final Pro review regressions

* Fix final Pro workflow review findings

* test: isolate app handoff rate limit

* Fix WebKit store build import

* Remove unused Pro fulfillment identity

* Fix browser handoff task result inference

* test: cover browser handoff review gaps

* Fix browser handoff failure recovery

* Scope browser handoff cookie cleanup

* test: cover final Pro security regressions

* Fix final Pro security review findings

* test: cover final Pro merge blockers

* Fix final Pro merge blockers

* test: preserve legacy Pro tester email provenance

* fix: preserve legacy Pro tester email ownership

* test: cover final Pro session lifecycle gaps

* fix: close Pro session lifecycle gaps

* test: cover final Pro access safety gaps

* fix: close Pro access safety gaps

* test: cover final Pro handoff safety gaps

* fix: close final Pro handoff safety gaps

* test: cover lowercase handoff cookie headers

* fix: parse handoff cookies case insensitively

* perf: linearize TestFlight target matching

* test: cover final Pro review regressions

* fix: resolve final Pro review findings

* test: require personal Pro TestFlight copy

* fix: clarify personal Pro TestFlight access

* test: cover web fallback for Pro links

* fix: preserve clean Pro link fallbacks

* test: cover final Pro restoration regressions

* fix: keep transient Pro flows out of shared state

* Fix merged Subrouter env test fixtures

* test: remove Subrouter timing waits

* test: cover final Pro lifecycle races

* fix: close Pro lifecycle races

* test: follow centralized TestFlight variant output

* fix: resolve Pro review policy findings

* test: include symbols in TestFlight archive fixture

* test: cover final Pro auth lifecycle regressions

* fix: close Pro auth lifecycle gaps

* fix: type browser session cleanup task

* test: cover Pro concurrency and privacy regressions

* fix: isolate Pro external lifecycle work

* ci: route TUI inventory through runner variables

* test: observe Python stream disconnect races

* Fix duplicate sign-out transition notification

* test: cover final Pro lifecycle races

* Fix final Pro lifecycle races

* Use configured Stack app for Pro reconciliation

* Update pricing tests for metadata mutation lease

* Update pricing tests for metadata mutation lease

* fix: satisfy final Pro concurrency review

* Fail closed on unknown TestFlight lanes

* test: allow cold social card rendering

* test: capture docs search before Next build

* fix: build docs search before Next assets

* test: remove handoff registry timing dependency

* test: cover dashboard auth suspension

* fix: suspend dashboard auth provider

* refactor: isolate Pro handoff types and tests

* test: cover Dock handoff and Pro metadata

* fix: close final Pro handoff review gaps

* test: cover shared Pro handoff placement

* fix: centralize Pro handoff placement

* refactor: isolate app-link placement policy

* refactor: inject app-link placement policy

* test: cover Pro plan reconciliation contention

* fix: defer contended Pro metadata reconciliation

* test: cover final Pro admission regressions

* fix(web): keep metadata errors provider-neutral

* fix: restore Pro admission and mobile nav state

* test(web): isolate Pro welcome locale mocks

* test: import restored session auth models

* test: qualify restored-session auth types
2026-07-31 15:47:54 -07:00
Abdulaziz Albahar d824c088a9 Evict pathless Iroh sessions at the lifecycle owner 2026-07-31 14:09:55 -07:00
Abdulaziz Albahar 5d0cba06c7 build: include Simulator app in iOS reload artifact 2026-07-31 14:08:38 -07:00
Abdulaziz Albahar fc007c7c5d Test pathless connectivity session eviction 2026-07-31 14:05:34 -07:00
Abdulaziz AlbaharandClaude Fable 5 f9aa3d2a7b Bound iOS iroh client retries with one shared backoff policy
PR 9269 removed every server-side broker quota, so nothing bounded a
runaway client: wedged phones re-ran registration/discovery/relay-policy
every 2-10 s for 40+ hours, while single transient blips overshot the
other way (32-36 s retryScheduled naps, 33-65 s idle gaps) because the
default CmxIrohRetrySchedule is a host profile (30 s first retry, 1 h cap).

CmxIrohReconnectBackoff is the one shared, injectable ladder: decorrelated
jitter drawn from [floor, min(cap, previous*3)] with a 1 s floor and 30 s
foreground cap, seedable SplitMix64 for exact-schedule tests, reset() to
the floor, and server Retry-After honored as a bounded lower bound.

Wired without changing success paths: a failed broker-bound activation
arms the ladder (emits retryScheduled) and reconciles inside the window
skip broker work, cleared on scenePhase-active, network-path change,
account switch, and success; the relay-policy refresh loop draws its
failure delay from the same ladder; the client runtime builds its relay
credential coordinator with CmxIrohRetrySchedule.foregroundClient; and
CmxIrohBrokerBackpressureGate paces registration mutations to 2 s-spaced
slots via an injected sleep, so a wedged phone cannot exceed ~30
challenge/register attempts per minute even with no server rate limit.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-31 13:58:10 -07:00
Abdulaziz AlbaharandClaude Fable 5 f922426465 Add failing coverage for unbounded iOS iroh activation retries
A failing activation currently re-runs registration, discovery, and
relay-policy against the broker on every dial or preparation, with no
client-side spacing: field phones wedged in this loop issued broker
mutations every 2-10 seconds for 40+ hours. These tests pin the intended
bounds: a failed activation arms a client backoff visible as a
retryScheduled diagnostic no longer than the 30 s foreground cap, dials
inside the window stay broker-silent with the unchanged inactive error
shape, and a scenePhase-active transition clears the window immediately.

Tests-first commit: red until the client backoff lands.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-31 13:57:44 -07:00
Abdulaziz Albahar 2c428d40d7 Stabilize Iroh connectivity regression tests 2026-07-31 13:51:10 -07:00
Abdulaziz Albahar 21b48132e6 Harden authoritative Iroh route reconciliation 2026-07-31 13:37:53 -07:00
Abdulaziz Albahar bedcfb2d46 Test atomic connectivity registration revisions 2026-07-31 13:35:58 -07:00
Abdulaziz Albahar 152f78d8b5 Test fail-closed connectivity revisions 2026-07-31 13:26:08 -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
Lawrence Chen 184bd48365 Add sidebar account and mobile pairing controls (#8354)
* Add sidebar account and mobile pairing controls

* Match mobile pairing pane to terminal theme

* Match sidebar help icon sizing

* Fix clipped titlebar Pro badge

* Add sidebar footer icon balance lab

* Centralize macOS Stack sign-in routing

* Keep footer lab independent of auth refactor

* Revert "Keep footer lab independent of auth refactor"

This reverts commit 8328e300fb.

* Size trailing titlebar accessory from hosted content

* Balance sidebar footer help icon

* Fix sidebar account avatar alignment

* Use bare sidebar footer symbols

* Add per-icon blur and hover tuning

* Blur footer icons independently together

* Refine sidebar auth and icon choices

* Improve footer icon balance lab

* Apply selected footer icon balance

* Test titlebar excludes sidebar account controls

* Keep account controls out of titlebar

* Add in-pane account sign-in flow

* Refine sidebar debug footer controls

* Register Footer Icon Balance Lab as an auxiliary window

The debug window sets identifier cmux.sidebarFooterIconBalanceDebug but was
missing from cmuxAuxiliaryWindowIdentifiers, so Cmd+W routed through
workspace/panel-close behavior instead of closing the window, and
workflow-guard-tests failed the auxiliary window close-shortcut lint.

* test: cover transient account workspace restoration

* Fix sidebar footer and transient button flows

* Refine minimal footer upgrade entrypoints

* Refine sidebar profile controls

* Expose sidebar footer balance lab

* Expose footer profile lab command

* Match footer account and help heights

* Activate footer profile lab window

* Tighten sidebar utility button spacing

* Use filled circular profile icon

* Resolve removed mobile titlebar project wiring

* Use outlined circular profile icon

* Match account and help icon weight

* Move footer lab into debug window coordinator
2026-07-31 07:39:58 -07:00
Lawrence Chen 5a8a973ebc ci: route cmux-tui spec through Linux runner (#9313) 2026-07-31 05:09:41 -07:00
Lawrence Chen 72c0d5dfa4 Poll iOS TestFlight every 20 minutes for new changes (#9280)
* Poll TestFlight every 20 minutes within budget

* Remove TestFlight upload caps

* Bound TestFlight upload history lookup
2026-07-31 04:39:32 -07:00
Lawrence Chen e63526c111 Add noun-first resource API and handwritten SDKs
PR: https://github.com/manaflow-ai/cmux/pull/9215
2026-07-31 04:08:06 -07:00
Abdulaziz Albahar 2b0017bf87 Use Swift-safe simulator identity locking 2026-07-31 04:00:34 -07:00
Abdulaziz Albahar 0431b689f5 Merge remote-tracking branch 'origin/feat-connectivity-v2' into feat-connectivity-v2-final 2026-07-31 03:49:04 -07:00
Abdulaziz Albahar 48dc729dbb Centralize authoritative connectivity recovery 2026-07-31 03:48:14 -07:00
Abdulaziz Albahar 2ea1057d5a fix: fall back from unversioned discovery prefetch 2026-07-31 03:41:41 -07:00
Abdulaziz Albahar 2c6a43f809 Merge remote-tracking branch 'origin/feat-connectivity-v2' into feat-connectivity-v2 2026-07-31 03:17:32 -07:00
Abdulaziz Albahar 91f66896ca docs: record Iroh startup latency decisions 2026-07-31 03:17:15 -07:00
Abdulaziz Albahar 91e530c7dd Merge remote-tracking branch 'origin/main' into feat-connectivity-v2-final 2026-07-31 03:06:49 -07:00
Abdulaziz Albahar a5598dc655 Harden connectivity lifecycle ownership 2026-07-31 03:06:49 -07:00
Abdulaziz Albahar 2f2b4e1bbd Test connectivity lifecycle hardening 2026-07-31 03:06:49 -07:00
Abdulaziz Albahar 217a26f801 test: model embedded discovery revision 2026-07-31 02:43:28 -07:00
Austin Wangandcmux reload-cloud 35fb1af322 Fix configurable browser and focus Back/Forward shortcuts (#9296)
* test: cover focus history Ghostty shortcut collision

* fix: give live shortcuts precedence over Ghostty fallbacks

* test: partition browser and focus history shortcuts

* fix: partition browser and focus history shortcuts

* refactor: share directional shortcut metadata

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-31 02:42:42 -07:00
Abdulaziz Albahar 9f1915e649 perf: overlap cached Iroh discovery with endpoint bind 2026-07-31 02:35:46 -07:00
Abdulaziz Albahar 387254534e test: cover cached Iroh discovery startup 2026-07-31 02:24:44 -07:00
Austin Wangandcmux reload-cloud 9e3e324926 Keep workspace customization scoped to workspace identity (#9270)
* Add failing same-directory workspace restore regressions

* Persist workspace customization by stable identity

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-31 02:01:38 -07:00
Abdulaziz AlbaharandClaude Fable 5 d4084732ad Stop restore from cloning one directory's sticky title onto every workspace (#9260)
* Add regression test for restore-time same-directory title cloning

On relaunch, session restore must keep each workspace's own custom
title and color even when several workspaces share one working
directory. Today the per-directory sticky customization record is
reapplied to every same-directory workspace during restore, stamping
one workspace's rename over all of its siblings.

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

* Stop cloning one directory's sticky identity onto restored workspaces

On relaunch, session restore reapplied the per-directory sticky
customization record over every same-directory workspace's own
snapshot identity, so after a restart most workspaces sharing a cwd
were renamed to whichever title the record held last. The reconcile
pass even seeds the record from the first restored workspace and
stamps it onto the rest within a single restore, so the clobber needs
no prior rename history.

Delete WorkspaceDirectoryCustomizationStore and its track/record/
reconcile wiring. Identity is per-workspace only: session snapshots
already persist and restore each workspace's own customTitle and
customColor keyed by the workspace itself, and closed-workspace reopen
keeps working from its own snapshot. The addWorkspace creation mode is
replaced by applyCreationTitleAsCustomTitle, since gating the explicit
creation title is its only remaining job.

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

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-31 03:48:11 -05:00
Abdulaziz AlbaharandClaude Fable 5 e3594dd83b Unbreak workflow-guard-tests: fake iOS archives need dSYMs and Symbols (#9279)
PR #9236 made upload-testflight.sh refuse archives without dSYM bundles
and final IPAs without Symbols/*.symbols, but did not update the
App Store lane identity guard's fake xcodebuild and archive fixtures.
Since then workflow-guard-tests fails for every PR gate run, which also
short-circuits the downstream required checks.

Give the fake archive a dSYMs/cmux.app.dSYM bundle and the fake export
a Symbols/cmux.symbols entry inside the IPA so the guard exercises the
new gates instead of tripping them.

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-31 03:46:46 -05:00
Abdulaziz Albahar 5dbbb7d080 perf: remove Iroh discovery startup round trips 2026-07-31 01:45:07 -07:00
Abdulaziz Albahar 06dd927923 test: cover lower-latency Iroh startup 2026-07-31 01:44:59 -07:00
Lawrence Chen 7dea5f752a Merge pull request #9288 from manaflow-ai/codex/pinned-workspace-groups
Fix pinned workspace grouping and group reordering
2026-07-31 00:40:22 -07:00
Abdulaziz Albahar 6a4dd9c81b Recover cached host endpoint registration 2026-07-31 00:07:19 -07:00
Abdulaziz Albahar c319919512 Test cached host endpoint port recovery 2026-07-31 00:05:46 -07:00
Abdulaziz Albahar 3ba2e6e492 Merge remote-tracking branch 'origin/main' into feat-connectivity-v2
# Conflicts:
#	web/services/iroh/routeHandler.ts
#	web/tests/iroh-route-handler.test.ts
2026-07-30 23:59:01 -07:00
Abdulaziz AlbaharandClaude Fable 5 541fe7f0c7 Remove all iroh broker quotas and rate limits (#9269)
Colleague phones have been locked out of registration for ~2 days by the
broker's own quotas: 6 challenges per device-instance per 10 minutes with a
600s penalty, retried faster than the window resets, forever. Per Aziz's
directive, remove every iroh quota and rate limit:

- challenge quotas (account 120/10m, device-instance 6/10m, outstanding 32)
- relay token quotas (endpoint 3/10m, endpoint 12/day, user 100/day)
- pair-grant hourly quota (60/h)
- the Vercel firewall rate-limit check on iroh routes (firewall.ts deleted)
- challengeQuotaForUser / developmentBindingQuotaAllowed config plumbing

Auth and correctness guards are untouched: challenge replay/supersede gates,
endpoint_already_bound, binding-slot ownership, discovery pagination bounds,
and the relay reservation-expiry sweep all remain. IrohQuotaExceededError
stays in the wire vocabulary and the 429 mapping stays in routeHandler for
compatibility.

Verified: bun run typecheck clean; iroh-route-handler/trust-broker/model-crypto
suites pass (91 tests). iroh-db-behavior quota tests removed with the quotas;
suite not run locally (docker daemon wedged) - it runs in the db test lane.

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-31 01:23:22 -05:00
Abdulaziz Albahar da4bf5edf1 Retire superseded connection candidates 2026-07-30 22:41:03 -07:00
Abdulaziz Albahar 4e87faebcb Merge remote-tracking branch 'origin/main' into feat-connectivity-v2 2026-07-30 22:34:12 -07:00
Abdulaziz AlbaharandClaude Fable 5 b5294c479a iOS: Tailscale connection method opt-in (Settings + onboarding) with QR-authorized pairing (#9247)
* iOS: user-selectable Tailscale connection method with QR-authorized pairing

Adds an Auto-Connect vs Tailscale connection-method choice to iOS Settings
and the last onboarding page. Choosing Tailscale reorders dialing to put
authorized Tailscale routes ahead of the iroh pin (iroh stays as fallback)
and routes the user to the Mac's compatibility QR scanner.

A scanned/pasted v2 compatibility code becomes the authorization event: a
new .userAuthorizedTailscalePairing transport mode dials only the exact
host:port the user entered, only while the peer is unidentified, and only
from explicit in-app code entry (external URL opens never mint it). After
the Mac authenticates, a device-local 'user'-origin grant row persists so
reconnects use the existing evidence path. v9 schema adds grant origin;
migration-origin grants keep dying on iroh arrival, user-origin grants
survive because the user chose Tailscale deliberately.

Mac pairing window's legacy toggle is relabeled "Use Tailscale Pairing
Code" (EN+JA) to match the iOS copy.

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

* Anchor user Tailscale pairing authorization on destination, not identity

The Mac pairing window's Tailscale code is the tokenless v1 compatibility
ticket, which carries a self-reported macDeviceID. Gating the user-entered
authorization on an empty ticket identity would reject exactly the code
users scan. The claimed identity adds no authority at first dial, so the
authorization now anchors on the exact user-entered host:port alone; the
in-app entry gate and the interface-bound route proof are unchanged.

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

* Onboarding: Tailscale-selected connect page gets a matching title

The body and primary button already switch to the Tailscale flow; the title
kept claiming automatic connection. Title now reads "Connect over Tailscale"
(EN+JA) while the method is selected and the Mac is not yet connected.

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

* Resolve actual row scope before persisting user Tailscale grants

The scoped-store decorators forwarded the selected team verbatim, but the
base store's grant write requires an exact existing row and silently no-ops
otherwise, so a Mac whose row still lives in the team-less fallback scope
would drop the user-entered grant. Mirror the sibling exact-instance writes
(visibleScope / setCustomizationUnlocked) in both decorators.

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

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-31 00:06:56 -05:00
lawrencecchen d839d476c4 fix: reject pinned group self drops 2026-07-30 22:02:41 -07:00
lawrencecchen 527ee5d7d7 test: reject pinned group self drop 2026-07-30 22:02:24 -07:00
Abdulaziz Albahar 0e44836fce Verify Mac presence authority in release gate 2026-07-30 21:50:15 -07:00
Abdulaziz Albahar 444a92e125 Test release gate Mac presence parity 2026-07-30 21:49:58 -07:00
Abdulaziz Albahar 72848a8762 Verify release gate backend parity 2026-07-30 21:28:51 -07:00
Abdulaziz Albahar 3790961f21 Test release gate artifact authority parity 2026-07-30 21:28:08 -07:00
Abdulaziz Albahar 4f5ec136f6 Merge remote-tracking branch 'origin/main' into feat-connectivity-v2 2026-07-30 21:27:18 -07:00
Abdulaziz AlbaharandClaude Fable 5 3d8e32ba28 Fix iOS TestFlight Release archive: guard DEBUG-only evidence probe (#9254)
MobileIrohDevelopmentFileEvidenceProbe references
MobileIrohRuntimeComposition.developmentStoreDirectory, which is defined
inside #if DEBUG. The struct itself was unguarded, so Release archives
(ios-testflight.yml) failed with 'has no member developmentStoreDirectory'
while Debug builds compiled fine. Its only call site is already inside
#if DEBUG (sameDeviceEvidenceProbe), so wrap the struct in #if DEBUG too.

Broken since 099e7eaaa8 picked up 6eeae1c619 (PR 8888); six consecutive
internal TestFlight uploads failed.

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-30 22:40:29 -05:00
Abdulaziz Albahar 2479c1974d Merge remote-tracking branch 'origin/main' into feat-connectivity-v2 2026-07-30 20:31:39 -07:00
Abdulaziz Albahar c30cf37b1f Preserve revisions across discovery pages 2026-07-30 20:31:21 -07:00
Abdulaziz Albahar f035393df2 Test paginated discovery revisions 2026-07-30 20:30:51 -07:00
Abdulaziz Albahar fbe4902384 Merge remote-tracking branch 'origin/main' into feat-connectivity-v2
# Conflicts:
#	web/services/iroh/repository.ts
#	web/services/iroh/trustBroker.ts
#	web/tests/iroh-db-behavior.test.ts
#	web/tests/iroh-trust-broker.test.ts
2026-07-30 20:30:10 -07:00
Abdulaziz AlbaharandClaude Fable 5 ca98281d8d Ship dSYMs with iOS TestFlight builds and persist them as run artifacts (#9236)
* Ship dSYMs with iOS TestFlight builds and persist them as run artifacts

App Store Connect reported "No dSYM files available" for every TestFlight
build, so crashes (e.g. build 20260730090940 on dev.cmux.app.internal)
arrive as raw `cmux + offset` frames and the ephemeral CI runner discards
the only dSYM copy.

Root cause: the export options already set uploadSymbols=YES, and the
archive does contain dSYMs, but the manual-signing re-sign path re-zips the
IPA from Payload/ alone, dropping the Symbols/ directory the export put in
the IPA for ASC crash symbolication.

- Re-zip every Apple package directory the export produced (Payload,
  Symbols, SwiftSupport, BCSymbolMaps when present).
- Fail closed before export when the archive has no dSYM bundles, and
  before upload when the final IPA carries no Symbols/*.symbols.
- Persist the archive's dSYM bundle as a 30-day run artifact
  ios-dsyms-<variant>-<build-number> for both internal and demo variants,
  via a pinned CMUX_IOS_UPLOAD_DIR so the workflow can find the archive.

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

* Address review: persist dSYMs on upload success, avoid grep -q SIGPIPE, require dSYM dirs

- Gate the dSYM artifact on steps.upload.outcome instead of job success()
  so a post-upload step failure cannot skip symbol persistence for a build
  that already reached TestFlight.
- Drop grep -q in the Symbols/ check: under pipefail its early exit can
  SIGPIPE zipinfo and fail a valid IPA.
- Require *.dSYM entries to be directories (bundle contract).

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

* Match only top-level Symbols/*.symbols entries in the IPA gate

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

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-30 22:28:34 -05:00
Abdulaziz Albahar c15f1e9652 Isolate Iroh release gate runtime state 2026-07-30 20:25:31 -07:00
Abdulaziz Albahar 487623446d Test isolated Iroh gate runtime inputs 2026-07-30 20:24:13 -07:00
Abdulaziz Albahar 26434da4a1 Merge pull request #9253 from manaflow-ai/feat-unbounded-iroh-bindings
Remove total cap from Iroh endpoint bindings
2026-07-30 22:21:56 -05:00
Abdulaziz AlbaharandClaude Fable 5 a411a370c9 Fix iOS startup crash: sentry-init racing environ mutation in ghostty_init (#9238)
* Bump ghostty: fix sentry-init racing environ mutation during init

Pulls manaflow-ai/ghostty#174: three iOS SIGSEGVs on 2026-07-30 (INTERNAL
builds 20260730090940 and 20260730213932) were the sentry-init thread
walking the freed environ snapshot while ghostty_init's ensureLocale ran
setenv on the main thread during the first terminal-surface mount. The fix
runs ensureLocale before crash.init and resolves the Sentry cache dir on
the spawning thread, so the spawned thread never reads the shared environ.

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

* Pin GhosttyKit checksum for sentry environ race fix

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

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-30 22:20:07 -05:00
Abdulaziz Albahar 9a8b9c74f7 fix(iroh): keep unbounded discovery linear 2026-07-30 20:03:45 -07:00
Abdulaziz Albahar 8377cafea1 Allow multihomed LAN interface overlap 2026-07-30 20:02:23 -07:00
Abdulaziz Albahar 92ffb4331d Test multihomed LAN discovery 2026-07-30 20:02:06 -07:00
Abdulaziz Albahar a45a503674 Fix iOS workspace list toolbar insets (#9171)
* Add regression test for hidden iOS workspace row

* Fix iOS workspace list toolbar inset

* Test workspace list scroll edge underlap

* Preserve iOS workspace scroll edge effects

* Test iOS workspace bottom edge sizing

* Size iOS workspace fade to tab bar

* Test compact iOS workspace bottom edge

* Keep workspace bottom effect at tab bar

* Test soft workspace edge at tab bar

* Anchor workspace effect to tab bar

* test(ios): require UIKit-owned workspace list insets

* fix(ios): leave workspace scrolling to UIKit

* test(ios): exercise workspace row interactions

* test(ios): require workspace table to respect toolbar safe areas

* fix(ios): keep workspace rows outside toolbar hit regions

* test(ios): require native workspace scroll-edge underlap

* fix(ios): restore native workspace scroll-edge effects

* test(ios): require workspace delete confirmation

* fix(ios): confirm workspace deletion at its source row

* perf(ios): avoid snapshots for workspace payload updates
2026-07-30 21:46:54 -05:00
Abdulaziz Albahar af7ca43f7a Update connectivity v2 verification notes 2026-07-30 19:39:12 -07:00
Abdulaziz Albahar 55a32e3bd9 Merge remote-tracking branch 'origin/main' into feat-connectivity-v2 2026-07-30 19:36:15 -07:00
Abdulaziz Albahar 6e131f8485 Filter LAN discovery to authenticated aliases 2026-07-30 19:34:43 -07:00
Abdulaziz Albahar 7dc7f33baa feat(iroh): paginate unbounded binding discovery 2026-07-30 19:26:16 -07:00
Abdulaziz Albahar fcedad8583 Test authenticated LAN discovery filtering 2026-07-30 19:23:11 -07:00
Abdulaziz Albahar bcf55c1ef7 test(iroh): cover unbounded paginated bindings 2026-07-30 19:11:31 -07:00
Abdulaziz Albahar eb9f6dedad Persist simulator device identities without Keychain 2026-07-30 18:43:45 -07:00
Abdulaziz Albahar 240cd47fc3 Test simulator device identity seeding 2026-07-30 18:40:58 -07:00
lawrencecchen 16fbe8432a fix: restore pinned workspace grouping and group drags 2026-07-30 18:28:37 -07:00
lawrencecchen 42422a252d test: cover recovering group header drags 2026-07-30 18:28:11 -07:00
Abdulaziz Albahar f13f257fc6 Restore cached host Iroh routes 2026-07-30 18:00:39 -07:00
lawrencecchen 4a0e52527e test: cover creating groups from pinned workspaces 2026-07-30 17:53:13 -07:00
Abdulaziz Albahar a799151d41 Support credential-free local relay policy 2026-07-30 17:30:22 -07:00
Abdulaziz AlbaharandClaude Fable 5 9112fe22f3 Batch approval and prefix generalization for surface resume command alerts (#9028)
* test resume approval batching behavior

* add generalized resume approval state

* batch surface resume approval prompts

* Add resume approval batching regressions

* Fix resume approval batch review findings

* Add round-two resume approval regressions

* Fix round-two resume approval findings

* Add round-three resume approval regressions

* Fix round-three resume approval findings

* test: cover unsafe resume command expansions

* fix: harden resume approval persistence

* test: cover resume approval authorization gaps

* fix: scope resume approvals to safe local execution

* test: cover remaining resume approval gaps

* fix: close remaining resume approval gaps

* test: cover env-flag and trailing-arg approval generalization

Regressions for the round-four review findings: env -i / env -u / nested
env wrappers must not become the scoped command, and commands with
arguments after the session id (codex resume <id> --yolo) must not
generalize to a wider prefix scope.

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

* fix: fail closed on env flags and trailing resume arguments

generalizedApprovalPrefix now rejects a command whose executable slot is
an option token or another env wrapper (env -i FOO=1 claude ... scoped
approval to bare 'env -i'), and only generalizes when the session id is
the sole unmatched token, so prefix matching can never re-authorize a
session launched with different trailing options.

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

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-30 19:27:48 -05:00
Abdulaziz Albahar 47d75d8b3c Encode initial connectivity revision explicitly 2026-07-30 17:26:11 -07:00
Abdulaziz AlbaharandClaude Fable 5 1690a334a8 fix: register company-information as an agent-readable page (#9245)
https://github.com/manaflow-ai/cmux/pull/9240 added /company-information
to the sitemap but not to agent-page-paths' englishOnlyPages and
agentReadablePages registries, so the sitemap-driven variant test fails
on main: resolveAgentPageVariant returns null for
/company-information.md|.txt. Register the page in both lists so the
Markdown and text variants resolve like the other legal pages.

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-30 19:21:47 -05:00
Abdulaziz Albaharandlawrencecchen befd3caf4c fix: clear Xcode 26.3 warning gate (#9233)
Co-authored-by: lawrencecchen <[email protected]>
2026-07-30 19:07:19 -05:00
Abdulaziz AlbaharandClaude Fable 5 7034fcfbe6 fix: discard presentBrowserAlert dismiss handles explicitly (#9239)
Xcode 26.3's Swift rejects a discarded function-typed result as an
error ('function is unused') even with @discardableResult, breaking
tests-build-and-lag and all app-host unit test shards on every branch
since https://github.com/manaflow-ai/cmux/pull/8298 added these two
presentAlert call sites. Companion to the warning-gate hotfix in
https://github.com/manaflow-ai/cmux/pull/9233, which covers
CLI/cmux.swift and FileExplorerView.swift but not BrowserPanel.

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-30 19:04:39 -05:00
Abdulaziz Albahar 22ab003e2c Align iOS Compose with the Search control (#9172)
* Add regression test for Compose bottom-row placement

* Align Compose with the iOS search control

* Test Compose above the iOS search control

* Stack Compose above the iOS search control

* Use the native iOS tab accessory for Compose

* Restore standalone iOS Compose placement
2026-07-30 18:54:19 -05:00
Abdulaziz Albahar f3a9415bbf Secure connectivity invalidation delivery 2026-07-30 16:45:44 -07:00
Abdulaziz Albahar 28265bb897 Merge remote-tracking branch 'origin/main' into feat-connectivity-v2 2026-07-30 16:29:20 -07:00
Abdulaziz Albahar 9eb240f262 Complete connectivity v2 ownership 2026-07-30 16:29:09 -07:00
Lawrence Chenandcmux-lawrence 980cef9ffd Add public company information page (#9240)
Co-authored-by: cmux-lawrence <[email protected]>
2026-07-30 16:21:24 -07:00
Abdulaziz Albahar e7e3761551 Route Apple runtimes through connectivity engine 2026-07-30 16:04:31 -07:00
Abdulaziz Albahar 0a238da00e Add unified connectivity engine 2026-07-30 15:48:48 -07:00
David Veselý 1349bc06cc Document workspace-action in the cmux skill (#8177)
The cmux skill covered windows, workspaces, panes, surfaces, focus, moves,
reorder, identify, and trigger-flash, but never mentioned `cmux
workspace-action` — the command behind the workspace context-menu actions
(set-color, set-description, rename, pin, mark-read, move-up/down, ...).

Because those actions live under `workspace-action` rather than as
`cmux workspace` subcommands, they were effectively undiscoverable from the
skill: an agent reading it (or exploring `cmux workspace --help`) would wrongly
conclude there was no CLI to color or describe a workspace.

Add a "Context-Menu Actions" section to references/windows-workspaces.md with
the full action/flag set and named-color list, plus Fast Start examples and a
reference-table hint in SKILL.md so it's found on first look.
2026-07-30 15:43:19 -07:00
Abdulaziz Albahar db16124676 Add revisioned connectivity authority 2026-07-30 15:38:40 -07:00
Abdulaziz AlbaharandClaude Fable 5 d304a0f0b6 Enforce iPhone+simulator default for iOS verification with an offline install queue (#9232)
* Enforce iPhone+simulator default for iOS verification with an offline install queue

iOS verification reloads now target BOTH an isolated per-tag simulator
(cmux-dev-<slug>, created on demand) and the configured iPhone
(CMUX_IPHONE_DEVICE_ID or ~/.config/cmux/iphone-device-id; never
hardcoded). When the phone is unreachable at build time, the signed
build is parked in a persistent queue (scripts/iphone-install-queue.sh,
under ~/Library/Application Support/cmux-dev/iphone-install-queue) and
a LaunchAgent (scripts/install-iphone-queue-agent.sh) auto-installs and
launches it within seconds of the phone reconnecting, via launchd IOKit
matching on Apple USB attach, WatchPaths on the queue, and a periodic
network backstop, then sends a cmux notification. Every phone build
hard-requires the same-tag Mac dev build: ios/scripts/reload.sh builds
the Mac tag first when missing and refuses phone-only otherwise.
scripts/ios-sim-install.sh installs cloud-built simulator apps into the
isolated simulator for the reload-cloud-ios path.

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

* Probe device reachability through the queue script in ios/scripts/reload.sh

One probe implementation (iphone-install-queue.sh probe) now decides
"unreachable" for both the local and cloud reload paths, including the
CMUX_IPHONE_QUEUE_FORCE_UNREACHABLE test hook; select_device still owns
name/ambiguity resolution for reachable devices and its failure is
treated as unreachable as before.

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

* Address review findings: name-target queueing, enqueue race, fail-closed sim install

A --device-name target no longer probes or queues against the DEFAULT
device id (queueing for a different phone than the one named would
install on the wrong device); name targets error with a hint to use
--device-id when unreachable. drain_entry now re-reads enqueued_at
before every terminal action so a re-enqueue during an in-flight drain
leaves the newer build queued instead of silently deleting or failing
it. ios-sim-install.sh fails closed on an unreadable
CFBundleIdentifier. Also: quote $tab expansions (SC2295), correct help
sed ranges, document the one-time LaunchAgent install in CLAUDE.md.

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

* Nudge PR sync

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

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-30 17:13:58 -05:00
Abdulaziz Albahar 1be89d7b30 Fix sidebar row clipping during height-changing reorder (#9189)
* Fix sidebar row clipping during reorder

* test: preserve sidebar viewport during height-changing reorder

* fix: preserve sidebar viewport during atomic reorder reload

* test: preserve sidebar edits during atomic reorder reload

* fix: preserve sidebar edits during atomic reorder reload

* refactor: satisfy sidebar review policy
2026-07-30 17:06:48 -05:00
Abdulaziz AlbaharandClaude Fable 5 099e7eaaa8 iOS: key all per-Mac state by pairing (device id + instance tag) so sibling builds are first-class (#8936)
* feat(ios): stamp workspace and notification rows with the pairing instance tag

Workspace and notification payloads carry no Mac identity; the phone
attributes rows to the connection they arrived on. That attribution now
includes the pairing's app-instance tag: foreground rows are stamped
with the active connection's tag in setForegroundWorkspaceState,
secondary rows with the subscription's proven tag, and notification
feed items with the pairing behind the feed target. Aggregated rows
carry macInstanceTag, per-pairing row ids include the tag so sibling
builds' workspaces cannot collide, and the feed item identity includes
the tag so sibling notifications never dedupe into one row. Works for
every existing Mac; no wire change needed.

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

* feat(ios): aggregate workspaces and notifications per pairing

Sibling builds of one Mac are now separate aggregation targets: the
one-build-per-device coalesce is removed from secondary candidate
selection, the foreground exclusion is pairing-exact so the sibling of
the connected build stays a candidate, and subscriptions, per-Mac
workspace state, and notification-feed maps are keyed by pairing id
(legacy untagged pairings keep device keys). Promotion resolves the
exact pairing and tagged switch requests can take the promotion fast
path. Workspace mutations route by the row's pairing, opens and
notification taps switch to the row's exact build, workspace counts and
the machine filter match per build (legacy untagged rows keep matching
device-wide), avatar colors stay per physical device, and hiding a
pairing tears down exactly that pairing's subscription and feed.

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

* test: cover sibling-build separation across aggregation, filter, and feed

Aggregation ordering now iterates aggregate KEYS (pairing ids since the
re-key) instead of state device ids, which returned duplicate device
ids for sibling builds and dropped their rows; sibling entries order
deterministically by instance tag.

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

* fix(ios): keep selection scope self-contained for tag comparison

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

* fix(ios): address review findings on pairing-scoped feed routing

Notification taps compare the exact pairing so a sibling build's
notification on the foreground device still switches builds; the
aggregate feed status compares owner keys instead of device ids;
snapshot stamping derives the tag from the owner key itself so sibling
items never dedupe even without a live subscription (covered by a new
tagged-owner-key test); hiding the foreground pairing also drops its
device-keyed feed snapshot when a sibling stays visible.

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

* Restore main's ghostty submodule pin

The merge-conflict resolutions staged the worktree's stale ghostty
gitlink via git add -A, silently reverting main's pin bump; this branch
carries no ghostty changes, so main's pin is authoritative.

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

* fix(ios): close autoreview findings on the pairing key-space migration

Secondary refresh validation now checks the subscription under its
pairing key instead of the device id, which was tearing down every
healthy tagged subscription on refresh. Device-only promotion requests
fail closed when sibling builds are both live instead of promoting an
arbitrary one. Tagged notification items never fall back to the bare
device key, so an offline pairing's mutation no-ops instead of hitting
a sibling with a colliding id. Hiding the foreground pairing also
removes its device-keyed workspace entry when a sibling stays visible.
Workspace-create gating uses the live connection's instance tag rather
than the stored isActive flag, which lags promotion. Notification feed
scoping preserves the selected build (entry-aware item matching), and
dismiss-outbox routing sends only through an unambiguous client for the
device, deferring while sibling builds are both live.

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

* fix(ios): close round-two review findings on pairing identity boundaries

Legacy untagged rows on the foreground device are excluded from
secondary aggregation (their pairing id is the foreground's own
aggregate key and would overwrite it). The picker's switch decision and
the workspace-groups gate compare the live foreground pairing instead
of the stored isActive flag, which lags promotion. Computers-screen
status lookups query the pairing key first so tagged secondaries keep
their connection dot. Notification availability matches the exact
selected pairing for every signal, and the alias-selection test asserts
the pairing-formed filter entries with sibling exclusion.

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

* fix(ios): close round-three findings on legacy identity and promotion

Secondary rows are stamped with the subscription's STORED pairing
identity so reconstructed owner keys always find their subscription,
including upgraded-legacy pairings that adopted a tag at auth time.
Device-only promotion requires the device to have a single stored
pairing, not merely a single live one, so a reconnect meant for an
offline sibling never promotes the other build. Exact pairing scopes
exclude unknown-tag rows (they stay under device entries and All
Computers). Promotion clears the promoted pairing's feed bookkeeping so
the foreground refetch under the device key cannot duplicate rows, and
the workspace-detail reconnect passes the row's tag.

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

* fix(ios): keep failure downgrades and retained-state pruning pairing-keyed

An unreachable sibling's establish failure marks its own pairing entry
unavailable instead of the device key (which can be the live foreground
sibling), and retained pairing-keyed workspace states with no live
subscription are pruned when no longer wanted so a pairing reconnected
as foreground via the dial path cannot duplicate its rows.

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

* fix(ios): dismiss routing requires a single stored sibling

Counting live clients was not enough: the emitting build may be offline
while a sibling is the sole live candidate, and Mac-local notification
ids can collide across builds. Device-scoped dismisses now route only
when the device has one stored pairing at all.

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

* fix(ios): reset foreground feed bookkeeping on sibling build switches

The foreground feed lives under the shared device key, so switching to
a sibling build left the previous build's snapshot and revision in
place and rejected the new build's lower revisions as stale. Both the
promotion and dial connect paths now clear the device-keyed feed state
when the foreground instance tag changes on the same device.

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

* fix(ios): close remaining round-four findings on feed and status identity

Notification-open navigation matches workspaces and surfaces by the
item's exact pairing so colliding Mac-local ids on a sibling build fail
closed instead of navigating to the wrong workspace. The connection
status rollup never overwrites an exact pairing entry and rolls the
foreground's device-keyed status only onto its own pairing
representative, so an offline sibling can no longer render green.

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

* WIP: typed MacPairingKey owner-key re-key (registry + composite core; not compiling yet)

* WIP: typed owner-key re-key compiles (composite, promotion, feed, hidden, actions)

* WIP: typed key test-target compiles; MacWorkspaceState.id pairing-unique

* WIP: pool suites 98/111; feed reset semantics reapplied; device-level drain admission

* WIP: pool+sibling suites converging; per-pairing candidate selection + drain-path replacement retirement

* Restore deeplink collision test hints eaten by bulk rewrite

* Fix review round 6: sibling promotion demotes previous focus by owner key, feed target ownerKey consistency, offline foreground key captured before identity clear, pairing-aware reconnect, exact-pairing retained-snapshot pruning

* Fix review round 7: exact-pairing reconnect decisions, sibling-ambiguity fail-closed deeplink lookups, fail-closed tagged create gate, feed completion by owner key

* Fix review round 8: openWorkspace routes by exact pairing, group/reorder gate requires exact foreground pairing, demoted-foreground feed re-keys to pairing

* Fix review round 9: foreground terminal lookups scope by live pairing; known-tag row resolution in list apply and create

* Fix review round 10: pairing-exact connected-refresh target, live-identity hide disconnect, tag-aware selection remap, allocation-free exact terminal lookup

* Fix review round 11: fresh-dial takeover clears pairing-keyed feed source; preparse machine scope entries for row projection

* Fix review round 12: foreground-scoped raw-input lookup with unowned-row fallback, exact-lookup no global fallback, ambiguous device-only switch fails closed, tagged secondary feed bootstrap by pairing id, hide authority requires proven live tag

* Fix review round 13: untagged selections match only untagged live foreground; recovery flags attribute to the exact recovering pairing

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-30 16:47:39 -05:00
Abdulaziz AlbaharandClaude Fable 5 6eeae1c619 iOS: stable Keychain device id + Forget computer (iroh re-key client) (#8888)
* iOS: stable Keychain device id + Forget computer (iroh re-key client)

Client complement to the broker binding re-key (manaflow-ai/cmux#8883),
which changes the iroh binding slot from unique(app_instance_id) to
unique(user_id, device_uuid, tag) and replaces the 409
binding_replacement_requires_revocation with a newest-authenticated-wins
in-place UPDATE.

Two changes make the phone cooperate with that slot:

1. Stable device id across reinstall. The iOS device-registry id moves
   from UserDefaults (erased on delete/reinstall) to a device-only
   Keychain item (service com.cmuxterm.deviceRegistry.iosDeviceID.v1,
   AfterFirstUnlockThisDeviceOnly). A returning phone now presents the
   same device_uuid and overwrites its own binding in place instead of
   stranding a fresh one. Keychain is authoritative; a pre-Keychain
   UserDefaults id is migrated on first read, and the generated id is
   mirrored back to UserDefaults for downgrade safety. This service is
   distinct from the iroh endpoint-identity store that sign-out/reinstall
   wipes, so forgetting the endpoint identity does not churn the slot key.

2. Forget a hidden computer. The per-phone Hidden Computers list gains a
   destructive Forget action (swipe + context menu, both gated behind a
   confirmation dialog, mirroring MacComputerRow's Hide) that revokes the
   Mac's account binding through the user-ownership-scoped broker endpoint.
   It resolves the binding id at action time via a fresh broker.discover()
   (so an offline Mac's binding is still listed and revocable), matches by
   canonical device id plus exact tag when known, revokes each match, then
   clears the local hidden marker and paired-Mac row. A still-online Mac
   re-registers and reappears on its next connect. Failure keeps the row
   and surfaces a toast.

New narrow capability MobileIrohMacForgetting keeps the shell store's
dependency minimal; en+ja localization added for the Forget copy.

* iOS: fail closed on unreadable device id, alert on Forget failure, pin account

Address the four P1 review findings on the iroh re-key iOS client branch.

Finding 1 (device-id read ambiguity): DeviceIdentityStoring.read() returned an
optional, collapsing "no id yet" and "Keychain locked before first unlock" into
nil. A background launch before first unlock therefore looked like a fresh
install and minted a NEW id, stranding the phone's existing (user, device, tag)
binding. read() now returns DeviceIdentityReadResult (.found/.absent/
.unavailable). deviceID(store:defaults:) fails closed on .unavailable: it reuses
the legacy UserDefaults mirror if readable, else a per-process ephemeral id that
is never persisted, so the durable id is adopted once the store unlocks. A
.found id is re-mirrored to UserDefaults (only when it differs) for downgrade
safety; a present-but-blank/corrupt item is treated as .absent and re-minted.

Finding 2 (account pinning): MobileIrohRuntimeComposition pins the expected
account and ensureAccountUnchanged guards Forget so a token-source swap mid-flow
can't revoke a binding under the wrong account (MobileIrohForgetError.
accountChanged).

Finding 3 (Forget ordering): MobileShellComposite forget removes the row before
clearing the hidden marker and returns Bool so a failed broker revoke surfaces
instead of silently dropping the row.

Finding 4 (Forget failure visibility): DeviceTreeView shows a .alert (not a
toast) on Forget failure, so the error surfaces even with the Toasts beta flag
off. Keys mobile.computers.forget.failureTitle/failureMessage, mobile.common.ok
localized en+ja.

CmuxMobileShell host-compiles and its 21 DeviceRegistry tests pass (incl. new
fail-closed + re-mirror coverage). DeviceTreeView and MobileIrohRuntimeComposition
transitively need GhosttyKit, so they compile only in the fleet iOS build.

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

* iOS: harden iroh re-key client per review (device-id, session snapshot)

Address the P1 findings from review of the iroh re-key client changes.

Finding 1 (composition-half): re-resolve the durable device id at each
activation via DeviceRegistryService.durableDeviceID(defaults:) instead of
capturing it once at root init. A value captured while the durable identity
store was unavailable (Keychain locked before first unlock, or a persistent
write failure) is an ephemeral throwaway id; registering a binding under it
would orphan the retained (user, device, tag) binding. When the durable id is
nil, activation now defers (throws .inactive) and retries on the next reconcile
once the store becomes readable. The injected resolver is @MainActor () ->
String? so it can capture UserDefaults, which is not Sendable under Swift 6.

Finding 2: forgetComputer now pins the revoke to one atomic
AuthenticatedSessionSnapshot (session generation + account id + both tokens)
captured from a single auth-session generation, and the caller passes the
row's captured expectedAccountID. Reading the observed identity and the live
tokens separately let a lagging observed id authorize a revoke that then ran
with a different account's freshly-stored tokens. The broker token source and
every mid-flight re-check now require BOTH the generation and the account id to
be unchanged, so a sign-out/sign-in (even as the same user) aborts safely.

Finding 4: clear the captured scope's durable row and hidden marker
unconditionally after a successful revoke. removeStoredPairedMacRow targets the
CAPTURED scope, so it cannot touch another account's data; skipping it on a
mid-flight scope flip reported success while the row survived, so returning to
the old scope showed the supposedly forgotten computer.

Tests: activationDefersWhenDurableDeviceIDUnavailable proves no endpoint binds
and the retained binding survives when the durable id is unavailable;
forgetRemovesCapturedScopeRowEvenWhenScopeFlipsMidRevoke proves the captured
account is forwarded and the row is removed on a mid-revoke scope flip;
DeviceRegistryRouteSelectionTests cover the durable-id defer/mirror/adopt paths.

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

* iOS: failing test — forget of team-less Mac deletes wrong team on mid-revoke switch

The forget-hidden-computer flow snapshots its owner scope before the async
iroh revoke, then deletes the stored row. When the captured scope is team-less
(no team selected) and the user switches into a team while the revoke is in
flight, local cleanup goes through the team-scoping decorator's plain remove,
which substitutes a nil teamID with the now-current team. It deletes that
team's row and leaves the forgotten team-less computer behind, so it reappears
on returning to no-team.

This commit adds only the failing regression test (drives forgetHiddenComputer
through a TeamScoped-wrapped store with a mid-revoke team flip); the fix follows.

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

* iOS: forget deletes the exact captured scope, not the live team

Add removeExactScope to MobilePairedMacStoring: same shape as remove but it
never substitutes a nil teamID with the currently-selected team. The team-scope
decorator (TeamScopedPairedMacStore) and the backup mirror (BackingUpPairedMacStore)
override it to forward the captured teamID verbatim; the base SQLite store,
MobileMacCompatible, and IOSBuildScoped decorators inherit the default forward
(none of them substitute, so plain remove and removeExactScope are equivalent
there).

forgetHiddenComputer captures its owner scope before the async iroh revoke, so
removeStoredPairedMacRow now deletes via removeExactScope — a mid-revoke team
switch can no longer retarget a team-less forget onto the freshly-selected team.

Also call clearSavedMacHintWhenNoStoredMacsRemainIfNeeded() on the forget path
after reloading, matching the hide path, so forgetting the last stored Mac drops
the saved-Mac hint instead of leaving a dangling reference.

Makes the prior commit's regression test pass.

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

* iOS: converge device identity under races, gate snapshot during token transition

Device id (FIX #3): adoptOrGenerateDeviceID now goes through Keychain
createOrAdopt instead of last-writer-wins write. createOrAdopt does SecItemAdd
first and, on errSecDuplicateItem, adopts the value already stored, so two
launches racing to mint an id converge on one instead of overwriting each other
and registering two device rows against the broker. The UserDefaults mirror is
reconciled to the winning id; Keychain stays authoritative and survives app
reinstalls so the broker binding is not orphaned.

Session snapshot (FIX #1): authenticatedSessionSnapshot() now also requires
!sessionTokenTransitionIsActive in both guards, so a snapshot taken mid token
rotation cannot hand back a half-swapped session that would drive a redundant
re-register.

Adds convergence coverage in DeviceRegistryRouteSelectionTests
(createOrAdopt adopts the concurrent winner rather than minting a second id).

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

* iOS: correct forget-scope regression test to genuinely catch mid-revoke team flip

The committed version of this test asserted contradictory post-conditions, so
it did not actually prove removeExactScope deleted the right row. Rewrite it to
load the base store once and partition rows by each row's own stamped teamID
(loadAll(teamID: nil) returns every team's rows, and loadAll(teamID:) also
returns team-less rows, so the returned set must be filtered by teamID to prove
which row was deleted). This version is red against the current
visibleScope-based removeExactScope: it deletes the flipped team-b row and the
team-less row survives, failing at the team-b assertion.

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

* iOS: forget deletes the exact captured team scope, no visibleScope re-derivation

removeExactScope forwarded through visibleScope/visibleMac, which call
inner.loadAll(teamID:): a nil team returns every team's rows and a set team
also returns team-less rows, ordered by lastSeenAt descending, so .first could
resolve a DIFFERENT team's row than the scope captured before the async revoke
and delete that row instead. When the user switches into a team mid-revoke, the
team-less forget then deleted the freshly-selected team's row and left the
forgotten team-less computer behind.

Make removeExactScope a pure pass-through to inner.removeExactScope, honoring
the exact (stackUserID, teamID, instanceTag) owner key verbatim; the layers
below do not substitute the team. Turns the regression test green.

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

* iOS: break corrupt-Keychain mint deadlock; move in-memory device store to tests

createOrAdopt, on errSecDuplicateItem, reads the item to converge racing
callers on one id. But read() maps a present-but-undecodable item to .absent
(so a fresh caller re-mints over garbage), which created a deadlock: a corrupt
Keychain item made every SecItemAdd return errSecDuplicateItem while read()
kept returning .absent, so the device could never mint a device-registry id and
iroh activation stayed permanently disabled. On .absent after a duplicate,
overwrite the corrupt item via SecItemUpdate and return desired, or nil (retry
a clean add) if a concurrent delete raced it to errSecItemNotFound. .unavailable
still defers so a locked-before-first-unlock item is never clobbered.

Also relocate the InMemoryDeviceIdentityStore test double out of the production
target into the test target; nothing in production or the app referenced it.

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

* iOS: hidden-computer unhide spinner tracks its own task, not forget's

The unhide Button's ProgressView keyed off forgetTask, so it never spun during
an actual unhide and could spin during an unrelated forget. performUnhide sets
actionTask; key the unhide spinner off actionTask.

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

* iOS: failing tests for forget deleting wrong paired-Mac scope

Two regression tests, RED before the fix (commit adds tests only):

- Finding 2 (release-reachable): a team-less pairing shown under a
  selected team (legacy visibility) is forgotten; the forget captures the
  LIVE display scope and deletes with it, so removeExactScope(teamID:
  "team-a") misses the team-less row, the hidden marker is cleared, and the
  row resurfaces as a normal computer on returning to no-team.

- Finding 3 (dev/tagged builds): removeExactScope falls back to the
  protocol-default remove through MobileMacCompatiblePairedMacStore over
  IOSBuildScopedPairedMacStore, so an exact-scope team removal also deletes
  the co-located team-less build-scope fallback row.

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

* iOS: forget deletes each pairing's own captured scope, not the live display scope

The forget flow captured the live display scope and deleted with it, so a
team-less paired-Mac row shown under a selected team (fetchAllMacs legacy
visibility) was missed by removeExactScope(teamID: "team-a"); the hidden marker
cleared and the row resurfaced (Finding 2, release-reachable). Plumb each row's
own stackUserID/teamID through MobileHiddenComputer and delete with the row's
own scope.

Keep exact-scope removal exact through both store decorators: add
removeExactScope overrides to MobileMacCompatiblePairedMacStore and
IOSBuildScopedPairedMacStore so the call no longer falls back to the protocol
default remove, which over-deleted the team-less build-scope fallback via
scopedTeamID(nil) on dev/tagged builds (Finding 3).

The pre-existing flip regression test seeded team-less then team-b for the same
device+instanceTag, but base upsert claims the team-less row into team-b
(moveMacRowScope), collapsing both into one team-b row, so the old assertions
passed vacuously (forget deleted a nonexistent owner_key). Reorder the seed
(team row first, which a later team-less upsert never claims) so two genuinely
independent rows exist, and forget the team-less one explicitly.

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

* iOS: failing tests for forget backup-team routing, revoke pinning, broker credential pairing

Three autoreview findings on the forget/revoke path, each with a failing
regression test. This commit adds only the tests plus the inert API surface they
reference; the behavior fixes land in the next commit so CI goes red then green.

A. removeExactScope reuses the nil local team for the backup tombstone, so a
   team-less row forgotten under a selected team routes its backup delete to
   whatever team is selected at flush time (can wipe the wrong team's backup).
   New removeExactScope(...backupTeamID:) surface (default forwards to the 4-arg,
   so behavior is unchanged until BackingUp overrides it next commit).

B. forgetHiddenComputer pins the revoke to the LIVE session account instead of
   the row's owning account, so a row left on screen after an account switch can
   revoke the new account's binding. Test only; the fix is a one-line arg change.

C. The broker reads access and refresh tokens through two independent snapshot
   calls; a force refresh between them pairs a stale access token with a rotated
   refresh token. New CmxIrohBrokerCredentials + credentialPair surface (unused by
   performRequest until next commit).

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

* iOS: fix forget backup-team routing, revoke account pinning, broker credential pairing

Behavior fixes for the three autoreview findings; the failing tests from the
prior commit now pass (CI red -> green).

A. BackingUpPairedMacStore.removeMirroring now takes a separate `backupTeam`
   scope: the local row still deletes under `team` (nil stays nil), but the
   backup tombstone routes to `backupTeam`. The new
   removeExactScope(...backupTeamID:) override supplies the captured display team,
   and MobileShellComposite's forget passes `displayScope.teamID`, so a team-less
   row forgotten under a selected team tombstones the right per-team Durable
   Object instead of whatever team is selected at flush time.

B. forgetHiddenComputer pins the revoke to `computer.stackUserID ?? scope.userID`
   (the row's owning account) instead of the live session, so the runtime forget's
   generation/account check fails closed when a stale row is forgotten after an
   account switch, rather than revoking the new account's binding.

C. CmxIrohTrustBrokerClient.performRequest prefers tokenSource.credentialPair
   (both tokens from one snapshot) over the two independent closures, and
   MobileIrohRuntimeComposition supplies a credentialPair closure that captures one
   authenticatedSessionSnapshot under the same generation/account pinning. A force
   refresh mid-request can no longer pair a stale access token with a rotated
   refresh token.

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

* iOS: failing test — session snapshot pairs stale access with rotated refresh

authenticatedSessionSnapshot() reads the access and refresh tokens through
two separate awaits (currentTokens()), so a concurrent force refresh can
rotate the pair between them and hand the broker an old access token with a
new refresh token. Neither snapshot guard trips on a plain token rotation.
The test scripts that torn store state and asserts the snapshot returns the
access minted for the captured refresh, not the stale stored access.

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

* iOS: session snapshot derives access from the captured refresh token

authenticatedSessionSnapshot() now reads both tokens through consistentTokenPair(),
which captures the refresh token once and mints the access token FOR that exact
refresh via freshAccessToken(accessToken: nil, refreshToken:). The returned access
always belongs to the returned refresh, so a concurrent forceRefreshAccessToken()
can no longer hand the iroh broker an old access token paired with a rotated
refresh token. currentTokens() is unchanged for its broader callers.

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

* iOS: failing test — forget routes backup tombstone to display team

A team-less row's backup was uploaded under the row's own (nil) team scope,
but forgetting it routes the tombstone to whatever team it happened to be
displayed under. The tombstone lands in the wrong per-team backup scope: the
row's real backup survives (and a restore under the row's own scope can
resurrect the forgotten row), while a same-device record in the displayed
team's backup can be wrongly deleted.

Replaces the previous test, which asserted the display-team routing as the
desired behavior.

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

* iOS: route forget backup tombstone to the row's own team scope

The forget path routed the backup delete to the team the row was displayed
under. For a team-less row that team is arbitrary (legacy visibility shows it
under every selected team), while upsert stamps the row and uploads its backup
under one resolved team, so the row's own team_id is the only client-side value
tied to where the backup lives. Display-team routing also split the pending-
delete lifecycle across two scopes: the tombstone was written and flushed under
the display team's outbox scope, but a restore under the row's own (team-less)
scope never saw it and could resurrect the forgotten row locally.

Route the tombstone to the row's own captured team, the same scope the backup
was uploaded under, keeping outbox key, local apply, flush, and restore-
suppression on one scope. This removes the removeExactScope(backupTeamID:)
variant entirely; the 4-arg exact-scope delete already carries the row's own
team.

Residual: a row uploaded while no team was selected client-side had its backup
scope resolved server-side, and that resolution is not echoed back or persisted,
so no client-only routing can name that scope with certainty. The symmetric nil
route re-resolves through the same server path as the upload. Persisting a
server-echoed backup team is a cross-stack follow-up.

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

* iOS: failing test — pending-delete replay deletes a surviving sibling row

A forget whose backup upload fails leaves its tombstone in the outbox; the
next read replays it through the broad remove path. TeamScopedPairedMacStore's
remove re-resolves the device under the scope's team, which also returns
team-less legacy rows, so with the exact row already deleted locally the
replay resolves a SURVIVING unrelated alias of the same device and deletes
it — the exact over-deletion the exact-scope forget path exists to prevent.

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

* iOS: replay pending backup tombstones through the exact-scope delete

A pending tombstone names one exact pairing and its outbox scope key pins the
exact (account, team) it was deleted under, so the replay's only job is to
finish or confirm that one deletion. Replaying through the broad remove
re-resolved visibility on the way down: TeamScopedPairedMacStore looks the
device up under the scope's team (which also returns team-less legacy rows)
and the build-scope decorator's broad remove drops its team-less fallback
alias. In the common failed-upload case the exact row is already deleted, so
the broad replay resolved a surviving unrelated alias of the same device and
deleted it.

Replaying via removeExactScope is a no-op there and, after a crash between
the tombstone write and the local delete, removes exactly the named row.
Residual: a crash-interrupted BROAD remove now replays exact too, so a
team-less build-fallback alias can outlive that narrow window in dev builds;
it resurfaces visibly and the next hide drops it.

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

* iOS: failing test — wildcard forget leaves the device's sibling rows saved

A row with no instance tag cannot name its broker binding, so forgetting it
revokes EVERY binding for the device. The local cleanup deleted only the
exact nil-tag row, leaving the device's coexisting tagged rows saved locally
while their bindings were just revoked: dead entries that resurface in the
computer list until the Mac happens to re-register. A tag-known forget stays
narrow on both sides (second test, passing).

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

* iOS: match wildcard forget's local cleanup to its revoke breadth

A tag-less row cannot name its own broker binding, so forgetting it revokes
every binding of the device for the pinned account. Local cleanup deleted
only the exact nil-tag row, stranding the device's coexisting tagged rows as
dead entries whose bindings were just revoked. After the wildcard revoke the
forget now also deletes the device's tagged sibling rows visible in the
captured display scope and owned by the pinned account, each through the same
exact-scope removal as the primary row. Tag-known forgets stay narrow on both
sides. Rows in other teams' scopes are not enumerable through the scoped
store rail and self-heal when the Mac re-registers; rows owned by other
accounts keep their live bindings and survive.

Closes https://github.com/manaflow-ai/cmux/issues/9078.

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

* iOS: failing test — forget mints a Stack token for every broker leg

The forget flow captures one coherent session snapshot up front, but the
broker token source re-snapshots on every request, and each snapshot now
mints a fresh access token over the network. Discovery plus every sequential
revoke each add a Stack round-trip, so forgetting a computer with many
bindings can stall for minutes and fail during a Stack outage even though
the pinned credentials in hand are valid. The test drives a forget across
four broker legs through a broker fake that fetches one credential pair per
request, exactly like the real client, and expects a single mint.

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

* iOS: reuse the forget's pinned credential pair for every broker leg

The forget captures one coherent session snapshot up front; the broker token
source now returns that pinned pair after only the cheap local session check
(generation + account), instead of re-capturing a snapshot per request. Each
snapshot performs a network token mint, so the old path added a Stack
round-trip for the discovery and for every sequential revoke: forgetting a
computer with many bindings could stall for minutes and fail during a Stack
outage despite holding valid credentials. The pinned pair is coherent by
construction, and the access token always travels with its refresh token, so
the server can re-mint server-side if it expires mid-operation. A mid-forget
sign-out or account switch still fails the check and yields nil, so the
revoke fails closed.

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

* iOS: failing test — tombstone ignores the server-reported backup team

A team-less row uploads with a nil team and the SERVER resolves which
per-team Durable Object stores it; that resolution is not derivable
client-side and can drift by the time the row is forgotten. The new
uploadReportingResolvedTeam seam (default: echo unknown) lets a transport
report the verified team an upload was stored under; the failing test shows
the backing-up store discards the echo and re-resolves nil at delete time, so
the tombstone can land in a different team's backup than the record it is
meant to delete.

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

* iOS: route delete tombstones to the server-reported backup team

A team-less row uploads with a nil team and the presence worker resolves which
per-team Durable Object stores it. That resolution is not derivable
client-side and can drift by the time the row is forgotten, so re-resolving
nil at delete time could send the tombstone to a different team's backup: the
forgotten Mac's record survived and restored later, and a same-device record
in the wrong team could be deleted.

The worker now echoes its verified resolved team in the backup POST and GET
responses (from the DO, which receives the verified value). The client
persists the echo per pairing in a UserDefaults-backed map owned by the
backing-up store, and the tombstone flush groups pending deletes by each
pairing's persisted backup team (falling back to the scope's own team when no
echo was ever seen), uploading each group to the backup its records actually
live in. A flushed pairing's mapping is dropped with its backup record.
Legacy rows converge on their next successful upload; restores still fetch
the live scope (read-path residual, benign).

Closes https://github.com/manaflow-ai/cmux/issues/9076.

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

* iOS: failing tests — restore drops the backup-team echo; wildcard forget refreshes per sibling

Two gaps in the round-4 fixes. Restored rows never pass through the upload
path, so the reinstall case (empty mapping store, rows arriving via restore)
loses the server's statement of where their backups live: a later forget
re-resolves nil and the wrong-backup deletion returns for exactly the restored
rows. The snapshot now carries the worker's echoed resolved team so the
restore can persist it. And the wildcard forget's cleanup refreshes the paired
list per deleted sibling, re-running the backup restore fetch each time — up
to the 256-binding snapshot limit of sequential round-trips for one tap; the
new test pins the whole cleanup to at most one refresh.

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

* iOS: persist the restore snapshot's backup team; batch wildcard cleanup

The restore path now records the worker's echoed resolved team for EVERY live
record in the snapshot (not just locally-written ones — each record lives in
that team's backup regardless of the local merge outcome), so a row restored
after a reinstall and forgotten later routes its delete tombstone to the
backup it actually lives in instead of re-resolving nil at delete time.

The wildcard forget now deletes all of the device's rows first and runs ONE
refresh (paired list + registry + reconnect hint) after the batch, instead of
reloading per deleted sibling — each per-row reload also re-ran the backup
restore fetch because the removal clears the restore memo, so a forget
covering many bindings issued that many sequential network round-trips.

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

* iroh: make the coherent credential pair the broker token source's only input

CmxIrohBrokerTokenSource previously accepted independent access and refresh
closures with the coherent pair optional. Several production constructions
(iOS reconcile/quarantine paths, macOS host activation) omitted the pair, and
their two closures each called auth.currentTokens() separately, so a session
transition between the two reads could assemble one session's access token
with another's refresh token and fail registration, discovery, or revocation.

The pair closure is now the ONLY construction input, so a two-source token
assembly is no longer expressible; the single-token accessors are derived from
the pair. Every construction site provides a coherent capture: pinned-session
pairs for the forget flow, pairs captured together up front for sign-out
revokes, and a single currentTokens() call per fetch for the runtime paths.
The performRequest legacy two-closure branch is gone. No new regression test:
the removed hazard is inexpressible at compile time, and
CmxIrohBrokerCredentialPairTests keeps asserting each request performs exactly
one atomic capture.

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

* iOS: failing tests — round-5 review findings

A wildcard forget must delete the device's same-account rows in OTHER teams
(their bindings were revoked account-wide and an offline Mac cannot re-register
to self-heal); the activation broker's credentials must fail closed after an
account switch instead of vending the new session's tokens against the old
activation; and a legacy device-id whose Keychain migration cannot persist is
NOT durable (a reinstall wipes the only copy and strands the slot). Supersedes
the adopt-legacy-despite-failed-persist test and the scope-flip test's
sibling-survives assertion, both of which pinned the rejected contracts.

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

* iOS: pin activation credentials; cross-team wildcard cleanup; defer non-durable legacy id

Round-5 review fixes. The activation path now captures one coherent session
snapshot, verifies it belongs to the activating account, and pins the broker
token source to it (same helper as the forget path): a mid-activation account
switch makes every later leg fail closed instead of mutating the new account's
broker state against the old activation's endpoint identity.

Wildcard forget cleanup now enumerates the device through a new cross-team
loadAllInstances seam on the paired-Mac store rail — the team-scoping decorator
forwards it verbatim (its live-team substitution is exactly what the cleanup
must see past), the build-scope decorator bounds it to its own build scope, and
the backup decorator forwards without triggering a restore. Every same-account
row of the device is deleted by its own exact scope, matching the account-wide
revoke.

DeviceRegistryService no longer reports a legacy UserDefaults id as durable
when the Keychain migration write fails: the store was readable (id absent) but
nothing durable holds the id, so binding activation defers and retries instead
of registering a slot a reinstall would strand.

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

* iOS: failing tests — round-6 review findings

A valid stored access token must be reusable without a network mint (forcing a
mint made the session snapshot, and with it broker activation, fail offline
despite a usable stored pair); and the persisted backup-team echo must be keyed
by the row's own team — the local store deliberately allows the same (account,
device, tag) pairing under several teams, so a team-agnostic key let team B's
upload overwrite team A's destination and route A's tombstone into B's backup.
Fixture fakes gain the SDK's likely-valid reuse semantics; the forget test's
mint expectation drops to zero accordingly.

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

* iroh: store-level coherent pair, per-request pinned activation source, keyed echo, forget deadline

Round-6 review fixes, one architectural piece plus three scoped ones.

coherentTokenPair() replaces the always-minting snapshot read: capture the
refresh token, resolve a usable access token FOR it (the SDK reuses a valid
stored access without the network and mints only otherwise), then re-read the
refresh — an unchanged refresh proves no rotation crossed the window, a changed
one retries. It runs inside the coordinator's bounded token-touching phase.
The session snapshot, the iOS quarantine-recovery source, and the macOS host
activation source all read through it, so no torn two-await assembly remains
and an offline launch with a valid stored pair succeeds.

Activation no longer freezes an activation-time pair for the runtime's
lifetime (ordinary force-refresh rotation does not bump the session
generation, so a frozen pair went stale and stranded relay refresh and
discovery until an unrelated reconcile). The activation gate is now a cheap
local identity check — no token read, so offline activation still reaches the
cached relay/offline-policy recovery — and every broker request re-checks the
account/generation pin and re-reads a coherent pair from the store.

The backup-team echo mapping key now includes the row's own team, and the
forget revoke loop gets a 60-second operation deadline (deadlineExceeded
surfaces the failure; applied revokes stand and a retry re-discovers what
remains) instead of up to 256 sequential broker timeouts.

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

* iOS: failing tests — round-7 review findings

An ordinary same-account foreground revalidation must not advance the session
generation (every generation-pinned broker source would starve after the first
foreground), and a UserDefaults device-id mirror must never be adopted when the
Keychain authoritatively reports the id absent — the mirror travels in device
backups onto NEW phones while the ThisDeviceOnly Keychain item does not, so
adoption would make two physical devices fight over one (user, device, tag)
slot on every phone upgrade. Also pins persist-and-reuse of refreshed access
tokens across repeated coherent captures (contract coverage: the ephemeral
side-store defect is not expressible through the fake), and reworks the fakes
to model the live store's stale-refresh-persist semantics. Supersedes the
legacy-mirror-adoption migration test.

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

* iroh: round-7 identity and credential lifecycle fixes

Same-account revalidation no longer bumps the session generation: the bump
now happens only on a genuine transition (signed-out -> signed-in, or a
different account), so generation-pinned broker sources survive ordinary
foreground returns while sign-out/sign-in still fences stale flows.

The device id is minted fresh when the Keychain authoritatively reports it
absent, never adopted from the UserDefaults mirror (which migrates in phone
backups and would collide two physical devices onto one binding slot); the
mirror remains trusted only while the Keychain is temporarily unreadable.
This deliberately drops the seamless pre-Keychain upgrade migration — a
one-time re-pair for existing installs — to prevent a permanent cross-device
identity collision on every phone upgrade.

The coherent pair now resolves the access token through the LIVE store inside
the refresh bracket, so a stale token is refreshed once, persisted, and
deduplicated by the SDK instead of re-minted per capture through an ephemeral
side store. The long-lived activation source reads a full authenticated
snapshot per request (atomic identity+credential capture, transition-checked)
validated against the activation pin, closing the check-then-read race. Both
credential containers get redacted descriptions so reflection cannot copy
live tokens into logs or crash reports.

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

* iOS: failing tests — round-8 review findings

An in-place upgrade (Keychain absent, mirror holding the id the live binding
already uses, no witness recorded) must ADOPT the mirror — minting there
changes every existing installation's identity once and strands all of their
bindings. A mirror whose recorded device witness belongs to ANOTHER phone (a
restored backup) must still mint fresh, and a witness matching this phone
adopts. These pin the provenance mechanism that separates the two cases the
last two rounds traded against each other. (The tests reference the new
witness parameter, so this commit is red at compile time without the fix.)

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

* iroh: device-witness provenance for the id mirror; pin the macOS broker source

The UserDefaults device-id mirror now carries a per-device witness
(identifierForVendor — a value a restored phone does not inherit), written on
every mirror update. On authoritative Keychain absence the mirror is adopted
only when the witness proves it was recorded on THIS device or predates the
mechanism (the in-place upgrade population, whose mirror holds the id their
live binding already uses); a mismatched witness means a backup restored onto
another phone, which mints fresh so two physical devices never share one
(user, device, tag) slot. The locked-Keychain fallback applies the same test.
Residual: restoring a PRE-witness backup onto a new phone is indistinguishable
from an upgrade and adopts — bounded to backups taken before this ships.

The macOS host runtime's broker source now mirrors the iOS one: activation
verifies the live account, captures the generation, and every request reads an
atomic authenticated snapshot validated against that pin, so an A-to-B account
switch fails the old runtime's requests closed instead of registering B's
credentials against A's endpoint state.

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

* iOS: failing tests — round-9 review findings

A wildcard forget's tombstones must travel in ONE request per destination (a
device can carry 256 bindings, and per-row flushes each burn a request
timeout); a pending tombstone must be visible to restores of its DESTINATION
scope, which must both suppress the deleted record and retry the flush; an
unmapped team-less tombstone must PARK instead of shipping with a guessed nil
team the server would re-resolve from current account state; and a failed
cross-team sibling enumeration is a cleanup failure, not silent success.
Legacy tests that modeled the pre-echo worker now arm the echo; the nil-team
routing test is superseded by the parked contract, and the crash-intent test
becomes the mapping-recovery test.

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

* iroh: destination-keyed tombstone outbox, batched wildcard flush, propagated enumeration failure

Round-9 review fixes.

Pending backup tombstones are now keyed by their DESTINATION scope — the team
whose Durable Object actually holds the record (the persisted echo, else the
row's own concrete team) — with the row's LOCAL team encoded in each record
for exact local replay. A restore of the destination therefore both suppresses
the deleted record while its upload is pending and retries the flush, closing
the resurrect-and-never-retry gap of local-scope keying. A team-less row with
NO verified destination is parked under the nil-team scope and never uploaded
with a guessed nil team; parked intents migrate to their destination and flush
once a restore's echo recovers the verified mapping. Legacy single-field
records decode as local==scope, preserving old outboxes. Residual, documented
in code: while parked, a restore of a different team's scope cannot see the
intent and may resurrect the record there; re-forgetting that row routes
exactly, which is recoverable — unlike a misrouted destructive delete.

removeExactScopes batches several rows: local deletes and outbox writes first,
then ONE tombstone flush per destination, replacing the per-row flush that
gave a wildcard forget up to one network round-trip per row. The composite
deletes the primary and all wildcard siblings through one batch and clears
markers only after it succeeds, and a failed sibling enumeration now fails the
forget instead of silently claiming success after an account-wide revoke.

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

* iOS: failing tests — round-10 review findings

A TAGGED forget's revoke is also account-wide for that (device, tag) binding,
so same-tag rows in other teams must be cleaned too while different-tag rows
survive; and reviving one team's row must clear only THAT row's pending
tombstone — the destination-keyed outbox can hold same-pairing records for
different local teams, and cancelling them all lets another team's forgotten
record survive in the backup and restore later.

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

* iOS: tag-scoped cross-team forget cleanup; revive clears only its own row's tombstone

Round-10 review fixes. Cross-team sibling cleanup now runs for EVERY forget:
a tagged revoke kills the (device, tag) binding account-wide, so other teams'
same-tag rows are dead and get cleaned, while different-tag rows keep their
own live bindings and survive; the tag-less wildcard keeps its every-tag
breadth. And a revive clears only the pending tombstone whose LOCAL team
matches the re-added row — same-pairing records for other local teams in the
same destination stay pending, so their forgotten backup records still get
deleted instead of surviving to restore later. Legacy unscoped records decode
their local team from the scope they sit in and so match only in the re-added
row's own scope.

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

* iOS: failing tests — round-11 review findings

Three confirmed defects, each with a failing test:

- A wildcard forget's exact-scope cleanup silently skips rows whose
  instance tag is incompatible with this build, while the tombstone
  still flushes and the forget reports success; the revoked-binding row
  survives to resurface as a dead entry.
- Forget clears hidden markers only in the display scope; markers are
  stored per (user, team), so another team's marker survives its row's
  deletion and keeps a re-registering Mac unexpectedly hidden there.
- A whitespace-only persisted device identity classifies as .found, so
  the corrupt-item repair deadlocks: the mint path re-reads and adopts
  the same whitespace value and every launch advertises an invalid
  opaque device id.

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

* iOS: exact-scope deletes match wildcard breadth; markers and identity repair

Round-11 review fixes:

- The build-compatibility store no longer guards exact-scope deletes.
  An exact-scope delete targets a row the cleanup explicitly captured
  from loadAllInstances, and the broker's wildcard revoke is tag-blind,
  so the local cleanup must cover incompatible tags too; the guard let
  the tombstone flush and the forget report success while the
  revoked-binding row survived. Ambient verbs keep the guard.
- Forget clears each deleted row's hidden marker in that row's OWN team
  scope in addition to the display scope. Markers are stored per
  (user, team); clearing only the display scope left another team's
  marker to keep a re-registering Mac unexpectedly hidden there.
- KeychainDeviceIdentityStore classifies a whitespace-only item as
  corrupt (.absent), so the duplicate-item repair path overwrites it
  instead of endlessly re-adopting it as .found; the in-memory test
  double mirrors the contract, now documented on DeviceIdentityStoring.

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

* iOS: failing tests — round-12 review findings

- A pre-witness UserDefaults mirror is adopted on authoritative Keychain
  absence with no proof this is the same physical device; a backup taken
  before the witness shipped restores onto a new phone and clones the
  old phone's (user, device, tag) binding slot.
- A concrete-team restore neither suppresses nor resolves a PARKED
  unknown-destination tombstone, so the supposedly forgotten computer is
  resurrected locally and its backup survives every future restore.
- A partially failed batched cleanup still runs the post-forget refresh,
  whose rowless-marker migration clears the deleted primary's hidden
  marker — the retry entry disappears while the failed sibling row keeps
  its already-revoked binding.

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

* iOS: continuity-gated mirror adoption; parked tombstones suppress and resolve

Round-12 review fixes:

- Pre-witness mirror adoption now requires device-continuity evidence: a
  non-migrating artifact proving the install continues on this hardware.
  The probe is the iroh endpoint identity — in Release an
  AfterFirstUnlockThisDeviceOnly Keychain item that never travels in a
  backup, and one every install with a live binding necessarily has. A
  restored pre-witness backup on a new phone lacks it and mints fresh
  (no more cloned (user, device, tag) slots); an in-place upgrade with a
  binding has it and keeps its id; an install that never activated iroh
  mints harmlessly. Both production device-id callers pass the same
  probe so concurrent resolutions agree, and the locked-Keychain mirror
  branch defers instead of trusting a possibly-restored mirror.
- Every restore's suppression list now includes the account's PARKED
  (unknown-destination) tombstones, and a verified team's snapshot echo
  resolves any parked intent whose pairing it contains: the mapping is
  recorded under the parked record's own key and the parked scope
  flushes, migrating the intent to its destination and deleting the
  backup. A forget the user was told succeeded can no longer be
  resurrected by the next restore. FakeBackup now honors successful
  delete uploads in its snapshot, mirroring the server.
- The post-forget refresh runs only after COMPLETE cleanup, so a partial
  batch failure keeps the hidden entry as the retry owner instead of
  letting the rowless-marker migration clear it.

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

* iOS: failing test — round-13 review finding

A forget's cleanup enumerates only the LOCAL store, but backups live in
per-team Durable Objects and only the selected team's backup has been
restored on this phone. The same device's records in another team's
backup get no tombstone even though the wildcard revoke killed their
bindings account-wide; switching to that team later restores the
supposedly forgotten computer as a dead entry. FakeBackup gains a
per-team-bucket mode to model the server's per-team storage.

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

* iOS: account-wide forget tombstones; device-id resolution off the UI actor

Round-13 review fixes:

- A forget now parks one ACCOUNT-WIDE tombstone per forgotten pairing in
  addition to the routed per-row intents. Backups are per-team Durable
  Objects and only restored teams have local rows, so the local
  enumeration cannot match the broker revoke's account-wide breadth; the
  parked intent suppresses the pairing in EVERY team's restore, each
  verified snapshot that proves its team holds the pairing gets a direct
  delete (a tag-less intent is the device-wide wildcard and matches
  every tag, with the snapshot supplying the concrete tags), and the
  intent persists until a re-pair revives the pairing. Parked intents no
  longer migrate to a single destination — no single team could retire
  an account-wide tombstone.
- Durable device-id resolution moved off the MainActor for activation:
  a private actor captures the identifierForVendor witness with one
  MainActor hop and runs the Keychain reads/writes, defaults mirror, and
  continuity probe on its own executor, restoring the off-UI-actor
  guarantee the merge reconciliation had dropped. DeviceRegistryService
  gains a nonisolated durableDeviceID(defaults:deviceWitness:...) for
  such callers, and currentDeviceWitness() is public.

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

* iOS: failing tests — round-14 review findings

- Parked (account-wide) tombstones replay their local delete only when
  the nil-team scope itself is requested, so an offline launch after a
  crash keeps showing the supposedly forgotten computer: crash recovery
  must be network-independent.
- The parked tombstone set retires only on revive and grows by every
  forget forever — unbounded persisted size and per-restore scan work;
  retention must be bounded.

The forget-deadline scope finding (discovery and in-flight broker calls
can suspend past the deadline) is fixed in the same round; it lives in
the iOS-only cmuxFeature target, where no host-runnable test exists.

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

* iOS: network-independent parked replay, bounded retention, full forget deadline

Round-14 review fixes:

- Both restore entry points now replay the account's PARKED tombstones
  locally before any backup fetch, so crash recovery (outbox written,
  local delete never landed) works offline instead of depending on the
  restore's suppression list reaching the network.
- The parked account-wide tombstone set is bounded at 256 entries
  (matching the discovery wire cap): intents are deduped by identity,
  stamped with a coarse insertion time via an injected clock, and
  evicted oldest-first when over the cap — an evicted intent's forget
  has had the longest time to propagate, and losing one degrades to the
  pre-account-wide behavior for that single pairing. Routed records'
  encodings are unchanged, so exact-string outbox clearing still works.
- The forget deadline now bounds the WHOLE operation: forgetComputer
  races credential capture, discovery, backpressure waits, and every
  revoke against a cancellable sleeper, cancelling in-flight broker work
  at the deadline instead of only checking between revokes; the
  per-revoke clock checks remain as a cheap early exit.

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

* iOS: fix Swift 6 isolation and stale optional binding in cmuxFeature

Round-15 review findings — both compile errors in the iOS-only targets
(no host-runnable or CI compile covers them, so no regression test is
practical):

- deviceLocalIrohIdentityExists (and its directory helper) are
  nonisolated so the off-main resolver actor's synchronous continuity
  probe closure can call them without a MainActor hop.
- The sign-out test fake still optional-bound credentialPair from
  before it became the token source's only, non-optional input.

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

* iOS: forget deadline sleeper becomes static — extensions cannot hold storage

Round-16 review finding: the cancellable sleeper was declared as an
instance stored property inside the extension that hosts the forget
flow, which does not compile. Static storage keeps the bounded-timeout
shape unchanged.

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

* iOS: failing test — round-17 review finding

A completed same-account sign-in (fresh credential exchange while
already authenticated) preserves the session generation, so operations
pinned to the prior session — the forget flow's frozen credential pair,
the activation runtime's pinned source — keep passing the session fence
with the replaced session's authority.

The sibling round-17 finding (the activation path creates the iroh
endpoint identity before the device-id continuity probe checks for it,
so a restored pre-witness backup sees its own moments-old identity as
continuity evidence) is fixed in the same round; it lives in the
iOS-only cmuxFeature target, where no host-runnable test exists.

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

* iOS: sign-in always advances the session generation; probe before identity

Round-17 review fixes:

- applySignedInUser now takes an explicit SessionPublication reason: a
  completed credential exchange (.signIn) always advances the session
  generation, even for the same account, because the token session was
  replaced and prior-session pins must fail closed; only .revalidation
  (foreground/startup re-checks of the already-published session)
  preserves the generation for the same account.
- The activation path resolves the durable device id BEFORE creating
  the iroh endpoint identity. The continuity probe treats a
  device-local identity as proof the install continues on this
  hardware; creating the identity first handed a phone restored from a
  pre-witness backup its own moments-old identity as evidence and
  adopted the migrated mirror id.

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

* iOS: drop @MainActor child annotation the isolation checker cannot verify

The hosted iOS build fails on the forget-deadline task group:
"pattern that the region-based isolation checker does not understand
how to check" at the @MainActor-annotated child. The plain child hops
to the MainActor implicitly at the revokeMatchingBindings call, which
is exactly what the annotation expressed.

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

* iOS: failing tests — round-19 review findings

- The upload echo is keyed by the live display team, but loadAll's
  legacy visibility can match a TEAM-LESS row: the forget then looks the
  mapping up under the row's own nil team, misses it, and parks the
  tombstone — undeliverable when the network is down at echo time.
- A parked delete suspended in its upload can race a concurrent re-pair
  on the reentrant actor: the revive clears the intent and uploads the
  record, the older delete lands after it, and nothing repairs the
  wiped backup.
- A partially failed batch cleanup returns before clearing ANY markers;
  rows deleted before the failure can never be re-enumerated on retry,
  so their per-team hidden markers keep a re-registering Mac hidden.

FakeBackup gains an on-delete-upload hook (to interleave a mutation
inside the uploader's suspension window), record-op application to its
buckets, and a post-construction fetch-failure switch.

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

* iOS: row-keyed echoes, delete/revive reentrancy fences, narrowed marker cleanup

Round-19 review fixes:

- The upload echo's mapping is keyed by the ROW's stored team
  (mac.teamID), not the live display scope: loadAll's legacy visibility
  matches team-less rows under a selected team, and the forget looks the
  mapping up under the row's own team — a display-keyed echo was never
  found, leaving the tombstone parked and undeliverable offline.
- Both delete uploaders (the concrete-scope flush and the parked echo
  resolver) now fence against the actor's reentrancy: any sent tombstone
  whose outbox record vanished during the upload suspension was revived
  by a concurrent re-pair, so its current local row is re-uploaded — the
  stale delete can no longer silently wipe the just-revived backup. The
  concrete flush also retires only the records it SENT, so intents added
  during the suspension survive to their own flush, and revived records
  keep their freshly re-saved mapping.
- A partially failed batch cleanup clears the markers of rows it DID
  delete — narrowly: only the deleted row's own team key and the
  user-wide key, never the display scope, which the failed scope (the
  retry owner) shares. Rows deleted before the failure can never be
  re-enumerated on retry, so this is the only moment their markers can
  be cleared.

FakeBackup applies record uploads to its per-team buckets only; the
legacy single-bucket mode serves its seeded list to every team, so
applying uploads there would leak one team's mirror into every other
team's restore.

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

* iOS: failing tests — round-20 review findings

- The account-wide parked intent is inserted only AFTER the batch's
  local deletes have awaited; a Mac re-registering during that window
  clears the routed tombstone but cannot clear the not-yet-created
  parked intent, which then suppresses the revived pairing forever.
- The flush retires sent tombstones by set subtraction computed AFTER
  its post-upload awaits; a re-pair plus second forget during those
  awaits re-adds the identical encoded record, which the subtraction
  silently consumes — an undelivered second tombstone loses its retry.
- The persisted backup-team mapping grows without bound: entries retire
  only when THIS device delivers the pairing's tombstone.

Test doubles: a paired-Mac store and a team-mapping store that fire a
one-shot hook inside their suspension windows.

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

* iOS: park before deletes, atomic flush retirement, bounded team mapping

Round-20 review fixes:

- removeExactScopes resolves accounts and persists the account-wide
  parked intents BEFORE the first local-delete suspension, so a Mac
  re-registering during a delete clears every tombstone covering its
  pairing — routed and parked alike — instead of leaving a stale
  account-wide intent that would suppress the revived pairing forever.
  The parked scope now also dedupes by identity in addPendingDelete and
  applies the same oldest-first cap there, so a row intent never stacks
  a second encoding beside its account-wide twin and single exact-scope
  removes cannot grow the scope unbounded.
- The concrete flush retires its sent tombstones atomically in one actor
  turn right after the upload (synchronous cache read + write), before
  the mapping-cleanup and repair awaits: a re-pair plus second forget
  interleaving those awaits re-adds its identical record AFTER
  retirement and keeps its own retry.
- The persisted backup-team mapping is bounded at 512 entries with
  move-to-newest insertion order and oldest-first eviction; losing an
  evicted mapping degrades that pairing's next forget to the parked,
  echo-recovered path.

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

* iOS: failing tests — round-21 review findings

- A parked intent matches later snapshots solely by pairing id and is
  cleared only by a LOCAL re-pair: when another device re-creates the
  record, this phone deletes the revival on every restore and keeps the
  intent forever, making cross-device re-pairing impossible to persist.
- The restore echo records every snapshot mapping under the restore
  team, but LWW can retain a NEWER team-less local row un-stamped; the
  later forget looks the mapping up under the row's actual nil team,
  misses, and parks — undeliverable when the network drops.

The third round-21 finding (a same-account sign-in advances the session
generation but the long-lived activation runtimes stay pinned to the
old generation and return nil credentials until restart) is fixed in
the same round; it lives in the iOS-only and macOS app targets, where
no host-runnable test exists.

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

* iOS: account-pinned runtimes, revival-aware tombstones, retained-row echoes

Round-21 review fixes:

- The LONG-LIVED activation runtimes (iOS composition and the macOS
  host) pin their broker token sources to the ACCOUNT only, not the
  session generation: every completed sign-in now advances the
  generation, and a same-account re-sign-in must keep the runtime
  serviceable — it is the same user, so serving the new session's
  credentials via the atomic snapshot is correct, where the generation
  pin stranded the runtime on nil credentials until relaunch. The
  forget's short-lived frozen pair stays strictly generation-pinned.
- The restore echo now fires AFTER the merge and carries, per snapshot
  record, the RETAINED local row's actual team and the record's creation
  time. Mappings are keyed by the retained row's own scope (LWW can keep
  a newer team-less row un-stamped, and the forget looks the mapping up
  under the row's real team), falling back to the restore scope for
  records with no local row (the reinstall case).
- A snapshot record CREATED after a parked intent's stamp is a REVIVAL —
  another device re-paired the Mac — and retires the intent instead of
  feeding it a delete; without this the forgetting phone deleted the
  revival on every restore forever. Unstamped legacy intents keep the
  old delete behavior (no boundary is known for them).

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

* iOS: failing tests — round-22 review findings

- A revived record is recognized only AFTER suppression already filtered
  it out of the merge; with the completed restore memoized, the
  re-paired Mac stays missing locally until relaunch.
- The revival signal compared client-authored createdAt, which another
  phone preserves across a re-pair; the genuine revival misclassifies as
  stale and is deleted on every restore. The record model gains the
  SERVER-authored serverUpdatedAtMs (decoded from the snapshot, never
  uploaded).
- Restore echoes persist mappings one save per record; the production
  store rewrites its whole state per save, so a large restore does
  quadratic UserDefaults work. The mapping protocol gains a batched
  saveAll (default forwards per entry).

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

* iOS: server-authored revival signal, in-merge revivals, batched mappings

Round-22 review fixes:

- The worker now surfaces the sync machinery's server-authored per-record
  write time as serverUpdatedAtMs on the restore read (never accepted
  from clients — sanitize strips it). Revival classification compares
  THAT against the tombstone's stamp through a shared skew-margined rule
  biased toward revival: client-authored createdAt is preserved across
  re-pairs on other phones and proves nothing.
- Restore suppression is now stamp-aware: run() takes suppression
  entries (pairing + tombstone stamp), and a record every covering
  tombstone sees as revived MERGES in the same restore instead of being
  filtered out and stranded behind the completed-restore memo until
  relaunch. The post-merge echo then retires the covering intents.
- Restore echoes persist their mappings through one batched saveAll —
  the UserDefaults store performs a single read-modify-write of its
  dictionary and ordering for the whole snapshot instead of a full-state
  rewrite per record.

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

* iOS: failing tests — round-23 review findings

- The revival skew allowance accepts server writes up to a minute BEFORE
  the forget as revivals. Forgetting a currently-online Mac whose backup
  was route-mirrored seconds earlier is the COMMON case; the allowance
  bypasses suppression, retires the intent, and the supposedly forgotten
  Mac restores instead of receiving its delete.
- A partial batch failure never records a hidden marker for a FAILED
  undisplayed sibling: the deleted primary's marker turns rowless and is
  migrated away, so the sibling — with its already-revoked binding —
  resurfaces as a normal computer with no Hidden Computers entry left to
  retry from.

The third round-23 finding (the sign-out quarantine's destructive retry
captures live credentials without pinning them to the pending
revocation's account) is fixed in the same round; it lives in the
iOS-only cmuxFeature target, where no host-runnable test exists.

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

* iOS: strict revival boundary, pinned quarantine retry, sibling retry markers

Round-23 review fixes:

- The revival boundary is STRICT: only a server write after the
  tombstone's stamp counts. Forgetting a currently-online Mac whose
  backup was mirrored seconds earlier is the common case, and the skew
  allowance let those pre-forget writes bypass suppression and retire
  the intent. The residual (phone clock behind the server) fails in the
  recoverable direction: the revival is deleted once and the other
  device's next mirror re-uploads it with a fresh server stamp.
- The sign-out quarantine's destructive retry pins its credentials to
  the pending revocation's account through the atomic session snapshot,
  failing closed if the user switched accounts between the guard and the
  credential capture.
- A partial batch failure records a hidden marker for every SURVIVING
  failed scope in its own team, so an undisplayed sibling with a revoked
  binding keeps a durable Hidden Computers retry entry even offline —
  where the account-wide parked intent cannot yet finish the cleanup.
  Once any restore completes it, the marker turns rowless and the
  existing migration clears it.

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

* iOS: failing test — round-24 review finding

The tombstone stamp is floored to whole seconds while server write times
carry milliseconds, so a server write from the same second but BEFORE
the forget classifies as a post-forget revival: the intent retires and
the stale record restores instead of being deleted.

Of the two sibling round-24 findings: the forget deadline race is fixed
in the same round (the throwing task group structurally awaits an
unresponsive cancelled child past the deadline; it lives in the iOS-only
cmuxFeature target with no host-runnable test), and the retained-teams
dictionary finding is factually incorrect — assigning a String? through
the subscript wraps it (Swift removes only when the assigned expression
is already the subscript's doubly-optional type), which the passing
restoreEchoTracksTheRetainedTeamlessRow regression proves — but the code
switches to updateValue(_:forKey:) to make the retention explicit.

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

* iOS: millisecond forget boundary, non-blocking deadline, explicit retention

Round-24 review fixes:

- Tombstone stamps carry epoch MILLISECONDS with an explicit `ms` unit
  marker in the encoding (bare-integer third fields from earlier builds
  decode as whole seconds). Flooring to seconds classified a server
  write from the same second but before the forget as a revival,
  retiring the intent and restoring the stale record.
- The forget deadline no longer structurally awaits the losing racer: a
  throwing task group waits for every child, so a revoke suspended on a
  dependency that ignores cooperative cancellation kept the forget busy
  past the deadline — the exact stalled-request case it exists to
  recover from. Unstructured racers resolve a one-shot gate; the
  deadline returns immediately, cancellation is still requested, and the
  stalled work unwinds in the background.
- The restore's retained-row map uses updateValue(_:forKey:) so the
  retention of a TEAM-LESS row is explicit rather than relying on
  optional-wrapping subscript semantics (behavior unchanged — the
  routed-delete regression already proved the entry was stored).

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

* iOS: failing tests — round-25 review findings (bounded pair)

- One tagged instance's revival retires the whole DEVICE-WIDE tombstone,
  dropping suppression and deletion for a stale different-tag record
  that exists only in another team's backup.
- The account-wide parked record stores a nil local team, so offline
  crash recovery replays only nil-team rows: a concrete-team row whose
  local delete never landed survives every offline launch.

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

* iOS: exact revival retirement; parked records carry their row's team

Round-25 review fixes (the two bounded findings):

- A revival retires only its EXACT pairing's intent, and the revive-clear
  mirrors it: one tagged instance returning no longer retires the
  device-wide tombstone (or clears it on local re-pair), so a stale
  different-tag record in another team's backup keeps its suppression
  and still receives its delete. Per-record revival classification lets
  the revived pairing through everywhere, so retaining the wildcard
  intent costs the revival nothing; deletes explicitly spare records
  every covering intent classifies as revived.
- Account-wide parked records preserve the captured ROW's local team, so
  offline crash recovery replays the exact delete for concrete-team rows
  (a nil local team replayed only nil-team rows). Coverage semantics are
  unchanged — suppression and echo matching key on the pairing id alone,
  and the revive-clear cancels the pairing's intents regardless of the
  recorded team.

The two remaining round-25 findings are deferred with rationale in the
PR discussion: cross-clock revival ordering (a sound fix needs
server-issued causal revisions — a worker protocol change reintroducing
a form of server-side tombstones, which this codebase deliberately
retired; the strict boundary fails only in the recoverable direction)
and post-deadline task abandonment (every dependency in the revoke path
is URLSession-backed and cancellation-aware; the detached racer is
cancellation-requested and cannot outlive its own bounded requests).

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

* iOS: widen developmentStoreDirectory to fileprivate for the evidence probe

The DEBUG same-device evidence probe struct lives at file scope in
MobileIrohRuntimeComposition.swift and cannot reach a type-scoped private
static. Caught by the on-device build; host-side SwiftPM tests do not
compile the iOS-only cmuxFeature target.

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

* Drop committed review logs from the branch

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

* Restore main's ghostty submodule pin (theme picker fix from #9218)

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

---------

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-07-30 16:45:01 -05:00
b86cce2e7d iOS: stream Mac browser panes to the phone (pixel-perfect, interactive, dialogs mirrored) (#8298)
* docs: iOS browser streaming design

* Add mobile browser streaming wire protocol

* Add Mac mobile browser stream host

* Fix stream session compile (nonisolated encoder init) and momentum end phase

* Fix keyCode method shadowing in SyntheticKeyEventFactory

* Add iOS browser stream surface package

* Add browser stream RPC client plumbing

* Wire browser streams through the mobile shell

* Integrate browser streams into mobile shell UI

* Beacon: detect canvas/WebGL painting via requestAnimationFrame wrap

* ci: reload-build gains an ios-simulator platform

Builds the unsigned simulator .app and uploads it as an artifact, for
callers whose local xcodebuild is unavailable; the sim bundle installs
directly via simctl.

* ci: build the ios-simulator app arm64-only

GhosttyKit's simulator slice is arm64-only, so the generic destination's
x86_64 half fails at link; every target simulator is arm64.

* Fix display link teardown for Swift 6 nonisolated deinit

* Fix frame stall via store-owned decode pipeline; move chrome to bottom floating bar

* Self-heal browser stream: force restart past dedupe on recovery, unanswered-input watchdog, keyboard-pinned bottom bar

* Add mobile browser dialog wire model and broker

* Mirror Mac browser dialogs over mobile RPC

* Render mirrored browser dialogs on iOS

* Wire mobile browser dialog Mac sources into Xcode project

* Capture owner explicitly in basic-auth startPrompt closure

* Stack browser dialog buttons vertically for 3+ or long labels

* Reserve bottom bar space so chrome never occludes streamed page content

* Take main's reconnect route-isolation test (recoveryTask removed by Iroh fix)

* Browser bar: always-visible standard controls, drop collapse pill + confusing X/chevron; stop stream on surface exit

* Add mobile browser viewport RPC DTOs

* Reflow Mac browser streams to phone viewport

* iOS: report phone viewport to reflow the streamed Mac browser

* Fix streamed browser white-out: force repaint after viewport reflow so idle pages don't capture a blank frame

* White-out fix v2: real two-frame scroll repaint nudge + settle-capture burst after reflow

* Replace iOS tab switcher surface

* Fix iOS switcher integration and verification

* Test persistent browser render host portal ownership

* Share persistent browser offscreen render hosting

* Capture mobile browser streams in persistent render host

* Fix switcher initial positioning and accessibility

* Test switcher reopening after browser selection

* Reset switcher state for each presentation

* iOS browser stream: mirror phone frames in the Mac pane instead of blanking it

While a browser pane streams to the phone, the live WKWebView renders in the
offscreen host at phone width, so the Mac pane went fully blank. Show a
read-only, letterboxed, click-through mirror of the exact frames the phone
receives (fed from the same capture in MobileBrowserStreamSession at the same
cadence), added to the pane's superview on stream start and removed on teardown
when the full-width live web view returns.

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

* Speed up browser stream capture on the offscreen render host

Continuous JPEG frames were snapshotting with afterScreenUpdates:true, which
blocks each takeSnapshot on the host window's screen-update cycle. The stream's
offscreen render host lives off all screens at alpha ~0, where macOS throttles
that cycle hard, so capture was capped to a few fps: the phone showed "super
slow" streaming that barely moved on scroll.

Snapshot continuous JPEG frames with afterScreenUpdates:false instead. That
captures the currently committed render, which already reflects the new scroll
offset, without waiting on the throttled cycle; the dirty loop re-captures to
stay current. The rare lossless PNG settle frame keeps afterScreenUpdates:true
for a pixel-perfect rest state.

Add DEBUG per-capture instrumentation (capture ms, encode ms, byte size, pixel
size, unacked count) so stream throughput is measurable from the debug log and
capture-bound vs flow-controlled is distinguishable.

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

* Revert "Reset switcher state for each presentation"

This reverts commit ed9d5f8b46.

* Revert "Test switcher reopening after browser selection"

This reverts commit 2bfebb3346.

* Revert "Fix switcher initial positioning and accessibility"

This reverts commit 607ef33924.

* Revert "Fix iOS switcher integration and verification"

This reverts commit 9cfb181750.

* Revert "Replace iOS tab switcher surface"

This reverts commit 89105d342d.

* Revert "ci: build the ios-simulator app arm64-only"

This reverts commit f5e9324940.

* Revert "ci: reload-build gains an ios-simulator platform"

This reverts commit 42b65b2300.

* Scope PR to browser streaming: drop switcher residue from title menu and string catalog

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

* Test replayed browser input requests a stream capture

* Keep the stream render host visible to WebKit: on-screen floating window, input-replay dirty, event-driven scroll beacon

The persistent render host window sat at (-100000,-100000); AppKit reports a
window with no on-screen portion as fully occluded, and WebKit suspends
requestAnimationFrame and degrades trusted-event hit testing for occluded
hosts. The rAF-throttled dirty beacon therefore never fired during a scroll
gesture (one frame per gesture, captured after gesture end) and replayed taps
intermittently hit a stale tree and never navigated.

Host window now anchors on-screen (bottom-trailing, >=64pt visible, .floating
so ordinary windows cannot occlude it) while staying imperceptible (1% alpha,
click-through, non-activating). Hardening: every replayed input batch marks
the session dirty directly, and the beacon posts scroll/wheel dirt from the
event listener with a 16ms throttle instead of waiting for a rAF tick.

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

* Bind the browser-stream keyboard button to real keyboard visibility

The button showed the input proxy's focus intent, so a keyboard raised by the
address field or a dialog's text field left it stuck on 'Show Keyboard'.
The glyph now binds to MobileKeyboardVisibilityObserver (UIKit keyboard
notifications); tapping while the keyboard is up resigns whichever responder
raised it (shared dismissMobileKeyboard, moved to CmuxMobileSupport) and
releases the proxy's focus reasons via the policy's new explicit hide, which
never flips into a focus request the way toggling would.

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

* Give dialog text fields a visible input well

The dialog card is glass, so the fields' glass background vanished into it and
prompt/basic-auth inputs read as labels. Fields now sit in a filled rounded
well with a hairline border, the same fill language as the bottom bar's
address field.

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

---------

Co-authored-by: cmux reload-cloud <[email protected]>
Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-07-30 16:13:11 -05:00
Austin Wang d55a482758 Replay sidebar clicks after reveal actions return (#9225)
* Add regression test for reveal-time sidebar click

* Replay sidebar clicks after reveal actions return
2026-07-30 13:52:47 -07:00
Abdulaziz Albahar 26402973ea Shrink Iroh pairing QR payload (#9174)
* Test endpoint-only Iroh pairing QR

* Use endpoint-only Iroh pairing QR
2026-07-30 15:16:21 -05:00
Lawrence Chen 6d899d08f0 Remove TUI install path notes (#9219) 2026-07-30 02:33:31 -07:00
Austin Wang 57d7d5f46d Pin GhosttyKit checksum for theme picker fix (#9218) 2026-07-30 01:51:29 -07:00
Austin Wang 669bbf1a4a Fix hanging cmux theme picker (#9207) (#9211)
Bump the ghostty submodule to pick up the theme picker fix, and stop the regression test from misreporting the failure. The picker never rendered a frame, so the test never sent Enter, yet it reported that the picker did not exit after Enter.
2026-07-30 01:34:20 -07:00
Austin Wang 1381129aa4 Fix detached transfer test fixture (#9216) 2026-07-30 00:44:59 -07:00
Austin Wang 6ab4595552 Fix remote PTY lifecycle test fake (#9214) 2026-07-30 00:31:18 -07:00
Lawrence Chen a847654314 Add cmux TUI product and docs pages (#9049)
* Add cmux TUI product and docs pages

* Use real macOS capture for cmux TUI

* Match TUI page to home design

* Add native TUI installers and Hunk capture

* Move TUI installer into hero

* Highlight TUI install commands

* Compact TUI install controls

* Harden TUI installer delivery
2026-07-30 00:31:15 -07:00
Austin Wang 35fd5a2983 Fix SSH retry script compiler timeout (#9213) 2026-07-30 00:25:25 -07:00
Austin Wang 1fe65ebfe0 Fix Ghostty Zig workflow guard false positives (#9209)
* test: cover Ghostty Zig workflow execution guard

* Fix Ghostty Zig workflow execution guard
2026-07-30 00:25:01 -07:00
Austin Wang bddbd4934e Unify local resume launcher scripts (#9200) (#9205)
* Add failing resume launcher regression tests (#9200)

* Unify local resume launcher scripts (#9200)

* Address resume launcher review findings

* Address PR review feedback

* Handle inaccessible resume cwd ancestors (#9200)

* Preserve remote resume working directories (#9200)

* Update resume launcher regression expectations (#9200)

* Preserve remote resume command cwd (#9200)

* Add resume cwd consistency regressions (#9200)

* Keep resume cwd delivery consistent (#9200)

* Add resume wrapper review regressions (#9200)

* Avoid nested login startup in resume wrappers (#9200)
2026-07-29 23:34:55 -07:00
Austin Wangandcmux reload-cloud 51b4ba8cd8 Fix resumed Codex Teams subagent pane backfill (#9180)
* test: cover resumed Codex subagent pane backfill

* fix: open resumed Codex subagent panes

* test: deduplicate Codex resume fixture tracking

* fix: harden Codex Teams watcher diagnostics

* test: pin Codex watcher diagnostic locale

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-29 21:57:41 -07:00
Austin Wangandcmux reload-cloud af519dd19b Bound overlapping agent hibernation evaluations (#9113)
* test: bound agent hibernation evaluations

* fix: serialize agent hibernation evaluations

* test: harden hibernation evaluation scheduling coverage

* docs: clarify hibernation gate recheck

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-29 21:22:36 -07:00
Austin Wang 4bf73202fa fix: preserve claude-teams tmux routing (#9033)
* test: preserve claude teams tmux launch context

* fix: preserve claude teams tmux routing

* fix: scope claude teams tmux routing

* fix: harden claude teams launch routing

* fix: close tmux compat review gaps

* fix: require inherited tmux launch identity

* fix: validate managed launcher context

* fix: preserve non-launch management commands

* fix: cover managed launcher aliases

* fix: validate remote managed launch context

* test: cover managed teams launch invariants

* fix: keep managed teams shims authoritative

* fix: preserve managed launcher compatibility

* fix: harden managed launch classification

* fix: reject ambiguous Claude debug filters

* fix: require context for session hosts

* fix: keep managed child identity coherent

* test: migrate OMO plugin without a session

* fix: preserve non-launch command compatibility

* fix: align managed launch policy ownership

* test: cover moved teams launch identity

* fix: honor shell snapshot argument contract

* fix: preserve managed launcher operator commands

* fix: preserve managed launcher shell contracts

* fix: close managed launcher review gaps

* test: keep focused cmux sockets below AF_UNIX limits

* fix: require launch context for ultrareview

* fix: preserve managed launcher compatibility

* fix: preserve Claude passthrough arguments

* fix: preserve managed provider passthrough

* test: cover managed launcher operator commands

* fix: preserve managed launcher team operators

* test: cover nested Codex Teams help

* fix: pass nested Codex Teams help through

* test: cover Claude Teams shell wrapper reentry

* fix: harden managed Teams launch identity

* test: consolidate managed Teams regressions

* test: cover managed provider administrative help

* fix: preserve managed provider administrative help

* test: cover Claude forward subagent text flag

* fix: recognize Claude forward subagent text flag

* test: cover OMO subcommand global options

* fix: preserve OMO subcommand global options

* test: keep Claude import surface-bound

* fix: require surface context for Claude import

* test: cover Codex Teams help subcommand

* fix: pass Codex Teams help through

* fix: preserve managed wrapper root help

* fix: apply retry binding predicate to both phases

* test: handle teammate column equalization

* test: model managed tmux focus changes

* test: expect tmux-compatible pane IDs

* fix: capture RPC session actor immutably
2026-07-29 21:22:05 -07:00
Austin Wang 8d6221348b Fix large paste truncation under transient backpressure (#9093)
* Fix large paste truncation under backpressure

* Fix Ghostty backpressure portability

* Pin GhosttyKit for backpressure fix

* Pin merged GhosttyKit archive
2026-07-29 21:11:53 -07:00
Austin Wangandcmux reload-cloud f003c63bae Prevent recursive deferred-action release chains (#9179)
* Add guard for stored DispatchWorkItem replacement chains

* Replace stored work items with deferred action scheduler

* Address deferred scheduler review findings

* Harden stored work item ownership guard

* Test deferred scheduler state transitions

* Cover deferred guard edge cases

* Preserve newest reentrant browser refresh

* Keep file explorer deinit actor-safe

* Bridge file explorer scheduler to main actor

* Enforce file explorer main actor ownership

* Close deferred scheduler review gaps

* Close deferred scheduler performance review

* Move deferred schedulers into CmuxFoundation

* Align deferred scheduler package APIs

* Add package tests for deferred schedulers

* Document deferred-action audit ownership

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-29 21:08:54 -07:00
Austin Wang 2229eb2028 Fix LiveSetting DynamicProperty executor crash (#9124)
* test(settings): cover LiveSetting isolation boundary

* fix(settings): remove DynamicProperty executor thunk

* refactor(settings): use signal-driven read lifetime

* test(settings): exercise DynamicProperty witness
2026-07-29 20:52:20 -07:00
Austin Wang 8b087a981b Fix Codex resume notification rebinding (#9185)
* test: cover Codex resume notification rebinding

* fix: preserve notifications across Codex resume
2026-07-29 19:55:35 -07:00
Austin Wangandcmux reload-cloud 0e80d0895d Fix stale SSH workspace connection status (#9085)
* test: cover authoritative SSH terminal liveness

* fix: derive SSH status from terminal liveness

* fix: close SSH terminal lifecycle races

* fix: authenticate SSH terminal readiness

* fix: bind SSH liveness to terminal authority

* fix: make Dock SSH readiness transactional

* test: assert remote terminal end acceptance

* test: reject retired PTY lifecycle readiness

* fix: revalidate PTY lifecycle at readiness commit

* test: cover remote readiness lifecycle races

* fix: harden remote terminal lifecycle ownership

* test: cover stale remote terminal generations

* fix: authenticate remote terminal lifecycle callbacks

* fix: bound remote lifecycle commit side effects

* chore: document remote lifecycle ownership boundaries

* test: cover reordered remote readiness callbacks

* fix: order remote terminal lifecycle callbacks

* test: cover remaining SSH lifecycle ordering gaps

* fix: close remaining SSH lifecycle ordering gaps

* test: cover lossy SSH liveness reconciliation

* fix: make SSH liveness reconciliation resilient

* test: cover remaining SSH liveness races

* fix: close remaining SSH liveness races

* test: cover Mosh and transient SSH readiness

* fix: make terminal readiness authoritative

* test: cover premature terminal readiness

* fix: require proven terminal readiness

* test: pass Dock readiness attempt generation

* test: cover remote lifecycle review regressions

* fix: preserve remote lifecycle routing

* fix: retire orphaned remote lifecycles

* test: cover raw SSH readiness gating

* fix: decouple raw SSH readiness reporting

* test: cover restored SSH lifecycle reporting

* fix: restore SSH lifecycle authority

* test: cover SSH lifecycle review regressions

* fix: close SSH lifecycle review gaps

* test: cover bounded SSH readiness lifecycle

* fix: bound persistent SSH readiness retries

* test: expose queued SSH readiness duplicates

* fix: coalesce persistent SSH readiness delivery

* fix: compile remote lifecycle app adapters

* fix: satisfy ssh readiness closeout policy

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-29 19:42:38 -07:00
Austin Wangandcmux reload-cloud c269269404 Fix OMP hook binding from live PID/TTY identity (#9091)
* test: expose OMP hook PID/TTY binding drift

* test: make OMP binding regression runnable

* fix: bind OMP hooks from live controlling TTY

* fix: harden OMP session reconciliation

* fix: expose shared hook binding helpers

* fix: negotiate and bound OMP hook delivery

* fix: isolate OMP binding and drain hook shutdown

* fix: preserve hook routing boundaries

* fix: make OMP cleanup recoverable

* fix: demote all superseded OMP claims

* fix: harden OMP hook lifecycle coverage

* Retry all superseded OMP cleanup records

* Bound OMP cleanup retries and preserve Stop hooks

* Harden superseded OMP cleanup ownership

* Preserve agent runtime across Dock ownership

* Test Dock agent session ownership gaps

* Test Dock binding-only lifecycle transfer

* Test Dock retry ownership across bindings

* Test Dock resume cwd binding ownership

* Test authoritative Dock binding clears

* Test Dock session and directory provenance

* Test completed Dock tombstone after binding clear

* Test managed Dock hook identity across tmux replacement

* Fix managed Dock hook identity across tmux replacement

* fix: disambiguate restorable agent selection

* fix: return workspace resume binding

* Split agent hook process binding types

* fix: preserve managed identity after retry revert

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-29 19:38:39 -07:00
Austin Wangandcmux reload-cloud 61c4f1077e Fix subprocess pipe descriptor leaks (#9187)
* Add subprocess pipe lifecycle regression tests (#9175)

* Fix subprocess pipe descriptor lifecycle (#9175)

* Harden subprocess lifecycle regression coverage

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-29 19:36:33 -07:00
Austin Wang 65b211ce28 Fix workspace group anchor numbering (#9176)
* test: cover workspace group numbered selection

* fix: align workspace numbering with sidebar rows

* perf: build workspace number index in one pass

* fix: keep workspace numbering outside view builder

* test: exercise numbered selection in key window
2026-07-29 19:35:38 -07:00
Austin Wangandcmux reload-cloud 92dda7db35 Chain SSH config RemoteCommand into cmux interactive sessions (#9114)
* Add failing SSH RemoteCommand chaining tests

* Chain SSH config RemoteCommand into interactive sessions

* Preserve SSH RemoteCommand across workspace restore

* Bound SSH config resolution and sanitize RemoteCommand

* Preserve RemoteCommand intent for fallback restores

* Reuse resolved SSH executable across startup

* fix(ssh): pin managed hops to system OpenSSH

* test(ssh): use Swift Testing for remote command regressions

* test: cover SSH config fallback and resume chaining

* fix: keep managed SSH launch and resume resilient

* test: cover SSH resume and config fallback precedence

* fix: define SSH command precedence during recovery

* test: cover authoritative SSH and Mosh fallbacks

* test: inspect generated SSH fallback artifact

* fix: preserve authoritative SSH and Mosh command fallbacks

* test: cover Mosh config-resolution fallback

* fix: retain SSH fallback when Mosh config is unavailable

* test: cover RemoteCommand token expansion

* test: cover raw RemoteCommand token output

* test: instantiate RemoteCommand policy fixture

* test: drop synthetic raw SSH config fixture

* docs: record SSH config token expansion contract

* test: cover SSH and Mosh fallback consistency

* fix: align SSH fallback transport and command intent

* test: execute restored RemoteCommand through SSH quoting

* fix: preserve RemoteCommand quoting across SSH restore

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-29 19:25:14 -07:00
Austin Wang 6e4093378c Retry restored SSH after boot-time network failure (#9083)
* test: reproduce restored SSH boot retry failure

* fix: retry initial SSH foreground authentication

* fix: classify retryable SSH authentication failures

* fix: preserve terminal SSH authentication errors

* test: cover bounded SSH auth diagnostics

* fix: bound SSH authentication diagnostics

* test: cover SSH auth retry phases

* fix: preserve established SSH auth retries

* test: cover SSH classifier edge cases

* fix: bound SSH diagnostic classification

* fix: preserve interactive SSH authentication

* fix: harden SSH authentication retry lifecycle

* test: cover signals during SSH retry backoff

* fix: make SSH retry backoff interruptible

* test: cover SSH PTY input during retry

* fix: preserve terminal input across SSH backoff

* test: cover SSH authentication process cleanup

* fix: terminate SSH authentication process trees

* test: require SSH authentication cleanup escalation

* fix: escalate SSH authentication process cleanup

* test: cover SSH startup signal during auth backoff

* fix: harden SSH retry signal cleanup

* test: exercise persistent SSH foreground auth lifecycle

* fix: support Sonoma SSH authentication capture

* test: bound SSH authentication process waits

* test: cover SSH classifier diagnostic overlap

* fix: refine SSH transport classification

* test: cover SSH server-alive timeout retry

* fix: retry SSH server-alive timeouts

* test: cover standard OpenSSH transport exits

* fix: classify standard OpenSSH transport exits

* test: align SSH retry coverage with project policy

* test: fail closed on unclassified SSH retries

* fix: fail closed on unclassified SSH retries

* test: cover SSH permission warnings and replacement cleanup

* fix: harden SSH diagnostics and process cleanup

* test: bound and anchor SSH authentication cleanup

* fix: bound and anchor SSH authentication cleanup

* test: ignore inherited SSH signal state

* fix: clear inherited SSH signal state
2026-07-29 19:23:48 -07:00
69efba1489 Reclaim hidden Ghostty renderer memory (#8998)
* Add five-tab renderer memory regression test

* Reclaim hidden terminal renderers by default

* Pin shared Metal pipeline Ghostty build

* Pin final Ghostty memory build

* Pin competitive Ghostty memory build

* Test renderer reclamation catalog defaults

* Use catalog renderer reclamation defaults

* test: require atomic first renderer presentation

* fix: make first renderer presentation atomic

* fix: resolve renderer defaults through catalog

* Exercise renderer defaults through UserDefaults

* Pin forced renderer rebuild Ghostty head

* Pin forced rebuild GhosttyKit checksum

* Test forced renderer rebuild presentation

* Preserve forced renderer rebuild presentation

* Make renderer defaults regression test throwable

* Pin merged Ghostty renderer reclamation head

* Pin final GhosttyKit checksum

* Pin reviewed Ghostty renderer retry fix

* Pin reviewed Ghostty shader cache follow-up

* Add red test for Ghostty Zig version drift

* Derive Zig version from pinned Ghostty

* Run Ghostty Zig version drift test in CI

* Test all Ghostty Zig workflow consumers

* Synchronize Ghostty Zig workflows

* Test Ghostty Zig helper as TestFlight input

* Track Ghostty Zig helper in TestFlight inputs

* Pin Ghostty shader failure backoff

* Pin Ghostty shader attempt backoff

* test: require renderer reclaim deadline scheduling

* test: initialize linked Ghostty runtime

* fix: schedule renderer reclaim at idle deadlines

* test: retain synthetic Ghostty argv

* fix: coalesce renderer visibility evaluation

* test: retain Ghostty runtime argv

* fix: wire renderer visibility coalescing

* Pin integrated Ghostty mailbox fix

* refactor: inject renderer reclaim scheduler inputs

* test: exercise renderer reclaim scheduler lifecycle

* fix: bound renderer visibility scheduling

* test: look up linked Ghostty runtime dynamically

* Validate per-consumer Ghostty Zig wiring

* test: require fail-closed Ghostty Zig workflows

* fix: fail closed on Ghostty Zig resolution

* fix: make renderer scheduling verification deterministic

* test: coalesce staggered renderer reclaim deadlines

* fix: coalesce renderer reclaim deadlines

* Update Ghostty renderer retry artifact

* test: measure five-tab renderer memory

* test: cover compatible Zig patch releases

* fix: accept compatible Zig patch releases

* refactor: separate renderer realization surface seam

---------

Co-authored-by: Austin Wang <[email protected]>
Co-authored-by: austinpower1258 <[email protected]>
2026-07-29 19:23:22 -07:00
Abdulaziz AlbaharandClaude Fable 5 8f17afcc1a iOS: mount the connection-status toast presenter at the shell root (#9159)
* iOS: regression test for reconnect toast firing on view remount

Extract the reconnect-toast decision into MobileReconnectedToastGate,
faithfully preserving the current WorkspaceShellView semantics (toast on
any observed .connected once a connection has been held), and add tests
for the intended behavior: toast only on a genuine disconnected ->
connected transport transition. viewRemountRefireDoesNotToast fails on
this commit because the current semantics cannot distinguish a genuine
reconnect from SwiftUI re-firing onChange(initial: true) when the
observing tab content remounts. Also add CmuxMobileShellModel to the CI
Swift package test list (its suite resolves and passes standalone).

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

* iOS: only toast Reconnected on genuine transport reconnects

Switching Notifications -> Workspaces showed "Reconnected to your Mac."
with the connection never dropping. The toast decision lived in an
onChange(of: store.connectionState, initial: true) inside the workspaces
tab's content, while its hasHeldConnection guard lived on the shell view:
every return to the tab remounts the content and re-fires the initial
onChange with the guard already primed, so a plain tab switch while
connected re-toasted.

MobileReconnectedToastGate now also requires a genuine disconnected ->
connected edge (previous != current), so the synthetic equal-value edge
from initial/remount calls can never toast, and the presenter is mounted
once at the always-mounted shell root (MobileReconnectedToastPresenter),
so transitions observed while the user sits on another tab still toast.

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

* iOS: UI test pins connection toasts on the Notifications tab

Covers the presenter mount-point regression: the mock host dies and is
revived on a fixed port while the Notifications tab is selected, and the
status capsule plus the "Reconnected to your Mac." toast must present
there. With the presenter mounted inside the workspaces tab (as before
this PR), it is out of the hierarchy on that tab and neither presents.
MobileSyncMockHostServer gains an optional fixed port with local
endpoint reuse so a revived listener can rebind the paired address.

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

* iOS: read the reconnect toast through the combined MobileToast label

ToastCardView combines its children into one accessibility element, so
the success message never appears as a descendant static text; wait on
the MobileToast element's label instead.

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

* iOS: harden the Notifications-tab toast test queries

Match the capsule by identifier across any element type (the failure
variant combines an action Button, so the combined element's type is not
stable), lengthen the loss-detection wait, and dump the accessibility
tree into the log when either wait times out.

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

* iOS: pair the toast lifecycle test manually so the dead Mac stays visible

The loopback debug attach drops the only visible Mac with its
workspaces when the host dies, and the workspace-list policy then
deliberately reads connected (no visible reconnect target), so no
capsule can present regardless of the presenter mount. Manual pairing
persists the Mac, keeping it visible through the outage like a real
pairing, which is the scenario the presenter serves. The revived host
keeps serving attach tickets for the redial's re-mint.

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

* iOS: open the pairing sheet from the toolbar when it is not auto-presented

The no-computers shell lands on the empty state without presenting the
Add Computer sheet, so the manual-pairing helper taps
MobileShowAddDeviceToolbarButton before waiting for the form.

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

* iOS: drop the Notifications-tab toast UI test; coverage moves to a follow-up

Four dispatch iterations showed the mock-host harness cannot produce a
visible connection-status transition deterministically: the loopback
debug attach drops the only visible Mac (the list policy then
deliberately reads connected), and even with persisted manual pairing
the recovery layer keeps the visible status untouched for the whole
test window, so no capsule presents regardless of the presenter mount
point. The mount fix stays verified by the tagged-build simulator runs
recorded on the PR; behavior-level coverage needs a harness that can
drive the recovery phases and is tracked in a follow-up issue.

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

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-29 21:22:17 -05:00
Abdulaziz Albahar ebd7638850 Keep iOS control connections warm across paired Macs (#8931)
* Keep online Mac control connections warm

* Test warm multi-Mac role changes

* Show live Mac pool roles in iOS settings

* Test retaining prior Mac during full switch

* Retain control connection during full Mac switch

* Test coalescing multi-Mac retry backoff

* Coalesce control pool retry outages

* Fix multi-Mac pool lifecycle and focus boundaries

* Make multi-Mac focus handoffs transactional

* Keep pool membership and focus state scoped

* Scope pool retry ownership by Mac

* Enforce pool scope at focus handoff

* Fence control streams during focus promotion

* Validate control stream ownership and acknowledgements

* Fence initial control activation and anonymous adoption

* Bound pool retries and promotion freshness

* Fence pool promotion and retry ownership

* Repair recreated control subscriptions

* Close promotion and catch-up failure windows

* Fence control pool freshness races

* Sequence pool routes and focus repair

* Bound control pool and repair Stack auth

* Restore promoted stream recovery

* Fence cancelled Mac handoffs

* Close control pool lifecycle races

* Fence staged focus ownership

* Preserve legacy aggregate fallback

* Serialize pooled Mac refresh ownership

* Bound promotion and presence recovery

* Classify pooled refresh failures

* Fence pooled teardown and capacity

* Bound control refresh and role metadata

* Fence multi-Mac workspace freshness

* Close multi-Mac handoff races

* Fence pooled actor handoffs

* Fence terminal role transitions

* Preserve Mac authority across role changes

* Bound per-Mac presence reconciliation

* Drain coalesced route sync before aggregation

* Separate state-sync and legacy refresh events

* Serialize connection establishment and transport drain

* Drain abandoned connects across role fallback

* Retain same-peer reservations through full drain

* Hide retired clients behind drain reservations

* Close remaining multi-Mac transport ownership gaps

* Bound multi-Mac retry ownership and presence authority

* Track physical route cleanup debt explicitly

* Isolate peer cleanup and drain authority replacements

* Fail timed-out handoffs without cancelling cleanup

* Reconcile pooled retry and transport role races

* Bound global cleanup and control pool admission

* Make promotion and transport cleanup atomic

* Unify physical transport cleanup ownership

* Track cancellation close under route cleanup

* Join teardown and retry cleanup-blocked controls

* Reuse reserved Mac drains and validate identity first

* Test anonymous same-route foreground repair

* Sequence same-route foreground replacement

* Test manual same-route ticket reprobe

* Release same-route focus before ticket probe

* Test autoreview connection liveness findings

* Close autoreview connection liveness gaps

* Bound cleanup registration and feed catch-up

* Test targeted offline alias reconciliation

* Reconcile presence across physical Mac aliases

* Test physical alias ownership handoffs

* Handoff physical alias control ownership

* Align cleanup tests with physical route ownership

* Satisfy multimac autoreview findings

* Finish multimac policy cleanup

* Resolve final multimac review findings

* Exclude focused Mac physical aliases

* Preserve foreground during ticket probe failure

* Retire discarded focus before teardown

* Bound notification feed refresh retries

* Harden multi-Mac keepalive and alias recovery

* Bound feed recovery and canonicalize cleanup peers

* Preserve feed cooldown and normalize URL ports

* Retire stale physical Mac alias snapshots

* Prune deleted Mac aggregate snapshots

* Preserve pool state across store load failures

* Retry all transient paired store reads

* Canonicalize legacy IPv4 route aliases

* Preserve pooled Macs across authority read failures
2026-07-29 20:39:06 -05:00
Abdulaziz AlbaharandClaude Fable 5 d4a7bb27c5 Fix 9071 review P1s: adopt legacy device id on in-place upgrade, total-order challenge mints (#9196)
Two verified findings from the structured review of 1472990921 (#9071):

1. Upgrade-path device identity rotation (iOS). resolveDurableDeviceID's
   .absent branch deleted the legacy UserDefaults device-id mirror and minted
   a fresh id. On an in-place upgrade from a pre-Keychain build the mirror IS
   the id of the phone's active iroh binding and the endpoint identity
   survives the upgrade, so registration targeted a new (user, device, tag)
   slot while the endpoint still owned the old one -> endpoint_already_bound,
   iroh disabled for every upgrading install. The mirror could not be adopted
   blindly because encrypted backups restore UserDefaults onto different
   hardware. Disambiguate with ThisDeviceOnly evidence: the iroh
   endpoint-identity Keychain item (kSecAttrAccessibleAfterFirstUnlock-
   ThisDeviceOnly, non-synchronizable) cannot cross hardware, so its presence
   proves same-device continuation -> adopt the mirror via createOrAdopt;
   absence -> mint as before; unreadable (locked) -> fail closed and defer,
   mirroring the store's own .unavailable behavior. New SameDeviceEvidence
   probe + full matrix tests.

2. Challenge ordering tie (web). The register gate rejects only strictly-older
   challenges (createdAt < registeredAt) and createdAt is a millisecond wall
   clock, so serialized mints could tie; a delayed older twin then passed the
   gate and could clobber newer state. issueChallenge now assigns each
   challenge a createdAt strictly above the slot's latest prior challenge
   (mints serialize under the per-user advisory lock), making the strict gate
   exact. Regression test covers the equal-millisecond reversal.

Verification: CmuxMobileShell DeviceRegistry suites 41/41 (full suite has 2
pre-existing failures on main, unrelated: terminalReplay/staleReplay); web
typecheck clean; iroh-route-handler + trust-broker suites 41/41; the new db
regression test runs under CMUX_DB_TEST=1 in CI.

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-29 20:33:09 -05:00
Abdulaziz AlbaharandClaude Fable 5 975c332ffa iOS: keep Mac discovery alive through onboarding so the connect page is ready on arrival (#9163)
* iOS: keep Mac discovery alive through onboarding so the connect page is ready on arrival

During first-run onboarding, automatic same-account Mac discovery ran once at
an auth edge (the root startup one-shot) and then not again until the final
connect page appeared, so the page opened into a fresh multi-second search.

Add OnboardingMacDiscoveryKeepAlive, owned by CMUXMobileRootView as @State:
while onboarding's pre-connect pages are visible and the user is Stack-
authenticated but unconnected, it re-runs the full stored-Mac reconnect pass
(backup refresh + registry + zero-touch discovery + dial) with a growing
delay (4s up to 15s). Attempts claim the shared
MobileStartupConnectionCoordinator, so they serialize with the startup
one-shot and injected-attach launches, and they use
reconnectActiveMacIfAvailable so automatic iroh backoff is respected.

Lifecycle: hard-cancels on sign-out or account/team change (and restarts
under the new scope), gracefully stops re-arming without killing an in-flight
dial when the connect page takes over, the app connects, or the app
backgrounds. The loop also pulls a live eligibility check before every
attempt and re-arm, so a dropped SwiftUI onChange push can never leave it
searching after the page took over.

Sim-verified: keep-alive connects a Mac that comes online mid-tour ~12s
later, and the connect page renders "Your Mac is connected" in under 0.5s
of arrival instead of starting a fresh search.

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

* Re-key onboarding discovery when the user ID changes without an auth edge

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

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-29 20:22:52 -05:00
Abdulaziz Albahar b2734c72b1 Fix CmuxMobileShellUI build: missing Bool return in verified-replay cancellation guard (#9195) 2026-07-29 20:12:18 -05:00
Abdulaziz Albahar 37c267ad16 Fix per-keystroke render-grid replay loop on iOS, instrument sync latency (#9146)
* Add iOS↔Mac sync latency tracing, probe, and analyzer

* Auto-navigate latency probe to first workspace (DEBUG)

* Add failing replay continuity and ack recency tests

* Preserve replay continuity and skip fresh ack resubscribe

* Address review: gate trace writes, fail-closed continuity, queued-input stamp

* Make latency stamps settle correctly and join by surface identity

* Address review: recovery retry, bounded trace writer, stamp identity fixes

* Address review: retry catch-up replay, bounded host trace writer, visible drops

* Address review: end-to-end bounded host sink, lazy trace tokens, FIFO wire joins
2026-07-30 00:59:45 +00:00
Abdulaziz AlbaharandClaude Fable 5 960dfe8a26 docs: make second-model review opt-in, not a handoff default (#9194)
CLAUDE.md told every agent to launch a background $autoreview loop at
handoff, which made routine PRs run multi-round review loops and slow
sessions badly. Review agents are now explicit user opt-in; agents let
required checks and the automatic review bots run asynchronously and
address only concrete failures and actionable findings before merge.

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-29 19:56:48 -05:00
Austin Wangandcmux reload-cloud 77c3da8e58 Make Cmd+Shift+T reopen the last closed item (#9132)
* test: cover Cmd-Shift-T closed window restore

* fix: reopen the last closed item with Cmd-Shift-T

* fix: keep legacy reopen binding consistent in Settings

* Preserve legacy shortcut unbindings

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-29 17:41:23 -07:00
oscarbrey 406c28bcab Fix Debug-build crash on macOS 26.5: non-finite event coordinates trap in sidebar divider diagnostics (#9156)
* Guard debug divider-routing log against non-finite event coordinates

The DEBUG-only left-mouse-down diagnostics monitor in
SidebarDividerTrackingView formats the event's window x coordinate with
Int(_:), which traps when the coordinate is NaN or infinite. On macOS
26.5 AppKit can deliver such events, so any Debug build crashes with
'Double value cannot be converted to Int because it is either infinite
or NaN' the moment one arrives (EXC_BREAKPOINT in
installDiagnosticsIfNeeded, observed reproducibly on macOS 26.5.2).
Render non-finite coordinates as 'non-finite' instead of trapping.

No regression test: the trap lives in a DEBUG-only NSEvent local-monitor
closure with no runtime seam to inject a synthetic NSEvent carrying a
NaN location; per the test-quality policy this ships without a fake
source-shape test.

* Format the divider-routing coordinate without any trapping conversion

Review follow-up: isFinite still lets a finite value beyond Int's range
trap. %.0f formats any Double safely.
2026-07-29 17:39:49 -07:00
Lawrence Chen f5f5d942f5 Fix TUI pointer motion during layout updates (#8709)
* fix(tui): gate input on surface attach readiness

* test(tui): cover redraw and config routing barriers

* fix(tui): render before replaying routed input

* refactor(tui): generalize event loop backend

* test(tui): cover deferred pointer ordering

* fix(tui): preserve deferred input order

* test(tui): cover cell pixel pointer barrier

* fix(tui): route cell pixel geometry updates

* test(tui): cover routing state boundaries

* fix(tui): separate pointer routes from destination intent

* test(tui): cover paint pointer route barrier

* fix(tui): guard pointer routes during paint

* test(tui): cover retained input ordering

* fix(tui): preserve pointer input across render barriers

* test(tui): cover replay requeue ordering

* fix(tui): preserve replay order across requeue

* test(tui): cover pointer frame and focus barriers

* fix(tui): bind deferred pointer input to rendered frames

* test(tui): cover trusted pointer frame barriers

* fix(tui): close trusted pointer frame gaps

* test(tui): cover merged pointer session lifecycle

* fix(tui): reset pointer frame state across sessions

* test(tui): expose browser release lifecycle gaps

* fix(tui): preserve browser release across session retirement

* test(tui): expose remaining pointer routing gaps

* fix(tui): close remaining pointer routing gaps

* test(tui): expose pointer semantic ownership gaps

* fix(tui): preserve pointer routing semantics

* test(tui): expose immediate pointer semantic race

* fix(tui): admit pointer input against terminal semantics

* test(tui): expose final pointer admission gaps

* fix(tui): close final pointer admission races

* test(tui): expose remaining deferred input races

* fix(tui): preserve deferred input identities

* test(tui): expose remaining pointer admission gaps

* fix(tui): stabilize pointer admission owners

* test(tui): expose replay lifecycle regressions

* fix(tui): close replay lifecycle gaps

* test(tui): reject stale pointer geometry

* fix(tui): guard and inline pointer encoding

* test(tui): expose pointer capture ownership gaps

* fix(tui): complete pointer capture ownership

* test(tui): reject replay across pane repaints

* fix(tui): bind pointer replay to rendered content

* test(tui): expose remaining pointer ordering races

* fix(tui): guard terminal pointer ordering

* fix(tui): gate browser input on presentation

* test(tui): expose stale presentation identities

* fix(tui): bind menus and graphics to presentation

* test(tui): expose UTF-8 mouse mode collision

* fix(tui): make mouse mode scan UTF-8 aware

* test(tui): expose graphics writer failure wedge

* fix(tui): settle failed graphics presentations

* test(tui): expose cross-session graphics collision

* fix(tui): scope graphics to machine sessions

* test(tui): expose browser pointer admission gaps

* fix(tui): guard browser pointer dispatch

* test(tui): expose remaining pointer ownership gaps

* fix(tui): preserve scoped pointer ownership

* test(tui): preserve browser drag ownership across frames

* fix(tui): preserve browser pointer captures

* test(tui): expose pointer lifecycle gaps

* fix(tui): close pointer lifecycle gaps

* test(tui): expose browser frame admission leak

* fix(tui): propagate browser pointer admission

* test(tui): expose pointer admission compatibility gaps

* fix(tui): close pointer admission compatibility gaps

* test(tui): expose reviewed pointer authority gaps

* test(tui): target guarded browser mouse shape

* test(tui): expose diagnostic pointer authority gap

* fix(tui): preserve pointer authority across boundaries

* test(tui): expose pointer barrier ownership races

* test(tui): expose ambiguous navigation rollback

* fix(tui): preserve pointer barriers across CDP races

* test(tui): expose frame epoch recovery gaps

* fix(tui): recover frame epoch transitions

* test(tui): expose pointer lifecycle ordering gaps

* fix(tui): reconcile pointer lifecycle epochs

* test(tui): expose pointer transition failure gaps

* fix(tui): settle failed pointer transitions

* test(tui): cover unresolved pointer authority transitions

* fix(tui): keep pointer authority fail-closed

* test(tui): cover download and mouse probe regressions

* fix(tui): settle downloads and gate mouse probes

* test(tui): cover document paint authority gaps

* test(tui): reject delayed pre-restart browser frames

* fix(tui): bind pointer authority to painted documents

* test(tui): advertise guarded pointer fixture capability

* test(tui): cover pointer authority recovery gaps

* fix(tui): recover guarded browser frame authority

* test(tui): cover remote screencast recovery bounds

* fix(tui): bound remote screencast recovery

* test(tui): cover bounded pointer recovery gaps

* fix(tui): bound pointer authority recovery

* test(tui): cover unresolved navigation authority gaps

* fix(tui): reconcile unresolved browser navigation

* test(tui): cover end-to-end pointer authority gaps

* test(tui): stop rejected frame recovery at ingress

* fix(tui): enforce end-to-end pointer authority

* test(tui): preserve guarded pointer wire compatibility

* fix(tui): negotiate guarded browser pointer attach

* test(tui): cover final pointer authority races

* fix(tui): close pointer authority races

* test(tui): cover remaining pointer authority gaps

* fix(tui): separate pointer authority from graphics processing

* test(tui): cover pointer worker lifecycle gaps

* fix(tui): close pointer worker lifecycle gaps

* test(tui): cover final pointer worker gaps

* fix(tui): eliminate idle pointer polling

* test(tui): cover pointer ownership invalidation

* fix(tui): preserve browser release ownership

* test(tui): cover remaining pointer ordering gaps

* fix(tui): close final pointer ordering gaps

* test(tui): cover graphics fence recovery

* fix(tui): recover graphics fence timeouts

* test(tui): cover final graphics lifecycle gaps

* fix(tui): bound graphics recovery lifecycle

* test(tui): cover pointer lifecycle liveness

* fix(tui): bound browser pointer ownership

* test(tui): cover final pointer recovery gaps

* fix(tui): close negotiated pointer lifecycles

* test(tui): cover same-document capture exhaustion

* fix(tui): surface capture recovery failures

* test(tui): cover terminal resize and hover recovery

* fix(tui): settle terminal pointer recovery

* test(tui): cover final pointer review findings

* fix(tui): close final pointer review findings

* test(tui): cover final navigation review findings

* fix(tui): preserve browser recovery authority

* test(tui): cover loaderless snapshot failure

* fix(tui): settle loaderless snapshot failures

* test(tui): cover rejected browser authority races

* fix(tui): reject stale browser authority

* test(tui): cover bootstrap snapshot invalidation

* fix(tui): retry invalidated bootstrap snapshots

* test(tui): reject unproven timestampless pixels

* fix(tui): verify each timestampless browser frame

* test(tui): cover stale capture suppression races

* fix(tui): scope browser capture suppression

* test(tui): release superseded capture reservations

* fix(tui): release superseded capture ownership

* test(tui): reject stale cleanup releases

* fix(tui): discard stale cleanup releases

* test(tui): hide stale attach pointer tokens

* fix(tui): mask stale attach pointer tokens

* test(tui): reject legacy guarded browser attach

* fix(tui): require scoped browser attach owner

* test(tui): retain timed-out image deletions

* test(tui): clean images after fence exhaustion

* fix(tui): preserve graphics cleanup after timeout

* test(tui): bound timestampless frame recovery

* fix(tui): throttle timestampless frame recovery

* test(tui): cover graphics backpressure cleanup

* fix(tui): bound graphics output ownership

* test(tui): cover unresolved output and navigation

* fix(tui): recover unresolved output and navigation

* test(tui): preserve browser timeout accounting

* fix(tui): preserve browser timeout accounting

* test(tui): keep verification failures terminal

* fix(tui): keep verification failures terminal

* test(tui): verify failed browser retries

* test(tui): keep failed browser input blocked

* test(tui): attach browser recovery epochs

* fix(tui): verify failed browser retries

* test(tui): retain committed navigation races

* test(tui): keep raced navigation paint

* test(tui): restore verified superseded navigation

* fix(tui): reconcile committed navigation races

* test(tui): cover stale browser bitmap authority

* fix(tui): bind pointer authority to exact browser bitmap

* test(tui): cover guarded pointer scheduling

* fix(tui): schedule guarded pointer input per surface

* test(tui): cover cross-surface browser input isolation

* fix(tui): isolate browser input by surface

* test(tui): cover browser pointer authority gaps

* fix(tui): preserve safe browser pointer authority

* test(tui): cover loaderless navigation snapshot race

* fix(tui): retry invalidated loaderless snapshots

* test(tui): cover impossible graphics response prefix

* fix(tui): replay impossible graphics prefixes

* test(tui): cover restart overtaking same-document navigation

* fix(tui): preserve queued same-document navigation

* test(tui): bound browser input workers

* fix(tui): bound browser input workers

* test(tui): cover state then frame attach coalescing

* fix(tui): coalesce attach state with newer frames

* test(tui): cover colliding browser surface workers

* fix(tui): schedule browser input per surface

* test(tui): cover scheduler fairness and global bounds

* fix(tui): bound and time-slice browser input

* test(tui): cover scheduler lifecycle regressions

* fix(tui): preserve bounded browser input lifecycles

* test(tui): cover end-to-end key press admission

* fix(tui): carry key presses through final queue

* test(tui): cover sustained graphics authority

* fix(tui): advance acknowledged graphics authority

* test(tui): cover final browser frame admission

* fix(tui): admit presented browser frame ranges

* test(tui): require presented pointer authority

* fix(tui): bind pointer input to presented frames

* test(tui): dedupe presentation acknowledgements

* fix(tui): publish presentation changes once

* test: preserve replaced navigation command order

* fix: preserve replacement browser command order

* test: cover resilient screencast resize barrier

* fix: fall back when screencast clock probe fails

* test: cover browser authority liveness gaps

* fix: bound browser authority lifecycles

* test(tui): cover bounded pointer authority recovery

* fix(tui): bound pointer authority retries

* test(tui): preserve deferred pointer ordering

* fix(tui): retain motion behind deferred pointer input

* test(tui): cover pointer recovery degradation

* fix(tui): recover bounded pointer fallbacks

* test(tui): cover pointer retry scheduling

* fix(tui): sleep until pointer release retry
2026-07-29 17:33:47 -07:00
Lawrence Chen bf6f113f36 Align TestFlight guard with scheduled uploads (#9170)
* test(ci): align TestFlight guard with scheduled uploads

* test(ci): execute TestFlight scheduling decision

* test(ci): cover whitespace-only workflow lines

* test(ci): execute TestFlight ordering guard

* test(ci): verify TestFlight artifact handoff

* test(ci): cover every TestFlight scheduling path

* test(ci): model post-upload workflow activity

* test(ci): cover manual TestFlight upload ordering

* test(ci): parameterize prior TestFlight event

* test(ci): cover TestFlight scheduling fail-open cases

* test(ci): model TestFlight upload history phases

* test(ci): cover TestFlight scheduling API failures

* test(ci): model transient TestFlight API failures

* test(ci): cover internal artifact and later-run ordering

* test(ci): parameterize TestFlight run ids

* test(ci): cover scoped and unchanged TestFlight runs

* test(ci): record TestFlight workflow queries

* test(ci): cover paginated TestFlight upload history

* test(ci): model paginated TestFlight history

* test(ci): cover TestFlight override artifact isolation

* test(ci): model TestFlight override input

* test(ci): exercise TestFlight concurrency mapping

* test(ci): parse TestFlight concurrency config
2026-07-29 17:33:19 -07:00
Austin Wang 3136fcabc2 Revert "feat: auto-retry failed agent sessions (#9024)" (#9184) 2026-07-29 16:29:44 -07:00
Abdulaziz AlbaharandClaude Fable 5 d2c80b4893 Fix iOS workspace-list scroll stutter from live updates (#9139)
* Add frame pacing to the workspace-list scroll probe and a timestamps live-update fixture mode

The DEBUG scroll-metrics probe now records display-link frame pacing
during its sweep (hitch frames, hitch ms/s, max frame ms) so workspace
list scroll work is quantifiable before and after changes. The layout
preview fixture gains CMUX_UITEST_WORKSPACE_LIST_PREVIEW_LIVE_UPDATES=timestamps,
which restamps previewAt/lastActivityAt sub-minute without visible
changes - the exact update shape the Mac emits while agents stream.

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

* Stop live workspace-list updates from re-running the diffable apply and re-rendering unchanged rows

Two measured main-thread costs ran on every workspace-list emission
while agents stream (the iOS workspace-list scroll stutter):

1. Any workspace field delta reconfigured the row, but the Mac restamps
   preview_at/last_activity_at from the latest notification on every
   emission while the row renders that time at minute granularity.
   Reconfigure now decides by render equivalence: full struct equality
   (fail-closed for future fields) with same-minute timestamps
   normalized out.

2. Payload-only updates rode NSDiffableDataSourceSnapshot.apply, which
   runs the diffable apply queue plus UITableView's whole batch-update
   pass per tick (~1.3ms on an M-series simulator, more on device) with
   nothing to diff. When no changed row's height key moved, the visible
   changed cells are now re-configured in place and offscreen rows pick
   up the payload on dequeue; height-changing payloads keep the
   snapshot path so UITableView re-queries heights.

30s fixture window, 400 rows, updates every 80ms, M-series simulator:
timestamp-only churn 3.27s -> 2.59s CPU, visible churn 3.33s -> 3.00s;
the diffable-apply subtree disappears from the sample profile. Sweep
invariants hold: 0 contentSize corrections, 1 distinct draw height.

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

* Address review: isolate the DEBUG route probe, per-frame hitch budgets, in-bucket fixture restamps

The apply-route test hook moves out of the production coordinator into a
dedicated DEBUG file (extension + registry) with #if DEBUG call sites, so
Release builds also stop allocating the changed-id array. The scroll
probe judges each sweep frame against the expected interval captured at
the callback that started it instead of the median, so a mid-sweep
refresh-rate change is not misclassified as a hitch. The timestamps
fixture mode restamps relative to each row's own clock so the first tick
no longer jumps seeded hours-old timestamps across a rendered minute.

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

* Address review round 2: in-bucket fixture restamps, weak-key route probe storage

The timestamps fixture bump now wraps to the start of the row's current
minute instead of crossing into the next one, so every tick honors the
mode's zero-work contract. The DEBUG route registry keys weakly through
NSMapTable so entries die with their coordinator and a reused address
cannot return a predecessor's route.

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

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-29 18:23:26 -05:00
Abdulaziz Albahar 93bfd5ea6e iOS: preserve terminal scroll position across mid-stream verified replays (#9032)
* test(ios): cover verified replay viewport restore math

* fix(ios): preserve viewport across verified replay

* test(ios): expose verified replay anchor leak

Reproduce the len 54 capture / len 53 restore cycle that makes targetTop climb 3990, 3991, ... while preRowsFromBottom counts down on each replay.

* fix(ios): anchor verified replay from viewport top

Exclude flickering visible-row counts from the stored anchor so repeated verified replays preserve the captured top row.

* Fix verified replay viewport operation races

* fix(ios): close replay viewport restore race

* fix(ios): fence restored replay viewport before reveal

Re-arm the ready fence after presenting the restored viewport and hold the interaction clock through the revision-matched scroll call.

No new test: the presentation path has no pure seam, and the atomic interaction gate is race-free by construction.

* fix(ios): gate replay viewport restoration

Label viewport anchors from the queued Ghostty snapshot and claim restore tickets without holding the gate across renderer calls. Invalidate queued restores when deadlines or recovery resume their continuations.

No new tests: the gate has no pure seam; the existing verified replay viewport suite covers the compiled behavior.

* fix(ios): serialize replay viewport scrolling

* fix(ios): coalesce applied viewport scrolls
2026-07-29 17:26:04 -05:00
Abdulaziz AlbaharandClaude Fable 5 bd55030773 Drag the whole cmd-selection when dragging a selected sidebar workspace (#8933)
* Add multi-selection sidebar drag blocks

* Fix block drop gap resolution and group-boundary membership

The drop plan's targetIndex is removal-adjusted for the dragged row
(SidebarDropPlanner.resolvedTargetIndex), so the block reference row must
be resolved in the row space without that row; resolving against the full
order landed the block one row early whenever the grab row sat above the
drop gap and could never express the bottom gap. Past-the-end targets now
append.

Ambiguous group boundaries (one grouped neighbor, one not) now preserve
each member's membership like the single-drag inference instead of
stripping the whole block to top-level, and anchors never receive
membership writes.

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

* Add failing within-group block drop tests

The accepted-no-op case is red: dropping an adjacent selected block at
its own boundary gap inside a group resolves to no movement, and the
block API reports refusal instead of a handled drop, so the AppKit
table animates a snap-back (the sidebar.drop.perform performed=0 repro).

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

* Report handled no-op for block drops resolving to their own position

A block dropped at a gap that resolves to no movement (typical inside a
group section: the painted gap is the adjacent block's own boundary)
went through the batch machinery, changed nothing, and returned false.
The AppKit table treated that as a rejected drop and animated a
snap-back, so within-group multi-drags read as broken. The single-drag
path already returns true for from == to; the block path now does the
same, publishing an order change only when order or membership actually
changed.

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

* Support multi-select group header drags

Keep sidebar selections kind-exclusive through a shared foundation policy. Expand selected anchors into tier-aware top-level blocks so whole group sections reorder together without changing membership, including handled no-op drops.

* Address review: legacy drop path moves blocks, headers clear on toggle

Route the legacy SwiftUI sidebar drop through SidebarWorkspaceDragBlockResolver
so multi-selection drags move the whole block on that surface too, matching the
painted-plan path. Let modifier-clicking the last selected group header clear
the selection instead of pinning it to the clicked anchor, and assert the
accepted in-group no-op emits no order-change publication.

* Give header clicks a single selection owner

The AppKit group header cell installed its own click recognizer that
called onFocusAnchor while the table view's action already routes the
same click through didClickTableRow. Two invocations per click cancel a
modifier-click toggle (add then remove), so header multi-selection never
accumulated. Remove the cell recognizer; the table action is the sole
selection owner, matching workspace rows.

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-29 17:25:50 -05:00
Abdulaziz AlbaharandClaude Fable 5 9c5c9adb5c Make the iOS notification feed scroll fast with thousands of items (#9141)
* Add notification-feed scroll-perf stress fixture

CMUX_UITEST_NOTIFICATION_FEED_PREVIEW_COUNT=<n> seeds the DEBUG preview
harness with n deterministic synthetic items (day spread, three Macs,
mixed read/connection/body variants).
CMUX_UITEST_NOTIFICATION_FEED_PREVIEW_AUTOSCROLL=1 drives one animated
scroll pass down and back up, hopping ten rows per step, bracketed by
OSSignposter intervals for Instruments comparison.

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

* Add frame-pacing monitor to feed scroll stress driver

A CADisplayLink tick monitor (stress-harness-only, env-gated DEBUG code)
counts frames arriving 1.5x past the frame interval and logs
rows/frames/hitches/hitchTotalMs/worstHitchMs at the end of the run, so
baseline and fix builds compare on hitch metrics without Instruments.

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

* Build feed row strings off-main and skip no-op section publishes

NotificationFeedRowPresentation moves out of the row body into
NotificationFeedRowModel, built per item inside the projection's
detached rebuild: string trimming, case/diacritic folding, localization
lookups, and the accessibility value (including its relative-date
format) no longer run on the main thread per row materialization.
Row equality still compares the item alone, so diffs stay cheap.

NotificationFeedProjection now publishes sections only when the rebuilt
output differs, so redundant source recomputes (per-Mac connection
churn producing identical items) no longer force the List to re-diff
every row.

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

* Window the mounted notification feed rows with progressive reveal

Profiling 2,000 mounted rows showed cell self-sizing and far-jump
layout resolution dominating the main thread (34s cumulative hitch
time over a 66s scripted scroll, worst stall 2.7s). The projection now
mounts the newest 300 filtered rows and appends 300 more whenever the
load-more sentinel row becomes visible, so initial publish, whole-list
diffs, and scroll-to-top layout spans stay proportional to what the
user can reach instead of the full 2,000-item retained history.

Feed refreshes preserve the extended window (background updates never
collapse scroll depth); filter and search changes reset it. Also drops
the unread indicator's invisible Color.clear overlay, which cost a
layout node in every cell sizing pass, and teaches the stress driver
to follow window growth.

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

* Collapse feed row icon lines into single interpolated Texts

Cell self-sizing dominated the remaining scroll cost (StackLayout
arithmetic and per-cell SF Symbol image nodes). The headline icon,
workspace line, and computer line now render as one interpolated Text
each instead of HStack{Image, Text} pairs, roughly halving the layout
nodes each materializing cell measures (twice, via the provenance
ViewThatFits trials). The row ignores child accessibility, so the
interpolated symbols never reach VoiceOver.

Stress run (2,000 rows, scripted fling, same sim): worst frame stall
2,696ms -> 298ms, cumulative hitch time 34.1s -> 22.6s, frames
delivered +27% vs baseline.

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

* Address review: monitor defer-stop, single pending extension, render-time a11y date

The stress driver's frame monitor now stops via defer so task
cancellation mid-pass cannot leak the display link. extendRowWindow
accepts one extension per publish (hasMoreRows only flips after the
rebuild lands, so repeated sentinel appearances stacked increments).
The precomputed accessibility details no longer bake in the relative
date; the row appends a render-time date so VoiceOver never reads a
timestamp frozen at model-build time.

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

* Use localized interpolation instead of String(format:) in row rebuild

NotificationFeedRowPresentation runs per row inside the detached
whole-window rebuild; C-varargs String(format:) is banned in concurrent
hot paths (the PR 5347 regression class flagged by autoreview). The
catalog values keep their positional placeholders and interpolation
arguments bind in order, so rendered strings are unchanged in both
locales.

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

* Address policy review: one type per file, file-scope helpers

NotificationFeedRowPresentation moves to its own file with its pure
helpers as file-scope private funcs (matching the projection file's
convention), NotificationFeedLoadMoreRow and the stress harness's
frame monitor move to their own files.

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

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-29 16:45:16 -05:00
Abdulaziz AlbaharandClaude Fable 5 53bd1a082f Batch iOS TestFlight uploads on a schedule instead of per merge (#9165)
App Store Connect's per-app upload quota was exhausted by per-merge
internal uploads. cmux INTERNAL now uploads hourly and cmux DEMO every
12 hours, both from current main. Scheduled runs skip when the variant
already shipped the current head or when the delta touches no
iOS-relevant paths. The variant is resolved once in the decide job
(cron string or dispatch input) and build metadata artifacts are
variant-specific so skip logic and notes ranges stay independent.

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-29 16:31:52 -05:00
Abdulaziz AlbaharandClaude Fable 5 e8e1182a2b Show Game of Life backdrop on every onboarding page (#8880)
The sign-in view renders a GameOfLifeHeader background, but the
onboarding flow used a flat system background. Layer the same
GameOfLifeHeader into OnboardingBackdrop so all onboarding pages
(agents, notifications, connect/sign-in bridge) share the sign-in
backdrop.

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-29 16:04:14 -05:00
Abdulaziz AlbaharandClaude Fable 5 5d89b26093 Make iOS onboarding tour a swipeable horizontal pager (#9158)
* Make iOS onboarding tour a swipeable horizontal pager

The onboarding page track previously moved only when the footer buttons
changed the committed stage. Replace the offset-driven HStack with a native
paging ScrollView (scrollTargetBehavior(.paging) + scrollPosition(id:)) so
the user can swipe between the agents, notifications, and connect pages in
both directions, with the existing header dots as page indicators.

The committed OnboardingFlowView stage stays the single source of truth:
swipes report back through onNavigate into the same navigate(to:) path the
buttons use, so scene analytics and onReachedConnection fire identically.
Completion, sign-in, permission, and pairing actions remain button-only on
the last (connect) page, and the pager clamps at the track ends, so a swipe
can never skip a gated step. The pageOffset track-math tests are removed
with the extension they covered.

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

* Disable page scroll bounce when content fits

Each tour page's vertical ScrollView now uses
scrollBounceBehavior(.basedOnSize): with no vertical overflow it neither
scrolls nor rubber-bands, so vertical and diagonal drags on short pages
reach the horizontal pager instead of being eaten by an empty scroll.

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

* Fix pager initial position when onboarding resumes at connect

Seeding scrollPosition alone is dropped on first layout when the initial
stage is a later page: the app resumed at the connect stage with connect
chrome but page 1 content. defaultScrollAnchor expresses the initial stage
as a content fraction (rawValue over lastIndex), which survives the first
layout pass; scrollPosition still tracks swipes and button navigation.

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

* Derive pager anchor index from allCases ordering

The ForEach renders OnboardingStage.allCases, so the initial anchor now
uses firstIndex(of:) instead of rawValue; the two only agree while raw
values stay contiguous, zero-based, and ordered like allCases.

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

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-29 15:24:25 -05:00
Abdulaziz AlbaharandClaude Fable 5 87b072736c Route iOS connection statuses through toasts behind the beta flag (#8784)
* Route iOS connection statuses through toasts behind the beta flag

With the Toasts beta flag on, the floating "Connection lost / Retry"
pill and the fullscreen "Disconnected" terminal overlay stop rendering.
Every connection state surfaces through one toast capsule instead: a
shared coalescing key makes reconnecting -> disconnected -> reconnected
replace each other in place rather than stacking. Disconnected and
connection-lost toasts carry Reconnect/Retry actions; only the
account-mismatch toast persists (it needs acknowledgement), everything
else auto-dismisses so the toast queue never starves. The top-left
status pill stays and becomes tappable to reconnect while the flag is
on, and sign-out now clears all toasts. Adds
ToastCenter.dismiss(coalescingKey:) so a stale status capsule clears
the moment the connection is back. Flag off keeps legacy behavior.

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

* Guard status toasts against reauth replacement and stale idle

Review fixes from PR feedback:
- WorkspaceDetailView no longer presents transient unavailable/reconnecting
  toasts while reauth is required; they share the coalescing key with the
  never-dismissing account-mismatch toast and would replace it, then
  auto-dismiss, losing the sign-out affordance.
- Enabling the Toasts flag while a workspace is already disconnected now
  presents the current status toast (the status onChange doesn't re-fire on
  flag flips).
- Recovery overlay dismisses the toast on lost/recovering -> idle when the
  store isn't connected, so recovery state that evaporates without a
  connection (mac switch, disconnect-and-hide) doesn't leave a stale toast.
  The connected path still belongs to the shell to protect the success toast.

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

* Present success toast for workspace-scoped recovery

markMacConnectionHealthy() flips macConnectionStatus back to .connected
while connectionState is already .connected and unchanged, so the shell's
connectionState observer never fires for a same-session workspace recovery:
the "Reconnecting..." toast went stale and no "Reconnected" success showed.

The detail view now tracks the previous status via onChange and presents
the success toast when a workspace recovers from unavailable/reconnecting.
Presenting (never bare-dismissing) cannot kill the shell's success toast,
since a later present on the shared coalescing key replaces in place; the
initial fire has previous == status, so mounting stays silent.

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

* Scope reconnected toast to workspace identity

Split layout reuses WorkspaceDetailView across selection changes, so the
onChange previous value could compare statuses of two different
workspaces: selecting a connected workspace right after viewing a
disconnected one falsely toasted "Reconnected to your Mac." The status
onChange now observes a (workspace.id, status) pair and treats a
cross-workspace diff as an initial attach rather than a recovery.

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

* Centralize connection-status toasts in one shell-owned presenter

Three rounds of review kept finding instances of the same defect class:
ephemeral views (workspace details retained by parallel TabView stacks,
the recovery overlay, the shell) raced as independent producers of the
single coalesced connection-status capsule, each with partial knowledge.
An inactive detail could replace the visible toast and aim Reconnect at
the wrong Mac, a same-client probe recovery left "Reconnecting..." stale
because neither transport state nor workspace status transitioned, and
the swipe-dismissable reauth toast was the only reauth surface.

ConnectionStatusToastPresenter is now the only producer. It mounts once
from the always-mounted WorkspaceShellView and derives one display state
from the authoritative signals together: reauth/lost/recovering flags,
transport connectionState, and the selected workspace's Mac status.
Transitions are decided by pure, unit-tested logic scoped to the selected
workspace identity, so selection changes dismiss stale capsules instead
of toasting false recoveries.

Reauth returns to the durable compact banner even when Toasts is on: it
is a blocking action, not a transient status, and a toast can be swiped
away with nothing left to re-present it. The never-dismissing reauth
toast factory is gone, which also removes the only .never toast that
could starve the queue.

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

* Make blocking recovery states durable and silence startup restoration

Review round-4 fixes on the centralized presenter:
- Failed recovery joins reauth on the durable banner: its Retry was a
  dismissible 6s toast, and in the same-client failure case the workspace
  status stays connected so even the pill was hidden, leaving no visible
  retry control. The failed state now dismisses the capsule (banner owns
  Retry) and recovering back to connected still toasts success.
- First-attach-silent is restored, centralized in the presenter: startup
  restoration passes through disconnected snapshots, so before the session
  has ever held a connection nothing presents. This was lost when the
  shell's hasHeldConnection moved out.
- Sign-out can no longer strand a capsule on the sign-in screen: the
  snapshot now derives from isSignedIn, so the presenter converges to
  dismiss even though store.signOut() changes connection state before the
  auth flags flip.

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

* Block dead-terminal input and stop transport overriding per-Mac status

Round-5 review fixes:
- With the fullscreen overlay gone, a disconnected terminal stayed
  hit-testable and keystrokes were silently discarded by the disconnected
  drain path. The terminal content now disables hit testing while toasts
  are enabled and the workspace isn't connected; the pill and toast
  overlays attach after that modifier and stay tappable.
- Display derivation no longer consults the foreground transport
  connectionState. It describes only the foreground RPC connection, so a
  selected workspace on a healthy secondary Mac read as disconnected
  (workspaceListConnectionStatus documents the same trap). The per-Mac
  workspace status the pill already displays is the single display truth.

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

* Resign live terminal input on disconnect and scope recovery flags

Round-6 review fixes:
- allowsHitTesting only blocks new touches; a terminal focused before the
  drop keeps its keyboard and keystrokes drain silently. The detail view
  now calls GhosttySurfaceView.resignActiveInput() when the workspace
  leaves .connected while toasts are enabled.
- connectionRecoveryFailed / isRecoveringConnection describe the
  foreground RPC connection. New store-owned
  selectedWorkspaceUsesForegroundConnection scopes them, so a workspace on
  a healthy secondary Mac no longer shows a false "Reconnecting..." or
  "Reconnected" while the foreground connection cycles.

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

* Route reconnect through a store entrypoint and resign input on mount

Round-7 review fixes:
- switchToMac's already-foreground fast path returns true without dialing,
  so the pill/toast Reconnect no-oped when the unavailable Mac was already
  foreground (live event stream dead, RPC transport object alive). New
  store-owned reconnectToMac(macDeviceID:) switches only when the target
  isn't the foreground Mac and otherwise runs the recovery redial; the
  detail helper and the toast presenter both route through it.
- The keyboard resign now fires with initial: true and on Toasts flag
  flips, covering a detail that mounts already disconnected and the
  window-attach autofocus that ignores connection status.

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

* Scope disconnect keyboard resignation to the selected workspace

resignActiveInput() acts on the process-wide active input surface, while
retained hidden details (parallel TabView stacks) observe their own
connection status. A hidden workspace's disconnect could therefore steal
the visible healthy terminal's keyboard. The resign now requires the
detail's workspace to be the store's selected workspace, the same
authoritative identity the toast presenter uses.

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

* Redial the requested foreground Mac directly in reconnectToMac

reconnectToMac fell through to reconnectOrRefresh for a foreground-Mac
target, but that path gates on the aggregate workspaceListConnectionStatus.
With any healthy secondary Mac the aggregate reads connected, so the
pill/toast Reconnect merely refreshed (stale surviving RPC client) or
switched to the secondary Mac instead of redialing the requested one. The
entrypoint now applies the disconnected-branch recipe directly to the
supplied Mac: clear the automatic-retry backoff, tear down a stale live
client so switchToMac cannot fast-path, dial it, and fall back to
reconnectActiveMacIfAvailable. A healthy target short-circuits to a
workspace refresh so a stray gesture cannot tear down a live connection.

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

* Preserve secondary Macs on redial, resign on selection, align nil target

Round-10 review fixes:
- The targeted foreground redial now tears down the stale client with
  preservingOtherMacWorkspaceState: true; the default teardown dropped
  healthy secondary-Mac subscriptions and their workspaces if the redial
  failed.
- A nil/empty target in reconnectToMac now means the foreground
  connection (the status the caller displayed) instead of aggregate
  recovery, so a Disconnected toast for the foreground can't switch to a
  healthy secondary Mac on tap.
- The disconnect keyboard resign also observes selectedWorkspaceID: a
  detail that went unavailable while hidden re-checks when it becomes
  selected, since neither status nor flag changes at that moment.

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

* Fold foreground recovery into the detail view's effective status

Same-client foreground recovery flips the store recovery flags while the
per-workspace status stays .connected, so the input protection added for
the toast mode never engaged in that state (and the pill kept claiming
Connected). The detail view now derives an effective status matching the
presenter's derivation, scoped to the selected workspace on the
foreground connection, and uses it for hit testing, keyboard
resignation, and the flag-on pill. Flag-off surfaces keep the raw status
byte-for-byte.

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

* Retain the recovery-target Mac identity across connection teardown

Automatic recovery calls clearRemoteConnectionContext(), which nils
foregroundMacDeviceID before the bounded redial begins, so
selectedWorkspaceUsesForegroundConnection went false for exactly the
workspace being redialed: the presenter and the detail view's effective
status stopped scoping isRecoveringConnection/connectionRecoveryFailed to
it and showed an actionable "Disconnected" mid-dial. The store now
retains recoveryTargetMacDeviceID (updated whenever the foreground
identity is set, cleared on sign-out) and the ownership check falls back
to it while the foreground identity is torn down.

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

* Follow the list status policy when no workspace is selected

Hiding the last visible Mac after connecting leaves the shell mounted
with no selected workspace and macConnectionStatus unavailable, so the
presenter's raw fallback toasted an actionable "Disconnected" whose
Reconnect could not reach any visible Mac. workspaceListConnectionStatus
already encodes that policy (hidden-only reads connected); use it as the
no-selection fallback.

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

* Clear toasts on automatic sign-out too

Session expiry/revalidation reaches store.signOut() through
syncShellAuthentication, unmounting the workspace shell (and its
connection presenter) before anything can dismiss, so visible or queued
connection toasts stayed actionable over the sign-in screen. The root
view's sync wrapper now dismisses all toasts on the same condition the
auth gate uses to issue the sign-out, mirroring the manual path.

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

* Rebuild secondary state after failed redial; keep flag-off path legacy

Round-15 review fixes:
- A fully failed targeted redial runs the dial paths' own cleanup with
  the default non-preserving teardown (foreground id already nil, so the
  aggregate filter keeps only the anonymous key), stranding healthy
  secondary Macs. reconnectToMac now rebuilds secondary aggregation via
  refreshSecondaryMacWorkspaces() when both dial attempts fail.
- The shared reconnect helper had leaked the new targeted entrypoint into
  the flag-off TerminalDisconnectedOverlay; flag off now keeps the
  original switchToMac-then-reconnectOrRefresh sequence byte-for-byte,
  and only flag-on surfaces use reconnectToMac.

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

* Use explicit workspace selection for toast status and reconnect target

selectedWorkspace falls back to workspaces.first, so after a cleared
selection (e.g. a failed cross-Mac open) the presenter derived status
from and reconnected an arbitrary first row. The presenter and the
foreground-ownership check now use explicitlySelectedWorkspace (made
public); with no explicit selection the capsule follows the aggregate
workspaceListConnectionStatus and its Reconnect runs the list recovery
policy, which fails closed with multiple candidate Macs.

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

* Route retry of the recovery target as foreground; keep keyboard on probes

Round-17 review fixes:
- reconnectToMac compared the target only against foregroundMacDeviceID,
  which automatic recovery nils; retrying the just-failed foreground Mac
  therefore took the cross-Mac branch, whose failed-switch cleanup uses
  the non-preserving teardown and whose reconnectOrRefresh fallback skips
  the secondary rebuild. The comparison now includes the retained
  recoveryTargetMacDeviceID so that retry takes the foreground-redial
  branch with preserved secondary state.
- Input gating no longer keys off the displayed effective status: a
  same-client probe reads "Reconnecting" while the transport still
  carries keystrokes, so blocking there dismissed a working keyboard
  mid-typing. New terminalInputIsBlocked blocks/resigns only when the
  workspace status itself is disconnected or foreground recovery actually
  failed; the pill keeps the recovery-aware display.

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

* Gate chrome-return terminal refocus on the input-block policy

Returning from chat/browser chrome refocused the terminal input proxy
unconditionally; with the connection down, the blocked predicate had not
changed, so nothing resigned the keyboard opened by that path and
keystrokes drained silently despite disabled hit testing. The refocus now
shares terminalInputIsBlocked (widened to internal) so every focus
entrypoint follows one policy.

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

* Split the connection-status state machine into its own file

Pure code motion for the one-major-type-per-file policy:
ConnectionStatusDisplayState, ConnectionStatusSnapshot, and
ConnectionStatusToastTransition move to ConnectionStatusTransition.swift;
ConnectionStatusToasts.swift keeps only the Toast factory extension.

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

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-29 15:17:25 -05:00
Abdulaziz AlbaharandClaude Fable 5 3185b2be3d Add a dev target to the presence deploy workflow (#9161)
* Add a dev target to the presence deploy workflow

The shared cmux-presence-dev worker could only be redeployed with a
personal Cloudflare login on the org account, which most of the team
does not have (and local wrangler OAuth tokens rot). presence.yml
already holds the org's deploy token as repo secrets for prod, so a
`target` dispatch input (prod default, dev = wrangler.dev.toml) lets
anyone keep the shared dev baseline current with
`gh workflow run presence.yml -f target=dev`. Also corrects the README,
which claimed deploys run on push to main; the workflow is manual
dispatch only.

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

* Fail closed on unknown deploy targets, pass target via env

CodeRabbit: interpolating inputs.target into the script is a
template-injection pattern (API dispatch is not limited to the UI's
choice list), and unknown values fell through to the prod branch. The
target now reaches the shell as an env var and anything but dev/prod
errors out.

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

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-29 14:58:33 -05:00
Abdulaziz Albahar 06356f2afa Render realistic onboarding iPhone frame
Render onboarding screenshots in a more realistic modern iPhone frame and increase the footer spacing above Continue by 6 points.
2026-07-29 14:45:16 -05:00
Abdulaziz Albahar 69d05f442f iOS: anchor the keyboard-rise render slide to the cursor row (#9133)
Even with the deferred shrink resize, the keyboard rise still shoved
every terminal row up by the keyboard height: the old render is
bottom-pinned to the live viewport, and for a prompt sitting in the
upper half of the screen the rows the keyboard covers are the BLANK
rows below it. Riding the screen bottom pushed the content rows to
renderRect y=-287 in the captured repro and the settle resize dropped
them back — the "momentary push of all terminal rows".

While the negotiation is unsettled and the viewport is not growing,
the render now slides only as much as needed to keep the cursor row
visible: the blank space below the cursor absorbs the keyboard
intrusion first (zero motion for short content), and a full-screen
prompt keeps the legacy ride so it never hides under the keyboard.
The cursor bottom comes from the same non-blocking
ghostty_surface_ime_point read the cursor overlay uses. The anchor
also holds at live == target while the deferred resize waits on the
grid echo, so the final stretch of the transition cannot snap.

Verified on-device-sim with the same scripted dance as the repro:
renderRect held at 440x714@0 through the entire rise (previously
-45 -> -287) and frame analysis of the recording shows the text top
at the same pixel in all 384 frames across raise and dismiss.
2026-07-29 14:38:31 -05:00
Abdulaziz AlbaharandClaude Fable 5 7d870a43a6 docs: open iOS builds on the connected iPhone by default (#9160)
Agents building anything iOS-related must install and launch the tagged
build on the user's connected iPhone in addition to the simulator,
without waiting to be asked, and report explicitly when no phone is
reachable.

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-29 14:28:44 -05:00
Abdulaziz AlbaharandClaude Fable 5 7200a8fdb6 Keep iOS New Task button clear of the bottom search pill (#9136)
* Add regression test for New Task vs search pill overlap

On iOS 26 the workspace list preview now renders the New Task button the
live shell mounts next to the system search pill, and a UI test asserts
the two controls do not intersect and stay tappable. The fix lands in
the next commit, so this run documents the overlap.

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

* Keep iOS New Task button clear of the bottom search pill

On iOS 26 the workspace list mounted New Task as a bottomBar toolbar
item, but the TabView search-role tab renders its pill in the same
bottom-trailing slot, so the two controls stacked and New Task was
occluded and untappable. Mount the shared TaskComposerButton in the
bottom safe-area bar instead, which the system lays out above the tab
bar chrome, and move the pre-iOS-26 overlay mounting from both shell
layouts into the same WorkspaceListSearchHost so the button has one
shared layout path.

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

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-29 13:28:12 -05:00
Abdulaziz Albahar 800c3f6ebf Pipeline iOS terminal input over ordered mobile RPC (#9036)
* Pipeline iOS terminal input over ordered mobile RPC

* Address review: fold classifier into request, dispose abandoned pipelined handles

Replace the static-only MobileHostOrderedRequestClassifier namespace with an
isOrderedTerminalInput computed property on MobileHostRPCRequest. Add
MobileCoreRPCPipelinedRequest.abandon() and call it from
MobileTerminalInputRPCPipeline.clear() so dropped handles release their session
settlement slots instead of lingering until the request deadline or teardown.
Resume one capacity waiter per settlement to keep enqueue arrival order
self-contained, and stop routing the pipeline's teardown CancellationError into
the operational-error path.

* Close pipeline generation races found by review

Abandon a pipelined handle when clear() lands during makeRequest(), so its
session settlement slot is released instead of retained until teardown. After
the RPC-to-lane settle barrier, revalidate the captured connection generation
and client identity and fail closed, so a barrier resumed by a lifecycle
clear() cannot write the chunk into a lane from the previous connection.

* Decouple ordered input application from response writes; order paste_image

Review round 2: the ordered worker now serializes authorization and
application only and hands each response to a tracked concurrent send task,
so a peer that stops reading (issue 8842's stall) cannot freeze later typing
behind a wedged response write; stalled sends accumulate in responseTasks
until quota admission closes the connection. terminal.paste_image joins the
ordered set because its handler writes the materialized image path into the
PTY. Adds a stalled-response-write regression test.

* Scope the RPC-to-lane barrier per surface

Review round 3: pipeline entries now carry their surface, and both
hasUnsettledRequests and the lane-activation barrier consider only requests
targeting the lane being activated, so one terminal's delayed response can no
longer stall a different terminal's healthy lane. Ordering only matters within
one PTY.

* Refuse the lane after an ambiguous pipelined input failure

Review round 4: a client-side response timeout is deliberately scoped to that
one RPC (connection, client, and generation survive), but the host's ordered
worker may still apply the timed-out input late, so releasing the lane barrier
on such a settlement could deliver later lane bytes first. Settlement failures
without a host-produced response now poison the surface; a poisoned surface
skips the lane and stays on the ordered RPC path, which remains correctly
ordered with a late apply, until the next connection-lifecycle clear. Host
responses (rpcError, authorizationFailed, accountMismatch) prove the input was
rejected and do not poison. Covered by a timeout-driven regression test.

* Order PTY-writing RPCs per surface, include scroll and mouse

Review round 5: the connection-wide FIFO created cross-surface head-of-line
blocking (a slow paste_image on one surface delayed typing on another), so the
ordered queue and worker are now keyed by the request's surface; ordering is a
per-PTY property. scroll and mouse join the ordered set because their handlers
emit mouse-report bytes when mouse reporting is active. Requests without a
surface selection share one conservative bucket, which keeps the existing
serial-order tests meaningful, and a new cross-surface test proves one held
surface no longer blocks another.

* Reap pipelined settlements per surface; keep teardown outcomes claimable

Review round 6: a single FIFO reaper let one surface's stalled response hold
the barrier and capacity slots of every other surface, so entries and reapers
are now keyed per surface (capacity stays shared at 4), with a regression test
proving a held surface no longer blocks another surface's settlement or lane
transition. Session teardown now converts pending pipelined slots to the real
teardown failure and retains settled outcomes until claimed, so unclaimed
handles report connectionClosed instead of a misleading protocol error.
2026-07-29 12:55:46 -05:00
Austin Wang 2068b7c008 Fix SSH relay deadlock after app restart (#9105)
* test: keep SSH relay off shared ControlMaster (#8894)

* fix: give SSH reverse relay an app-owned transport (#8894)

* test: prove dedicated relay startup is reached (#8894)

* test: observe relay startup in regression scope (#8894)

* test: capture dedicated reverse relay launch argv (#8894)

* fix: cancel inherited SSH relay forwards (#8894)

* test: remove reverse relay launch seam (#8894)

* fix: keep relay cleanup off coordinator queue (#8894)

* fix: recover relay from inherited ControlMaster lease (#8894)

* docs: justify synchronous relay cancellation bridge (#8894)

* test: isolate relay recovery and sanitize status (#8894)

* test: cover successful relay conflict recovery (#8894)

* test: isolate reverse relay recovery launch (#8894)

* fix: recover relay through the shared SSH master (#8894)

* fix: coordinate conflicted SSH master recovery

* fix: make shared master reset recoverable

* fix: scope unresolved master reset events

* fix: resolve SSH master paths before reset

* fix: preserve retryable SSH reset state

* fix: bound reverse relay startup lifecycle

* fix: wait for SSH forward confirmation

* fix: retain shared SSH reset ownership

* fix: gate SSH master resets across processes

* fix: preserve SSH master lifecycle invariants

* fix: close SSH ownership coordination gaps

* fix: bound reverse relay termination

* fix: preserve SSH master recovery identity

* fix: cancel only inherited relay forwards

* fix: retry inherited forward recovery

* fix: bound SSH relay recovery lifecycle

* refactor: isolate SSH recovery lifecycle state

* Address SSH relay ownership review findings

* Fix app target remote session import

* fix: expose adopted SSH control path

* test: cover rotated relay auth recovery

* fix: recover rotated persistent relay leases

* test: cover inherited SSH master reap

* fix: reap inherited SSH masters after relay conflicts

* refactor: isolate inherited master reap types
2026-07-29 09:03:24 -07:00
Austin Wang 47b49e87f1 Merge pull request #9090 from manaflow-ai/issue-8997-memory-growth-panics
Hibernate idle agents before critical memory pressure panics
2026-07-29 08:58:06 -07:00
austinpower1258 235449d7be Eliminate Swift concurrency warnings 2026-07-29 07:37:55 -07:00
austinpower1258 e04c7f4f8b Fix hibernation process test compilation 2026-07-29 07:10:48 -07:00
austinpower1258 c1b19fb0e6 Merge remote-tracking branch 'origin/main' into issue-8997-memory-growth-panics 2026-07-29 06:53:14 -07:00
austinpower1258 812930e655 fix: make hibernation cleanup fallbacks bounded 2026-07-29 06:51:54 -07:00
austinpower1258 5037f250f8 test: cover hibernation cleanup fallback failures 2026-07-29 06:47:52 -07:00
austinpower1258 d212472f2e refactor: split agent hibernation panel types 2026-07-29 06:41:19 -07:00
austinpower1258 bb29d1470a test: repair merged main CI guards 2026-07-29 06:33:17 -07:00
Austin Wang 83bc021363 Prevent stalled remote PTY starts and bound wedged reattach loops (#9111)
* test(remote): reproduce PTY hub start wedge

* fix(remote): isolate persistent PTY session startup

* test: bound zero-progress SSH PTY reattach churn

* fix: stop zero-progress SSH PTY attach loops

* test: prove concurrent PTY starts coalesce

* Fix SSH PTY retry policy target ownership

* fix: bound managed SSH PTY retry churn

* fix: isolate stalled remote PTY attaches

* fix: close remote PTY attach teardown races

* fix: preserve remote PTY session generations

* fix: harden remote PTY lifecycle handoffs

* fix: bound PTY exit output draining

* test: preserve remote PTY capacity failures

* fix: retry remote PTY capacity failures

* test: bound remote PTY capacity recovery

* fix: bound remote PTY start waiters

* test: update persistent PTY retry contract

* test(remote): hide retained fast-exit generations

* fix(remote): separate retained and live PTY state

* test: preserve daemon transport after PTY attach timeout

* fix: isolate PTY attach call timeouts

* test: cancel timed-out remote PTY attaches

* fix(remote): cancel timed-out PTY attach requests

* fix(remote): bound PTY attach cancellation writes

* fix(remote): use cancellable attach timeout timers

* test(remote): pin canceled start publication race

* fix(remote): linearize PTY start waiter cancellation

* test(remote): assert timeout cancellation ordering

* test(remote): expose completed attachment context leak

* fix(remote): release completed attachment contexts

* test(remote): advertise attach cancellation in bridge fixture

* test(remote): expose canceled anonymous PTY retention

* fix(remote): terminate canceled anonymous PTY starts

* test(remote): distinguish replay from live PTY output

* fix(remote): exclude replay from attach progress

* test(remote): require cancellable attachment identities

* fix(remote): require cancellable attachment ids
2026-07-29 06:29:21 -07:00
austinpower1258 387fc94d92 Merge remote-tracking branch 'origin/main' into issue-8997-memory-growth-panics
# Conflicts:
#	Sources/DockSplitStore+Reset.swift
#	Sources/DockSplitStore.swift
#	Sources/Workspace+PanelLifecycle.swift
#	Sources/Workspace.swift
2026-07-29 06:18:37 -07:00
austinpower1258 7d9c8ee11b fix: hibernate idle agent panes under memory pressure 2026-07-29 06:15:42 -07:00
Lawrence Chenandcmux-lawrence 1e0aecd0a3 Add workspace-wide terminal font zoom shortcuts (#8791)
* Add workspace-wide terminal font zoom shortcuts

* Avoid C formatting in font zoom actions

* Add terminal font zoom ownership regressions

* Fix terminal font zoom ownership and migration

* Add terminal font zoom lifecycle regressions

* Fix terminal font zoom lifecycle safety

* Clarify workspace font zoom documentation

* Add equalize shortcut precedence regression test

* Preserve custom binding at equalize default

* Add workspace font shortcut precedence regression

* Add workspace terminal font reset shortcut

* Add workspace font reset safety regressions

* Exercise live workspace font reset regression

* Keep workspace font regression black-box

* Preserve workspace font reset state

* Cover window Dock font inheritance

* Inherit font size from window Dock

* Cover font-only reset and Dock inheritance

* Keep font reset local and inherit Dock zoom

* Import Dock font lineage model

* test: cover font zoom coalescing regressions

* test: cover Ghostty font shortcut collision

* fix: bound repeated workspace font zoom work

* test: cover remaining workspace font zoom regressions

* fix: preserve ordered workspace font zoom runs

* test: cover bounded ordered font zoom draining

* fix: bound ordered workspace font zoom draining

* fix: return configured font zoom lineage

* test: cover terminals created during font zoom drain

* fix: preserve font zoom provenance while draining

* test: seed font zoom fixtures through public config

* test: cover workspace font zoom review regressions

* fix: bound workspace font zoom lifecycle

* test: cover font zoom coalescing and stale Dock fallback

* test: stabilize workspace font zoom timing coverage

* fix: coalesce workspace font zoom repeats

* test: cover transferred and alternating font zoom work

* fix: bound cross-window workspace font zoom work

* test: cover font zoom move ordering and provenance

* fix: serialize font zoom across surface moves

* test: cover Dock lineage and remote pane inheritance cost

* fix: preserve Dock font lineage without remote config churn

* test: cover ordered bounded workspace font lineage

* fix: unify workspace and Dock font event lineage

* test: cover cross-window font event ownership

* fix: serialize cross-window font event ownership

* test: cover entering and fitted font lineage

* fix: preserve entering and fitted font lineage

* test: cover bounded transfer reconciliation

* fix: bound batch transfer reconciliation

* test: cover bounded transfer lifecycle

* fix: drain transfer reconciliation incrementally

* test: cover transfer provenance edges

* fix: preserve ordered transfer provenance

* test: cover font transfer ownership failures

* fix: isolate font-size reconciliation ownership

* test: cover failed transfer request ordering

* fix: preserve ordering after transfer failure

* test: cover font reconciliation lifecycle gaps

* fix: retain failed font reconciliation work

* test: cover font mutation retry state

* fix: reconcile font mutation retry state

* test: cover parked cross-window backpressure

* fix: wake and bound deferred font joins

* test: cover font backpressure and removal wakeups

* fix: bound font work and wake on removal

* test: cover remote removal and foreign cancellation

* fix: close remaining font lifecycle gaps

* test: serialize config refresh with font work

* fix: serialize config refresh with font work

* test: cover config transaction ordering and liveness

* fix: serialize Ghostty config with font work

* test: cover magnification reload and retry retention

* test: keep unrealized font followers config-owned

* fix: reconcile font reloads without retaining panels

* test: cover clamp, queued scale, and backpressure

* fix: preserve bounded font routing across reloads

* test: cover bounded reload and fit ownership

* fix: bound font reconciliation ownership

* test: cover reload scale transaction ordering

* test: cover follower inheritance during reload

* test: promote soft reloads when scale changes

* fix: make font config reload transactional

* test: cover reload reconciliation lifecycle

* test: cover reload registration and rollback

* fix: make font config reload incremental

* test: require fixed registry traversal cutoff

* fix: bound terminal config reload capture

* test: cover font transfer state boundaries

* fix: preserve font state across transfer boundaries

* test: cover clamped font input during reload

* fix: preserve clamped font input ownership

* test: cover late dormant font reload follower

* test: cover rebased late follower inheritance

* fix: rebase late dormant font followers

* test: cover entered dock transfer ownership

Add a red cross-window regression that proves an active panel transfer remains associated with the Dock it entered. Repair current-main test constructors and drop the app-target duplicate of package-level live Ghostty lineage coverage, which referenced test-only C stubs unavailable to the app test bundle.

* fix: retain entered dock transfer ownership

* test: keep reload appearance behind config commit

* fix: stage reload appearance until config commit

Resolve and retain the pending background values without publishing them, restore the previously applied runtime color scheme during bounded surface capture, then apply Ghostty config, swap the owned config, publish appearance, and synchronize the resolved scheme in one main-actor commit.

* test: cover non-FIFO transfer cancellation

* fix: unlink canceled transfer requests by token

* test: require reload reply after config commit

* fix: acknowledge config reload after commit

* fix: index transfer cancellation cleanup

* refactor: clarify workspace font size ownership

* test: cover font mutation lifecycle stalls

* fix: settle font mutations across lifecycle edges

* test: cover bounded font snapshot projection

* fix: project pending font intent into snapshots

* test: cover transferred descendant font inheritance

* fix: inherit pending font work across transfers

* test: cover deferred font ownership edge cases

* fix: preserve deferred font intent ownership

* test: cover projected font replay

* fix: preserve projected font request provenance

* test: cover deferred font reconciliation boundaries

* fix: preserve deferred font reconciliation state

* test: cover Ghostty font action formatting

* fix: encode Ghostty font actions invariantly

* test: cover pre-promotion font provenance

* fix: retain deferred font provenance

* test: cover font drain and fit recovery

* fix: release drains and preserve fit ceiling

* refactor: align font lifecycle with review policy

* refactor: make font dependency wiring explicit

* test: cover absolute font input during reload

* fix: preserve absolute font input during reload

* test: cover asynchronous config reload lifetime

* fix: retain config reload activity through reconciliation

* test: cover queued config reload requests

* fix: serialize reloads and observe native font actions

* test: cover reload config waiter admission

* fix: bound reload config waiters

* docs: record font action GhosttyKit pin

* test: bound coalesced reload completions

* fix: bound coalesced reload completions

---------

Co-authored-by: cmux-lawrence <[email protected]>
2026-07-29 03:53:32 -07:00
Abdulaziz Albaharandlawrencecchen 21e195088e Retire QuickLook previews after window loss (#8789)
* test: reproduce QuickLook reuse after window loss

* fix: retire stale QuickLook preview views

* test: tighten QuickLook lifecycle coverage

* test: exercise mounted QuickLook retirement

* test: keep QuickLook lifecycle setup in tests

* test: exercise deactivated QuickLook replacement

* fix: retire orphaned QuickLook previews

* test: reproduce QuickLook reuse after window close

* test: import QuickLook lifecycle types

* fix: own QuickLook preview lifecycle

* fix: close app-owned QuickLook views off-window

* refactor: modernize QuickLook lifecycle tests

* test: await file preview save completion

* test: use Swift Testing comment literals

* test: isolate file preview save from watcher

* test: keep async save coverage on XCTest

* test: isolate file preview save dispatch

* test: isolate preview shortcut override

* test: retain preview focus window through close

* test: restore absent preview shortcut override

* test: exercise the production preview editor

---------

Co-authored-by: lawrencecchen <[email protected]>
2026-07-29 02:58:38 -07:00
Abdulaziz AlbaharandClaude Fable 5 cf817f7e2d Bump iroh-ffi to 1.0.2-cmux.7: idle-path stall evidence fix (#9134)
* Bump iroh-ffi to 1.0.2-cmux.7: idle-path stall evidence fix

Pulls the path-health detector correction into cmux
(manaflow-ai/iroh#10, merge 4152d81047a6). Structured review of the
detector merge found it counted raw udp_tx datagrams as stall evidence,
which includes the 5s keepalive PING and its PTO probe retransmissions:
on an IDLE selected direct path a transient 2-10s radio gap (WiFi roam,
channel switch) reached the 3-datagram threshold inside the 1s stall
floor and demoted AND quarantined (5s doubling to 300s) a healthy path
with zero application data pending, ratcheting repeat transients onto
the relay. Stall evidence now comes only from application-bearing
frames (STREAM/DATAGRAM/RESET_STREAM/STOP_SENDING); PTO probes
retransmit pending app data whenever any exists, so genuinely dead
paths under load still fail over fast (fork red/green: idle 8s gap no
longer demotes; active-blackhole failover 1.8s, deadline 6s).

Verification: CmuxIrohTransport 494/494, CMUXMobileCore 306/306.

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

* Regenerate SwiftPM lockfiles for iroh-ffi 1.0.2-cmux.7

Updates the remaining lockfiles CodeRabbit flagged so the pin change is
visible in every resolution root: ios/cmuxPackage/Package.resolved
(regenerated with swift package resolve), the root Xcode workspace
lockfile (regenerated with xcodebuild -resolvePackageDependencies on a
fleet Mac), and the iOS workspace lockfile (pin entry set to the same
tool-produced revision 20f0e67cc3cb / version 1.0.2-cmux.7; its
originHash refresh is left to the next Xcode resolution because the
fleet builder ran out of disk mid-resolve and the dedicated builder is
unreachable — the pin is exact, so re-resolution cannot drift).

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

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-29 04:30:50 -05:00
Lawrence Chen 6f58efbdca Add cmux.com team-vault APIs for local Subrouter egress (#9099)
* Add authenticated Subrouter team-vault APIs

* Decouple Subrouter CLI login from transcript storage

* Log safe Subrouter control-plane failures

* Test Subrouter auth, validation, and team pagination

* Harden Subrouter auth and team authorization

* Test lease auth mode and bounded permission discovery

* Preserve lease constraints and bound team discovery

* Test fail-closed Subrouter authorization

* Harden Subrouter authorization and device consent

* Test Subrouter authorization review regressions

* Harden Subrouter device and team authorization

* Test Subrouter dashboard authorization recovery

* Render Subrouter authorization recovery state

* Test complete Subrouter authorization deadlines

* Bound cloud authorization and harden API edges
2026-07-29 02:03:33 -07:00
Austin Wang dec474e89d Merge pull request #9096 from manaflow-ai/issue-8627-html-click-render
Render Command-clicked HTML files in browser panes
2026-07-29 01:46:05 -07:00
Lawrence Chen 9a16a84c9e Add token multitasking workflow blog post (#9135) 2026-07-29 00:58:14 -07:00
austinywang 0c2c39703a fix: capture RPC session actor immutably 2026-07-29 00:34:59 -07:00
cmux reload-cloud e88486a21f Merge remote-tracking branch 'origin/main' into issue-8627-html-click-render 2026-07-28 23:59:25 -07:00
cmux reload-cloud 74b1dd9faf Distinguish non-web browser user agent policy 2026-07-28 23:59:12 -07:00
Lawrence Chen ae52684c53 Restore the Swift warning budget on current main (#9100)
* fix: isolate goto split focus observer

* fix: handle retry binding phases without overlap

* test: cover active retry binding identity

* test: respect retry coordinator boundary
2026-07-28 23:55:08 -07:00
Abdulaziz AlbaharandClaude Fable 5 058dc304f2 Scope iOS primary search to the search tab (#9129)
The .searchable modifier sat on the primary TabView, so every tab's
NavigationStack inherited it and rendered a second search field at the
top of the Workspaces and Notifications tabs, on top of the iOS 26
bottom search tab. Attach .searchable (and its .onSubmit) to the search
tab's destination instead, so the only search entrypoint is the bottom
tab-bar search pill.

Verified on an iOS 26.5 simulator: no top search field on Workspaces or
Notifications; tapping the search pill from either tab still presents
the bottom search field with the matching scope prompt and keyboard.

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-29 01:34:25 -05:00
Abdulaziz AlbaharandClaude Fable 5 d40948e169 Fix iOS RPC cancellation reconnect churn (#8929)
* test(ios): cover cancellation preserving healthy RPC transport

* fix(ios): preserve healthy RPC transport on cancellation

* test(ios): cover demand-gated cancelled write recovery

* fix(ios): demand-gate cancelled write recovery

* test(ios): remove cancellation timing sleep

* test(ios): cover expired demand recycling stalled write

* fix(ios): recycle stalled write for expired demand

* fix: allow off-main mobile flag reads

* test(ios): queued request behind cancelled stalled write must recycle within grace

A request already queued behind a cancelled stalled active write has
passed the send() recovery gate, so nothing recycles the transport for
it: it hangs until its own deadline and fails with requestTimedOut while
the wedged transport stays installed. Red on this commit; fix follows.

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

* fix(ios): recycle cancelled stalled write when demand was already queued

startCancelledActiveWriteResolution now starts a grace-bounded watchdog
whenever live queued writes exist at cancellation time. If the cancelled
send has not resolved when the grace expires and queued demand still
exists, the transport is recycled so queued requests fail fast with
connectionClosed instead of hanging until their own deadlines behind a
write their timeout cannot recycle. Demand arriving after cancellation
is unchanged: it is gated in send() and needs no watchdog.

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

* docs(ios): state wire request id uniqueness contract on requestData

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

* test(ios): queued request timing out behind a cancelled write must recycle promptly

When a queued follower's deadline is shorter than the cancellation
grace, its timeout erases it from queuedWriteIDs before the grace
watchdog re-checks demand, so the wedged transport stays installed and
the next request pays for the recycle. Red on this commit; fix follows.

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

* fix(ios): condemn cancelled write when queued demand times out behind it

A queued request dying at its deadline while head-of-line blocked
behind a cancelled unresolved write now recycles that write's transport
immediately and fails with transportWriteTimedOut. Previously its
timeout only erased it from queuedWriteIDs, so the grace watchdog
mistook the timed-out demand for explicit cancellation, preserved the
wedged transport, and made the next request pay for the recycle.

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

* test(ios): time-bound close/teardown wait polling

Bare Task.yield() loops can burn out in under a millisecond under suite
load before the session's async close task is scheduled, flaking
cancelledPostConnectOnlyWaiterClosesTransport about 1 in 8 runs.

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

* refactor(ios): move RPCTaskTimeoutCancellation to its own file with safety argument

Matches the RPCTaskTimeoutRace precedent and documents why the type is
@unchecked Sendable with an NSLock: withTaskCancellationHandler's
onCancel is synchronous on an arbitrary thread and cannot await an
actor; all mutable state is lock-guarded and finish paths must win the
race actor, so the continuation finishes at most once.

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

* fix(ios): coalesce cancelled-write resolution waiters on the session actor

Each gated request used to spawn a Task awaiting the resolution
observer's value; cancelling it does not detach from Task.value, so a
burst of callers behind a cancellation-ignoring send parked one task
each until the wedged send eventually returned. Waiters are now
CheckedContinuations stored on the actor, resumed when the cancelled
write completes, fails, is recycled, or the session tears down, so
recovery frees them instead of the stalled send. Recycle also disposes
the resolution task it previously orphaned by clearing activeWrite
before tearDown could reach it.

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

* fix(ios): unregister coalesced resolution waiters on caller cancellation

A gated request cancelled while the cancelled write was still pending
left its CheckedContinuation in writeResolutionWaiters until the write
resolved or the session tore down, so repeated cancelled requests
behind a never-resolving send grew the map. awaitCancelledWriteResolution
now wraps registration in withTaskCancellationHandler with a stable
waiter ID and removes+resumes the waiter on cancellation. Covered by a
drain assertion in cancelledWriteResolutionHonorsNextRequestCancellation.

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

* fix(ios): resume resolution waiters only on real active-write transitions

cancelledActiveWriteDidComplete resumed every coalesced waiter even
when its identity guard made clearActiveWrite a no-op, so a stale
completion callback from an older write generation could spuriously
satisfy the queued-demand watchdog of a newer cancelled write and
degrade queued requests from 250ms recovery to their full deadline.
Waiter resumption now lives inside clearActiveWrite behind the same
connection+request identity guard, so waiters wake iff the current
write actually transitions (complete, fail, recycle, teardown).

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

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-29 01:29:36 -05:00
cmux reload-cloud 1c848d8311 Add failing distinct user agent policy test 2026-07-28 23:29:35 -07:00
cmux reload-cloud fb56973857 Add failing local-file user agent policy test 2026-07-28 22:51:55 -07:00
Austin Wang 1dd429dd5c Emit Pi compact and subagent lifecycle Feed events (#9106)
* test: cover Pi compact and subagent feed events

* fix: emit Pi compact and subagent feed events
2026-07-28 22:48:58 -07:00
austinpower1258 37ce5fd980 test: preserve live Dock panel aliases during reconcile 2026-07-28 22:43:05 -07:00
Austin Wang aaddb97ffb Merge pull request #9115 from manaflow-ai/issue-8722-omp-restore-nested-sessions
Fix OMP restore tracking for nested task sessions
2026-07-28 22:25:02 -07:00
Austin Wang 4eee8cee33 Merge pull request #9098 from manaflow-ai/issue-6291-spinner-title-sidebar-freeze
Collapse spinner terminal titles before ingress dedup
2026-07-28 21:59:16 -07:00
Abdulaziz AlbaharandClaude Fable 5 4941384418 iOS: scroll-reveal the terminal files chip (#8928)
* iOS: scroll-reveal the terminal files chip

The chip is now hidden at rest and revealed by scroll activity,
scrollbar-style: touch-down on the scroll surface shows it and holds it
while the finger is down, every movement delta (tracking and momentum)
pushes the idle linger out, and 2.2s after scrolling settles it fades
away. Mount state (whether there are files to show) is unchanged and
orthogonal — the reveal is a visibility gate on top, alongside the
toolbar and zoom-HUD gates. Detach/dismantle reset the reveal.

Linger runs in a cancellable Task on an injectable Clock (no
asyncAfter); a finger resting mid-drag produces no deltas, so the hide
deadline re-arms while the scroll view is still tracking.

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

* iOS: no per-frame task churn in the chip scroll reveal

Review finding: re-arming the linger from every scroll delta cancelled
and allocated a MainActor task per frame (~120/s on ProMotion), even
with the chip disabled. Movement deltas are now guard-only (reveal is a
single bool flip per gesture, gated on mounted chip content and a
user-driven scroll); the fade-out linger is armed once, by the
drag-end/deceleration-end callbacks.

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

* iOS: keep the files chip reachable for assistive tech and first-scroll mounts

Review findings: (1) the scroll-reveal gate removed the only Files
control from VoiceOver and Switch Control at rest (the host hides its
accessibility descendants while invisible) — the transient reveal is
now bypassed whenever either is running. (2) the reveal was only
recorded when chip content was already mounted, so the scroll that
discovers the FIRST file mounted an invisible chip until a second
scroll; the reveal state is now recorded independently of mount state
and applies when content arrives.

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

* iOS: re-run chip visibility when assistive-technology status changes

Review finding: the VoiceOver/Switch Control bypass was only sampled on
incidental visibility updates, so toggling either over an idle terminal
could leave the Files control hidden from (or stuck visible for)
assistive users. The surface now observes both status notifications for
the chip container's lifetime and re-runs the visibility update.

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

* iOS: unregister the chip accessibility observers at dismantle

Review finding: block-based NotificationCenter observers stay
registered until explicitly removed, so each terminal surface remount
leaked two registrations whose closures kept firing on VoiceOver /
Switch Control status changes. Removal happens in prepareForDismantle
(main-actor teardown); Swift 6 forbids touching the non-Sendable token
array from nonisolated deinit.

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

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-28 23:42:38 -05:00
Abdulaziz Albahar 4574582563 iOS: stop terminal zoom + push-up during keyboard transitions (#8907)
* iOS: stop terminal zoom + push-up during keyboard transitions

Dogfood of the kbpin build showed two glitches synchronized with the
software keyboard: the terminal font visibly zoomed when the keyboard
closed (then snapped back), and rows were shoved off the top of the
screen mid-transition before sliding back down.

Three causes, all in the shared-grid negotiation around a keyboard
container change:

1. The stretch-to-fill auto-fit ran on every geometry pass, including
   the pass right after the keyboard target changed, while
   effectiveGrid still held the PREVIOUS grant (the phone itself just
   invalidated it). It stretched the rendered font toward filling the
   new container with the stale row count and decayed one RPC
   round-trip later. The fit is now deferred until the negotiation is
   settled: no keyboard animation in flight, no report debouncing, the
   newest report's echo confirmed, and the pass's capacity equal to
   the last reported grid. The settle paths (animation completion,
   echo confirmation, retry exhaustion) each schedule one final sync
   so exactly one fit runs on the settled grant.

2. The render rect bottom-pinned to the LIVE viewport
   unconditionally. During a dismissal the surface is already sized
   for the taller target viewport, so pinning to the still-small live
   bottom pushed the top rows off screen by renderHeight - liveHeight
   (renderRect y hit -344pt in the captured logs) and they slid back
   as the keyboard left. TerminalLetterboxGeometry.renderPinnedBottomEdge
   now caps the clip at the settled amount, and while the negotiation
   is unsettled a provisionally pinned render holds its top edge
   instead of riding the departing keyboard down and snapping back up
   on the fresh grant. Settled letterbox boxes and the keyboard-rise
   path keep the legacy live-edge ride.

3. Capacity reports normalized the measured cell size with the
   main-actor liveFontSize read at apply time. A font change queued
   between the measurement and the apply broke the base-font
   normalization by the zoom ratio, reporting a grid several times too
   small and feeding bogus grants back into the loop (the 10-row
   grants visible in the field recording). The geometry pass now
   captures the font it measured with and the report/fit use that
   paired value.

Verified on cmux-kbpin-sim against a live Mac kbpin instance: debug
log shows zoom.autofit.deferred during transitions, renderRect pinned
at y=0 through the dismissal (previously -344), no font change across
the whole cycle, and frame analysis of the recorded dance shows the
text top and pitch constant through both transitions.

* iOS: defer local shrink resize until the grid negotiation settles

The keyboard-rise direction still showed a momentary "all rows pushed
up": the local mirror resized to the smaller container immediately,
and its reflow keeps the bottom of the SCREEN (trailing blank rows
included), so the visible content collapsed to the tail of the old
screen jumped to the top until the remote reflow landed one round-trip
later.

While the negotiation is unsettled and the container shrank at the
same width (keyboard rising), the geometry pass now skips the local
set_size and letterbox fit: the old render keeps its size and the
bottom-pinned render rect slides it up with the keyboard, prompt glued
to the keyboard top. The capacity report is pure container/cell math,
so the negotiation still starts immediately, and the settle pass
(echo confirmed, or retries exhausted) applies ONE resize whose result
matches the remote's reflowed content. Deferred passes also skip
re-stamping the render's source-layout height so the stale-live clamp
cannot snap the old render to the target viewport mid-ride, and the
applied-container tracker resets with the render pipeline.

Width changes (rotation, split) and growth keep the immediate resize.
2026-07-28 23:39:51 -05:00
cmux reload-cloud 804add5084 Merge remote-tracking branch 'origin/main' into issue-8722-omp-restore-nested-sessions 2026-07-28 21:35:18 -07:00
Abdulaziz AlbaharandClaude Fable 5 8ba7781f8f Directed presence channel: server can wake the Mac (nudge push) (#9012)
* Give the Mac a directed presence channel so the server can wake it

The Mac publishes to presence and the broker but receives nothing, so a
server-side change to its iroh binding (revocation, re-key replacement)
only reached it on the next scheduled broker round trip, up to ~45
minutes later. The phone already holds a presence WebSocket; this adds
the Mac-side equivalent as a quiet directed channel.

Presence worker: `?deviceScope=<deviceId>` on the subscribe route turns
the stream into a WebSocket-only nudge channel — no snapshot, no team
presence chatter, no sync — gated by the same first-heartbeat owner pin
as heartbeats (subscribing never writes the pin). A new owner-only
`POST /v1/presence/nudge {deviceId, tag?, kind}` delivers a
`{type: "nudge"}` frame to that device's scoped sockets. Nudges are
never sent to normal subscribers, mirroring how sync frames are gated
on `sync.hello`, so legacy presence decoders that throw on unknown
event types never see one. Kinds are a server-side allowlist
(`iroh-binding-changed`); the frame carries no route or binding data.

Mac app: `PresenceNudgeSubscriber` mirrors `PresenceHeartbeatClient`'s
gating and holds the directed stream with 1s→60s reconnect backoff. A
nudge for this device (and build tag, when given) calls the new
`CmxIrohHostRuntime.requestRegistrationRefresh()` — one immediate
registration/policy round through the existing coalesced refresh path —
plus `retryIfNeeded()` for absent runtimes. Against a pre-nudge worker
the same endpoint serves snapshot/presence frames, which the subscriber
ignores, so old servers degrade to a no-op.

The broker-side hook that fires the nudge on revocation/replacement is
deliberately a follow-up: those mutation paths are being rewritten by
the in-flight binding re-key work (PR 8883), and the endpoint is
independently drivable until then.

Worker: bun test 183 pass (9 new), typecheck clean. Package: 37
CmxIrohHostRuntime tests pass (2 new for requestRegistrationRefresh).

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

* Address autoreview: wss scheme, replaced-binding rebuild, socket lifecycle, delivery ownership

Five review findings, all confirmed against the code:

- The subscribe URL kept the https scheme; URLSessionWebSocketTask needs
  wss, so the channel never connected. Convert https/http to wss/ws,
  same as the iOS PresenceClient.
- A nudge-triggered refresh that discovers the binding was replaced
  (different binding id) fails closed into the terminal .failed phase
  and nothing rebuilt it. requestRegistrationRefresh now awaits the
  refresh round settling, and the composition root reads the
  post-refresh snapshot and rebuilds through reconcile with
  restartActiveRuntime so a fresh activation re-registers under the new
  server state. New package test pins the fail-closed contract for a
  replaced binding id.
- evaluate() only toggled on/off, so a team or service-URL change rode
  the old socket to the 15-minute deadline. The loop is now keyed by a
  team+URL scope and restarts when the scope changes.
- URLSessionWebSocketTask.receive() ignores Swift task cancellation, so
  disabling presence left the socket suspended in receive until expiry.
  The receive loop runs under withTaskCancellationHandler that cancels
  the socket, and frames received after cancellation are dropped.
- The DO delivered nudges by deviceScope alone; a subscriber who lost
  the first-heartbeat pin race could still receive owner-only frames.
  Delivery now also requires the socket's verified user to equal the
  device's current pinned owner.

Worker: 183 bun tests pass, typecheck clean. Package: 38
CmxIrohHostRuntime tests pass (replaced-binding case new).

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

* Address review round 2: refresh await, scope key, nudge coalescing, quiet-close backoff

Four fixes from the second structured review pass:

- requestRegistrationRefresh() now awaits across the coalesced replay
  round, not just the in-flight one, so a caller that rebuilds on
  `.failed` observes the state AFTER the replay the pending bit
  scheduled.
- PresenceNudgeSubscriber's scope key includes the authenticated user id
  and requires isAuthenticated, so two solo accounts (nil resolvedTeamID)
  can never share a directed stream scope, and auth identity changes
  restart the loop via an @Observable tracking re-arm.
- MobileHostIrohRuntime.refreshRegistrationFromServerSignal() is
  single-flight with a pending bit: a burst of nudge frames coalesces
  into one follow-up refresh instead of fanning out one main-actor
  waiter per frame.
- subscribeOnce() treats a normal/going-away close as healthy service:
  a directed stream is silent between nudges, so the quiet 15-minute
  renewal close must reset backoff instead of doubling it toward 60s
  gaps that could swallow a one-shot nudge.

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

* Extract and test the owner-only nudge delivery decision

Review round 3 flagged that the security-sensitive delivery filter (owner
re-check per frame, directed-socket routing) had no behavior coverage.
Following the suite's no-Workers-runtime pattern (checkDeviceOwner), the
per-socket decision moves into a pure shouldDeliverNudge in core.ts, the
DO delivery loop calls it, and tests cover: normal presence subscribers
never receive nudges, wrong-device scopes and expired sockets are
excluded, a subscriber who lost the first-heartbeat pin race is excluded
at delivery despite an accepted subscription, legacy sockets without a
verified user id never match, and a mixed subscriber set delivers to
exactly the pinned owner's directed socket.

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

* Detect legacy presence endpoints instead of decoding their traffic on main

Review round 4 P1: against a pre-nudge worker, ?deviceScope= is ignored
and the directed socket degrades to a full presence subscription — a
team snapshot (megabytes at the service's caps) followed by seen events,
each JSON-parsed on the main actor before being discarded.

The receive loop now classifies each frame before parsing: anything over
a 2 KiB bound (a real nudge is ~200 bytes) is foreign in O(1), so a
snapshot is never parsed. The first foreign frame proves the endpoint is
legacy — a nudge-aware worker sends only nudge frames on a directed
stream — so the subscriber closes immediately and re-probes every 15
minutes instead of pumping team traffic. A legacy worker has no nudges
to deliver, so the slow probe loses nothing; once the worker upgrades,
the next probe holds a normal directed stream.

Also documents the deliberately accepted first-writer pin residual on
the nudge authorization path (do.ts, README): the presence worker keeps
no synchronous registry dependency by design, and a squatted pin only
suppresses the acceleration — the Mac falls back to its pre-nudge
renewal cadence, never to a correctness failure.

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

* Split subscriber pools and require lifetime before trusting a clean close

Review round 5:

- Directed (device-scoped) sockets no longer draw from the shared
  64-subscriber presence pool. Every enabled Mac instance holds one, so
  a fleet of Macs or tagged dev builds could deterministically 429 the
  phones' presence streams. Admission is now a pure, tested decision
  (checkSubscriberAdmission): directed sockets get their own bounded
  pool of 256 and each pool only rejects its own kind.

- An EMPTY cleanly-closed stream counts as served only after living 60
  seconds. The close code alone let an accept-then-close loop
  (persistent drain, misbehaving proxy) pin every Mac at one WebSocket
  handshake per second forever; the healthy quiet close arrives at the
  service's 15-minute deadline, far above the threshold, so normal
  renewals still resubscribe promptly.

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

* Bound the directed pool per user and the nudge frame at the transport

Review round 6:

- Directed subscribe admits unpinned devices by design (a Mac subscribes
  before its first heartbeat), which let one member park sockets on
  arbitrary fresh UUIDs until the 256-socket team pool 429'd legitimate
  owners. Admission now also enforces a per-user slice (32), so one
  member can never reach the team ceiling; the pure decision and its
  tests cover both pools and the slice.

- The Mac's 2 KiB nudge bound moved from post-receive classification to
  URLSessionWebSocketTask.maximumMessageSize, so a legacy worker's
  team snapshot fails the receive (EMSGSIZE) before it is buffered
  instead of after megabytes land in memory. That failure classifies as
  .legacyEndpoint, converging with the parsed-foreign-frame path on the
  15-minute reprobe. The in-classifier length check stays as a second
  layer.

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

* Document that a nudge accelerates the renewal round without changing it

Comment-only. Review round 7 flagged that a superseded host answering a
replacement nudge re-registers (mutating the newest-wins slot) before it
detects the changed binding id. That ordering is the pre-existing
renewal path; the nudge deliberately reuses it unchanged, and the
displaced-instance disposition (stand down without re-taking the slot)
belongs to the nudge-emission hook that fires from the authoritative
broker mutation — deferred with it to the follow-up PR behind
https://github.com/manaflow-ai/cmux/pull/8883.

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

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-28 23:22:14 -05:00
Abdulaziz Albahar a20fc59edd Fix stale GhosttyKit in iOS reloads (#9121) 2026-07-28 23:18:40 -05:00
Abdulaziz AlbaharandClaude Fable 5 99cd94b98e iOS: make the files chip count exactly match the gallery rows (#8902)
* test: pin files chip to gallery row count

* feat: make files chip match gallery rows

* iOS/Mac: address review findings on the gallery-count path

- Count-only scans no longer capture terminal text up front: session
  workspaces never use it, and the capture takes the Ghostty surface
  lock inside v2MainSync on every settled-output refresh. Only the
  no-session fallback re-resolves with viewport-only text.
- Counting is now stat-only via a shared isEligible predicate: no
  ChatArtifactGalleryItem construction and no directory child
  enumeration for counts; page rows route inclusion through the same
  predicate so the rule cannot drift (invariant test unchanged).
- A held authoritative zero now yields to fresh positive local evidence
  when a refresh scan fails, so the chip cannot stay unmounted until
  the transport recovers; a later successful scan restores authority.

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

* iOS/Mac: cheap existence-only counting and a corrected hold test

Review findings: counting statted every historical reference through
ArtifactByteReader (which can read file bytes to classify
extension-less files) and page rows statted twice with a TOCTOU window
between decision and construction. The inclusion rule is now one pure
function fed by each caller's own filesystem observation: counts use a
single fileExists syscall per reference; rows use the one
ArtifactByteReader stat for both the decision and the payload. The
hold-across-failure test now seeds a positive gallery total (a held
zero yielding to local evidence is the separately tested drop rule it
previously contradicted).

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

* Shared: match symlink semantics between count and row eligibility

Review finding: fileExists traverses a final symlink while
ArtifactByteReader.stat (attributesOfItem) observes the link itself, so
a dangling symlink counted as missing but rendered as an existing row.
The cheap count path now reads attributesOfItem too, and the invariant
test covers a dangling link.

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

* Shared/Mac: coalesce concurrent row-count sweeps and fix a rebase brace

Concurrent count-only callers that miss on the same (session,
generation, filters) key now await one shared computation inside the
cache actor instead of issuing overlapping sweeps; the helper's manual
miss-compute-store path collapses into it. Also removes a stray brace
introduced while resolving the tri-state rebase conflict.

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

* Production-shape the failed-scan hold test and cover successful no-session clearing

Mirrors the same test fix on the base branch: the failed-scan test now marks
the second completion as an explicit scan failure under a seeded session, and
a sibling test proves a successful no-session response clears the held total.

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

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-28 23:17:30 -05:00
austinpower1258 120713c6e0 fix: release restore monitors after teardown aborts 2026-07-28 21:08:25 -07:00
cmux reload-cloud 5480fdab69 Merge remote-tracking branch 'origin/main' into issue-8722-omp-restore-nested-sessions 2026-07-28 21:05:10 -07:00
austinpower1258 a751a4b2a9 perf: batch Dock panel ownership cleanup 2026-07-28 20:57:06 -07:00
austinpower1258 8e7796f44a test: cover incomplete hibernation process identity scope 2026-07-28 20:52:29 -07:00
Abdulaziz Albahar 246f6eec87 Validate production Iroh trust in release gates (#9118)
* test(iroh): expose retained production gate identity

* fix(iroh): validate production gate trust profile

* test(projects): cover synchronized workspace groups

* fix(projects): support synchronized workspace groups
2026-07-28 22:49:32 -05:00
cmux reload-cloud ade1331b4c Merge remote-tracking branch 'origin/main' into issue-8722-omp-restore-nested-sessions 2026-07-28 20:46:33 -07:00
Austin Wang 955df1465f Merge pull request #9117 from manaflow-ai/issue-9092-drainmailbox-livelock
Fix main-thread livelock under saturated terminal output
2026-07-28 20:42:43 -07:00
Abdulaziz AlbaharandClaude Fable 5 7587005cb6 iOS: stop the terminal files chip from flickering (#8822)
* iOS: add failing test — files chip count regresses on a failed session scan

A transient terminal artifact scan failure (nil session total) makes the
chip fall back from the session total to the viewport-only local count,
which oscillates while output streams. The chip should hold the last
session total until a scan succeeds again.

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

* iOS: stop the terminal files chip from flickering

The files chip unmounted with a fade the moment its count hit zero and
remounted when it went positive again. Zero counts are produced
transiently all the time: the visible-viewport scan re-runs on every
output settle while an agent streams (paths scroll out of the grid), a
reconnect flips the artifact capabilities and resets the count, and a
failed session scan regressed the count from the session total to the
viewport-only count. Each zero crossing played a 0.18s fade-out plus
0.2s fade-in, so the chip flickered continuously during agent output.

Fixes, all at the coordinator seam:
- TerminalArtifactChipVisibilityState turns count updates into mount
  transitions: shows are immediate, a zero count only schedules a hide.
- The coordinator waits out a 2s grace period (injected Clock sleep in
  a cancellable Task, per the no-asyncAfter rule) before unmounting;
  any positive count cancels the pending hide. Disabling the chip and
  dismantling the surface still unmount immediately.
- TerminalArtifactChipCountState now remembers the last successful
  session total and holds it across a failed scan instead of regressing
  to the oscillating local count; reset() forgets it.

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

* iOS: report the local files-chip count immediately, refine with the session scan

Live sim verification of the grace-period fix still showed the chip
blinking every few seconds under streaming output. Cause: with session
counts enabled, every report waited on the terminalArtifactScan RPC,
and a completion only survives if no output bumped the surface
generation while it was in flight. Positive counts get scanned right
before the next output burst, so they were dropped systematically;
zero counts get scanned in quiet pauses, so they landed. The standing
count parked at zero long enough for the hide grace to expire.

The local count needs no RPC: report it synchronously (holding the
last known session total once one is known so the number does not
regress), and let the async session scan only refine the number when
it completes. The chip now mounts instantly and stays put while paths
stream through the viewport.

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

* iOS: keep the files chip above the verified-replay freeze layer

Frame-by-frame analysis of the live sim repro showed the chip blinking
fully off for ~40-90ms on every output burst even after the count-side
fixes. The verified-replay frozen presentation mounts a full-bounds
snapshot layer at zPosition 2000 for the length of each freeze/reveal
transaction, and the chip sat at 1050, so every transaction covered it
for a frame or two. With an agent streaming, that is a metronomic
once-per-burst blink — the dominant part of the reported flicker.

Raise the chip to 2050. The zoom HUD conflict that motivated the old
1050 value is already handled by the zoomOverlayShown visibility gate
(the chip hides while the HUD shows), not by z-order.

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

* iOS: widen the files chip hide grace to 3.5s

The round-4 sim run still showed one graceful hide+remount in 41s of
streaming: the positive rescan after a zero can be delayed ~2.7s when
output keeps re-arming the settle window, just past the 2s grace.

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

* iOS: keep provisional chip reports chip-local and cache only accepted totals

Review findings: provisional reports fire on every settled viewport
change during streaming, and each gallery refresh signal makes an open
Files sheet run a session transcript query — so provisional deliveries
(reportAndRequest's immediate report and the new provisionalReport
in-flight case) now update the chip only; authoritative scan
completions and the legacy no-session-support report keep signaling.
And a response dropped for a surface-generation mismatch no longer
seeds the held session total: a generation bump can coincide with a new
agent session binding, so only accepted current-generation responses
are cached (the re-armed request re-fetches).

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

* iOS: key the held session total to its session and revalidate at hide time

Review findings: (1) a terminal can bind a new agent session without
remounting the coordinator, and the new session's first count-only
responses carry its ID with no total yet — the held total from the old
session was shown for it. The state now remembers which session the
held total belongs to and invalidates it when an accepted response
names a different session; transport failures (no response) still hold.
(2) a positive report can land in the delegate just before the hide
grace deadline and only cancel the hide after its SwiftUI round trip;
the hide task now re-drives the state machine with the fresh count
instead of unmounting.

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

* iOS: invalidate the held session total on any response naming a new session

Review finding: the identity check sat inside the generation-accepted
branch, and during streaming a new session's responses commonly arrive
after the viewport generation advanced — so they were dropped without
clearing the old session's total, which kept seeding provisional
reports. Session identity is generation-independent; the invalidation
now runs before the generation gate while totals are still cached only
from accepted responses.

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

* iOS: distinguish an authoritative no-session response from a failed scan

Review finding: optional chaining collapsed a transport failure and a
successful response whose session binding is gone, so a stale total
could stay attributed to a surface after its session moved elsewhere.
Completions now carry scan success explicitly: a successful nil-session
response clears the held total (when a session was previously known),
while failures keep holding.

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

* Production-shape the failed-scan hold test and cover successful no-session clearing

The failed-scan test previously used the default scanSucceeded: true with no
session ID, so it exercised the success path rather than the transport-failure
hold it claims to cover. Seed the held total under an explicit session, mark
the second completion as an explicit failure, and add a sibling test proving a
SUCCESSFUL no-session response clears the held total instead of holding it.

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

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-28 22:41:03 -05:00
austinpower1258 4f6d2295dd fix: compile transcript revalidation closure 2026-07-28 20:40:59 -07:00
Austin Wang c30fc24a32 Merge pull request #9087 from manaflow-ai/issue-8971-find-next-navigate-search
Fix terminal Find Next navigation
2026-07-28 20:27:24 -07:00
austinpower1258 53914adf1f fix: make agent hibernation teardown irreversible 2026-07-28 20:27:13 -07:00
Austin Wang 88af67aff5 Merge pull request #9102 from manaflow-ai/issue-9056-ssh-tmux-pane-input-capture
Fix ssh-tmux sibling pane input capture
2026-07-28 20:23:34 -07:00
austinpower1258 cc98f6aa6b fix: bound Ghostty app mailbox drain turns 2026-07-28 20:22:57 -07:00
austinpower1258 6c9247df57 test: reproduce app mailbox drain starvation 2026-07-28 20:22:56 -07:00
austinpower1258 9fa57a4822 Merge remote-tracking branch 'origin/main' into issue-6291-spinner-title-sidebar-freeze 2026-07-28 20:12:10 -07:00
cmux reload-cloud eb98bead43 fix: ignore nested OMP artifact sessions 2026-07-28 20:08:13 -07:00
Abdulaziz AlbaharandClaude Fable 5 364da2a355 Bump iroh-ffi to 1.0.2-cmux.6: dead-path failover + make-before-break relay rotation (#9116)
Pulls two transport fixes into cmux:
- Path failover (manaflow-ai/iroh#8): dead selected direct path detected in
  ~1-3 RTT and demoted to relay (quarantine + backoff) instead of black-holing
  data for the 15s path idle timeout while the host send queue overflows and
  kills the session. Removes the ~2s WiFi connect/die metronome. Fork
  red/green: 15.1s stall -> 1.55-1.58s relay failover, connection never closes.
- Relay credential rotation without disconnecting (manaflow-ai/iroh#9).

Verification: CmuxIrohTransport 473/473, CMUXMobileCore 305/305 (one
non-reproducing flake on a first run, clean on two reruns).

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-28 21:54:20 -05:00
Abdulaziz AlbaharandClaude Opus 4.8 1472990921 feat(iroh): complete release closeout and recovery hardening (#9071)
* test(iroh): expose reconnect outage gaps

* fix(iroh): keep reconnects alive through outages

* fix(ios): signal reconnect deadlines without sleeping

* test(iroh): cover close attribution diagnostics

* feat(iroh): attribute connection closes and path events

* iroh: re-key binding slot to (user, device, tag), newest-auth-wins

The active-binding slot was keyed on app_instance_id with a unique index,
so a reinstall, sign-out/in, or key rotation produced a fresh app instance
that collided with its own past self and got a 409
binding_replacement_requires_revocation. That stranded the App Store review
Mac behind a stale non-revoked binding for 17h with no client-side recovery.

Re-key the slot to (user_id, device_uuid, tag), partial-unique where
revoked_at is null. A registration for an existing slot now overwrites it in
place (newest authenticated registration wins) and preserves the binding row
id so existing pair grants keep resolving. No generation gate: a reinstall
resets identity_generation to 1, and gating on it would reintroduce the wedge.
The endpoint id stays globally unique, re-checked excluding self so a slot can
rotate its own key.

Drop the per-device (8) and per-account (32) binding caps, the stale-binding
recycler, and the bindingQuota plumbing; the challenge-issuance quota is kept.
Advisory locks move from iroh:app:<appInstance> to
iroh:slot:<user>:<device>:<tag> so same-slot registrations serialize.

Migration collapses any duplicate active (user, device, tag) rows (keep most
recently seen, soft-revoke the rest, revoke their pair grants, bump LAN
discovery generation), drops active_app_instance_unique, and adds
active_slot_unique.

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

* iOS: stable Keychain device id + Forget computer (iroh re-key client)

Client complement to the broker binding re-key (manaflow-ai/cmux#8883),
which changes the iroh binding slot from unique(app_instance_id) to
unique(user_id, device_uuid, tag) and replaces the 409
binding_replacement_requires_revocation with a newest-authenticated-wins
in-place UPDATE.

Two changes make the phone cooperate with that slot:

1. Stable device id across reinstall. The iOS device-registry id moves
   from UserDefaults (erased on delete/reinstall) to a device-only
   Keychain item (service com.cmuxterm.deviceRegistry.iosDeviceID.v1,
   AfterFirstUnlockThisDeviceOnly). A returning phone now presents the
   same device_uuid and overwrites its own binding in place instead of
   stranding a fresh one. Keychain is authoritative; a pre-Keychain
   UserDefaults id is migrated on first read, and the generated id is
   mirrored back to UserDefaults for downgrade safety. This service is
   distinct from the iroh endpoint-identity store that sign-out/reinstall
   wipes, so forgetting the endpoint identity does not churn the slot key.

2. Forget a hidden computer. The per-phone Hidden Computers list gains a
   destructive Forget action (swipe + context menu, both gated behind a
   confirmation dialog, mirroring MacComputerRow's Hide) that revokes the
   Mac's account binding through the user-ownership-scoped broker endpoint.
   It resolves the binding id at action time via a fresh broker.discover()
   (so an offline Mac's binding is still listed and revocable), matches by
   canonical device id plus exact tag when known, revokes each match, then
   clears the local hidden marker and paired-Mac row. A still-online Mac
   re-registers and reappears on its next connect. Failure keeps the row
   and surfaces a toast.

New narrow capability MobileIrohMacForgetting keeps the shell store's
dependency minimal; en+ja localization added for the Forget copy.

* iroh: mint new binding id on endpoint rotation, add active-binding sanity cap

Address the two P1 review findings on the re-key branch.

Finding 1 (ABA wedge): register reused the same binding id when an existing
slot re-registered with a rotated endpoint key. A peer host that had denied the
OLD endpoint tuple keeps the denial keyed on binding id, so the rotated device
was permanently denied behind its own past self. Now a same-endpoint
registration is treated as a heartbeat and updates in place (stable id, no ABA),
while a rotated endpoint on an existing slot soft-revokes the old row
(revokedReason "slot_reincarnated", cleared ports/path hints) and inserts a NEW
binding id, carrying live pair grants (initiator + acceptor) onto the new id so
pairings follow the device without a re-pair. No lanDiscoveryGeneration bump: a
device rotating its own key is not an account-wide trust revocation.

Finding 2 (unbounded growth): under unique(user, device, tag) a stuck client
spamming fresh tuples could grow the active row set without bound. Add
IROH_ACTIVE_BINDING_SANITY_CAP (512) enforced only on the genuinely-new-slot
path, evicting the oldest-seen bindings (LRU by lastSeenAt) with reason
"active_binding_cap_evicted". No-op for every normal account (a handful of
bindings; heavy multi-tag dev at most low hundreds).

Tests: reinstall now asserts new-id semantics + retired-row reason; added
grant-carry and cap-eviction coverage. 33 DB-behavior tests and 26 route-layer
tests pass against isolated Postgres; typecheck clean.

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

* iOS: fail closed on unreadable device id, alert on Forget failure, pin account

Address the four P1 review findings on the iroh re-key iOS client branch.

Finding 1 (device-id read ambiguity): DeviceIdentityStoring.read() returned an
optional, collapsing "no id yet" and "Keychain locked before first unlock" into
nil. A background launch before first unlock therefore looked like a fresh
install and minted a NEW id, stranding the phone's existing (user, device, tag)
binding. read() now returns DeviceIdentityReadResult (.found/.absent/
.unavailable). deviceID(store:defaults:) fails closed on .unavailable: it reuses
the legacy UserDefaults mirror if readable, else a per-process ephemeral id that
is never persisted, so the durable id is adopted once the store unlocks. A
.found id is re-mirrored to UserDefaults (only when it differs) for downgrade
safety; a present-but-blank/corrupt item is treated as .absent and re-minted.

Finding 2 (account pinning): MobileIrohRuntimeComposition pins the expected
account and ensureAccountUnchanged guards Forget so a token-source swap mid-flow
can't revoke a binding under the wrong account (MobileIrohForgetError.
accountChanged).

Finding 3 (Forget ordering): MobileShellComposite forget removes the row before
clearing the hidden marker and returns Bool so a failed broker revoke surfaces
instead of silently dropping the row.

Finding 4 (Forget failure visibility): DeviceTreeView shows a .alert (not a
toast) on Forget failure, so the error surfaces even with the Toasts beta flag
off. Keys mobile.computers.forget.failureTitle/failureMessage, mobile.common.ok
localized en+ja.

CmuxMobileShell host-compiles and its 21 DeviceRegistry tests pass (incl. new
fail-closed + re-mirror coverage). DeviceTreeView and MobileIrohRuntimeComposition
transitively need GhosttyKit, so they compile only in the fleet iOS build.

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

* Recover the Mac iroh host runtime from terminal failure without relaunch

A non-transient broker rejection (401/403/404/409, invalid response)
tears CmxIrohHostRuntime down into a terminal .failed phase. That
fail-closed teardown is deliberate, but nothing ever rebuilt the
runtime: MobileHostIrohRuntime.retryIfNeeded() only re-synced LAN
publication while it held a runtime reference, and no timer retried a
failed activation. A Mac whose registration was rejected once stayed
unregistered until sign-out/sign-in, a Settings-triggered restart, or
an app relaunch (the 17-hour App Store review 409 wedge).

Recovery is now owned by the macOS composition root, level-triggered
through the existing reconcile path:

- Every failed activation and every runtime self-teardown into .failed
  (reported through the existing handleDeactivation callback, filtered
  by lifecycle revision so deliberate stops are ignored) arms one
  pending rebuild with bounded exponential backoff (30s doubling to a
  1h cap, jittered, via CmxIrohRetrySchedule and an injected clock).
- retryIfNeeded() now rebuilds a .failed runtime immediately on any
  external wake signal (network path change, app-level retry) and
  resets the backoff ladder, instead of only re-syncing LAN state.
- Each reconcile cancels the pending attempt and re-derives recovery
  from its own outcome: success resets the ladder, failure re-arms it,
  sign-out/deactivation ends it.

The new package test pins the contract this depends on: a rejected
registration refresh fails closed (endpoint torn down, deactivation
notified) and the same runtime accepts start() again once the broker
allows registration. The two-commit red/green structure does not apply
because the wedge lives in app-target singleton wiring that has no
practical automated harness; the package test guards the enabling
semantics instead.

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

* iroh: harden binding re-key against ABA wedge, LAN staleness, and cap churn

Address review findings on the slot re-key path:

- Heartbeat-in-place now requires every signed grant-identity field
  (endpoint id, platform, identity generation) to be unchanged, not just
  the endpoint id. Overwriting platform/generation on a live binding id
  would let a still-valid grant signed against the old value mismatch the
  current binding, so a host records this id in its permanent denial set —
  the exact ABA wedge the fresh-id path exists to prevent. Any divergence
  now falls through to reincarnation and mints a fresh id.

- Reincarnation retires the old slot through revokeActiveBindings instead
  of a bespoke soft-revoke. That rotates lanDiscoveryGeneration (so a
  displaced install can no longer derive future LAN rendezvous aliases)
  and marks the retired binding's pair grants revoked.

- Drop the pair-grant foreign-key carry-over. iroh_pair_grant_issuances is
  an audit-only ledger of compact JWS tokens already returned to clients;
  reassigning the FK cannot rewrite a held token, and re-keying forces a
  re-pair anyway because the token names the dead endpoint. Carrying the FK
  only made the JTI audit point at a binding it was never signed for.

- Sanity cap now rejects a genuinely-new slot at the cap
  (IrohQuotaExceededError code active_binding_limit) instead of evicting the
  oldest-seen binding, so a stuck client spamming fresh device/tag tuples
  can no longer shed the account's real, older hosts and phones.

Update iroh-db-behavior and iroh-trust-broker tests to the corrected
contract.

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

* iOS: harden iroh re-key client per review (device-id, session snapshot)

Address the P1 findings from review of the iroh re-key client changes.

Finding 1 (composition-half): re-resolve the durable device id at each
activation via DeviceRegistryService.durableDeviceID(defaults:) instead of
capturing it once at root init. A value captured while the durable identity
store was unavailable (Keychain locked before first unlock, or a persistent
write failure) is an ephemeral throwaway id; registering a binding under it
would orphan the retained (user, device, tag) binding. When the durable id is
nil, activation now defers (throws .inactive) and retries on the next reconcile
once the store becomes readable. The injected resolver is @MainActor () ->
String? so it can capture UserDefaults, which is not Sendable under Swift 6.

Finding 2: forgetComputer now pins the revoke to one atomic
AuthenticatedSessionSnapshot (session generation + account id + both tokens)
captured from a single auth-session generation, and the caller passes the
row's captured expectedAccountID. Reading the observed identity and the live
tokens separately let a lagging observed id authorize a revoke that then ran
with a different account's freshly-stored tokens. The broker token source and
every mid-flight re-check now require BOTH the generation and the account id to
be unchanged, so a sign-out/sign-in (even as the same user) aborts safely.

Finding 4: clear the captured scope's durable row and hidden marker
unconditionally after a successful revoke. removeStoredPairedMacRow targets the
CAPTURED scope, so it cannot touch another account's data; skipping it on a
mid-flight scope flip reported success while the row survived, so returning to
the old scope showed the supposedly forgotten computer.

Tests: activationDefersWhenDurableDeviceIDUnavailable proves no endpoint binds
and the retained binding survives when the durable id is unavailable;
forgetRemovesCapturedScopeRowEvenWhenScopeFlipsMidRevoke proves the captured
account is forwarded and the row is removed on a mid-revoke scope flip;
DeviceRegistryRouteSelectionTests cover the durable-id defer/mirror/adopt paths.

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

* iOS: failing test — forget of team-less Mac deletes wrong team on mid-revoke switch

The forget-hidden-computer flow snapshots its owner scope before the async
iroh revoke, then deletes the stored row. When the captured scope is team-less
(no team selected) and the user switches into a team while the revoke is in
flight, local cleanup goes through the team-scoping decorator's plain remove,
which substitutes a nil teamID with the now-current team. It deletes that
team's row and leaves the forgotten team-less computer behind, so it reappears
on returning to no-team.

This commit adds only the failing regression test (drives forgetHiddenComputer
through a TeamScoped-wrapped store with a mid-revoke team flip); the fix follows.

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

* iOS: forget deletes the exact captured scope, not the live team

Add removeExactScope to MobilePairedMacStoring: same shape as remove but it
never substitutes a nil teamID with the currently-selected team. The team-scope
decorator (TeamScopedPairedMacStore) and the backup mirror (BackingUpPairedMacStore)
override it to forward the captured teamID verbatim; the base SQLite store,
MobileMacCompatible, and IOSBuildScoped decorators inherit the default forward
(none of them substitute, so plain remove and removeExactScope are equivalent
there).

forgetHiddenComputer captures its owner scope before the async iroh revoke, so
removeStoredPairedMacRow now deletes via removeExactScope — a mid-revoke team
switch can no longer retarget a team-less forget onto the freshly-selected team.

Also call clearSavedMacHintWhenNoStoredMacsRemainIfNeeded() on the forget path
after reloading, matching the hide path, so forgetting the last stored Mac drops
the saved-Mac hint instead of leaving a dangling reference.

Makes the prior commit's regression test pass.

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

* iOS: converge device identity under races, gate snapshot during token transition

Device id (FIX #3): adoptOrGenerateDeviceID now goes through Keychain
createOrAdopt instead of last-writer-wins write. createOrAdopt does SecItemAdd
first and, on errSecDuplicateItem, adopts the value already stored, so two
launches racing to mint an id converge on one instead of overwriting each other
and registering two device rows against the broker. The UserDefaults mirror is
reconciled to the winning id; Keychain stays authoritative and survives app
reinstalls so the broker binding is not orphaned.

Session snapshot (FIX #1): authenticatedSessionSnapshot() now also requires
!sessionTokenTransitionIsActive in both guards, so a snapshot taken mid token
rotation cannot hand back a half-swapped session that would drive a redundant
re-register.

Adds convergence coverage in DeviceRegistryRouteSelectionTests
(createOrAdopt adopts the concurrent winner rather than minting a second id).

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

* iroh: reject over-cap registrations, gate stale challenges, document deviceUuid contract

Review round 2 for the binding re-key.

- Sanity cap: keep the reject-not-evict semantics (over-cap registrations throw
  IrohQuotaExceededError so a churning client can never shed real hosts) and hold
  the value at 512, well above any legitimate multi-tag developer's low-hundreds
  active-slot count. (An earlier draft lowered it to 256 citing an iOS
  'maximumBindingCount' wire limit; no such constant exists — the only 256 in the
  client is MobileSyncFrameCodec's per-read frame cap on the terminal RPC
  transport, unrelated to iroh discovery responses. Dropped that false rationale.)

- Challenge-freshness gate: reject a registration whose challenge was minted
  before the slot's current registeredAt. Registrations for one slot serialize
  under the slot advisory lock; without this, a delayed/replayed older challenge
  could land second and overwrite or reincarnate away the newer incarnation, an
  out-of-order wedge. A live heartbeat's own challenge is always newer, so it
  passes; registeredAt only advances on insert/reincarnation, so it is the right
  high-water mark.

- schema: document that deviceUuid MUST be stable across reinstalls or a reinstall
  orphans the old active slot; the client owns this (iOS now derives it from a
  Keychain identity that survives reinstall), the DB cannot enforce it.

- test: the mac->ios platform change on one slot reincarnates (revoke old id +
  mint new) instead of overwriting in place, so a still-valid grant signed against
  the old platform can't ABA-wedge into the host's permanent denial set.

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

* iroh: map active-slot unique violation to typed 409

databaseConflict only mapped the endpoint-unique index (23505 ->
endpoint_already_bound); a violation on the new (user, device, tag)
active-slot partial unique index fell through to a raw IrohDatabaseError
(HTTP 500). The slot advisory lock serializes same-slot registrations so
this is unreachable in practice, but map it defensively to a typed 409
(slot_registration_superseded) so a concurrent newest-wins race surfaces
as a retryable conflict instead of a 500.

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

* iOS: correct forget-scope regression test to genuinely catch mid-revoke team flip

The committed version of this test asserted contradictory post-conditions, so
it did not actually prove removeExactScope deleted the right row. Rewrite it to
load the base store once and partition rows by each row's own stamped teamID
(loadAll(teamID: nil) returns every team's rows, and loadAll(teamID:) also
returns team-less rows, so the returned set must be filtered by teamID to prove
which row was deleted). This version is red against the current
visibleScope-based removeExactScope: it deletes the flipped team-b row and the
team-less row survives, failing at the team-b assertion.

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

* iOS: forget deletes the exact captured team scope, no visibleScope re-derivation

removeExactScope forwarded through visibleScope/visibleMac, which call
inner.loadAll(teamID:): a nil team returns every team's rows and a set team
also returns team-less rows, ordered by lastSeenAt descending, so .first could
resolve a DIFFERENT team's row than the scope captured before the async revoke
and delete that row instead. When the user switches into a team mid-revoke, the
team-less forget then deleted the freshly-selected team's row and left the
forgotten team-less computer behind.

Make removeExactScope a pure pass-through to inner.removeExactScope, honoring
the exact (stackUserID, teamID, instanceTag) owner key verbatim; the layers
below do not substitute the team. Turns the regression test green.

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

* iOS: break corrupt-Keychain mint deadlock; move in-memory device store to tests

createOrAdopt, on errSecDuplicateItem, reads the item to converge racing
callers on one id. But read() maps a present-but-undecodable item to .absent
(so a fresh caller re-mints over garbage), which created a deadlock: a corrupt
Keychain item made every SecItemAdd return errSecDuplicateItem while read()
kept returning .absent, so the device could never mint a device-registry id and
iroh activation stayed permanently disabled. On .absent after a duplicate,
overwrite the corrupt item via SecItemUpdate and return desired, or nil (retry
a clean add) if a concurrent delete raced it to errSecItemNotFound. .unavailable
still defers so a locked-before-first-unlock item is never clobbered.

Also relocate the InMemoryDeviceIdentityStore test double out of the production
target into the test target; nothing in production or the app referenced it.

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

* iOS: hidden-computer unhide spinner tracks its own task, not forget's

The unhide Button's ProgressView keyed off forgetTask, so it never spun during
an actual unhide and could spin during an unrelated forget. performUnhide sets
actionTask; key the unhide spinner off actionTask.

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

* iroh: add failing test for reversed heartbeat completion

Two heartbeats for one live slot, minted older-then-newer, completing in
reverse: the newer lands first and takes the slot, then the delayed older
challenge lands second. Without a registration high-water mark that advances
on the in-place heartbeat update, the older challenge passes the staleness
gate and clobbers the newer incarnation's mutable fields (appInstanceId here)
back to a stale value until the next heartbeat self-heals. This commit adds
only the failing test; the fix follows.

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

* iroh: advance registration high-water mark on heartbeat; pin sanity cap to client wire limit

Finding 3 (reversed heartbeat completion): the in-place heartbeat update left
registeredAt frozen at the slot's original insert time, so two reversed
heartbeats both cleared the staleness gate and the later-landing OLDER challenge
clobbered the newer refresh. Stamp registeredAt to the applied challenge's
createdAt on the heartbeat path too, making it a true monotonic high-water mark
of the newest challenge that has landed (the gate already guarantees
challenge.createdAt >= registeredAt, so it only moves forward). Turns the added
reversed-completion regression test from red to green.

Finding 1 (cap above client wire limit): lower IROH_ACTIVE_BINDING_SANITY_CAP
from 512 to 256 to match the iOS discovery decoder's maximumBindingCount. The
broker's discoverySnapshot returns every active binding uncapped, and the client
rejects any snapshot carrying more than 256 bindings; admitting a 257th active
slot would make the account's own discovery response undecodable on every device.
The existing sanity-cap test references the constant symbolically, so it tracks
the new value automatically.

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

* iroh: add failing test for reversed challenge completion on a fresh slot

Covers the empty-slot ordering case the heartbeat test does not: two
challenges minted older->newer for a slot that does not exist yet, the
older landing first through the insert path. The genuinely newer
registration, landing second, must refresh the slot rather than be
rejected as superseded. Fails on current code because the insert stamps
registeredAt with its own landing time instead of the challenge mint
time, setting the high-water mark above the newer challenge.

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

* iroh: seed insert high-water mark from challenge mint time

The staleness gate treats registeredAt as the mint time of the newest
challenge that has landed, and the heartbeat path already stamps
challenge.createdAt. The insert/reincarnation path still stamped the
register-request landing time, so an older challenge that created the
slot could set the high-water mark above a newer outstanding challenge's
mint time and get it wrongly rejected as challenge_superseded, stranding
the older registration. Stamp challenge.createdAt on insert too, making
registeredAt an ordering-consistent high-water mark on every write path.

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

* iOS: failing tests for forget deleting wrong paired-Mac scope

Two regression tests, RED before the fix (commit adds tests only):

- Finding 2 (release-reachable): a team-less pairing shown under a
  selected team (legacy visibility) is forgotten; the forget captures the
  LIVE display scope and deletes with it, so removeExactScope(teamID:
  "team-a") misses the team-less row, the hidden marker is cleared, and the
  row resurfaces as a normal computer on returning to no-team.

- Finding 3 (dev/tagged builds): removeExactScope falls back to the
  protocol-default remove through MobileMacCompatiblePairedMacStore over
  IOSBuildScopedPairedMacStore, so an exact-scope team removal also deletes
  the co-located team-less build-scope fallback row.

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

* iOS: forget deletes each pairing's own captured scope, not the live display scope

The forget flow captured the live display scope and deleted with it, so a
team-less paired-Mac row shown under a selected team (fetchAllMacs legacy
visibility) was missed by removeExactScope(teamID: "team-a"); the hidden marker
cleared and the row resurfaced (Finding 2, release-reachable). Plumb each row's
own stackUserID/teamID through MobileHiddenComputer and delete with the row's
own scope.

Keep exact-scope removal exact through both store decorators: add
removeExactScope overrides to MobileMacCompatiblePairedMacStore and
IOSBuildScopedPairedMacStore so the call no longer falls back to the protocol
default remove, which over-deleted the team-less build-scope fallback via
scopedTeamID(nil) on dev/tagged builds (Finding 3).

The pre-existing flip regression test seeded team-less then team-b for the same
device+instanceTag, but base upsert claims the team-less row into team-b
(moveMacRowScope), collapsing both into one team-b row, so the old assertions
passed vacuously (forget deleted a nonexistent owner_key). Reorder the seed
(team row first, which a later team-less upsert never claims) so two genuinely
independent rows exist, and forget the team-less one explicitly.

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

* iOS: failing tests for forget backup-team routing, revoke pinning, broker credential pairing

Three autoreview findings on the forget/revoke path, each with a failing
regression test. This commit adds only the tests plus the inert API surface they
reference; the behavior fixes land in the next commit so CI goes red then green.

A. removeExactScope reuses the nil local team for the backup tombstone, so a
   team-less row forgotten under a selected team routes its backup delete to
   whatever team is selected at flush time (can wipe the wrong team's backup).
   New removeExactScope(...backupTeamID:) surface (default forwards to the 4-arg,
   so behavior is unchanged until BackingUp overrides it next commit).

B. forgetHiddenComputer pins the revoke to the LIVE session account instead of
   the row's owning account, so a row left on screen after an account switch can
   revoke the new account's binding. Test only; the fix is a one-line arg change.

C. The broker reads access and refresh tokens through two independent snapshot
   calls; a force refresh between them pairs a stale access token with a rotated
   refresh token. New CmxIrohBrokerCredentials + credentialPair surface (unused by
   performRequest until next commit).

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

* iOS: fix forget backup-team routing, revoke account pinning, broker credential pairing

Behavior fixes for the three autoreview findings; the failing tests from the
prior commit now pass (CI red -> green).

A. BackingUpPairedMacStore.removeMirroring now takes a separate `backupTeam`
   scope: the local row still deletes under `team` (nil stays nil), but the
   backup tombstone routes to `backupTeam`. The new
   removeExactScope(...backupTeamID:) override supplies the captured display team,
   and MobileShellComposite's forget passes `displayScope.teamID`, so a team-less
   row forgotten under a selected team tombstones the right per-team Durable
   Object instead of whatever team is selected at flush time.

B. forgetHiddenComputer pins the revoke to `computer.stackUserID ?? scope.userID`
   (the row's owning account) instead of the live session, so the runtime forget's
   generation/account check fails closed when a stale row is forgotten after an
   account switch, rather than revoking the new account's binding.

C. CmxIrohTrustBrokerClient.performRequest prefers tokenSource.credentialPair
   (both tokens from one snapshot) over the two independent closures, and
   MobileIrohRuntimeComposition supplies a credentialPair closure that captures one
   authenticatedSessionSnapshot under the same generation/account pinning. A force
   refresh mid-request can no longer pair a stale access token with a rotated
   refresh token.

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

* fix(iroh): harden lifecycle and close attribution

* test(iroh): decode Effect failures through public API

* test(ios): require stable simulator device identity

* fix(ios): seed simulator Iroh device identity

* test(iroh): accept unscoped workspace events in rollover gate

* fix(iroh): validate fresh rollover events by topic

* test(iroh): cover release closeout regressions

* fix(iroh): preserve trusted connection recovery

* test(iroh): cover redaction and binding cap semantics

* fix(iroh): harden release lifecycle boundaries

* fix(iroh): clear retry inspection on scope exit

* test(iroh): reproduce multi-Mac release gate targeting

* fix(iroh): pin release gate to foreground Mac

* fix(ios): isolate durable identity defaults safely

* test(iroh): reproduce release gate readiness race

* fix(iroh): require stable gate readiness

* test(ios): reproduce stale reconnect client clobber

* fix(ios): reject stale reconnect before client mutation

* test(ios): reproduce restored identity and backup scope leaks

* fix(ios): preserve device and backup scope identity

* chore(iroh): adopt continuous relay token handoff

* test(ios): cover exact release-gate simulator targeting

* fix(ios): target release gate simulator by identifier

* test(ios): reproduce release gate output sink displacement

* fix(ios): isolate release gate terminal observation

* test(ios): reproduce stale release gate workspace identity

* fix(ios): reacquire long-lived release gate workspace

* test(ios): cover complete relay refresh suspension

* fix(ios): suspend every automatic relay renewal lane

---------

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-07-28 21:41:40 -05:00
cmux reload-cloud 294d83ae13 test: reject nested OMP restore sessions 2026-07-28 18:09:18 -07:00
Lawrence Chenandcmux-lawrence c3e16646fc Add Windows and Linux download pages (#9070)
* Add Windows and Linux download pages

* Localize and gate Windows and Linux downloads

* Send download telemetry before navigation

* Complete download page discovery contracts

* Keep download release states coherent

* Localize browser download social metadata

* Wrap localized download CTAs on mobile

* Balance wrapped installer labels

---------

Co-authored-by: cmux-lawrence <[email protected]>
2026-07-28 17:53:35 -07:00
austinpower1258 76c6ebd2ea test: cover committed hibernation and Dock cleanup 2026-07-28 17:49:07 -07:00
Austin Wang cbcd505f75 Merge pull request #9046 from manaflow-ai/pr-2639-goto-split-cycle
fix: complete goto_split previous/next pane cycling
2026-07-28 17:47:24 -07:00
austinpower1258 1e897ff31e Merge remote-tracking branch 'origin/main' into issue-8997-memory-growth-panics
# Conflicts:
#	web/messages/en.json
#	web/messages/ja.json
2026-07-28 17:39:04 -07:00
Austin Wang 32f1e83324 Merge pull request #9086 from manaflow-ai/issue-9069-memory-attribution-helpers
Fix aggregate memory attribution across workspaces
2026-07-28 17:35:16 -07:00
austinpower1258 bb94ce5fdc Merge remote-tracking branch 'origin/main' into issue-9056-ssh-tmux-pane-input-capture
# Conflicts:
#	Sources/GhosttyNSView+PointerFocusActivation.swift
#	Sources/GhosttyTerminalView.swift
#	Sources/RemoteTmuxWindowMirror+FocusNavigation.swift
#	Sources/Workspace+RemoteTmuxControlTopology.swift
#	Sources/Workspace+SurfaceNavigation.swift
#	cmux.xcodeproj/project.pbxproj
#	cmuxTests/RemoteTmuxMirrorPaneInputMappingTests.swift
2026-07-28 17:29:30 -07:00
cmux reload-cloud a9331bdc65 test: isolate goto split config from user state 2026-07-28 17:20:25 -07:00
austinpower1258 ca7ca8fff2 fix: await emergency agent process exit 2026-07-28 17:19:42 -07:00
austinpower1258 262caf718c fix: project ssh-tmux pane input focus 2026-07-28 17:17:39 -07:00
Austin Wangandcmux reload-cloud 2757d9de84 Fix ssh-tmux focus after single-pane promotion (#9020)
* test: reproduce ssh-tmux single-pane focus failure

* fix: preserve ssh-tmux focus after pane promotion

* fix: reject stale remote tmux focus seeds

* test: expose promoted tmux container focus theft

* fix: project tmux container focus to active pane

* test: fail closed on stale nested tmux focus

* test: construct outer focus neighbor in mirror harness

* fix: fail closed on invalid nested tmux focus

* test: expose nested tmux key repair target

* fix: canonicalize nested tmux input focus

* perf: avoid tmux topology allocation during key repair

* test: use tmux mirror teardown API

* fix: fail closed on unresolved tmux focus

* fix: project all tmux terminal consumers

* fix: mirror tmux panes from initial attachment

* test: cover external tmux pane projection

* fix: project tmux panes through external inputs

* test: cover projected tmux metadata and notifications

* fix: propagate projected tmux panes to consumers

* fix: index projected tmux surface lookup

* test: cover projected tmux notification lifecycle

* fix: route projected tmux notification lifecycle

* fix: preserve projected tmux window recovery route

* fix: disambiguate projected terminal readiness

* test: cover projected tmux pointer and search focus

* fix: project tmux focus into Ghostty interactions

* test: cover projected tmux surface ownership

* fix: unify projected tmux surface ownership

* test: cover tmux focus handoff regressions

* fix: complete projected tmux focus handoff

* test: cover projected tmux focus lifecycle regressions

* fix: harden projected tmux focus lifecycle

* test: cover projected terminal ownership consumers

* fix: canonicalize projected terminal ownership consumers

* test: cover exact tmux split focus ownership

* fix: bind tmux split focus to command result

* test: cover projected focus confirmation boundaries

* test: cover projected focus rollback

* fix: confirm projected pane focus before dismissal

* fix: wait for authoritative notification focus

* test: cover projected trust boundary regressions

* fix: preserve projected tmux trust boundaries

* test: tolerate queued tmux topology commands

* fix: forward active projected pane focus to tmux

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-28 17:15:52 -07:00
austinpower1258 ca86a20690 fix: preserve non-spinner Braille titles 2026-07-28 17:13:40 -07:00
austinpower1258 50023de67b test: reproduce ssh-tmux sibling input capture 2026-07-28 17:13:04 -07:00
Austin Wangandcmux reload-cloud 4f5aafd973 feat: auto-retry failed agent sessions (#9024)
* feat: auto-retry failed agent sessions

* fix: preserve retry state through idle teardown

* refactor: align retry policy with package conventions

* fix: harden agent retry provenance and cleanup

* fix: bind retries to the ended shell command

* test: make browser quarantine recovery deterministic

* fix: address final agent retry review feedback

* fix: preserve classified retries through teardown

* fix: bind retries to authoritative ended sessions

* fix: correlate agent retries with shell generations

* fix: make agent retry ownership fail closed

* fix: wire retry state through surface transfers

* fix: clarify retry shell-state fallback

* fix: close agent retry review gaps

* test: cover agent retry event ordering

* fix: make agent retry ordering event-driven

* test: cover retry launch acknowledgement gaps

* fix: bound agent retry launch acknowledgement

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-28 17:10:05 -07:00
austinpower1258 e7a0727215 Merge remote-tracking branch 'origin/main' into issue-6291-spinner-title-sidebar-freeze 2026-07-28 17:04:28 -07:00
cmux reload-cloud 19697295ce test: isolate goto split UI bridge under debug support 2026-07-28 16:57:38 -07:00
cmux reload-cloud 006ed142ad Merge origin/main into pr-2639-goto-split-cycle 2026-07-28 16:57:24 -07:00
cmux reload-cloud 5f98f961b5 Isolate terminal file routing defaults 2026-07-28 16:51:10 -07:00
austinpower1258andMaxx Yung 0b5c0faf79 fix: collapse spinner titles before ingress dedup
Co-authored-by: Maxx Yung <[email protected]>
2026-07-28 16:50:30 -07:00
Austin Wang d61e6688ef Instrument direct Codex fork sessions (#9081)
* test: cover direct Codex fork hook injection

* fix: instrument direct Codex fork sessions

* docs: name Codex exec alias in wrapper comment
2026-07-28 16:46:28 -07:00
austinpower1258 5839b0e9c9 Merge remote-tracking branch 'origin/main' into issue-8997-memory-growth-panics 2026-07-28 16:43:54 -07:00
austinpower1258 4d5f1428a2 fix: harden emergency agent hibernation 2026-07-28 16:43:48 -07:00
austinpower1258 28f6255982 test: expose spinner title ingress churn 2026-07-28 16:43:16 -07:00
cmux reload-cloud 9f0472dcd4 Render terminal-linked HTML in browser panes 2026-07-28 16:37:16 -07:00
cmux reload-cloud 147d96f417 Add failing HTML terminal click routing tests 2026-07-28 16:34:22 -07:00
austinpower1258 48d13aacef fix: navigate terminal search results 2026-07-28 16:26:21 -07:00
cmux reload-cloud b34c4c3af0 Address memory attribution review findings 2026-07-28 16:19:31 -07:00
austinpower1258 102e733026 fix: retain confirmed process generations 2026-07-28 16:18:14 -07:00
austinpower1258 4cfff09d8d fix: harden critical-pressure agent teardown 2026-07-28 16:16:03 -07:00
cmux reload-cloud 1e55a10166 Expose memory group accumulator initializer 2026-07-28 16:06:12 -07:00
austinpower1258 131781c47b fix: hibernate safe agents under critical pressure 2026-07-28 16:04:03 -07:00
austinpower1258 f3ad9eba08 test: cover terminal find navigation actions 2026-07-28 15:59:06 -07:00
cmux reload-cloud 921d783eb7 Fix aggregate memory ownership reporting 2026-07-28 15:51:53 -07:00
austinpower1258 a9069343ab test: cover critical-pressure agent reclamation 2026-07-28 15:44:36 -07:00
cmux reload-cloud ab3bbb2a7f Add failing memory attribution aggregation tests 2026-07-28 15:42:53 -07:00
cmux reload-cloud 7995994f71 fix: isolate goto split cycle navigation support 2026-07-28 06:02:10 -07:00
cmux reload-cloud dc62f7de1b fix: gate goto split UI setup on focus notification 2026-07-28 04:31:05 -07:00
cmux reload-cloud d9a5baefb7 fix: address goto split cycle review feedback 2026-07-28 04:14:10 -07:00
cmux reload-cloud d0a7bbfd74 fix goto split cycle verification gaps 2026-07-28 02:14:44 -07:00
Myk Melez 235ead818d Merge remote-tracking branch 'manaflow/main' into fix/goto-split-cycle-navigation
# Conflicts:
#	cmux.xcodeproj/project.pbxproj
2026-07-27 15:09:59 -07:00
Myk Melez 34144df234 Merge remote-tracking branch 'manaflow/main' into fix/goto-split-cycle-navigation
# Conflicts:
#	Sources/AppDelegate.swift
#	Sources/Workspace.swift
#	cmux.xcodeproj/project.pbxproj
2026-07-27 14:44:32 -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
Myk Melez b0458702e7 Merge remote-tracking branch 'manaflow/main' into fix/goto-split-cycle-navigation
# Conflicts:
#	Sources/TabManager.swift
#	cmux.xcodeproj/project.pbxproj
2026-06-15 08:51:56 -07:00
Myk Melez e080bd826a Fix goto split cycle shortcut routing 2026-06-01 10:23:52 -07:00
Myk Melez a0555ef1d5 Merge branch 'main' into fix/goto-split-cycle-navigation 2026-06-01 09:41:17 -07:00
Myk Melez 5459d03e58 Merge remote-tracking branch 'manaflow/main' into fix/goto-split-cycle-navigation 2026-04-22 08:57:42 -07:00
Myk MelezandClaude Opus 4.6 e8553d7692 fix: record cycle state from routed workspace, not active window
recordGotoSplitCycleMoveIfNeeded now accepts tabId and resolves the
workspace via tabManagerFor(tabId:), consistent with how cycleSplitFocus
itself is routed. Previously it used the active window's tabManager,
which could snapshot the wrong workspace in multi-window scenarios.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-12 20:52:24 -07:00
Myk Melez 49dc45084e Merge branch 'main' into fix/goto-split-cycle-navigation 2026-04-12 19:24:18 -07:00
Myk MelezandClaude Opus 4.6 a420ad9e54 test: add resolved guard to prevent duplicate setupComplete writes
The checkAndSignal poll and .ghosttyDidFocusSurface observer could
both fire and write setupComplete twice. Add a resolved flag so the
first successful path short-circuits subsequent invocations.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-12 19:21:05 -07:00
Myk MelezandClaude Opus 4.6 a3cd333783 fix: resolve TabManager by tabId for cycle navigation
Use tabManagerFor(tabId:) instead of AppDelegate.shared?.tabManager
so that goto_split:previous/next routes to the correct window's
TabManager in multi-window scenarios, rather than biasing toward
the active window.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-06 19:01:57 -07:00
Myk MelezandClaude Opus 4.6 5a93244b14 test: wait for terminal focus before signaling three-pane setup complete
The setupThreePaneTerminalLayout helper was writing setupComplete
immediately after creating splits, before a terminal surface became
first responder. Ghostty keybinds only fire when GhosttyNSView has
focus, so early keystrokes could miss.

Now waits for .ghosttyDidFocusSurface and verifies a terminal panel
is focused before signaling readiness, matching the pattern used by
the existing browser split setup.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-06 19:01:46 -07:00
Myk MelezandClaude Opus 4.6 cdaa58b80d fix: goto_split:previous/next now cycle through all panes with wrapping
Previously, goto_split:previous and goto_split:next were mapped to
directional left/right navigation in Bonsplit, which only found spatially
adjacent panes and skipped vertically-split panes entirely.

This adds cycle-based navigation that traverses all panes in tree order
(using Bonsplit's allPaneIds) and wraps around at the ends, matching
Ghostty's intended behavior for these actions.

Changes:
- Workspace.cycleFocus(forward:) traverses allPaneIds with wrapping
- TabManager.cycleSplitFocus delegates to Workspace.cycleFocus
- GhosttyTerminalView.handleAction routes PREVIOUS/NEXT through cycle
  navigation instead of mapping to directional .left/.right
- focusDirection() no longer handles PREVIOUS/NEXT cases

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-06 11:44:58 -07:00
Myk MelezandClaude Opus 4.6 fe30a4c6a0 test: add UI tests for goto_split:previous/next cycle navigation
Add tests verifying that goto_split:previous and goto_split:next cycle
through all panes regardless of split direction (horizontal and vertical)
and wrap at the ends. Uses Ghostty's default keybinds (Cmd+]/[).

Extends the goto_split test infrastructure with a three_pane_terminal
layout mode (CMUX_UI_TEST_GOTO_SPLIT_LAYOUT=three_pane_terminal) and
a cycle navigation recorder for test observability.

These tests are expected to FAIL without the accompanying fix, because
goto_split:previous/next currently map to directional left/right
navigation which skips vertically-split panes.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-06 11:44:58 -07:00
3199 changed files with 731051 additions and 49737 deletions
+2
View File
@@ -2,5 +2,7 @@
# Format: relpath<TAB>rule<TAB>short reason
# A finding whose (path, rule) appears here is suppressed.
# Remove a line once the underlying test is determinized.
Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCTransportDrainTests.swift sleep-then-assert temporary outage debt owned by #8931; remove when determinized
Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/OnboardingMacDiscoveryKeepAliveTests.swift sleep-then-assert temporary outage debt owned by #9163; remove when determinized
cmuxTests/WorkspaceForkConversationContextMenuTests.swift assert-on-duration grandfathered
cmuxTests/WorkspaceForkConversationContextMenuTests.swift sleep-then-assert grandfathered
+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'
+56 -5
View File
@@ -121,6 +121,9 @@ jobs:
with:
python-version: "3.9"
- name: Install workflow guard Python dependencies
run: python3 -m pip install --disable-pip-version-check --no-input PyYAML==6.0.3 bashlex==0.18
- name: Validate nightly prune Python compatibility
run: PYTHON_BIN=python3.9 bash ./tests/test_ci_nightly_prune_python_compat.sh
@@ -163,12 +166,18 @@ jobs:
- name: Validate external TestFlight group assignment helper
run: python3 tests/test_ios_testflight_external_distribution.py
- name: Validate Pro TestFlight distribution workflow
run: python3 tests/test_ios_testflight_pro_distribution.py
- name: Validate iOS App Store lane identity
run: python3 tests/test_ios_appstore_lane_identity.py
- 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
@@ -180,6 +189,12 @@ jobs:
- name: Validate Zig install without sudo
run: ./tests/test_install_zig_ci_no_sudo.sh
- name: Initialize Ghostty for Zig version guard
run: git submodule update --init --depth 1 ghostty
- name: Validate Ghostty Zig version synchronization
run: ./tests/test_ghostty_zig_version_sync.sh
- name: Validate virtual display lock
run: ./tests/test_ci_virtual_display_lock.sh
@@ -231,6 +246,11 @@ jobs:
- name: Validate pbxproj test-wiring lint
run: ./tests/test_ci_pbxproj_test_wiring.sh
- name: Validate stored DispatchWorkItem ownership
run: |
python3 tests/test_lint_stored_dispatch_work_items.py
python3 scripts/lint-stored-dispatch-work-items.py
- name: Validate pbxproj objectVersion pin and normalization
run: ./scripts/check-pbxproj.sh
@@ -238,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
@@ -493,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'
@@ -625,6 +647,26 @@ jobs:
-only-testing:cmuxTests/BrowserViewportRuntimeTests \
test
- name: Run five-tab renderer memory regression
if: ${{ matrix.shard == fromJSON(env.CMUX_APP_HOST_FOCUSED_REGRESSION_SHARD) }}
run: |
# Use a dedicated app-host process so task_vm_info deltas only compare
# one versus five real Ghostty renderers in this workload. The focused
# invocation also makes footprint assertion failures non-tolerant.
set -euo pipefail
SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
CMUX_RENDERER_MEMORY_REGRESSION=1 \
scripts/ci/run-in-console-session.sh \
scripts/ci/run-app-host-xcodebuild.sh \
-project cmux.xcodeproj -scheme cmux-unit -configuration Debug \
-derivedDataPath "$CMUX_DERIVED_DATA_PATH" \
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
-disableAutomaticPackageResolution \
-destination "platform=macOS" \
CMUX_SKIP_ZIG_BUILD=1 \
-only-testing:cmuxTests/GhosttySurfaceOverlayTests/testFiveTabRendererFootprintReturnsToOneRendererTargetAcrossHideRevealCycles \
test
- name: Run notification routing regressions
if: ${{ matrix.shard == fromJSON(env.CMUX_APP_HOST_FOCUSED_REGRESSION_SHARD) }}
run: |
@@ -1008,16 +1050,25 @@ jobs:
python3 tests/test_codex_wrapper_resume_hooks.py
python3 tests/test_claude_wrapper_hooks.py
python3 tests/test_claude_wrapper_mutual_shim_loop.py
python3 tests/test_claude_wrapper_shim_root_survives_tmpdir_change.py
python3 tests/test_claude_wrapper_user_binary_resolution.py
python3 tests/test_claude_teams_test_utils.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_codex_teams_informational.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_claude_teams_fallback_path.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_claude_teams_env.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_claude_teams_existing_shim.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_claude_teams_main_vertical.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_claude_teams_moved_surface.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_claude_teams_tmux_sequence.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_claude_teams_trust_optin.py
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
@@ -1202,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
@@ -1514,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'
@@ -1835,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'
+52 -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,10 +33,12 @@ 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
publish:
name: publish to R2
@@ -48,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(),
@@ -74,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:
@@ -84,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" \
@@ -95,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
@@ -108,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"
+187 -9
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,12 +94,19 @@ 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
shell: bash
run: |
version="$(bash ./scripts/ghostty-zig-version.sh)"
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Install zig
uses: mlugg/setup-zig@8d6198c65fb0feaa111df26e6b467fea8345e46f # v2.0.5
with:
version: 0.15.2
version: ${{ steps.ghostty-zig-version.outputs.version }}
- name: Install Rust toolchain
shell: bash
@@ -115,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: |
@@ -123,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: |
@@ -136,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:
@@ -157,12 +225,73 @@ 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
runs-on: ${{ vars.WINDOWS_RUNNER || 'windows-latest' }}
timeout-minutes: 60
continue-on-error: true
permissions:
contents: read
steps:
@@ -182,10 +311,17 @@ jobs:
- name: Init ghostty submodule
run: git submodule update --init --depth 1 ghostty
- name: Resolve Ghostty Zig version
id: ghostty-zig-version
shell: bash
run: |
version="$(bash ./scripts/ghostty-zig-version.sh)"
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Install zig
uses: mlugg/setup-zig@8d6198c65fb0feaa111df26e6b467fea8345e46f # v2.0.5
with:
version: 0.15.2
version: ${{ steps.ghostty-zig-version.outputs.version }}
- name: Install Rust toolchain
shell: bash
@@ -201,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
@@ -208,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: |
@@ -330,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
+541
View File
@@ -0,0 +1,541 @@
name: cmux-tui SDKs
on:
push:
branches:
- 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:
group: cmux-tui-sdks-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
contract:
name: protocol contract
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 8
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
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
- name: Check protocol and TUI action inventory
run: python3 cmux-tui/scripts/check-spec-inventory.py
- name: Test SDK schema drift checker
run: python3 cmux-tui/scripts/test_check_sdk_schema.py
- name: Check SDK schema against runtime fields
run: python3 cmux-tui/scripts/check-sdk-schema.py
- name: Test public resource boundary checker
run: python3 cmux-tui/scripts/test_check_resource_api_boundary.py
- name: Check public resource API boundary
run: python3 cmux-tui/scripts/check-resource-api-boundary.py
- name: Test deterministic SDK generator
env:
PYTHONPATH: cmux-tui/bindings
run: python3 -m unittest discover -s cmux-tui/bindings/codegen/tests -v
- name: Check all generated SDK wire layers
run: python3 cmux-tui/bindings/codegen/generate.py --check
- name: Test package version guard
run: |
python3 -m unittest discover \
-s cmux-tui/bindings/tests \
-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 --published-only
- name: Test shared conformance runner
run: |
python3 -m unittest discover \
-s cmux-tui/bindings/conformance \
-p 'test_*.py' \
-v
packages:
name: ${{ matrix.language }} package
needs: contract
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 25
strategy:
fail-fast: false
matrix:
language:
- python
- typescript
- rust
- go
- java
- cpp
- zig
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python 3.9
if: matrix.language == 'python'
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.9"
- name: Set up Node.js
if: matrix.language == 'typescript'
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "20.19.5"
cache: npm
cache-dependency-path: cmux-tui/bindings/typescript/package-lock.json
- name: Set up Rust 1.88
if: matrix.language == 'rust'
run: |
rustup toolchain install 1.88.0 --profile minimal --component clippy,rustfmt
cargo +1.88.0 --version
rustc +1.88.0 --version
- name: Set up Go
if: matrix.language == 'go'
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: "1.22.12"
cache-dependency-path: cmux-tui/bindings/go/go.mod
- name: Select JDK 17
if: matrix.language == 'java'
run: |
test -x "$JAVA_HOME_17_X64/bin/java"
echo "$JAVA_HOME_17_X64/bin" >> "$GITHUB_PATH"
echo "JAVA_HOME=$JAVA_HOME_17_X64" >> "$GITHUB_ENV"
"$JAVA_HOME_17_X64/bin/java" -version
"$JAVA_HOME_17_X64/bin/javac" -version
- name: Select Clang C++20
if: matrix.language == 'cpp'
run: clang++ --version
- name: Set up Zig
if: matrix.language == 'zig'
uses: mlugg/setup-zig@8d6198c65fb0feaa111df26e6b467fea8345e46f # v2.0.5
with:
version: 0.15.2
- name: Test and install Python SDK
if: matrix.language == 'python'
env:
PYTHONPATH: cmux-tui/bindings/python
run: |
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 \
--target "$RUNNER_TEMP/cmux-python-package" \
./cmux-tui/bindings/python
CMUX_PYTHON_PACKAGE="$RUNNER_TEMP/cmux-python-package" python3 - <<'PY'
import importlib.metadata
import os
import pathlib
import sys
package = pathlib.Path(os.environ["CMUX_PYTHON_PACKAGE"]).resolve()
sys.path.insert(0, str(package))
import cmux
assert pathlib.Path(cmux.__file__).resolve().is_relative_to(package)
distribution = next(
item
for item in importlib.metadata.distributions(path=[str(package)])
if item.metadata["Name"] == "cmux-sdk"
)
assert not distribution.requires
PY
- name: Test packed TypeScript SDK
if: matrix.language == 'typescript'
working-directory: cmux-tui/bindings/typescript
run: |
npm ci --no-audit --no-fund
npm test
- name: Test and package Rust SDKs
if: matrix.language == 'rust'
working-directory: cmux-tui
run: |
cargo +1.88.0 fmt -p cmux-sdk -p cmux-sidebar -- --check
cargo +1.88.0 test \
-p cmux-sdk \
-p cmux-sidebar \
--all-targets \
--locked
cargo +1.88.0 test \
-p cmux-sdk \
-p cmux-sidebar \
--doc \
--locked
cargo +1.88.0 clippy \
-p cmux-sdk \
-p cmux-sidebar \
--all-targets \
--locked \
-- -D warnings
RUSTDOCFLAGS="-D warnings" \
cargo +1.88.0 doc \
-p cmux-sdk \
-p cmux-sidebar \
--locked \
--no-deps
cargo +1.88.0 package -p cmux-sdk --locked
# Full sidebar packaging resolves its versioned crates.io dependency.
# 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
if: matrix.language == 'go'
working-directory: cmux-tui/bindings/go
run: |
go test ./...
go test -race ./...
go vet ./...
- name: Test external Java jar consumer
if: matrix.language == 'java'
working-directory: cmux-tui/bindings/java
run: bash scripts/test.sh
- name: Test installed C++ package consumer
if: matrix.language == 'cpp'
env:
CC: clang
CXX: clang++
run: |
cmake \
-S cmux-tui/bindings/cpp \
-B "$RUNNER_TEMP/cmux-cpp-sdk" \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_CXX_FLAGS="-Wall -Wextra -Wpedantic -Werror"
cmake --build "$RUNNER_TEMP/cmux-cpp-sdk" --parallel
ctest --test-dir "$RUNNER_TEMP/cmux-cpp-sdk" --output-on-failure
- name: Test Zig SDK
if: matrix.language == 'zig'
working-directory: cmux-tui/bindings/zig
run: |
test "$(zig version)" = "0.15.2"
zig fmt --check build.zig src examples
zig build test
zig build
consumers:
name: ${{ matrix.language }} consumer
needs: contract
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 25
strategy:
fail-fast: false
matrix:
language:
- python
- typescript
- rust
- go
- java
- cpp
- zig
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python 3.9
if: matrix.language == 'python'
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.9"
- name: Set up Node.js
if: matrix.language == 'typescript'
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "20.19.5"
cache: npm
cache-dependency-path: |
cmux-tui/bindings/typescript/package-lock.json
cmux-tui/bindings/examples/typescript-browser-controller/package-lock.json
- name: Set up Rust 1.88
if: matrix.language == 'rust'
run: |
rustup toolchain install 1.88.0 --profile minimal --component clippy,rustfmt
cargo +1.88.0 --version
rustc +1.88.0 --version
- name: Set up Go
if: matrix.language == 'go'
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: "1.22.12"
cache-dependency-path: |
cmux-tui/bindings/go/go.mod
cmux-tui/bindings/examples/go-terminal-bot/go.mod
- name: Select JDK 17
if: matrix.language == 'java'
run: |
test -x "$JAVA_HOME_17_X64/bin/java"
echo "$JAVA_HOME_17_X64/bin" >> "$GITHUB_PATH"
echo "JAVA_HOME=$JAVA_HOME_17_X64" >> "$GITHUB_ENV"
"$JAVA_HOME_17_X64/bin/java" -version
"$JAVA_HOME_17_X64/bin/javac" -version
- name: Select Clang C++20
if: matrix.language == 'cpp'
run: clang++ --version
- name: Set up Zig
if: matrix.language == 'zig'
uses: mlugg/setup-zig@8d6198c65fb0feaa111df26e6b467fea8345e46f # v2.0.5
with:
version: 0.15.2
- name: Test Python agent watchdog
if: matrix.language == 'python'
working-directory: cmux-tui/bindings/examples/python-agent-watchdog
env:
PYTHONPATH: ${{ github.workspace }}/cmux-tui/bindings/python
run: python3 -m unittest discover -s tests -v
- name: Test Python development orchestrator
if: matrix.language == 'python'
working-directory: cmux-tui/bindings/examples/python-dev-orchestrator
env:
PYTHONPATH: ${{ github.workspace }}/cmux-tui/bindings/python:${{ github.workspace }}/cmux-tui/bindings/examples/python-dev-orchestrator
run: python3 -m unittest discover -s tests -v
- name: Test TypeScript browser controller
if: matrix.language == 'typescript'
working-directory: cmux-tui/bindings/examples/typescript-browser-controller
run: |
npm ci --no-audit --no-fund
npm test
- name: Test Rust resource consumers
if: matrix.language == 'rust'
run: |
cargo +1.88.0 test \
--manifest-path cmux-tui/bindings/examples/rust-agent-dashboard/Cargo.toml \
--locked
cargo +1.88.0 clippy \
--manifest-path cmux-tui/bindings/examples/rust-agent-dashboard/Cargo.toml \
--locked \
--all-targets \
-- -D warnings
cargo +1.88.0 test \
--manifest-path cmux-tui/bindings/examples/rust-sidebar-monitor/Cargo.toml \
--locked \
--all-targets
cargo +1.88.0 clippy \
--manifest-path cmux-tui/bindings/examples/rust-sidebar-monitor/Cargo.toml \
--locked \
--all-targets \
-- -D warnings
- name: Test Go terminal bot
if: matrix.language == 'go'
working-directory: cmux-tui/bindings/examples/go-terminal-bot
run: |
go test ./...
go test -race ./...
go vet ./...
- name: Test Java CI orchestrator
if: matrix.language == 'java'
run: cmux-tui/bindings/examples/java-ci-orchestrator/scripts/test.sh
- name: Test installed C++ terminal frontend
if: matrix.language == 'cpp'
env:
CC: clang
CXX: clang++
run: |
cmake \
-S cmux-tui/bindings/cpp \
-B "$RUNNER_TEMP/cmux-cpp-install-build" \
-DCMAKE_BUILD_TYPE=Release \
-DCMUX_BUILD_TESTS=OFF
cmake --build "$RUNNER_TEMP/cmux-cpp-install-build" --parallel
cmake \
--install "$RUNNER_TEMP/cmux-cpp-install-build" \
--prefix "$RUNNER_TEMP/cmux-cpp-install"
cmake \
-S cmux-tui/bindings/examples/cpp-terminal-frontend \
-B "$RUNNER_TEMP/cmux-cpp-frontend" \
-DCMAKE_BUILD_TYPE=Release \
-DCMUX_CPP_SDK_DIR= \
-DCMAKE_PREFIX_PATH="$RUNNER_TEMP/cmux-cpp-install"
cmake --build "$RUNNER_TEMP/cmux-cpp-frontend" --parallel
ctest --test-dir "$RUNNER_TEMP/cmux-cpp-frontend" --output-on-failure
- name: Test Zig session supervisor
if: matrix.language == 'zig'
working-directory: cmux-tui/bindings/examples/zig-session-supervisor
run: |
test "$(zig version)" = "0.15.2"
zig fmt --check build.zig src tests
zig build test -Doptimize=Debug
zig build test -Doptimize=ReleaseSafe
zig build -Doptimize=Debug
zig build -Doptimize=ReleaseSafe
conformance:
name: seven-language live conformance
needs: contract
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-8vcpu-ubuntu-2404' }}
timeout-minutes: 45
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Initialize Ghostty protocol submodule
run: git submodule update --init --depth 1 ghostty
- name: Install Linux build dependencies
run: |
sudo apt-get update
sudo apt-get install -y clang libclang-dev pkg-config
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.12.8"
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "20.19.5"
cache: npm
cache-dependency-path: cmux-tui/bindings/typescript/package-lock.json
- name: Set up Rust 1.95
run: |
rustup toolchain install 1.95.0 --profile minimal
cargo +1.95.0 --version
rustc +1.95.0 --version
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: "1.22.12"
cache-dependency-path: cmux-tui/bindings/go/go.mod
- name: Select JDK 17
run: |
test -x "$JAVA_HOME_17_X64/bin/java"
echo "$JAVA_HOME_17_X64/bin" >> "$GITHUB_PATH"
echo "JAVA_HOME=$JAVA_HOME_17_X64" >> "$GITHUB_ENV"
"$JAVA_HOME_17_X64/bin/java" -version
"$JAVA_HOME_17_X64/bin/javac" -version
- name: Set up Zig for Ghostty
uses: mlugg/setup-zig@8d6198c65fb0feaa111df26e6b467fea8345e46f # v2.0.5
with:
version: 0.16.0
- name: Install TypeScript adapter build tools
working-directory: cmux-tui/bindings/typescript
run: npm ci --no-audit --no-fund
- name: Build exact headless cmux-tui
working-directory: cmux-tui
run: |
test "$(zig version)" = "0.16.0"
cargo +1.95.0 build -p cmux-tui --bin cmux-tui --locked
- name: Set up Zig for SDK conformance
uses: mlugg/setup-zig@8d6198c65fb0feaa111df26e6b467fea8345e46f # v2.0.5
with:
version: 0.15.2
- name: Run shared fake and live protocol contract
env:
CC: clang
CXX: clang++
CMUX_ZIG: zig
NODE_OPTIONS: --experimental-websocket
RUSTUP_TOOLCHAIN: 1.95.0
run: |
test "$(zig version)" = "0.15.2"
test "$(node -p 'typeof WebSocket')" = "function"
python3 cmux-tui/bindings/conformance/runner.py \
--require python,typescript,rust,go,java,cpp,zig \
--cmux-tui-bin "$GITHUB_WORKSPACE/cmux-tui/target/debug/cmux-tui"
+56
View File
@@ -0,0 +1,56 @@
name: cmux-tui spec inventory
on:
push:
branches:
- main
paths:
- "cmux-tui/**"
- ".github/workflows/cmux-tui-spec.yml"
pull_request:
paths:
- "cmux-tui/**"
- ".github/workflows/cmux-tui-spec.yml"
workflow_dispatch:
concurrency:
group: cmux-tui-spec-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
inventory:
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 5
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Test inventory checker
run: python3 cmux-tui/scripts/test_check_spec_inventory.py
- name: Check protocol and TUI action inventory
run: python3 cmux-tui/scripts/check-spec-inventory.py
- name: Test deterministic SDK generator
env:
PYTHONPATH: cmux-tui/bindings
run: python3 -m unittest discover -s cmux-tui/bindings/codegen/tests -v
- name: Test SDK schema checker
run: python3 cmux-tui/scripts/test_check_sdk_schema.py
- name: Check SDK schema against runtime inventory
run: python3 cmux-tui/scripts/check-sdk-schema.py
- name: Test public resource boundary checker
run: python3 cmux-tui/scripts/test_check_resource_api_boundary.py
- name: Check public resource API boundary
run: python3 cmux-tui/scripts/check-resource-api-boundary.py
- name: Check generated SDK wire layers
run: python3 cmux-tui/bindings/codegen/generate.py --check
+8 -1
View File
@@ -243,10 +243,17 @@ jobs:
- name: Init ghostty submodule
run: git submodule update --init --depth 1 ghostty
- name: Resolve Ghostty Zig version
id: ghostty-zig-version
shell: bash
run: |
version="$(bash ./scripts/ghostty-zig-version.sh)"
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Install zig
uses: mlugg/setup-zig@8d6198c65fb0feaa111df26e6b467fea8345e46f # v2.0.5
with:
version: 0.15.2
version: ${{ steps.ghostty-zig-version.outputs.version }}
- name: Install Rust GNU target
shell: bash
+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
+237 -140
View File
@@ -1,24 +1,14 @@
name: iOS TestFlight (CMUX INTERNAL)
on:
# Automatically upload only when main changes the iOS app or a direct archive
# input. Manual dispatch remains available for intentional rebuilds.
push:
branches:
- main
paths:
- "ios/**"
- "Packages/iOS/**"
- "Packages/Shared/**"
- "Sources/Mobile/**"
- "vendor/stack-auth-swift-sdk-prerelease/**"
- "ghostty"
- "ghostty.h"
- "scripts/ensure-ghosttykit.sh"
- "scripts/ghosttykit-checksums.txt"
- "scripts/install-zig-ci.sh"
- "scripts/validate-xcframework-archive.py"
- ".github/workflows/ios-testflight.yml"
# Poll main every 20 minutes and batch merges into one upload. The decide job
# skips scheduled runs when main is unchanged or the changes do not affect iOS.
# Manual dispatch remains available for intentional rebuilds.
schedule:
# Check current main for a cmux INTERNAL upload every 20 minutes.
- cron: "7,27,47 * * * *"
# Twice-daily (every 12 hours) cmux DEMO upload of current main.
- cron: "37 5,17 * * *"
workflow_dispatch:
inputs:
build_number:
@@ -42,10 +32,10 @@ on:
- demo
concurrency:
# A shared group keeps only one pending run and silently replaces older pending
# SHAs during merge bursts. Key qualifying push runs by SHA so every iOS change
# survives. Manual runs use run_id because an operator may intentionally rebuild.
group: ios-testflight-${{ github.event_name == 'push' && github.sha || github.run_id }}
# Every run keys by run_id: scheduled runs must not cancel each other (the
# decide job already serializes uploads), and an operator may intentionally
# rebuild via dispatch.
group: ios-testflight-${{ github.run_id }}
cancel-in-progress: false
permissions:
@@ -61,6 +51,7 @@ jobs:
outputs:
should_build: ${{ steps.decide.outputs.should_build }}
last_uploaded_sha: ${{ steps.decide.outputs.last_uploaded_sha }}
variant: ${{ steps.decide.outputs.variant }}
steps:
- name: Decide whether a TestFlight upload is needed
id: decide
@@ -69,6 +60,32 @@ jobs:
script: |
const { owner, repo } = context.repo;
// Resolve the upload variant once. Scheduled runs select the variant
// by which cron fired; dispatch runs use the variant input. Every
// downstream job reads needs.decide.outputs.variant instead of
// re-deriving it from event fields.
const internalCron = '7,27,47 * * * *';
const demoCron = '37 5,17 * * *';
const requestedVariant = context.payload?.inputs?.variant;
const schedule = context.payload?.schedule;
let variant;
if (context.eventName === 'schedule' && schedule === internalCron) {
variant = 'internal';
} else if (context.eventName === 'schedule' && schedule === demoCron) {
variant = 'demo';
} else if (
context.eventName === 'workflow_dispatch' &&
['internal', 'demo'].includes(requestedVariant)
) {
variant = requestedVariant;
} else {
core.setFailed('unsupported TestFlight event, schedule, or variant');
return;
}
const canonicalArtifactName = variant === 'demo'
? 'ios-testflight-build-metadata-demo'
: 'ios-testflight-build-metadata';
// Per-SHA workflow concurrency preserves every push, but it also lets
// several runs reach App Store Connect at once. Build numbers must be
// uploaded monotonically, so wait on a cheap Linux runner until every
@@ -91,7 +108,7 @@ jobs:
(run) =>
Number(run.id) < currentRunId &&
run.status !== 'completed' &&
['push', 'workflow_dispatch'].includes(run.event)
['push', 'schedule', 'workflow_dispatch'].includes(run.event)
);
for (const run of earlierActiveRuns) {
const jobs = await github.rest.actions.listJobsForWorkflowRun({
@@ -139,10 +156,10 @@ jobs:
}
// Resolve the most recent successful canonical upload as the base for
// this build's "What to Test" commit range. Key off the upload job,
// not whole-workflow success, because internal-group assignment happens
// after the IPA is already in App Store Connect. Restrict the lookup to
// main so a blocked feature-branch dispatch cannot poison the notes base.
// this build's "What to Test" commit range. Metadata artifacts are
// written only after the upload succeeds, and the repository artifact
// response includes the originating run's branch and head SHA. Looking
// them up directly keeps skipped schedule runs out of this history scan.
//
// Manual marketing-version-override uploads are deliberately excluded
// from this canonical lane. They ship the current main SHA under an
@@ -159,65 +176,137 @@ jobs:
// is necessarily a normal immediate beta cut and SHOULD count as the
// last canonical upload.
let lastUploadedSha = null;
let uploadHistoryKnown = true;
try {
for (let page = 1; page <= 20 && !lastUploadedSha; page += 1) {
const runs = await github.rest.actions.listWorkflowRuns({
owner,
repo,
workflow_id: 'ios-testflight.yml',
branch: 'main',
per_page: 100,
page,
});
for (const run of runs.data.workflow_runs) {
if (run.id === context.runId) continue;
const jobs = await github.rest.actions.listJobsForWorkflowRun({
owner,
repo,
run_id: run.id,
per_page: 100,
});
const uploadJob = jobs.data.jobs.find((job) => job.name === 'Upload to TestFlight');
if (uploadJob?.conclusion === 'success') {
const artifacts = await github.rest.actions.listWorkflowRunArtifacts({
owner,
repo,
run_id: run.id,
per_page: 100,
});
const artifactNames = new Set(
(artifacts.data.artifacts || []).map((artifact) => artifact.name)
);
if (
artifactNames.has('ios-testflight-build-metadata-override') &&
!artifactNames.has('ios-testflight-build-metadata')
) {
continue;
}
lastUploadedSha = run.head_sha;
break;
const perPage = 100;
const firstPage = await github.rest.actions.listArtifactsForRepo({
owner,
repo,
name: canonicalArtifactName,
per_page: perPage,
page: 1,
});
const totalCount = Number(firstPage.data.total_count);
if (!Number.isSafeInteger(totalCount) || totalCount < 0) {
throw new Error('invalid artifact count');
}
const pageCount = Math.max(1, Math.ceil(totalCount / perPage));
let latestArtifact = null;
const considerArtifacts = (artifacts) => {
for (const artifact of artifacts) {
const run = artifact.workflow_run;
if (
artifact.name !== canonicalArtifactName ||
run?.head_branch !== 'main' ||
!run.head_sha ||
Number(run.id) === Number(context.runId)
) {
continue;
}
const createdAt = Date.parse(artifact.created_at);
if (!Number.isFinite(createdAt)) {
throw new Error('invalid artifact creation time');
}
if (
!latestArtifact ||
createdAt > latestArtifact.createdAt ||
(createdAt === latestArtifact.createdAt &&
Number(artifact.id) > Number(latestArtifact.id))
) {
latestArtifact = {
id: artifact.id,
createdAt,
headSha: run.head_sha,
};
}
}
if (runs.data.workflow_runs.length < 100) break;
};
considerArtifacts(firstPage.data.artifacts || []);
for (let page = 2; page <= pageCount; page += 1) {
const response = await github.rest.actions.listArtifactsForRepo({
owner,
repo,
name: canonicalArtifactName,
per_page: perPage,
page,
});
considerArtifacts(response.data.artifacts || []);
}
} catch (e) {
core.warning(`could not resolve last uploaded sha: ${e.message}`);
lastUploadedSha = latestArtifact?.headSha || null;
} catch {
uploadHistoryKnown = false;
core.warning('could not resolve last uploaded sha; skipping scheduled upload');
}
// Push runs have already passed the workflow's iOS path filter.
// Manual runs are intentional rebuilds, so every event that reaches
// this workflow should build.
const shouldBuild =
context.eventName === 'push' || context.eventName === 'workflow_dispatch';
// Manual runs are intentional rebuilds and always build. Scheduled
// runs batch merges instead of shipping each one: skip when this
// variant already shipped the current head, or when the delta since
// the last upload touches no iOS-relevant paths, so idle hours never
// spend App Store Connect upload quota.
const iosRelevantPaths = [
'ios/',
'Packages/iOS/',
'Packages/Shared/',
'Sources/Mobile/',
'vendor/stack-auth-swift-sdk-prerelease/',
'ghostty',
'ghostty.h',
'scripts/ensure-ghosttykit.sh',
'scripts/ghosttykit-checksums.txt',
'scripts/install-zig-ci.sh',
'scripts/ghostty-zig-version.sh',
'scripts/validate-xcframework-archive.py',
'.github/workflows/ios-testflight.yml',
];
const touchesIOS = (filename) =>
iosRelevantPaths.some((p) =>
p.endsWith('/') ? filename.startsWith(p) : filename === p
);
let shouldBuild = true;
let reason = 'manual dispatch';
if (context.eventName === 'schedule') {
if (!uploadHistoryKnown) {
shouldBuild = false;
reason = 'upload history unavailable';
} else if (!lastUploadedSha) {
reason = 'no prior upload found for this variant';
} else if (lastUploadedSha === context.sha) {
shouldBuild = false;
reason = 'main unchanged since last upload';
} else {
// Fail open: when the compare is unavailable or truncated, build
// rather than silently skipping a real iOS change.
reason = 'new commits since last upload';
try {
const compare = await github.rest.repos.compareCommits({
owner,
repo,
base: lastUploadedSha,
head: context.sha,
});
const files = compare.data.files || [];
const truncated = files.length >= 300;
if (!truncated && !files.some((file) => touchesIOS(file.filename))) {
shouldBuild = false;
reason = 'no iOS-relevant changes since last upload';
}
} catch (e) {
core.warning(`could not compare against last upload: ${e.message}`);
}
}
}
core.setOutput('should_build', shouldBuild ? 'true' : 'false');
core.setOutput('last_uploaded_sha', lastUploadedSha || '');
core.setOutput('variant', variant);
core.summary
.addHeading('iOS TestFlight upload decision')
.addTable([
[{ data: 'event', header: true }, context.eventName],
[{ data: 'variant', header: true }, variant],
[{ data: 'head sha', header: true }, context.sha],
[{ data: 'last uploaded sha (notes base)', header: true }, String(lastUploadedSha)],
[{ data: 'should build', header: true }, String(shouldBuild)],
[{ data: 'reason', header: true }, reason],
])
.write();
@@ -232,19 +321,15 @@ jobs:
timeout-minutes: 60
outputs:
final_build_number: ${{ steps.upload.outputs.final_build_number }}
bundle_id: ${{ steps.distribution.outputs.bundle_id }}
assign_internal_group: ${{ steps.distribution.outputs.assign_internal_group }}
env:
ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }}
ASC_API_ISSUER_ID: ${{ secrets.ASC_API_ISSUER_ID }}
ASC_API_KEY_P8_BASE64: ${{ secrets.ASC_API_KEY_P8_BASE64 }}
CMUX_TESTFLIGHT_EXTERNAL_GROUP_ID: ${{ vars.IOS_TESTFLIGHT_EXTERNAL_GROUP_ID }}
CMUX_TESTFLIGHT_EXTERNAL_GROUP_NAME: ${{ vars.IOS_TESTFLIGHT_EXTERNAL_GROUP_NAME }}
CMUX_TESTFLIGHT_ASSIGN_EXTERNAL_GROUP: ${{ github.event.inputs.marketing_version_override != '' && '1' || '0' }}
# Internal builds use separate bundle ID and display name (set here so
# provisioning profile step can use it). The manual demo variant ships the
# same main head as a separate app so demos are isolated from the internal
# firehose (and its per-app upload limit).
IOS_BETA_BUNDLE_ID: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.variant == 'demo' && 'dev.cmux.app.demo' || 'dev.cmux.app.internal' }}
IOS_BETA_DISPLAY_NAME: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.variant == 'demo' && 'cmux DEMO' || 'cmux INTERNAL' }}
CMUX_TESTFLIGHT_PRO_GROUP_ID: ${{ vars.IOS_TESTFLIGHT_PRO_GROUP_ID }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -256,6 +341,18 @@ jobs:
fetch-depth: 0
fetch-tags: true
- name: Resolve TestFlight distribution
id: distribution
env:
INPUT_VARIANT: ${{ needs.decide.outputs.variant }}
INPUT_MARKETING_VERSION_OVERRIDE: ${{ github.event.inputs.marketing_version_override }}
run: |
python3 ./ios/scripts/resolve_testflight_distribution.py \
--variant "$INPUT_VARIANT" \
--marketing-version-override "$INPUT_MARKETING_VERSION_OVERRIDE" \
--github-env "$GITHUB_ENV" \
--github-output "$GITHUB_OUTPUT"
- name: Select Xcode
run: |
set -euo pipefail
@@ -349,59 +446,49 @@ jobs:
IOS_BETA_PROVISIONING_PROFILE_INTERNAL_BASE64: ${{ secrets.IOS_BETA_PROVISIONING_PROFILE_INTERNAL_BASE64 }}
run: |
set -euo pipefail
# Determine which profile to use based on bundle ID
if [ "${IOS_BETA_BUNDLE_ID:-dev.cmux.app.beta}" = "dev.cmux.app.demo" ]; then
if [ "$IOS_BETA_PROFILE_TYPE" = "beta" ]; then
PROFILE_BASE64="${IOS_BETA_PROVISIONING_PROFILE_BASE64}"
elif [ "$IOS_BETA_PROFILE_TYPE" = "demo" ]; then
# The demo profile is fetched from the ASC API by name instead of a
# repository secret, so regenerating it in the developer portal
# needs no secret rotation. Same credentials the upload uses.
PROFILE_BASE64="$(python3 ./ios/scripts/asc_download_profile.py --name "cmux Demo Distribution")"
EXPECTED_APP_ID="7WLXT3NR37.dev.cmux.app.demo"
PROFILE_TYPE="demo"
SKIP_APS_ENVIRONMENT_CHECK=false
elif [ "${IOS_BETA_BUNDLE_ID:-dev.cmux.app.beta}" = "dev.cmux.app.internal" ]; then
elif [ "$IOS_BETA_PROFILE_TYPE" = "internal" ]; then
PROFILE_BASE64="${IOS_BETA_PROVISIONING_PROFILE_INTERNAL_BASE64}"
EXPECTED_APP_ID="7WLXT3NR37.dev.cmux.app.internal"
PROFILE_TYPE="internal"
SKIP_APS_ENVIRONMENT_CHECK=false
else
PROFILE_BASE64="${IOS_BETA_PROVISIONING_PROFILE_BASE64}"
EXPECTED_APP_ID="7WLXT3NR37.dev.cmux.app.beta"
PROFILE_TYPE="beta"
SKIP_APS_ENVIRONMENT_CHECK=false
fi
if [ -z "${PROFILE_BASE64:-}" ]; then
echo "Missing provisioning profile secret for $PROFILE_TYPE" >&2
echo "Unsupported TestFlight profile type: $IOS_BETA_PROFILE_TYPE" >&2
exit 1
fi
TMP_PROFILE="$RUNNER_TEMP/cmux-${PROFILE_TYPE}.mobileprovision"
TMP_PLIST="$RUNNER_TEMP/cmux-${PROFILE_TYPE}-profile.plist"
if [ -z "${PROFILE_BASE64:-}" ]; then
echo "Missing provisioning profile secret for $IOS_BETA_PROFILE_TYPE" >&2
exit 1
fi
TMP_PROFILE="$RUNNER_TEMP/cmux-${IOS_BETA_PROFILE_TYPE}.mobileprovision"
TMP_PLIST="$RUNNER_TEMP/cmux-${IOS_BETA_PROFILE_TYPE}-profile.plist"
printf '%s' "$PROFILE_BASE64" | base64 --decode > "$TMP_PROFILE"
security cms -D -i "$TMP_PROFILE" > "$TMP_PLIST"
APP_ID="$(/usr/libexec/PlistBuddy -c "Print :Entitlements:application-identifier" "$TMP_PLIST")"
if [ "$APP_ID" != "$EXPECTED_APP_ID" ]; then
echo "$PROFILE_TYPE provisioning profile targets unexpected app ID: $APP_ID (expected $EXPECTED_APP_ID)" >&2
if [ "$APP_ID" != "$IOS_BETA_EXPECTED_APP_ID" ]; then
echo "$IOS_BETA_PROFILE_TYPE provisioning profile targets unexpected app ID: $APP_ID (expected $IOS_BETA_EXPECTED_APP_ID)" >&2
exit 1
fi
# Check aps-environment for every TestFlight provisioning profile.
if [ "${SKIP_APS_ENVIRONMENT_CHECK:-false}" != "true" ]; then
APS_ENVIRONMENT="$(/usr/libexec/PlistBuddy -c "Print :Entitlements:aps-environment" "$TMP_PLIST" 2>/dev/null || echo "")"
if [ -z "$APS_ENVIRONMENT" ] || [ "$APS_ENVIRONMENT" != "production" ]; then
echo "$PROFILE_TYPE provisioning profile aps-environment is '$APS_ENVIRONMENT', expected 'production'" >&2
exit 1
fi
APS_ENVIRONMENT="$(/usr/libexec/PlistBuddy -c "Print :Entitlements:aps-environment" "$TMP_PLIST" 2>/dev/null || echo "")"
if [ -z "$APS_ENVIRONMENT" ] || [ "$APS_ENVIRONMENT" != "production" ]; then
echo "$IOS_BETA_PROFILE_TYPE provisioning profile aps-environment is '$APS_ENVIRONMENT', expected 'production'" >&2
exit 1
fi
PROFILE_NAME="$(/usr/libexec/PlistBuddy -c "Print :Name" "$TMP_PLIST")"
PROFILE_UUID="$(/usr/libexec/PlistBuddy -c "Print :UUID" "$TMP_PLIST")"
mkdir -p "$HOME/Library/MobileDevice/Provisioning Profiles"
cp "$TMP_PROFILE" "$HOME/Library/MobileDevice/Provisioning Profiles/$PROFILE_UUID.mobileprovision"
echo "IOS_BETA_PROVISIONING_PROFILE_NAME=$PROFILE_NAME" >> "$GITHUB_ENV"
echo "Installed $PROFILE_TYPE provisioning profile: $PROFILE_NAME ($PROFILE_UUID)"
echo "Installed $IOS_BETA_PROFILE_TYPE provisioning profile: $PROFILE_NAME ($PROFILE_UUID)"
- name: Use DEMO-badged app icon
if: github.event_name == 'workflow_dispatch' && github.event.inputs.variant == 'demo'
if: needs.decide.outputs.variant == 'demo'
run: |
set -euo pipefail
# Swap the AppIcon PNGs in the CI checkout instead of overriding
@@ -419,6 +506,7 @@ jobs:
- name: Archive, export, and upload to TestFlight
id: upload
env:
IOS_BETA_BUNDLE_ID: ${{ steps.distribution.outputs.bundle_id }}
# Only set for manual workflow_dispatch with an explicit build number.
# For push this is empty and the script generates a monotonic
# 14-digit UTC timestamp itself (single source of the numbering scheme,
@@ -430,6 +518,10 @@ jobs:
# The script writes the CFBundleVersion that actually shipped here (the
# monotonic guard may bump it), so the summary reports the real value.
CMUX_BUILD_NUMBER_OUT_FILE: ${{ runner.temp }}/cmux-final-build-number.txt
# Pin the archive/export workspace: the default is a /tmp dir keyed by
# the build number, which this workflow cannot compute, and the
# post-upload dSYM artifact step must find the archive's dSYM bundle.
CMUX_IOS_UPLOAD_DIR: ${{ runner.temp }}/cmux-ios-upload
# The previous beta's commit (the last successful run's head_sha): base
# of the per-build "What to Test" commit range. Empty on the very first
# run / a missing history, where the generator falls back gracefully.
@@ -437,20 +529,9 @@ jobs:
# Optional manual one-off override that reuses an older approved beta
# marketing version so external testers can install it immediately.
INPUT_MARKETING_VERSION_OVERRIDE: ${{ github.event.inputs.marketing_version_override }}
# Display name for automatic internal builds (auto-synced to internal group).
# The demo variant ships as "cmux DEMO" / dev.cmux.app.demo instead.
IOS_BETA_DISPLAY_NAME: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.variant == 'demo' && 'cmux DEMO' || 'cmux INTERNAL' }}
# Internal builds use separate bundle ID (dev.cmux.app.internal) so internal
# and external can coexist on same device.
IOS_BETA_BUNDLE_ID: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.variant == 'demo' && 'dev.cmux.app.demo' || 'dev.cmux.app.internal' }}
INPUT_VARIANT: ${{ github.event.inputs.variant }}
run: |
set -euo pipefail
if [ "${INPUT_VARIANT:-internal}" = "demo" ] && [ -n "${INPUT_MARKETING_VERSION_OVERRIDE:-}" ]; then
echo "variant=demo cannot be combined with marketing_version_override (the override path ships the external cmux BETA app)" >&2
exit 1
fi
if [ -n "${INPUT_MARKETING_VERSION_OVERRIDE:-}" ]; then
if [ "$IOS_TESTFLIGHT_UPLOAD_MODE" = "marketing_version_override" ]; then
# One-time operator escape hatch: upload latest main as another build
# of an already-approved beta marketing version, which avoids starting a
# fresh Beta App Review for that version. This path intentionally
@@ -461,8 +542,6 @@ jobs:
echo "build_number is not supported together with marketing_version_override in the cloud override path" >&2
exit 1
fi
unset IOS_BETA_DISPLAY_NAME
unset IOS_BETA_BUNDLE_ID
./ios/scripts/cloud-testflight.sh \
--external \
--marketing-version "$INPUT_MARKETING_VERSION_OVERRIDE" \
@@ -498,10 +577,10 @@ jobs:
env:
BUILD_NUMBER: ${{ steps.upload.outputs.final_build_number || github.event.inputs.build_number || 'unknown' }}
INPUT_MARKETING_VERSION_OVERRIDE: ${{ github.event.inputs.marketing_version_override }}
UPLOAD_BUNDLE_ID: ${{ github.event.inputs.marketing_version_override != '' && 'dev.cmux.app.beta' || github.event_name == 'workflow_dispatch' && github.event.inputs.variant == 'demo' && 'dev.cmux.app.demo' || 'dev.cmux.app.internal' }}
UPLOAD_DISPLAY_NAME: ${{ github.event.inputs.marketing_version_override != '' && 'cmux BETA' || github.event_name == 'workflow_dispatch' && github.event.inputs.variant == 'demo' && 'cmux DEMO' || 'cmux INTERNAL' }}
UPLOAD_AUDIENCE: ${{ github.event.inputs.marketing_version_override != '' && 'external TestFlight testers' || 'internal TestFlight group' }}
UPLOAD_REVIEW_NOTE: ${{ github.event.inputs.marketing_version_override != '' && 'Beta App Review may be required' || 'no beta review needed' }}
UPLOAD_BUNDLE_ID: ${{ steps.distribution.outputs.bundle_id }}
UPLOAD_DISPLAY_NAME: ${{ steps.distribution.outputs.display_name }}
UPLOAD_AUDIENCE: ${{ steps.distribution.outputs.audience }}
UPLOAD_REVIEW_NOTE: ${{ steps.distribution.outputs.review_note }}
run: |
{
echo "### iOS TestFlight upload"
@@ -510,6 +589,7 @@ jobs:
echo "- signing: manual (CI-imported iOS distribution cert + beta profile)"
if [ -n "${INPUT_MARKETING_VERSION_OVERRIDE:-}" ]; then
echo "- marketing version override: \`${INPUT_MARKETING_VERSION_OVERRIDE}\`"
echo "- external groups: Founder's Edition and cmux Pro"
else
echo "- marketing version: checked-in beta marketing version"
fi
@@ -521,14 +601,9 @@ jobs:
if: success()
env:
FINAL_BUILD_NUMBER: ${{ steps.upload.outputs.final_build_number }}
INPUT_MARKETING_VERSION_OVERRIDE: ${{ github.event.inputs.marketing_version_override }}
UPLOAD_MODE: ${{ steps.distribution.outputs.upload_mode }}
run: |
set -euo pipefail
if [ -n "${INPUT_MARKETING_VERSION_OVERRIDE:-}" ]; then
UPLOAD_MODE="marketing_version_override"
else
UPLOAD_MODE="checked_in_version"
fi
cat > "$RUNNER_TEMP/ios-testflight-build.json" <<EOF
{"head_sha":"${GITHUB_SHA}","build_number":"${FINAL_BUILD_NUMBER}","upload_mode":"${UPLOAD_MODE}"}
EOF
@@ -537,23 +612,45 @@ jobs:
if: success()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ github.event.inputs.marketing_version_override != '' && 'ios-testflight-build-metadata-override' || 'ios-testflight-build-metadata' }}
# Variant-specific names keep the decide job's last-upload lookup (and
# therefore skip logic + notes ranges) independent per app.
name: ${{ steps.distribution.outputs.metadata_artifact }}
path: ${{ runner.temp }}/ios-testflight-build.json
retention-days: 30
- name: Persist dSYM bundle as run artifact
# The runner is ephemeral, so the archive's dSYMs are the only durable
# copy of this build's symbols besides the Symbols/ files uploaded
# inside the IPA; without this artifact, a TestFlight crash whose
# symbols ASC cannot serve is permanently unsymbolicatable (build
# 20260730090940). Keyed by variant + CFBundleVersion so a crash
# report's build number finds the right bundle. The override path is
# excluded like the canonical metadata artifact: it archives on a fleet
# Mac via cloud-testflight.sh, not under CMUX_IOS_UPLOAD_DIR.
# Gated on the UPLOAD step's outcome, not whole-job success(): once the
# IPA reached TestFlight its symbols must be persisted even if a later
# step (summary/metadata artifact) failed, or a shipped build becomes
# unsymbolicatable again.
if: ${{ !cancelled() && steps.upload.outcome == 'success' && steps.distribution.outputs.assign_internal_group == '1' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ios-dsyms-${{ needs.decide.outputs.variant }}-${{ steps.upload.outputs.final_build_number }}
path: ${{ runner.temp }}/cmux-ios-upload/cmux.xcarchive/dSYMs
if-no-files-found: error
retention-days: 30
- name: Cleanup keychain
if: always()
run: |
security delete-keychain ios-testflight.keychain >/dev/null 2>&1 || true
# NOTE: this lane uploads to dev.cmux.app.internal only, so there is no
# external-group assignment job here. The old assign-external-group job
# polled dev.cmux.app.beta for a build that now never arrives there and hung
# for its full 40-minute timeout on every run.
# Normal and demo runs assign their internal group after upload. A manual
# external marketing-version override assigns Founder's Edition and Pro
# inline in cloud-testflight.sh, so it must skip this internal-app lookup.
assign-internal-group:
name: Assign build to internal TestFlight group
needs: [decide, upload]
if: github.ref == 'refs/heads/main' && needs.upload.result == 'success' && github.event.inputs.marketing_version_override == ''
if: github.ref == 'refs/heads/main' && needs.upload.result == 'success' && needs.upload.outputs.assign_internal_group == '1'
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 40
env:
@@ -567,8 +664,8 @@ jobs:
# repo variables were wired in.
# The demo variant assigns to the "cmux DEMO" internal group on the
# dev.cmux.app.demo app record instead.
CMUX_TESTFLIGHT_INTERNAL_GROUP_ID: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.variant == 'demo' && 'dd5c5cde-05a6-44e5-bd71-c2ec08a3ebfe' || vars.IOS_TESTFLIGHT_INTERNAL_GROUP_ID }}
ASSIGN_BUNDLE_ID: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.variant == 'demo' && 'dev.cmux.app.demo' || 'dev.cmux.app.internal' }}
CMUX_TESTFLIGHT_INTERNAL_GROUP_ID: ${{ needs.decide.outputs.variant == 'demo' && 'dd5c5cde-05a6-44e5-bd71-c2ec08a3ebfe' || vars.IOS_TESTFLIGHT_INTERNAL_GROUP_ID }}
ASSIGN_BUNDLE_ID: ${{ needs.upload.outputs.bundle_id }}
BUILD_NUMBER: ${{ needs.upload.outputs.final_build_number }}
steps:
- name: Checkout
+26 -1
View File
@@ -4,6 +4,13 @@
# the Durable Object migrations declared in wrangler.toml atomically with the code
# upload, so the service's schema can never lag a deploy.
#
# The `target` input picks the worker: `prod` (default) deploys `cmux-presence`
# on presence.cmux.dev; `dev` deploys the shared integration baseline
# `cmux-presence-dev` from wrangler.dev.toml. Both use the repository's
# Cloudflare secrets, so nobody needs a personal Cloudflare account membership
# to keep the shared dev worker current. Per-developer isolated workers stay on
# `scripts/deploy-dev.sh` (they need per-instance Stack secrets at creation).
#
# Required repository secrets (deploy job):
# CLOUDFLARE_API_TOKEN API token with Workers Scripts:Edit on the account
# CLOUDFLARE_ACCOUNT_ID the Cloudflare account id
@@ -16,6 +23,14 @@ name: presence
on:
workflow_dispatch:
inputs:
target:
description: "Worker to deploy"
type: choice
options:
- prod
- dev
default: prod
permissions:
contents: read
@@ -100,7 +115,17 @@ jobs:
fi
- name: Deploy (applies DO migrations atomically)
run: bunx wrangler deploy
# The target reaches the shell via env, never template interpolation
# (an API dispatch is not limited to the UI's choice list), and any
# value other than the two known targets fails closed instead of
# silently deploying production.
run: |
case "$DEPLOY_TARGET" in
dev) bunx wrangler deploy --config wrangler.dev.toml ;;
prod) bunx wrangler deploy ;;
*) echo "::error::Unsupported deployment target '$DEPLOY_TARGET'"; exit 1 ;;
esac
env:
DEPLOY_TARGET: ${{ inputs.target }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+36 -2
View File
@@ -173,10 +173,44 @@ jobs:
EXCLUDED_SOURCE_FILE_NAMES=Info.plist \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGN_IDENTITY=""
CODE_SIGN_IDENTITY="" \
SWIFT_OPTIMIZATION_LEVEL=-O \
SWIFT_COMPILATION_MODE=wholemodule \
GCC_OPTIMIZATION_LEVEL=s
[ -d "$archive" ] || { echo "archive not produced: $archive" >&2; exit 1; }
# Keep Blacksmith device reloads equivalent to the fleet path: one
# build supplies both the unsigned phone archive and the exact same
# source revision for an isolated Simulator verification.
xcodebuild build \
-workspace ios/cmux.xcworkspace \
-scheme cmux-ios \
-configuration Debug \
-destination 'generic/platform=iOS Simulator' \
-derivedDataPath "$RUNNER_TEMP/cmux-ios-dd" \
PRODUCT_BUNDLE_IDENTIFIER="$bundle_id" \
PRODUCT_DISPLAY_NAME="$display_name" \
CMUX_GIT_SHA="$(git rev-parse --short HEAD)" \
CMUX_DEV_TAG="$BUILD_TAG" \
CMUX_API_BASE_URL="$api_base_url" \
CMUX_IROH_BROKER_BASE_URL="$iroh_broker_base_url" \
EXCLUDED_SOURCE_FILE_NAMES=Info.plist \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGN_IDENTITY="" \
SWIFT_OPTIMIZATION_LEVEL=-O \
SWIFT_COMPILATION_MODE=wholemodule \
GCC_OPTIMIZATION_LEVEL=s
sim_app="$RUNNER_TEMP/cmux-ios-dd/Build/Products/Debug-iphonesimulator/cmux.app"
[ -d "$sim_app" ] || { echo "simulator app not produced: $sim_app" >&2; exit 1; }
mkdir -p artifact
( cd "$out" && ditto -c -k --keepParent "$(basename "$archive")" "$GITHUB_WORKSPACE/artifact/archive.zip" )
pkg="$out/cmux-ios-$slug-pkg"
rm -rf "$pkg"
mkdir -p "$pkg/simulator"
mv "$archive" "$pkg/"
ditto "$sim_app" "$pkg/simulator/cmux.app"
ditto -c -k "$pkg" "$GITHUB_WORKSPACE/artifact/archive.zip"
- name: Write timings.json
if: ${{ always() }}
+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
+84 -45
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
@@ -63,6 +73,7 @@ jobs:
"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"],
"rust-sidebar Cargo.toml": tomllib.loads((root / "cmux-tui/bindings/rust-sidebar/Cargo.toml").read_text())["package"]["version"],
}
mismatches = {name: got for name, got in versions.items() if got != expected}
if mismatches:
@@ -71,6 +82,9 @@ jobs:
raise SystemExit(1)
print(f"All package versions match {expected}")
PY
python3 cmux-tui/bindings/check-versions.py \
--published-only \
--expected "$version"
echo "version=$version" >> "$GITHUB_OUTPUT"
bindings-e2e-rust:
@@ -95,44 +109,69 @@ 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
run: cargo build -p cmux-tui --bin cmux-tui --locked
- name: Python conformance fixtures
run: python3 cmux-tui/bindings/conformance/runner.py
- name: Rust binding e2e
run: bash cmux-tui/bindings/conformance/e2e.sh --require rust
publish:
needs: 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 with crates.io trusted publishing
id: auth
uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1.0.5
- name: Publish cmux-client
- name: Test Rust SDK packages
working-directory: cmux-tui
env:
CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
run: cargo publish -p cmux-client --locked
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"
python3 cmux-tui/bindings/conformance/runner.py \
--language rust \
--require rust \
--cmux-tui-bin "$GITHUB_WORKSPACE/cmux-tui/target/debug/cmux-tui" |
tee "$report"
grep -Eq '^PASS +rust +live-creation-exit-restart-unix$' "$report"
+156 -17
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,9 +121,13 @@ jobs:
raise SystemExit(1)
print(f"All package versions match {expected}")
PY
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
@@ -110,15 +164,20 @@ jobs:
- name: Build cmux-tui server
working-directory: cmux-tui
run: cargo build -p cmux-tui
run: cargo build -p cmux-tui --bin cmux-tui --locked
- name: Python conformance fixtures
run: python3 cmux-tui/bindings/conformance/runner.py
- name: Go binding e2e
run: bash cmux-tui/bindings/conformance/e2e.sh --require go
- name: Go SDK conformance
run: |
report="$RUNNER_TEMP/cmux-sdk-conformance-go.txt"
python3 cmux-tui/bindings/conformance/runner.py \
--language go \
--require go \
--cmux-tui-bin "$GITHUB_WORKSPACE/cmux-tui/target/debug/cmux-tui" |
tee "$report"
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:
@@ -136,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 ./...
+16 -25
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
@@ -71,6 +57,7 @@ jobs:
raise SystemExit(1)
print(f"All package versions match {expected}")
PY
python3 cmux-tui/bindings/check-versions.py --expected "$version"
echo "version=$version" >> "$GITHUB_OUTPUT"
bindings-e2e-java:
@@ -113,13 +100,17 @@ jobs:
- name: Build cmux-tui server
working-directory: cmux-tui
run: cargo build -p cmux-tui
run: cargo build -p cmux-tui --bin cmux-tui --locked
- name: Python conformance fixtures
run: python3 cmux-tui/bindings/conformance/runner.py
- name: Java binding e2e
run: bash cmux-tui/bindings/conformance/e2e.sh --require java
- name: Java SDK conformance
run: |
report="$RUNNER_TEMP/cmux-sdk-conformance-java.txt"
python3 cmux-tui/bindings/conformance/runner.py \
--language java \
--require java \
--cmux-tui-bin "$GITHUB_WORKSPACE/cmux-tui/target/debug/cmux-tui" |
tee "$report"
grep -Eq '^PASS +java +live-creation-exit-restart-unix$' "$report"
maven-central-todo:
needs: bindings-e2e-java
+75 -69
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,6 +88,9 @@ jobs:
raise SystemExit(1)
print(f"All package versions match {expected}")
PY
python3 cmux-tui/bindings/check-versions.py \
--published-only \
--expected "$version"
echo "version=$version" >> "$GITHUB_OUTPUT"
bindings-e2e-typescript:
@@ -87,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:
@@ -109,65 +124,56 @@ jobs:
rustup default "$RUST_TOOLCHAIN"
rustc --version
- name: Build cmux-tui server
working-directory: cmux-tui
run: cargo build -p cmux-tui
- name: Python conformance fixtures
run: python3 cmux-tui/bindings/conformance/runner.py
- name: TypeScript binding e2e
run: bash cmux-tui/bindings/conformance/e2e.sh --require typescript
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
- name: Set up Node.js for conformance
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22.14.0"
registry-url: https://registry.npmjs.org
cache: npm
cache-dependency-path: cmux-tui/bindings/typescript/package-lock.json
- 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: Install TypeScript adapter dependencies
working-directory: cmux-tui/bindings/typescript
run: |
npm ci --no-audit --no-fund
npm run build
npm test
- name: Publish package to npm
- name: Build cmux-tui server
working-directory: cmux-tui
run: cargo build -p cmux-tui --bin cmux-tui --locked
- name: TypeScript SDK conformance
run: |
test "$(node -p 'typeof WebSocket')" = "function"
report="$RUNNER_TEMP/cmux-sdk-conformance-typescript.txt"
python3 cmux-tui/bindings/conformance/runner.py \
--language typescript \
--require typescript \
--cmux-tui-bin "$GITHUB_WORKSPACE/cmux-tui/target/debug/cmux-tui" |
tee "$report"
grep -Eq '^PASS +typescript +live-creation-exit-restart-unix$' "$report"
grep -Eq '^PASS +typescript +live-creation-exit-restart-websocket$' "$report"
- name: Pack the validated npm artifact
id: package
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
run: |
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: 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
+96 -41
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,6 +88,9 @@ jobs:
raise SystemExit(1)
print(f"All package versions match {expected}")
PY
python3 cmux-tui/bindings/check-versions.py \
--published-only \
--expected "$version"
echo "version=$version" >> "$GITHUB_OUTPUT"
bindings-e2e-python:
@@ -87,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
@@ -106,55 +127,89 @@ jobs:
- name: Build cmux-tui server
working-directory: cmux-tui
run: cargo build -p cmux-tui
run: cargo build -p cmux-tui --bin cmux-tui --locked
- name: Python conformance fixtures
run: python3 cmux-tui/bindings/conformance/runner.py
- name: Install declared Python build backend
run: |
python3 -m pip install \
--disable-pip-version-check \
"setuptools==80.9.0"
- name: Python binding e2e
run: bash cmux-tui/bindings/conformance/e2e.sh --require python
- 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"
python3 cmux-tui/bindings/conformance/runner.py \
--language python \
--require python \
--cmux-tui-bin "$GITHUB_WORKSPACE/cmux-tui/target/debug/cmux-tui" |
tee "$report"
grep -Eq '^PASS +python +live-creation-exit-restart-unix$' "$report"
build:
needs: bindings-e2e-python
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
+12 -4
View File
@@ -42,6 +42,14 @@ CMUX_TAG=<tag> scripts/cmux-debug-cli.sh send --workspace workspace:1 --surface
The helper refuses to run without `CMUX_TAG`, targets `/tmp/cmux-debug-<tag>.sock`, and uses the matching tagged CLI from DerivedData. It scrubs ambient cmux terminal context (`CMUX_SOCKET`, `CMUX_SOCKET_PASSWORD`, workspace/surface/tab/panel IDs, cmuxd socket, debug log), then sets `CMUX_SOCKET_PATH`, `CMUX_BUNDLE_ID`, and `CMUX_BUNDLED_CLI_PATH` for the tag.
## iOS builds open on the iPhone by default
Any work verified by opening the iOS app installs BOTH an isolated-simulator build AND the same build on the user's iPhone. Never stop at simulator-only. Use `ios/scripts/reload-cloud.sh --tag <tag>` (or `ios/scripts/reload.sh --tag <tag>`); with a default iPhone configured (`CMUX_IPHONE_DEVICE_ID` or `~/.config/cmux/iphone-device-id`) the device leg is automatic, and `--device-id <id>` still overrides (`xcrun devicectl list devices`). Auto sign-in and auto-pair apply as usual; launch the app so it is immediately open on the phone. The simulator leg uses the tag's own isolated device `cmux-dev-<slug>`, created on demand; do not target a shared or user-visible simulator.
Every phone build requires the same-tag Mac dev build (the iOS app is unusable without its Mac). The reload scripts build the Mac tag first when it is missing and refuse to ship a phone-only build if that fails; do not bypass this with `CMUX_IOS_SKIP_MAC_BUILD_CHECK` in normal work.
If the iPhone is unreachable at build time, the reload still completes: the signed build is parked in the offline install queue (`scripts/iphone-install-queue.sh`, persistent under `~/Library/Application Support/cmux-dev/iphone-install-queue`), and a LaunchAgent auto-installs and launches it within seconds of the phone being plugged back in or reappearing on the network, then sends a `cmux notify` with the installed tags. The LaunchAgent is a one-time per-Mac setup: `scripts/install-iphone-queue-agent.sh install`; it runs a stable copy of the queue script, so re-run the installer after changing that script. In the handoff, report the queued state (`scripts/iphone-install-queue.sh list`) instead of treating an unreachable phone as a failure; `drain` retries manually, `clear` abandons a queued build.
## iOS dev auth
`ios/scripts/reload.sh` and `scripts/mobile-dev-launch.sh` auto-sign-in from `~/.secrets/cmuxterm-dev.env`. If the phone lands on the login screen or the helper reports missing credentials, do not ask the user to authenticate every build. Tell them to run `scripts/setup-team-dev.sh` once; it verifies their Stack login and writes the file chmod 600. Manual fallback: create it with `CMUX_DOGFOOD_STACK_EMAIL=...` and `CMUX_DOGFOOD_STACK_PASSWORD=...`.
@@ -52,13 +60,13 @@ Two commits, so CI proves the test catches the bug: commit 1 adds the failing te
## First pass, then dogfood
A first pass ends when the change is implemented, the tagged build succeeded on the pushed HEAD, focused tests ran, and the PR is open (for `web/` PRs, also the live Vercel preview URL). Then hand off to the user. Do not fix CI failures, merge conflicts, or review findings inline in the main conversation after that point.
A first pass ends when the change is implemented, the tagged build succeeded on the pushed HEAD, focused tests ran, and the PR is open (for `web/` PRs, also the live Vercel preview URL). Then hand off to the user. Do not sit in the main conversation watching CI or running speculative review passes after that point.
At handoff, launch one background `$autoreview` subagent with a bounded prompt (PR URL, worktree, base ref, allowed write scope, required verification), never a vague "make it green". That loop owns CI and spawns a bounded repair subagent only when a check actually fails. One writer per worktree: do not run a second CI repair agent against the same worktree, and if dogfood feedback needs main-agent edits while the loop runs, stop the loop first or give it a sibling worktree.
Do not launch a background review agent (`$autoreview`, `codex review`, `claude review`, or a judge loop) by default. Second-model review is explicit user opt-in in the current conversation; an implementation request, open PR, CI failure, closeout, or handoff is not that opt-in. Let required GitHub checks and the automatic review bots run asynchronously, then return to address only concrete check failures and actionable findings before merge.
The loop may commit and push scoped fixes but never merges and never rebuilds the user's tagged build. The main agent inspects every pushed commit, rejects out-of-scope edits, and owns dogfood, approval, and merge. Merging app/runtime/UI changes requires the user's explicit approval after dogfood; if a pushed fix changes runtime behavior mid-dogfood, rebuild the tag and re-notify, since the earlier verdict covers only the build the user tested.
The main agent owns dogfood, approval, mergeability, and every pushed fix. Merging app/runtime/UI changes requires the user's explicit approval after dogfood; if a fix changes runtime behavior mid-dogfood, rebuild the tag and re-notify, since the earlier verdict covers only the build the user tested.
Notify through `cmux notify` so the user can leave and return. Handoff: `--title "Dogfood ready: <short task>" --subtitle "<branch> · <tag>" --body "Was: <prior bad behavior>. Now: <expected behavior>. <concrete check>. PR: <pr-url>"`. The loop reports its own outcome, e.g. `"CI green: <branch>"` or `"CI blocked: <branch>"` with a one-line cause and the next decision. Titles carry outcome and branch, bodies carry the single next action. Skip notify if there is no cmux socket.
Notify through `cmux notify` so the user can leave and return. Handoff: `--title "Dogfood ready: <short task>" --subtitle "<branch> · <tag>" --body "Was: <prior bad behavior>. Now: <expected behavior>. <concrete check>. PR: <pr-url>"`. Later closeout notifications use `"CI green: <branch>"` or `"CI blocked: <branch>"` with a one-line cause and the next decision. Titles carry outcome and branch, bodies carry the single next action. Skip notify if there is no cmux socket.
## Pitfalls
+5
View File
@@ -0,0 +1,5 @@
/// Stable observability stages for failures that would otherwise drop hook state silently.
enum AgentHookFailureStage: String, Sendable {
case targetResolution = "target-resolution"
case notificationDelivery = "notification-delivery"
}
+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
@@ -0,0 +1,39 @@
import Foundation
extension CMUXCLI {
/// The ownership result returned by a guarded agent resume-binding clear.
enum AgentSurfaceResumeBindingClearOutcome: Equatable {
case cleared
case checkpointDidNotOwnBinding
case failed
}
func clearAgentSurfaceResumeBindingOutcome(
client: SocketClient,
workspaceId: String,
surfaceId: String,
sessionId: String?,
sessionDidEnd: Bool = false
) -> AgentSurfaceResumeBindingClearOutcome {
let normalizedSessionId = normalizedHookValue(sessionId)
var params: [String: Any] = [
"surface_id": surfaceId,
"source": "agent-hook"
]
if let normalizedSessionId {
params["checkpoint_id"] = normalizedSessionId
}
if sessionDidEnd, normalizedSessionId != nil {
params["agent_session_ended"] = true
}
do {
let result = try client.sendV2(method: "surface.resume.clear", params: params)
guard let cleared = result["cleared"] as? Bool else {
return .failed
}
return cleared ? .cleared : .checkpointDidNotOwnBinding
} catch {
return .failed
}
}
}
+1
View File
@@ -1,4 +1,5 @@
import CmuxFoundation
import CmuxSentryReporting
import Darwin
import Foundation
@@ -0,0 +1,59 @@
import Foundation
import OSLog
nonisolated private let agentHookDeliveryLogger = Logger(
subsystem: "com.cmuxterm.cli",
category: "AgentHookDelivery"
)
extension CMUXCLI {
/// Chooses the live wrapper PID for Codex while preserving legacy precedence for other agents.
func preferredAgentHookEventPID(
agentName: String,
mappedPID: Int?,
inferredPID: Int?
) -> Int? {
agentName == "codex"
? inferredPID ?? mappedPID
: mappedPID ?? inferredPID
}
/// Reports a persistently throttled hook failure without serializing raw transport details.
func reportAgentHookFailure(
stage: AgentHookFailureStage,
agentName: String,
sessionId: String,
event: String,
error: Error? = nil,
store: ClaudeHookSessionStore,
telemetry: CLISocketSentryTelemetry
) {
guard (try? store.claimAgentHookFailureReport(
agentName: agentName,
stage: stage.rawValue,
sessionId: sessionId
)) == true else {
return
}
let shortSessionId = String(sessionId.prefix(12))
let errorType = error.map { String(reflecting: type(of: $0)) } ?? "unresolved-target"
let reportableError = NSError(
domain: "com.cmuxterm.cli.agent-hook.\(stage.rawValue)",
code: 1,
userInfo: ["underlying_error_type": errorType]
)
agentHookDeliveryLogger.error(
"Agent hook failed stage=\(stage.rawValue, privacy: .public) event=\(event, privacy: .public) agent=\(agentName, privacy: .public) session=\(shortSessionId, privacy: .private(mask: .hash)) errorType=\(errorType, privacy: .private(mask: .hash))"
)
telemetry.captureError(
stage: "agent-hook-\(stage.rawValue)",
error: reportableError,
data: [
"agent": agentName,
"hook_event": event,
"has_session_id": !sessionId.isEmpty,
"underlying_error_type": errorType,
]
)
}
}
+122
View File
@@ -0,0 +1,122 @@
import Foundation
extension CMUXCLI {
func liveAgentControllingTTYBinding(
pid: Int?,
client: SocketClient
) -> AgentHookProcessBindingProbe {
guard !client.isRelayBacked, let pid, pid > 0 else {
return .notAttempted
}
let payload: [String: Any]
do {
payload = try client.sendV2(
method: "agent.resolve_delivery_target",
params: [
"pid": pid,
"pid_resolution": AgentProcessBindingResolution.controllingTTY.rawValue,
],
responseTimeout: 2
)
} catch let error as CLIError where error.v2Code == "method_not_found"
|| error.v2Code == "unrecognized_method" {
return .unsupported
} catch {
return .failed
}
guard (payload["source"] as? String) == "pid",
(payload["pid_resolution"] as? String) == AgentProcessBindingResolution.controllingTTY.rawValue,
let workspaceId = normalizedHandleValue(payload["workspace_id"] as? String),
isUUID(workspaceId),
let surfaceId = normalizedHandleValue(payload["surface_id"] as? String),
isUUID(surfaceId) else {
return .failed
}
return .resolved(CallerTerminalBinding(workspaceId: workspaceId, surfaceId: surfaceId))
}
func resolveAgentHookProcessBinding(
pid: Int?,
resolution: AgentProcessBindingResolution,
client: SocketClient
) -> AgentHookProcessBindingResult {
guard resolution == .controllingTTY else {
return corroboratedAgentHookProcessBinding(pid: pid, client: client)
}
switch liveAgentControllingTTYBinding(pid: pid, client: client) {
case .resolved(let binding):
return AgentHookProcessBindingResult(binding: binding, source: .liveProcess, rejectsAmbientClaim: false)
case .unsupported:
return corroboratedAgentHookProcessBinding(pid: pid, client: client)
case .failed:
return AgentHookProcessBindingResult(binding: nil, source: nil, rejectsAmbientClaim: true)
case .notAttempted:
return AgentHookProcessBindingResult(
binding: uniqueCallerTerminalBindingByTTY(client: client),
source: .ambientTTY,
rejectsAmbientClaim: false
)
}
}
private func corroboratedAgentHookProcessBinding(
pid: Int?,
client: SocketClient
) -> AgentHookProcessBindingResult {
if let binding = uniqueCallerTerminalBindingByTTY(client: client) {
return AgentHookProcessBindingResult(binding: binding, source: .ambientTTY, rejectsAmbientClaim: false)
}
return AgentHookProcessBindingResult(
binding: resolveAgentProcessTerminalBinding(pid: pid, client: client),
source: .liveProcess,
rejectsAmbientClaim: false
)
}
func clearSupersededAgentHookSessions(
_ initialRecords: [ClaudeHookSessionRecord],
owner: ClaudeHookSessionRecord,
statusKey: String,
store: ClaudeHookSessionStore,
client: SocketClient
) {
var records = initialRecords
if records.isEmpty {
records = (try? store.pendingSupersededSessionCleanupCandidates(for: owner)) ?? []
}
var clearedRecords: [ClaudeHookSessionRecord] = []
for record in records {
let resumeClearOutcome = clearAgentSurfaceResumeBindingOutcome(
client: client,
workspaceId: record.workspaceId,
surfaceId: record.surfaceId,
sessionId: record.sessionId
)
guard resumeClearOutcome != .failed else {
continue
}
if record.surfaceId == owner.surfaceId {
// Registering the replacement structured PID on this panel has
// already evicted the superseded key. Avoid a redundant
// key-miss clear while the replacement may not have published
// its own PID yet.
clearedRecords.append(record)
continue
}
let pidKey = "\(statusKey).\(record.sessionId)"
do {
_ = try sendV1Command(
"clear_agent_pid \(pidKey) --tab=\(record.workspaceId)\(socketPanelOption(record.surfaceId)) --clear-status --require-owned-key",
client: client
)
clearedRecords.append(record)
} catch {
continue
}
}
try? store.acknowledgeSupersededSessionCleanup(clearedRecords)
}
}
@@ -0,0 +1,8 @@
extension CMUXCLI {
enum AgentHookProcessBindingProbe {
case notAttempted
case unsupported
case failed
case resolved(CallerTerminalBinding)
}
}
@@ -0,0 +1,12 @@
extension CMUXCLI {
struct AgentHookProcessBindingResult {
let binding: CallerTerminalBinding?
let source: AgentHookProcessBindingSource?
let rejectsAmbientClaim: Bool
func canReplaceAmbientWorkspace(_ workspaceId: String?) -> Bool {
guard let workspaceId else { return true }
return source == .liveProcess || binding?.workspaceId == workspaceId
}
}
}
@@ -0,0 +1,6 @@
extension CMUXCLI {
enum AgentHookProcessBindingSource {
case ambientTTY
case liveProcess
}
}
+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
}
+2
View File
@@ -53,6 +53,7 @@ extension CMUXCLI {
static let topLevelCommandNames: Set<String> = [
"__codex-teams-watch",
"__internal_flags",
"__sidebar_footer_icon_balance",
"__tmux-compat",
"agent-hibernation",
"ai-accounts",
@@ -165,6 +166,7 @@ extension CMUXCLI {
"resize-pane",
"respawn-pane",
"restore-session",
"restore",
"right-sidebar",
"rpc",
"select-workspace",
+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":
+69 -1
View File
@@ -3,6 +3,14 @@ import Darwin
import Foundation
extension CMUXCLI {
func managedTerminalRequiredMessage(displayName: String) -> String {
let format = String(
localized: "cli.tmux-compat.error.managedTerminalRequired",
defaultValue: "%@ must be launched from a cmux-managed terminal surface. Open a terminal surface in cmux and run this command there."
)
return String(format: format, displayName)
}
func missingProviderExecutableMessage(displayName: String, executableName: String) -> String {
let format = String(
localized: "agentSession.error.missingProviderExecutable",
@@ -77,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
@@ -148,6 +163,59 @@ extension CMUXCLI {
)
}
/// Whether a Claude-backed launcher will exit after printing help or version
/// information. Reuse the launch parser so flag-shaped prompt text and option values
/// cannot downgrade a real agent session to launcher-only tmux compatibility.
func tmuxCompatIsInformationalInvocation(commandArgs: [String]) -> Bool {
["--help", "-h", "--version", "-v"].contains { option in
AgentLaunchSanitizer.claudeTeamsLaunchHasOption(option, args: commandArgs)
}
}
func claudeTeamsIsNonLaunchInvocation(commandArgs: [String]) -> Bool {
tmuxCompatIsInformationalInvocation(commandArgs: commandArgs)
|| AgentLaunchInvocationClassifier().claudeTeamsLaunchIsManagementCommand(args: commandArgs)
}
/// Whether cmux delegates the complete argument tail to a managed provider.
/// These commands own flags such as `--json` and nested `--help`; cmux must
/// not consume them as presentation options or generic subcommand help.
func managedProviderArgumentsPassThrough(command: String) -> Bool {
switch command {
case "claude-teams", "codex-teams", "omo", "omx", "omc":
return true
default:
return false
}
}
/// Whether cmux should render its own subcommand help before launching a provider.
///
/// Claude and Codex own their help arguments. The legacy OMO/OMX/OMC wrappers
/// retain cmux's root `--help` contract while forwarding nested help unchanged.
func shouldDispatchCmuxSubcommandHelp(command: String, commandArgs: [String]) -> Bool {
switch command {
case "claude-teams", "codex-teams":
return false
case "omo", "omx", "omc":
return commandArgs.count == 1 && ["--help", "-h"].contains(commandArgs[0])
default:
return true
}
}
func codexTeamsIsInformationalInvocation(commandArgs: [String]) -> Bool {
AgentLaunchInvocationClassifier().codexTeamsLaunchIsInformational(args: commandArgs)
}
func omoIsNonLaunchInvocation(commandArgs: [String]) -> Bool {
AgentLaunchInvocationClassifier().omoLaunchIsNonLaunch(args: commandArgs)
}
func omxIsNonLaunchInvocation(commandArgs: [String]) -> Bool {
AgentLaunchInvocationClassifier().omxLaunchIsNonLaunch(args: commandArgs)
}
/// Environment the lead `claude` is launched with. CLAUDE_CODE_SANDBOXED skips
/// Claude Code's interactive "Do you trust this folder?" gate so the unattended
/// lead/teammate panes don't deadlock on it (#6447). That gate is a real safety
+1 -45
View File
@@ -165,7 +165,7 @@ extension CMUXCLI {
let processCount = padLeft(String(topInt(group["process_count"]) ?? 0), width: 5)
let name = topLabelText(group["name"] as? String)
let command = name.padding(toLength: 26, withPad: " ", startingAt: 0)
let attribution = memoryAttributionText(group["top_attribution"], idFormat: idFormat)
let attribution = memoryGroupAttributionText(group, idFormat: idFormat)
lines.append("\(rss) \(processCount) \(command) \(attribution)")
}
@@ -182,48 +182,4 @@ extension CMUXCLI {
)
}
private func memoryAttributionText(_ raw: Any?, idFormat: CLIIDFormat) -> String {
guard let attribution = raw as? [String: Any] else {
return String(localized: "cli.memory.output.unattributed", defaultValue: "unattributed")
}
var parts: [String] = []
if let workspace = memoryAttributionHandle(attribution, prefix: "workspace", idFormat: idFormat) {
parts.append(String.localizedStringWithFormat(
String(localized: "cli.memory.output.workspaceAttribution", defaultValue: "workspace %@"),
workspace
))
}
if let pane = memoryAttributionHandle(attribution, prefix: "pane", idFormat: idFormat) {
parts.append(String.localizedStringWithFormat(
String(localized: "cli.memory.output.paneAttribution", defaultValue: "pane %@"),
pane
))
}
if let surface = memoryAttributionHandle(attribution, prefix: "surface", idFormat: idFormat) {
parts.append(String.localizedStringWithFormat(
String(localized: "cli.memory.output.surfaceAttribution", defaultValue: "surface %@"),
surface
))
}
return parts.isEmpty ? String(localized: "cli.memory.output.unattributed", defaultValue: "unattributed") : parts.joined(separator: " / ")
}
private func memoryAttributionHandle(
_ attribution: [String: Any],
prefix: String,
idFormat: CLIIDFormat
) -> String? {
let ref = topLabelText(attribution["\(prefix)_ref"] as? String)
let id = topLabelText(attribution["\(prefix)_id"] as? String)
switch idFormat {
case .refs:
return ref.isEmpty ? (id.isEmpty ? nil : id) : ref
case .uuids:
return id.isEmpty ? (ref.isEmpty ? nil : ref) : id
case .both:
let values = [ref, id].filter { !$0.isEmpty }
return values.isEmpty ? nil : values.joined(separator: " ")
}
}
}
+79
View File
@@ -0,0 +1,79 @@
import Foundation
extension CMUXCLI {
func memoryGroupAttributionText(
_ group: [String: Any],
idFormat: CLIIDFormat
) -> String {
guard let groupAttribution = group["group_attribution"] as? [String: Any],
let kind = groupAttribution["kind"] as? String else {
return memoryAttributionText(group["top_attribution"], idFormat: idFormat)
}
switch kind {
case "common":
return memoryAttributionText(groupAttribution["owner"], idFormat: idFormat)
case "multiple":
let workspaceCount = topInt(groupAttribution["workspace_count"]) ?? 0
if workspaceCount > 1 {
return String.localizedStringWithFormat(
String(localized: "memory.attribution.multipleWorkspaces", defaultValue: "%lld workspaces"),
workspaceCount
)
}
return String(localized: "memory.attribution.multipleOwners", defaultValue: "multiple owners")
case "partial":
return String(localized: "memory.attribution.partial", defaultValue: "partially attributed")
case "unattributed":
return String(localized: "cli.memory.output.unattributed", defaultValue: "unattributed")
default:
return memoryAttributionText(group["top_attribution"], idFormat: idFormat)
}
}
private func memoryAttributionText(_ raw: Any?, idFormat: CLIIDFormat) -> String {
guard let attribution = raw as? [String: Any] else {
return String(localized: "cli.memory.output.unattributed", defaultValue: "unattributed")
}
var parts: [String] = []
if let workspace = memoryAttributionHandle(attribution, prefix: "workspace", idFormat: idFormat) {
parts.append(String.localizedStringWithFormat(
String(localized: "cli.memory.output.workspaceAttribution", defaultValue: "workspace %@"),
workspace
))
}
if let pane = memoryAttributionHandle(attribution, prefix: "pane", idFormat: idFormat) {
parts.append(String.localizedStringWithFormat(
String(localized: "cli.memory.output.paneAttribution", defaultValue: "pane %@"),
pane
))
}
if let surface = memoryAttributionHandle(attribution, prefix: "surface", idFormat: idFormat) {
parts.append(String.localizedStringWithFormat(
String(localized: "cli.memory.output.surfaceAttribution", defaultValue: "surface %@"),
surface
))
}
return parts.isEmpty
? String(localized: "cli.memory.output.unattributed", defaultValue: "unattributed")
: parts.joined(separator: " / ")
}
private func memoryAttributionHandle(
_ attribution: [String: Any],
prefix: String,
idFormat: CLIIDFormat
) -> String? {
let ref = topLabelText(attribution["\(prefix)_ref"] as? String)
let id = topLabelText(attribution["\(prefix)_id"] as? String)
switch idFormat {
case .refs:
return ref.isEmpty ? (id.isEmpty ? nil : id) : ref
case .uuids:
return id.isEmpty ? (ref.isEmpty ? nil : ref) : id
case .both:
let values = [ref, id].filter { !$0.isEmpty }
return values.isEmpty ? nil : values.joined(separator: " ")
}
}
}
+4 -2
View File
@@ -26,11 +26,12 @@ extension CMUXCLI {
localCommandScript: String?,
sshFallbackCommand: String
) -> String {
let invocationOptions = sshCommandOptionsWithoutRemoteCommand(options)
let capabilityProbeSSHArguments = sshArgumentsOverridingHostRemoteCommand(
baseSSHArguments(options)
baseSSHArguments(invocationOptions)
)
let sessionSSHArguments = sshArgumentsOverridingHostRemoteCommand(
baseSSHArguments(options)
baseSSHArguments(invocationOptions)
)
let remoteCommandArguments: [String]
let preparationShellScript: String?
@@ -58,6 +59,7 @@ extension CMUXCLI {
sessionSSHArguments: sessionSSHArguments,
destination: options.destination,
remoteCommandArguments: remoteCommandArguments,
remoteRelayPort: options.remoteRelayPort,
preparationShellScript: preparationShellScript,
managementReadyShellScript: localCommandScript,
sshFallbackCommand: sshFallbackCommand,
+153 -12
View File
@@ -74,6 +74,7 @@ function base64NulSeparated(values: string[]): string {
function hookEnvironment(cwd: string): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = { ...process.env };
env.CMUX_OMP_PID = String(process.pid);
if (!env.CMUX_AGENT_LAUNCH_ARGV_B64) {
const argv = normalizedLaunchArgv();
env.CMUX_AGENT_LAUNCH_KIND = "omp";
@@ -87,6 +88,7 @@ function hookEnvironment(cwd: string): NodeJS.ProcessEnv {
interface HookInvocation {
cmux: string;
cwd: string;
sessionId: string;
payload: string;
env: NodeJS.ProcessEnv;
}
@@ -128,9 +130,20 @@ function lastAssistantMessage(event: AgentEndEvent): string | undefined {
return undefined;
}
function boundedHookText(value: string | undefined): string | undefined {
if (value === undefined || value.length <= 32768) return value;
return value.slice(0, 32768);
}
function isNestedArtifactSession(ctx: ExtensionContext): boolean {
const sessionFile = firstString(ctx.sessionManager.getSessionFile());
return sessionFile !== null && fs.existsSync(`${path.dirname(sessionFile)}.jsonl`);
}
function hookInvocation(subcommand: string, ctx: ExtensionContext, extra: Record<string, unknown> = {}): HookInvocation | null {
if (process.env.CMUX_OMP_HOOKS_DISABLED === "1") return null;
if (!process.env.CMUX_SURFACE_ID) return null;
if (isNestedArtifactSession(ctx)) return null;
const sessionId = firstString(ctx.sessionManager.getSessionId());
if (!sessionId) return null;
@@ -147,36 +160,160 @@ function hookInvocation(subcommand: string, ctx: ExtensionContext, extra: Record
return {
cmux,
cwd,
sessionId,
payload: JSON.stringify(payload),
env: hookEnvironment(cwd),
};
}
async function sendHook(subcommand: string, ctx: ExtensionContext, extra: Record<string, unknown> = {}): Promise<void> {
const invocation = hookInvocation(subcommand, ctx, extra);
if (!invocation) return;
await new Promise<void>((resolve) => {
interface RunningHook {
completion: Promise<void>;
cancel: () => void;
}
function startHook(invocation: HookInvocation, subcommand: string): RunningHook {
let child: ReturnType<typeof spawn> | null = null;
let settle = () => {};
const terminate = () => {
if (child && !child.killed) child.kill("SIGKILL");
};
const completion = new Promise<void>((resolve) => {
let settled = false;
const settle = () => {
const timeout = setTimeout(() => {
terminate();
}, 5000);
timeout.unref();
settle = () => {
if (settled) return;
settled = true;
clearTimeout(timeout);
resolve();
};
try {
const child = spawn(invocation.cmux, ["hooks", "omp", subcommand], {
child = spawn(invocation.cmux, ["hooks", "omp", subcommand], {
env: invocation.env,
stdio: ["pipe", "ignore", "ignore"],
detached: true,
});
child.on("error", settle);
child.stdin.on("error", settle);
child.stdin.on("finish", settle);
child.unref();
child.on("close", settle);
child.stdin.on("error", () => {});
child.stdin.end(invocation.payload);
} catch (_) {
settle();
}
});
return {
completion,
cancel: () => {
terminate();
},
};
}
interface QueuedHook {
invocation: HookInvocation;
subcommand: string;
}
function hookPriority(subcommand: string): number {
switch (subcommand) {
case "stop":
return 2;
case "session-start":
return 1;
default:
return 0;
}
}
const maxQueuedHooks = 16;
const hookShutdownDeadlineMs = 2000;
const hookQueue: QueuedHook[] = [];
let hookWorker: Promise<void> | null = null;
let activeHook: RunningHook | null = null;
let activeHookSubcommand: string | null = null;
async function drainHookQueue(): Promise<void> {
while (hookQueue.length > 0) {
const next = hookQueue.shift();
if (!next) continue;
const running = startHook(next.invocation, next.subcommand);
activeHook = running;
activeHookSubcommand = next.subcommand;
await running.completion;
if (activeHook === running) {
activeHook = null;
activeHookSubcommand = null;
}
}
}
function startHookWorker(): void {
if (hookWorker) return;
hookWorker = drainHookQueue().finally(() => {
hookWorker = null;
if (hookQueue.length > 0) startHookWorker();
});
}
async function waitForHookWorker(worker: Promise<void>, timeoutMs: number): Promise<boolean> {
let completed = false;
let timeout: ReturnType<typeof setTimeout> | null = null;
await Promise.race([
worker.then(() => {
completed = true;
}),
new Promise<void>((resolve) => {
timeout = setTimeout(resolve, timeoutMs);
}),
]);
if (timeout) clearTimeout(timeout);
return completed;
}
async function awaitHookQueueDrain(): Promise<void> {
for (let index = hookQueue.length - 1; index >= 0; index -= 1) {
if (hookQueue[index]?.subcommand === "prompt-submit") hookQueue.splice(index, 1);
}
const worker = hookWorker;
if (!worker) return;
if (await waitForHookWorker(worker, hookShutdownDeadlineMs)) return;
for (let index = hookQueue.length - 1; index >= 0; index -= 1) {
if (hookQueue[index]?.subcommand !== "stop") hookQueue.splice(index, 1);
}
if (activeHookSubcommand !== "stop") activeHook?.cancel();
if (await waitForHookWorker(worker, hookShutdownDeadlineMs)) return;
hookQueue.splice(0);
activeHook?.cancel();
await worker;
}
function enqueueHook(invocation: HookInvocation, subcommand: string): void {
const duplicate = hookQueue.findIndex(
(queued) => queued.invocation.sessionId === invocation.sessionId && queued.subcommand === subcommand
);
if (duplicate >= 0) {
hookQueue.splice(duplicate, 1);
hookQueue.push({ invocation, subcommand });
} else {
if (hookQueue.length >= maxQueuedHooks) {
const priority = hookPriority(subcommand);
const evictable = hookQueue.findIndex((queued) => hookPriority(queued.subcommand) < priority);
if (evictable >= 0) hookQueue.splice(evictable, 1);
else return;
}
hookQueue.push({ invocation, subcommand });
}
startHookWorker();
}
function sendHook(subcommand: string, ctx: ExtensionContext, extra: Record<string, unknown> = {}): Promise<void> {
const invocation = hookInvocation(subcommand, ctx, extra);
if (!invocation) return Promise.resolve();
enqueueHook(invocation, subcommand);
return Promise.resolve();
}
export default function cmuxOmpSessionExtension(api: ExtensionAPI) {
@@ -185,11 +322,15 @@ export default function cmuxOmpSessionExtension(api: ExtensionAPI) {
});
api.on("before_agent_start", async (event, ctx) => {
await sendHook("prompt-submit", ctx, { prompt: event.prompt });
await sendHook("prompt-submit", ctx, { prompt: boundedHookText(event.prompt) });
});
api.on("agent_end", async (event, ctx) => {
await sendHook("stop", ctx, { last_assistant_message: lastAssistantMessage(event) });
await sendHook("stop", ctx, { last_assistant_message: boundedHookText(lastAssistantMessage(event)) });
});
api.on("session_shutdown", async () => {
await awaitHookQueueDrain();
});
}
"""#
+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 {
+140 -48
View File
@@ -259,21 +259,43 @@ async function clearResumeBinding(
}
}
function sendFeed(
type PiFeedEventName =
| "PreToolUse"
| "PostToolUse"
| "PreCompact"
| "PostCompact"
| "SubagentStart"
| "SubagentStop";
const subagentToolNames = new Set([
"subagent",
"team_spawn",
"superpowers_dispatch",
"Task",
]);
function isSubagentTool(event: unknown): boolean {
const toolName = firstString(objectValue(event, ["toolName", "tool_name", "name"]));
return toolName !== null && (subagentToolNames.has(toolName) || /subagent/i.test(toolName));
}
function isTerminalFeedEvent(eventName: PiFeedEventName): boolean {
return eventName === "PostToolUse" || eventName === "SubagentStop";
}
function prepareFeedDispatch(
dispatcher: PiCmuxCommandDispatcher,
sessionStates: Map<string, SessionState>,
eventName: "PreToolUse" | "PostToolUse",
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"]));
@@ -291,7 +313,7 @@ function sendFeed(
if (boundedToolName !== undefined) payload.tool_name = boundedToolName;
const toolInput = objectValue(event, ["args", "input"]);
if (toolInput !== undefined) payload.tool_input = projectPiFeedValue(toolInput, projectionState);
if (eventName === "PostToolUse") {
if (isTerminalFeedEvent(eventName)) {
const toolResult = objectValue(event, ["result", "details", "content"]);
if (toolResult !== undefined) {
payload.tool_result = projectPiFeedValue(toolResult, projectionState, 0, false);
@@ -299,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: eventName === "PostToolUse",
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(
@@ -314,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;
@@ -328,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,
@@ -342,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) {
@@ -352,52 +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);
sendFeed(dispatcher, sessionStates, "PreToolUse", 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);
sendFeed(dispatcher, sessionStates, "PostToolUse", context, event);
pi.on("tool_execution_end", (event, ctx) => {
enqueueFeed(isSubagentTool(event) ? "SubagentStop" : "PostToolUse", event, ctx);
});
pi.on("agent_end", async (event, ctx) => {
pi.on("session_before_compact", (event, ctx) => {
enqueueFeed("PreCompact", event, ctx);
});
pi.on("session_compact", (event, ctx) => {
enqueueFeed("PostCompact", 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) => {
@@ -413,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);
}
});
});
}
"""#
+436
View File
@@ -0,0 +1,436 @@
import CMUXAgentLaunch
import Darwin
import Foundation
extension CMUXCLI {
func controlAgentLaunchCommandPayload(
_ command: AgentLaunchCommand
) -> [String: Any] {
var payload: [String: Any] = ["arguments": command.arguments]
if let launcher = command.launcher {
payload["launcher"] = launcher
}
if let executablePath = command.executablePath {
payload["executable_path"] = executablePath
}
if let workingDirectory = command.workingDirectory {
payload["working_directory"] = workingDirectory
}
if let environment = command.environment {
payload["environment"] = environment
}
if let capturedAt = command.capturedAt {
payload["captured_at"] = capturedAt
}
if let source = command.source {
payload["source"] = source
}
return payload
}
func runRestoreCommand(
commandArgs: [String],
client: SocketClient,
processEnvironment: [String: String]
) throws {
let selector = try restoreSelector(commandArgs)
var params: [String: Any] = [:]
if let surface = selector.surface {
let surfaceID = try normalizeSurfaceHandle(
surface,
client: client,
workspaceHandle: nil,
windowHandle: nil
)
guard let surfaceID else {
throw loggedRestoreError(
stage: "surface.lookup",
detail: surface,
message: String(
localized: "cli.restore.error.surfaceNotFound",
defaultValue: "restore: the requested surface was not found. Check the surface reference, then retry."
)
)
}
params["surface_id"] = surfaceID
} else if selector.usesCurrentSurface,
let surfaceID = try currentRestoreSurfaceID(
client: client,
processEnvironment: processEnvironment
) {
params["surface_id"] = surfaceID
} else {
throw currentRestoreSurfaceUnknownError()
}
let payload = try client.sendV2(method: "surface.resume.get", params: params)
guard let rawRecord = payload["restore_record"] as? [String: Any] else {
throw loggedRestoreError(
stage: "record.missing",
message: String(
localized: "cli.restore.error.noRecord",
defaultValue: "restore: this session has nothing to restore. Start the agent again in this terminal."
)
)
}
let record = try restoreRecord(from: rawRecord)
if let expectedKind = selector.kind, expectedKind != record.kind {
throw loggedRestoreError(
stage: "record.kind-mismatch",
detail: "expected=\(expectedKind) actual=\(record.kind)",
message: String(
localized: "cli.restore.error.kindMismatch",
defaultValue: "restore: this command no longer matches the session. Run 'cmux restore --surface' to use the current record."
)
)
}
if let expectedCheckpointID = selector.checkpointID,
expectedCheckpointID != record.checkpointID {
throw loggedRestoreError(
stage: "record.checkpoint-mismatch",
detail: "expected=\(expectedCheckpointID) actual=\(record.checkpointID ?? "none")",
message: String(
localized: "cli.restore.error.checkpointMismatch",
defaultValue: "restore: this command no longer matches the session. Run 'cmux restore --surface' to use the current record."
)
)
}
let environment = processEnvironment.merging(record.environment) { _, restored in
restored
}
if record.launchCommand == nil,
record.preparedArguments == nil,
let legacyCommand = record.legacyCommand {
try execLegacyRestoreRecord(
legacyCommand,
record: record,
environment: environment,
client: client
)
}
guard let mode = AgentRestoreRequestMode(rawValue: record.mode) else {
throw loggedRestoreError(
stage: "record.mode",
detail: record.mode,
message: String(
localized: "cli.restore.error.unsupportedMode",
defaultValue: "restore: this session's saved restore data is not compatible. Start the agent again in this terminal."
)
)
}
let requestedWorkingDirectory = requestedRestoreWorkingDirectory(for: record)
let appliedWorkingDirectory = try applyRestoreWorkingDirectory(
requestedWorkingDirectory
)
let effectiveWorkingDirectory: String? =
if requestedWorkingDirectory?.isEmpty == false {
appliedWorkingDirectory ?? FileManager.default.currentDirectoryPath
} else {
nil
}
let request = AgentRestoreRequest(
mode: mode,
kind: record.kind,
checkpointID: record.checkpointID,
source: record.source,
workingDirectory: effectiveWorkingDirectory,
environment: record.environment,
launchCommand: record.launchCommand,
preparedArguments: record.preparedArguments,
preparedArgumentsWorkingDirectory: normalizedRestoreWorkingDirectory(
record.preparedArgumentsWorkingDirectory
),
observedPermissionMode: record.permissionMode
)
guard let invocation = AgentRestorePlanner(
executableFileResolver: AgentRestoreExecutableFileResolver()
).invocation(
for: request,
ambientEnvironment: processEnvironment
) else {
if let legacyCommand = record.legacyCommand {
try execLegacyRestoreRecord(
legacyCommand,
record: record,
environment: environment,
client: client
)
}
throw loggedRestoreError(
stage: "record.incomplete",
detail: "mode=\(record.mode) kind=\(record.kind)",
message: String(
localized: "cli.restore.error.incompleteData",
defaultValue: "restore: this session's saved restore data is not compatible. Start the agent again in this terminal."
)
)
}
for preflight in invocation.preflightInvocations {
try runRestorePreflight(
preflight,
appliedWorkingDirectory: effectiveWorkingDirectory
)
}
client.close()
try execRestoreInvocation(
invocation,
appliedWorkingDirectory: effectiveWorkingDirectory
)
}
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 {
return RestoreSelector(
surface: nil,
usesCurrentSurface: true,
kind: nil,
checkpointID: nil
)
}
guard arguments.count == 2, !arguments[1].isEmpty else {
throw CLIError(message: String(
localized: "cli.restore.usage.surface",
defaultValue: "Usage: cmux restore --surface [id|ref]"
))
}
return RestoreSelector(
surface: arguments[1],
usesCurrentSurface: false,
kind: nil,
checkpointID: nil
)
}
guard arguments.count == 2,
!arguments[0].trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
!arguments[1].trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw CLIError(message: String(
localized: "cli.restore.usage.positional",
defaultValue: "Usage: cmux restore <kind> <checkpoint-id>"
))
}
return RestoreSelector(
surface: nil,
usesCurrentSurface: true,
kind: arguments[0],
checkpointID: arguments[1]
)
}
private func restoreRecord(from object: [String: Any]) throws -> RestoreRecord {
guard let mode = object["mode"] as? String,
let kind = object["kind"] as? String else {
throw loggedRestoreError(
stage: "record.decode",
detail: "keys=\(object.keys.sorted().joined(separator: ","))",
message: String(
localized: "cli.restore.error.malformedRecord",
defaultValue: "restore: this session's saved restore data is not compatible. Start the agent again in this terminal."
)
)
}
let legacyCommand = object["legacy_command"] as? String
let launchCommand: AgentLaunchCommand?
do {
launchCommand = try restoreLaunchCommand(from: object["launch_command"])
} catch {
guard legacyCommand != nil else {
throw loggedRestoreError(
stage: "record.launch-command",
detail: String(reflecting: type(of: error)),
message: String(
localized: "cli.restore.error.malformedArguments",
defaultValue: "restore: this session's saved restore data is not compatible. Start the agent again in this terminal."
)
)
}
launchCommand = nil
}
return RestoreRecord(
mode: mode,
kind: kind,
checkpointID: object["checkpoint_id"] as? String,
source: object["source"] as? String,
workingDirectory: object["working_directory"] as? String,
environment: object["environment"] as? [String: String] ?? [:],
launchCommand: launchCommand,
preparedArguments: object["prepared_arguments"] as? [String],
preparedArgumentsWorkingDirectory:
object["prepared_arguments_working_directory"] as? String,
permissionMode: object["permission_mode"] as? String,
legacyCommand: legacyCommand
)
}
private func restoreLaunchCommand(from value: Any?) throws -> AgentLaunchCommand? {
guard let object = value as? [String: Any] else { return nil }
guard let arguments = object["arguments"] as? [String], !arguments.isEmpty else {
throw CLIError(message: String(
localized: "cli.restore.error.malformedArguments",
defaultValue: "restore: this session's saved restore data is not compatible. Start the agent again in this terminal."
))
}
return AgentLaunchCommand(
launcher: object["launcher"] as? String,
executablePath: object["executable_path"] as? String,
arguments: arguments,
workingDirectory: object["working_directory"] as? String,
environment: object["environment"] as? [String: String],
capturedAt: (object["captured_at"] as? NSNumber)?.doubleValue,
source: object["source"] as? String
)
}
}
+202
View File
@@ -0,0 +1,202 @@
import CMUXAgentLaunch
import Darwin
import Foundation
extension CMUXCLI {
@discardableResult
func applyRestoreWorkingDirectory(_ path: String?) throws -> String? {
guard let path = path?.trimmingCharacters(in: .whitespacesAndNewlines),
!path.isEmpty else {
return nil
}
if chdir(path) == 0 {
return path
}
let changeDirectoryError = errno
// Preserve the old guarded `cd`: a directory removed since capture
// falls back to the shell's current directory, while an existing but
// inaccessible path still blocks restore.
if changeDirectoryError == ENOENT || changeDirectoryError == ENOTDIR {
return nil
}
throw loggedRestoreError(
stage: "working-directory.change",
detail: path,
errorCode: changeDirectoryError,
message: String(
localized: "cli.restore.error.workingDirectoryFailed",
defaultValue: "restore: the saved working directory is inaccessible. Restore access to it, then retry."
)
)
}
func requestedRestoreWorkingDirectory(for record: RestoreRecord) -> String? {
normalizedRestoreWorkingDirectory(record.workingDirectory)
?? normalizedRestoreWorkingDirectory(record.launchCommand?.workingDirectory)
}
func normalizedRestoreWorkingDirectory(_ path: String?) -> String? {
let trimmed = path?.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed?.isEmpty == false ? trimmed : nil
}
func execRestoreInvocation(
_ invocation: AgentRestoreInvocation,
appliedWorkingDirectory: String?
) throws {
var invocationEnvironment = invocation.environment
if let appliedWorkingDirectory {
invocationEnvironment["PWD"] = appliedWorkingDirectory
}
guard let first = invocation.arguments.first,
let executable = resolveRestoreExecutable(
first,
environment: invocationEnvironment
) else {
throw loggedRestoreError(
stage: "executable.resolve",
detail: invocation.arguments.first ?? "none",
message: String(
localized: "cli.restore.error.executableNotFound",
defaultValue: "restore: the saved agent command is unavailable. Make sure the agent is installed, then retry."
)
)
}
let executionError = withCStringArray(invocation.arguments) { argv in
withEnvironmentCStringArray(invocationEnvironment) { environment in
executable.withCString {
_ = execve($0, argv, environment)
return errno
}
}
}
throw loggedRestoreError(
stage: "executable.exec",
detail: executable,
errorCode: executionError,
message: String(
localized: "cli.restore.error.execveFailed",
defaultValue: "restore: the saved process could not be started. Retry the visible restore command."
)
)
}
func execLegacyRestoreRecord(
_ command: String,
record: RestoreRecord,
environment: [String: String],
client: SocketClient
) throws {
let appliedWorkingDirectory = try applyRestoreWorkingDirectory(
requestedRestoreWorkingDirectory(for: record)
)
var legacyEnvironment = environment
if let appliedWorkingDirectory {
legacyEnvironment["PWD"] = appliedWorkingDirectory
}
client.close()
try execLegacyRestoreCommand(command, environment: legacyEnvironment)
}
private func execLegacyRestoreCommand(
_ command: String,
environment: [String: String]
) throws {
let shell = restoreCompatibilityShell(environment: environment)
let arguments = [shell, "-lc", command]
let executionError = withCStringArray(arguments) { argv in
withEnvironmentCStringArray(environment) { childEnvironment in
shell.withCString {
_ = execve($0, argv, childEnvironment)
return errno
}
}
}
throw loggedRestoreError(
stage: "legacy-shell.exec",
detail: shell,
errorCode: executionError,
message: String(
localized: "cli.restore.error.compatibilityShellFailed",
defaultValue: "restore: the saved process could not be started. Retry the visible restore command."
)
)
}
private func restoreCompatibilityShell(environment: [String: String]) -> String {
if let shell = environment["SHELL"],
shell.hasPrefix("/"),
isExecutableRegularFile(atPath: shell) {
return shell
}
if let record = getpwuid(getuid()),
let shellPointer = record.pointee.pw_shell {
let shell = String(cString: shellPointer)
if isExecutableRegularFile(atPath: shell) {
return shell
}
}
return "/bin/sh"
}
func resolveRestoreExecutable(
_ executable: String,
environment: [String: String]
) -> String? {
if executable.contains("/") {
return isExecutableRegularFile(atPath: executable)
? executable
: nil
}
let path = environment["PATH"] ?? "/usr/bin:/bin:/usr/sbin:/sbin"
// Shells treat an empty PATH component as the current directory. Restore
// may already be inside an untrusted project, so fail closed instead.
for directory in path.split(separator: ":") {
let root = String(directory)
let candidate = URL(fileURLWithPath: root, isDirectory: true)
.appendingPathComponent(executable, isDirectory: false)
.path
if isExecutableRegularFile(atPath: candidate) {
return candidate
}
}
return nil
}
private func isExecutableRegularFile(atPath path: String) -> Bool {
var isDirectory: ObjCBool = false
guard FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory),
!isDirectory.boolValue else {
return false
}
return FileManager.default.isExecutableFile(atPath: path)
}
func withCStringArray<Result>(
_ strings: [String],
body: (UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>?) -> Result
) -> Result {
var pointers = strings.map { strdup($0) }
pointers.append(nil)
defer {
for pointer in pointers where pointer != nil {
free(pointer)
}
}
return pointers.withUnsafeMutableBufferPointer {
body($0.baseAddress)
}
}
func withEnvironmentCStringArray<Result>(
_ environment: [String: String],
body: (UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>?) -> Result
) -> Result {
withCStringArray(
environment.keys.sorted().compactMap { key in
environment[key].map { "\(key)=\($0)" }
},
body: body
)
}
}
+23
View File
@@ -0,0 +1,23 @@
import Foundation
import OSLog
nonisolated private let restoreFailureLogger = Logger(
subsystem: "com.cmuxterm.cli",
category: "Restore"
)
extension CMUXCLI {
/// Records private restore diagnostics while returning a product-level error.
func loggedRestoreError(
stage: String,
detail: String = "none",
errorCode: Int32? = nil,
message: String
) -> CLIError {
let loggedErrorCode = errorCode.map { String($0) } ?? "none"
restoreFailureLogger.error(
"Restore failed stage=\(stage, privacy: .public) detail=\(detail, privacy: .private(mask: .hash)) errorCode=\(loggedErrorCode, privacy: .private(mask: .hash))"
)
return CLIError(message: message)
}
}
+278
View File
@@ -0,0 +1,278 @@
import CMUXAgentLaunch
import Darwin
import Foundation
extension CMUXCLI {
func runRestorePreflight(
_ invocation: AgentRestorePreflightInvocation,
appliedWorkingDirectory: String?
) throws {
var invocationEnvironment = invocation.environment
if let appliedWorkingDirectory {
invocationEnvironment["PWD"] = appliedWorkingDirectory
}
guard let executable = resolveRestoreExecutable(
invocation.executable,
environment: invocationEnvironment
) else {
throw loggedRestoreError(
stage: "provider.resolve",
detail: invocation.executable,
message: String(
localized: "cli.restore.error.providerSetupUnavailable",
defaultValue: "restore: provider setup is unavailable. Check the agent's provider settings, then retry."
)
)
}
var fileActions: posix_spawn_file_actions_t?
let actionsStatus = posix_spawn_file_actions_init(&fileActions)
guard actionsStatus == 0 else {
throw loggedRestoreError(
stage: "provider.file-actions",
errorCode: actionsStatus,
message: String(
localized: "cli.restore.error.providerSetupConfigurationFailed",
defaultValue: "restore: provider setup could not start. Check the agent's provider settings, then retry."
)
)
}
defer { posix_spawn_file_actions_destroy(&fileActions) }
var redirectStatus = "/dev/null".withCString {
posix_spawn_file_actions_addopen(
&fileActions,
STDIN_FILENO,
$0,
O_RDONLY,
0
)
}
if redirectStatus == 0 {
redirectStatus = "/dev/null".withCString {
posix_spawn_file_actions_addopen(
&fileActions,
STDOUT_FILENO,
$0,
O_WRONLY,
0
)
}
}
if redirectStatus == 0 {
redirectStatus = "/dev/null".withCString {
posix_spawn_file_actions_addopen(
&fileActions,
STDERR_FILENO,
$0,
O_WRONLY,
0
)
}
}
guard redirectStatus == 0 else {
throw loggedRestoreError(
stage: "provider.redirect",
errorCode: redirectStatus,
message: String(
localized: "cli.restore.error.providerSetupConfigurationFailed",
defaultValue: "restore: provider setup could not start. Check the agent's provider settings, then retry."
)
)
}
var processID: pid_t = 0
let status = withCStringArray(invocation.arguments) { argv in
withEnvironmentCStringArray(invocationEnvironment) { environment in
executable.withCString {
posix_spawn(
&processID,
$0,
&fileActions,
nil,
argv,
environment
)
}
}
}
guard status == 0 else {
throw loggedRestoreError(
stage: "provider.spawn",
detail: executable,
errorCode: status,
message: String(
localized: "cli.restore.error.providerSetupStartFailed",
defaultValue: "restore: provider setup could not start. Check the agent's provider settings, then retry."
)
)
}
try waitForRestorePreflight(processID)
}
private func waitForRestorePreflight(_ processID: pid_t) throws {
// This synchronous CLI is about to call `execve`; EVFILT_PROC provides
// signal-driven completion with a kernel-enforced deadline and no poll.
let exitQueue = try restorePreflightExitQueue(processID)
defer { close(exitQueue) }
guard try waitForRestorePreflightExit(
exitQueue,
timeout: 10
) else {
terminateRestorePreflight(processID, exitQueue: exitQueue)
throw loggedRestoreError(
stage: "provider.timeout",
detail: "pid=\(processID)",
message: String(
localized: "cli.restore.error.providerSetupTimedOut",
defaultValue: "restore: provider setup took too long. Check the provider connection, then retry."
)
)
}
let waitStatus = try reapRestorePreflight(processID)
let exitedNormally = waitStatus & 0x7f == 0
let exitStatus = (waitStatus >> 8) & 0xff
if exitedNormally {
guard exitStatus == 0 else {
throw loggedRestoreError(
stage: "provider.exit",
errorCode: exitStatus,
message: String(
localized: "cli.restore.error.providerSetupExited",
defaultValue: "restore: provider setup failed. Check the agent's provider settings, then retry."
)
)
}
return
}
let terminationSignal = waitStatus & 0x7f
throw loggedRestoreError(
stage: "provider.signal",
errorCode: terminationSignal,
message: String(
localized: "cli.restore.error.providerSetupSignaled",
defaultValue: "restore: provider setup failed. Check the agent's provider settings, then retry."
)
)
}
private func restorePreflightExitQueue(_ processID: pid_t) throws -> Int32 {
let queue = kqueue()
guard queue >= 0 else {
throw restorePreflightWaitError(
stage: "provider.wait-queue",
errorCode: errno
)
}
var event = kevent(
ident: UInt(processID),
filter: Int16(EVFILT_PROC),
flags: UInt16(EV_ADD | EV_ENABLE | EV_ONESHOT),
fflags: UInt32(NOTE_EXIT),
data: 0,
udata: nil
)
while kevent(queue, &event, 1, nil, 0, nil) != 0 {
if errno == EINTR {
continue
}
close(queue)
throw restorePreflightWaitError(
stage: "provider.wait-register",
errorCode: errno
)
}
return queue
}
private func waitForRestorePreflightExit(
_ queue: Int32,
timeout: TimeInterval
) throws -> Bool {
let deadline = ProcessInfo.processInfo.systemUptime + timeout
while true {
let remaining = deadline - ProcessInfo.processInfo.systemUptime
guard remaining > 0 else { return false }
var timeoutSpec = timespec(
tv_sec: Int(remaining),
tv_nsec: Int((remaining - floor(remaining)) * 1_000_000_000)
)
var triggeredEvent = kevent()
let result = kevent(queue, nil, 0, &triggeredEvent, 1, &timeoutSpec)
if result > 0 {
return true
}
if result == 0 {
return false
}
if errno != EINTR {
throw restorePreflightWaitError(
stage: "provider.wait-event",
errorCode: errno
)
}
}
}
private func terminateRestorePreflight(
_ processID: pid_t,
exitQueue: Int32
) {
_ = kill(processID, SIGTERM)
var observedExit = (try? waitForRestorePreflightExit(
exitQueue,
timeout: 0.25
)) == true
if !observedExit {
_ = kill(processID, SIGKILL)
observedExit = (try? waitForRestorePreflightExit(
exitQueue,
timeout: 1
)) == true
}
if observedExit {
_ = try? reapRestorePreflight(processID)
} else {
_ = try? reapRestorePreflight(processID, options: WNOHANG)
}
}
private func reapRestorePreflight(
_ processID: pid_t,
options: Int32 = 0
) throws -> Int32 {
var waitStatus: Int32 = 0
while true {
let waitResult = waitpid(processID, &waitStatus, options)
if waitResult == processID {
return waitStatus
}
if waitResult == 0, options & WNOHANG != 0 {
return waitStatus
}
if waitResult == -1 && errno == EINTR {
continue
}
throw restorePreflightWaitError(
stage: "provider.wait-reap",
errorCode: errno
)
}
}
private func restorePreflightWaitError(
stage: String,
errorCode: Int32
) -> CLIError {
loggedRestoreError(
stage: stage,
errorCode: errorCode,
message: String(
localized: "cli.restore.error.providerSetupWaitFailed",
defaultValue: "restore: provider setup could not complete. Retry the visible restore command."
)
)
}
}
+18
View File
@@ -0,0 +1,18 @@
import CMUXAgentLaunch
extension CMUXCLI {
/// The socket restore payload after validation and typed decoding.
struct RestoreRecord {
let mode: String
let kind: String
let checkpointID: String?
let source: String?
let workingDirectory: String?
let environment: [String: String]
let launchCommand: AgentLaunchCommand?
let preparedArguments: [String]?
let preparedArgumentsWorkingDirectory: String?
let permissionMode: String?
let legacyCommand: String?
}
}
+9
View File
@@ -0,0 +1,9 @@
extension CMUXCLI {
/// The surface identity constraints parsed from `cmux restore` arguments.
struct RestoreSelector {
let surface: String?
let usesCurrentSurface: Bool
let kind: String?
let checkpointID: String?
}
}
+19 -3
View File
@@ -2,6 +2,22 @@ import CmuxFoundation
import Foundation
extension CMUXCLI {
/// Returns an option copy suitable for an SSH invocation where cmux
/// supplies the remote command. The caller's `RemoteCommand` remains in
/// durable workspace configuration, but must not precede cmux's own
/// `RemoteCommand=<bootstrap>` or duplicate a `RemoteCommand=none`
/// override on the actual argv.
internal func sshCommandOptionsWithoutRemoteCommand(
_ options: SSHCommandOptions
) -> SSHCommandOptions {
var sanitized = options
sanitized.sshOptions = SSHAgentSocketResolver().removingOptions(
named: "RemoteCommand",
from: options.sshOptions
)
return sanitized
}
/// Inserts `-o RemoteCommand=none` right after the `ssh` executable so a
/// host-configured (or caller-supplied) `RemoteCommand` cannot conflict
/// with the command-line remote command this invocation appends OpenSSH
@@ -10,10 +26,10 @@ extension CMUXCLI {
/// for invocations that pass their own command; the interactive session
/// hop keeps its explicit `-o RemoteCommand=<bootstrap>`.
internal func sshArgumentsOverridingHostRemoteCommand(_ arguments: [String]) -> [String] {
guard arguments.first == "ssh" else {
return SSHHostConfiguredRemoteCommand().overrideArguments + arguments
guard let executable = arguments.first else {
return SSHHostConfiguredRemoteCommand().overrideArguments
}
return [arguments[0]] + SSHHostConfiguredRemoteCommand().overrideArguments + arguments.dropFirst()
return [executable] + SSHHostConfiguredRemoteCommand().overrideArguments + arguments.dropFirst()
}
internal func openSSHLocalCommandValue(shellScript: String?) -> String? {
+14 -4
View File
@@ -28,7 +28,18 @@ extension CMUXCLI {
}
}
func resolvedSSHConfigurationOutput(for options: SSHCommandOptions) -> String? {
func resolvedSSHConfigurationOutput(
for options: SSHCommandOptions,
timeout: TimeInterval = 2
) -> String? {
let result = resolvedSSHConfigurationResult(for: options, timeout: timeout)
return result.status == 0 ? result.stdout : nil
}
func resolvedSSHConfigurationResult(
for options: SSHCommandOptions,
timeout: TimeInterval = 2
) -> CLIProcessResult {
var arguments = ["-G"]
if let port = options.port {
arguments += ["-p", String(port)]
@@ -46,12 +57,11 @@ extension CMUXCLI {
arguments += ["-o", option]
}
arguments.append(options.destination)
let result = CLIProcessRunner.runProcess(
return CLIProcessRunner.runProcess(
executablePath: "/usr/bin/ssh",
arguments: arguments,
timeout: 2
timeout: timeout
)
return result.status == 0 ? result.stdout : nil
}
func sshConfigurationValue(named name: String, in output: String) -> String? {
+62 -6
View File
@@ -1,5 +1,6 @@
import Darwin
import Foundation
import CmuxFoundation
extension CLIError {
init(message: String, exitCode: SSHPTYAttachExitCode) {
@@ -8,7 +9,7 @@ extension CLIError {
}
extension CMUXCLI {
/// True when a persistent attach wrapper owns retrying a 254|255 failure.
/// True when a persistent attach wrapper has another general retry available.
/// Persistent wrappers export `CMUX_SSH_PTY_ATTACH_WRAPPER_CAN_RETRY=1`;
/// direct invocations leave it unset, so failures there always clean up.
func sshPTYAttachWrapperRetryPending() -> Bool {
@@ -16,6 +17,42 @@ extension CMUXCLI {
.trimmingCharacters(in: .whitespacesAndNewlines) == "1"
}
func sshPTYAttachWrapperWillRetry(_ exitCode: SSHPTYAttachExitCode) -> Bool {
guard sshPTYAttachWrapperRetryPending() else { return false }
if exitCode == .bridgeClosedWithoutProgress {
let environment = ProcessInfo.processInfo.environment
guard let retry = Int(environment["CMUX_SSH_PTY_ATTACH_NO_PROGRESS_RETRY"] ?? ""),
retry >= 0,
let limit = Int(environment["CMUX_SSH_PTY_ATTACH_NO_PROGRESS_LIMIT"] ?? ""),
limit > 0 else {
return false
}
return SSHPTYAttachExitCode.hasNoProgressRetryRemaining(
currentRetry: retry,
limit: limit
)
}
return exitCode.isWrapperRetryable
}
func sshPTYAttachBridgeClosedExitCode(
receivedLiveOutput: Bool,
readyUptime: TimeInterval
) -> SSHPTYAttachExitCode {
let environment = ProcessInfo.processInfo.environment
let bridgeUptime = ProcessInfo.processInfo.systemUptime - readyUptime
guard sshPTYAttachWrapperRetryPending(),
environment["CMUX_SSH_PTY_ATTACH_NO_PROGRESS_RETRY"] != nil,
environment["CMUX_SSH_PTY_ATTACH_NO_PROGRESS_LIMIT"] != nil,
SSHPTYAttachExitCode.bridgeClosureMadeNoProgress(
receivedLiveOutput: receivedLiveOutput,
bridgeUptime: bridgeUptime
) else {
return .bridgeClosedSessionRunning
}
return .bridgeClosedWithoutProgress
}
func cleanupFailedSSHPTYAttach(
client: SocketClient,
workspaceId: String,
@@ -93,7 +130,8 @@ extension CMUXCLI {
surfaceID: String?,
sessionID: String,
lifecycleID: String,
intentionalOnly: Bool
intentionalOnly: Bool,
sessionRunningExitCode: SSHPTYAttachExitCode = .bridgeClosedSessionRunning
) throws -> Bool {
let reconciliationFailure = "ssh-pty-attach: bridge closed before remote PTY exit could be confirmed"
let response: [String: Any]
@@ -144,9 +182,18 @@ extension CMUXCLI {
(($0["session_id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "") == sessionID
}
if !intentionalCleanup, sessionStillRunning {
let message: String
if sessionRunningExitCode == .bridgeClosedWithoutProgress {
message = String(
localized: "cli.sshPtyAttach.bridgeClosedWithoutProgress",
defaultValue: "ssh-pty-attach: bridge closed without receiving new output while the remote PTY session is still running"
)
} else {
message = "ssh-pty-attach: bridge closed while remote PTY session is still running"
}
throw CLIError(
message: "ssh-pty-attach: bridge closed while remote PTY session is still running",
exitCode: SSHPTYAttachExitCode.bridgeClosedSessionRunning
message: message,
exitCode: sessionRunningExitCode
)
}
guard let surfaceID else { return true }
@@ -165,7 +212,7 @@ extension CMUXCLI {
return true
}
func readSSHPTYBridgeReady(fd: Int32) throws -> String {
func readSSHPTYBridgeReady(fd: Int32) throws -> (attachmentToken: String, replayBytes: Int) {
let maxStatusBytes = 4096
// Bound only the pre-ready status wait: a bridge that accepts the TCP
// connection and then goes silent must not hang the attach (and its
@@ -189,8 +236,12 @@ extension CMUXCLI {
}
switch type {
case "ready":
return ((payload["attachment_token"] as? String)?
let attachmentToken = ((payload["attachment_token"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)) ?? ""
return (
attachmentToken: attachmentToken,
replayBytes: sshPTYBridgeReplayByteCount(payload["replay_bytes"])
)
case "error":
let message = ((payload["message"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)).flatMap { $0.isEmpty ? nil : $0 }
@@ -224,6 +275,11 @@ extension CMUXCLI {
throw CLIError(message: "ssh-pty-attach: bridge status exceeded \(maxStatusBytes) bytes")
}
private func sshPTYBridgeReplayByteCount(_ value: Any?) -> Int {
guard let count = value as? Int, count >= 0 else { return 0 }
return count
}
/// Ceiling for the bridge ready/error status wait. Defaults to 185s,
/// matching the `wait_for_ready` RPC response timeout in `runSSHPTYAttach`.
private func sshPTYBridgeReadyTimeoutSeconds() -> TimeInterval {
+60 -18
View File
@@ -1,3 +1,4 @@
import CmuxFoundation
import Foundation
extension CMUXCLI {
@@ -289,6 +290,9 @@ extension CMUXCLI {
? ""
: "export GHOSTTY_SHELL_FEATURES=\(shellQuote(trimmedFeatures))"
let lifecycleCleanup = buildSSHSessionEndShellCommand(remoteRelayPort: remoteRelayPort)
let lifecycleLaunching = remoteRelayPort > 0
? buildSSHTerminalSessionLaunchingShellCommand()
: ":"
let lifecycleRetirement = retryPTYAttachStatus
? buildSSHSessionEndShellCommand(remoteRelayPort: remoteRelayPort, lifecycleOnly: true)
: ":"
@@ -296,6 +300,8 @@ extension CMUXCLI {
.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedOneTimeCommand = oneTimeCommand?.trimmingCharacters(in: .whitespacesAndNewlines)
let hasOneTimeCommand = trimmedOneTimeCommand?.isEmpty == false
let authRetryPolicy = SSHForegroundAuthenticationRetryPolicy()
let backoffBuilder = SSHRetryBackoffScriptBuilder(context: .startup)
var scriptLines: [String] = []
if !shellFeaturesBootstrap.isEmpty {
scriptLines.append(shellFeaturesBootstrap)
@@ -330,9 +336,8 @@ extension CMUXCLI {
scriptLines.append(trimmedControlPathPreflight)
}
if let trimmedOneTimeCommand, !trimmedOneTimeCommand.isEmpty {
scriptLines.append("trap 'cmux_ssh_cleanup_password' EXIT")
scriptLines += ["cmux_ssh_foreground_auth() {", trimmedOneTimeCommand, "}"]
scriptLines += ["( cmux_ssh_foreground_auth )", "cmux_ssh_auth_status=$?", "if [ \"$cmux_ssh_auth_status\" -ne 0 ]; then exit \"$cmux_ssh_auth_status\"; fi", "trap - EXIT"]
scriptLines.append(authRetryPolicy.processTreeTerminationShellFunction())
}
let reconnectConfiguration = retryPTYAttachStatus ? [
"cmux_ssh_reconnect_limit=\"${CMUX_SSH_RECONNECT_LIMIT:-}\"",
@@ -357,37 +362,46 @@ extension CMUXCLI {
"export CMUX_SSH_STARTUP_PID",
] + reconnectConfiguration + [
"cmux_ssh_retry=0",
"cmux_ssh_reauth_required=0",
"CMUX_SSH_CHILD_PID=",
"CMUX_SSH_PENDING_SIGNAL=",
"cmux_ssh_auth_retry_limit=\(authRetryPolicy.maximumConsecutiveTransientFailures); cmux_ssh_auth_retry=0",
// Initial transient foreground-auth failures are a reconnect phase, so boot-time outages share this loop.
"cmux_ssh_reauth_required=\(hasOneTimeCommand ? 1 : 0)",
"CMUX_SSH_CHILD_PID=; CMUX_SSH_AUTH_PID=; CMUX_SSH_PENDING_SIGNAL=; CMUX_SSH_PENDING_SIGNAL_NAME=",
] + backoffBuilder.stateInitializationLines + [
"cmux_ssh_note() { if [ -t 2 ]; then printf \"$@\" >&2 || true; fi; }",
"cmux_ssh_register_attempt() { \(lifecycleLaunching); }",
"cmux_ssh_begin_attempt() { CMUX_SSH_ATTEMPT_ID=$(/usr/bin/uuidgen | /usr/bin/tr '[:upper:]' '[:lower:]') || return 1; export CMUX_SSH_ATTEMPT_ID; cmux_ssh_attempt_registration_retry=0; while ! cmux_ssh_register_attempt; do cmux_ssh_attempt_registration_retry=$((cmux_ssh_attempt_registration_retry + 1)); if [ \"$cmux_ssh_attempt_registration_retry\" -ge 3 ]; then return 1; fi; /bin/sleep 0.1; done; }",
"cmux_ssh_session_end() { if [ \"${CMUX_SSH_SESSION_ENDED:-0}\" = 1 ]; then return; fi; CMUX_SSH_SESSION_ENDED=1; cmux_ssh_cleanup_password; \(lifecycleCleanup); }",
"cmux_ssh_signal_exit() { cmux_ssh_signal_status=\"$1\"; if [ -z \"${CMUX_SSH_CHILD_PID:-}\" ]; then CMUX_SSH_PENDING_SIGNAL=\"$cmux_ssh_signal_status\"; return; fi; CMUX_SSH_SESSION_ENDED=1; cmux_ssh_cleanup_password; \(lifecycleRetirement); trap - EXIT HUP INT TERM; exit \"$cmux_ssh_signal_status\"; }",
"cmux_ssh_retire_for_signal() { cmux_ssh_signal_status=\"$1\"; CMUX_SSH_SESSION_ENDED=1; cmux_ssh_cleanup_password; \(lifecycleRetirement); trap - EXIT HUP INT TERM; exit \"$cmux_ssh_signal_status\"; }",
"cmux_ssh_signal_exit() { cmux_ssh_signal_status=\"$1\"; cmux_ssh_signal_name=\"$2\"; if [ -n \"${CMUX_SSH_AUTH_PID:-}\" ]; then cmux_ssh_terminate_auth_process_tree \"$CMUX_SSH_AUTH_PID\" \"$CMUX_SSH_STARTUP_PID\"; wait \"$CMUX_SSH_AUTH_PID\" 2>/dev/null || true; CMUX_SSH_AUTH_PID=; \(backoffBuilder.signalHandlerBranches) elif [ -z \"${CMUX_SSH_CHILD_PID:-}\" ]; then CMUX_SSH_PENDING_SIGNAL=\"$cmux_ssh_signal_status\"; CMUX_SSH_PENDING_SIGNAL_NAME=\"$cmux_ssh_signal_name\"; return; fi; cmux_ssh_retire_for_signal \"$cmux_ssh_signal_status\"; }",
"trap 'cmux_ssh_session_end' EXIT",
"trap 'cmux_ssh_signal_exit 129' HUP",
"trap 'cmux_ssh_signal_exit 130' INT",
"trap 'cmux_ssh_signal_exit 143' TERM",
"trap 'cmux_ssh_signal_exit 129 HUP' HUP",
"trap 'cmux_ssh_signal_exit 130 INT' INT",
"trap 'cmux_ssh_signal_exit 143 TERM' TERM",
"while :; do",
" if [ -n \"${CMUX_SSH_PENDING_SIGNAL:-}\" ]; then cmux_ssh_retire_for_signal \"$CMUX_SSH_PENDING_SIGNAL\"; fi",
]
if hasOneTimeCommand {
scriptLines.append(" if [ \"$cmux_ssh_reauth_required\" -eq 1 ]; then")
scriptLines += [" ( cmux_ssh_foreground_auth )", " cmux_ssh_status=$?", " if [ \"$cmux_ssh_status\" -eq 0 ]; then cmux_ssh_reauth_required=0; elif [ \"$cmux_ssh_status\" -ne 255 ]; then break; fi", " fi", " if [ \"$cmux_ssh_reauth_required\" -eq 0 ]; then"]
scriptLines += [" ( cmux_ssh_foreground_auth ) <&0 &", " CMUX_SSH_AUTH_PID=$!; if [ -n \"${CMUX_SSH_PENDING_SIGNAL:-}\" ]; then cmux_ssh_signal_exit \"$CMUX_SSH_PENDING_SIGNAL\" \"${CMUX_SSH_PENDING_SIGNAL_NAME:-TERM}\"; fi; wait \"$CMUX_SSH_AUTH_PID\"; cmux_ssh_status=$?; CMUX_SSH_AUTH_PID=; case \"$cmux_ssh_status\" in 129|130|143) cmux_ssh_retire_for_signal \"$cmux_ssh_status\" ;; esac; if [ -n \"${CMUX_SSH_PENDING_SIGNAL:-}\" ]; then cmux_ssh_session_end; trap - EXIT HUP INT TERM; exit \"$CMUX_SSH_PENDING_SIGNAL\"; fi", " if [ \"$cmux_ssh_status\" -eq 0 ]; then cmux_ssh_reauth_required=0; cmux_ssh_auth_retry=0; else case \"$cmux_ssh_status\" in 254) cmux_ssh_auth_retry=$((cmux_ssh_auth_retry + 1)); if [ \"$cmux_ssh_auth_retry\" -ge \"$cmux_ssh_auth_retry_limit\" ]; then cmux_ssh_status=255; break; fi ;; \(authRetryPolicy.unclassifiedFailureExitStatus)) cmux_ssh_status=255; break ;; *) break ;; esac; fi", " fi", " if [ \"$cmux_ssh_reauth_required\" -eq 0 ]; then"]
}
if let trimmedControlPathPreflight, !trimmedControlPathPreflight.isEmpty,
!hasOneTimeCommand {
scriptLines.append(" cmux_ssh_preflight_control_path")
}
if retryPTYAttachStatus {
// Advertise per attempt whether another 254|255 retry is queued so
// Advertise per attempt whether another 251|254|255 retry is queued so
// ssh-pty-attach only suppresses its pty_attach_end cleanup while a
// retry is actually pending; see CMUXCLI.sshPTYAttachWrapperRetryPending
// and keep in sync with CMUXCLI.sshPTYAttachRetryLoopLines /
// SSHPTYAttachStartupCommandBuilder.retryingAttachLines.
// and SSHPTYAttachRetryScriptBuilder.
scriptLines += [
" if [ \"$cmux_ssh_reconnect_unbounded\" -eq 1 ] || [ \"$cmux_ssh_retry\" -lt \"$cmux_ssh_reconnect_limit\" ]; then CMUX_SSH_PTY_ATTACH_WRAPPER_CAN_RETRY=1; else CMUX_SSH_PTY_ATTACH_WRAPPER_CAN_RETRY=0; fi",
" export CMUX_SSH_PTY_ATTACH_WRAPPER_CAN_RETRY",
]
}
scriptLines += [
" cmux_ssh_begin_attempt || exit 1",
" if [ -n \"${CMUX_SSH_PENDING_SIGNAL:-}\" ]; then cmux_ssh_retire_for_signal \"$CMUX_SSH_PENDING_SIGNAL\"; fi",
]
if isShellSnippet {
scriptLines += [
" (",
@@ -397,7 +411,7 @@ extension CMUXCLI {
} else {
scriptLines.append(" command \(sshCommand) <&0 &")
}
let retryableStatusPattern = retryPTYAttachStatus ? "254|255" : "255"
let retryableStatusPattern = retryPTYAttachStatus ? "251|254|255" : "255"
scriptLines += [
" CMUX_SSH_CHILD_PID=$!",
" if [ -n \"${CMUX_SSH_PENDING_SIGNAL:-}\" ]; then cmux_ssh_signal_exit \"$CMUX_SSH_PENDING_SIGNAL\"; fi",
@@ -408,7 +422,10 @@ extension CMUXCLI {
" case \"$cmux_ssh_status\" in \(retryableStatusPattern)) ;; *) break ;; esac",
]
if retryPTYAttachStatus {
scriptLines.append(" if [ \"$cmux_ssh_status\" -eq 254 ]; then cmux_ssh_reconnect_delay=\"$cmux_ssh_reconnect_initial_delay\"; fi")
let establishedBridgeFailed = hasOneTimeCommand
? "[ \"$cmux_ssh_status\" -eq 254 ] && [ \"$cmux_ssh_reauth_required\" -eq 0 ]"
: "[ \"$cmux_ssh_status\" -eq 254 ]"
scriptLines.append(" if \(establishedBridgeFailed); then cmux_ssh_reconnect_delay=\"$cmux_ssh_reconnect_initial_delay\"; fi")
}
if hasOneTimeCommand {
scriptLines += [" if [ \"$cmux_ssh_status\" -eq 255 ]; then cmux_ssh_reauth_required=1; fi", " fi"]
@@ -420,8 +437,8 @@ extension CMUXCLI {
scriptLines += [
" cmux_ssh_retry=$((cmux_ssh_retry + 1))",
" cmux_ssh_note '\\n\\033[33m[cmux] ssh exited with status %s; reconnecting (attempt %s/%s).\\033[0m\\n\\033[2m[cmux] close this pane or press Ctrl-C to stop reconnecting.\\033[0m\\n' \"$cmux_ssh_status\" \"$cmux_ssh_retry\" \"$cmux_ssh_reconnect_limit\"",
" if [ \"$cmux_ssh_reconnect_delay\" -gt 0 ]; then sleep \"$cmux_ssh_reconnect_delay\"; fi",
]
scriptLines += backoffBuilder.waitLines
if retryPTYAttachStatus {
scriptLines.append(" if [ \"$cmux_ssh_reconnect_delay\" -lt \"$cmux_ssh_reconnect_max_delay\" ]; then cmux_ssh_reconnect_delay=$((cmux_ssh_reconnect_delay * 2)); if [ \"$cmux_ssh_reconnect_delay\" -gt \"$cmux_ssh_reconnect_max_delay\" ]; then cmux_ssh_reconnect_delay=\"$cmux_ssh_reconnect_max_delay\"; fi; fi")
}
@@ -482,11 +499,36 @@ extension CMUXCLI {
"&& [ -n \"${CMUX_SOCKET_PATH:-}\" ]",
"&& [ -n \"${CMUX_WORKSPACE_ID:-}\" ]",
"&& [ -n \"${CMUX_SURFACE_ID:-}\" ]; then",
"\"${CMUX_BUNDLED_CLI_PATH}\" --socket \"${CMUX_SOCKET_PATH}\" ssh-session-end --relay-port \(remoteRelayPort) --workspace \"${CMUX_WORKSPACE_ID}\" --surface \"${CMUX_SURFACE_ID}\" --session-id \"${CMUX_SSH_PTY_SESSION_ID:-}\" --lifecycle-id \"${CMUX_SSH_PTY_LIFECYCLE_ID:-}\"\(lifecycleOnlyFlag) >/dev/null 2>&1 || true;",
"\"${CMUX_BUNDLED_CLI_PATH}\" --socket \"${CMUX_SOCKET_PATH}\" ssh-session-end --relay-port \(remoteRelayPort) --workspace \"${CMUX_WORKSPACE_ID}\" --surface \"${CMUX_SURFACE_ID}\" --terminal-lifecycle-id \"${CMUX_TERMINAL_LIFECYCLE_ID:-}\" --session-id \"${CMUX_SSH_PTY_SESSION_ID:-}\" --lifecycle-id \"${CMUX_SSH_PTY_LIFECYCLE_ID:-}\"\(lifecycleOnlyFlag) >/dev/null 2>&1 || true;",
"elif command -v cmux >/dev/null 2>&1",
"&& [ -n \"${CMUX_WORKSPACE_ID:-}\" ]",
"&& [ -n \"${CMUX_SURFACE_ID:-}\" ]; then",
"cmux ssh-session-end --relay-port \(remoteRelayPort) --workspace \"${CMUX_WORKSPACE_ID}\" --surface \"${CMUX_SURFACE_ID}\" --session-id \"${CMUX_SSH_PTY_SESSION_ID:-}\" --lifecycle-id \"${CMUX_SSH_PTY_LIFECYCLE_ID:-}\"\(lifecycleOnlyFlag) >/dev/null 2>&1 || true;",
"cmux ssh-session-end --relay-port \(remoteRelayPort) --workspace \"${CMUX_WORKSPACE_ID}\" --surface \"${CMUX_SURFACE_ID}\" --terminal-lifecycle-id \"${CMUX_TERMINAL_LIFECYCLE_ID:-}\" --session-id \"${CMUX_SSH_PTY_SESSION_ID:-}\" --lifecycle-id \"${CMUX_SSH_PTY_LIFECYCLE_ID:-}\"\(lifecycleOnlyFlag) >/dev/null 2>&1 || true;",
"fi",
].joined(separator: " ")
}
private func buildSSHTerminalSessionLaunchingShellCommand() -> String {
let arguments =
"rpc workspace.remote.terminal_session_launching " +
"\"{\\\"workspace_id\\\":\\\"${CMUX_WORKSPACE_ID}\\\"," +
"\\\"surface_id\\\":\\\"${CMUX_SURFACE_ID}\\\"," +
"\\\"terminal_lifecycle_id\\\":\\\"${CMUX_TERMINAL_LIFECYCLE_ID}\\\"," +
"\\\"attempt_id\\\":\\\"${CMUX_SSH_ATTEMPT_ID}\\\"}\""
return [
"if [ -n \"${CMUX_BUNDLED_CLI_PATH:-}\" ]",
"&& [ -x \"${CMUX_BUNDLED_CLI_PATH}\" ]",
"&& [ -n \"${CMUX_SOCKET_PATH:-}\" ]",
"&& [ -n \"${CMUX_WORKSPACE_ID:-}\" ]",
"&& [ -n \"${CMUX_SURFACE_ID:-}\" ]; then",
"CMUXTERM_CLI_RESPONSE_TIMEOUT_SEC=2 \"${CMUX_BUNDLED_CLI_PATH}\" --socket \"${CMUX_SOCKET_PATH}\" \(arguments) >/dev/null 2>&1;",
"elif command -v cmux >/dev/null 2>&1",
"&& [ -n \"${CMUX_SOCKET_PATH:-}\" ]",
"&& [ -n \"${CMUX_WORKSPACE_ID:-}\" ]",
"&& [ -n \"${CMUX_SURFACE_ID:-}\" ]; then",
"CMUXTERM_CLI_RESPONSE_TIMEOUT_SEC=2 cmux --socket \"${CMUX_SOCKET_PATH}\" \(arguments) >/dev/null 2>&1;",
"else",
"false;",
"fi",
].joined(separator: " ")
}
+341
View File
@@ -0,0 +1,341 @@
import Darwin
import Foundation
extension CMUXCLI {
struct TmuxCompatLaunchContext {
let socketPath: String
let workspaceId: String
let windowId: String?
let paneHandle: String
let paneId: String?
let surfaceId: String?
}
func tmuxCompatResolvedSocketPath(processEnvironment: [String: String]) throws -> String {
let envSocketPath = try CLISocketEnvironment.socketPath(in: processEnvironment)
let bundleIdentifier = CLISocketPathResolver.currentAppBundleIdentifier()
let requestedSocketPath = envSocketPath ?? CLISocketPathResolver.defaultSocketPath(
bundleIdentifier: bundleIdentifier,
environment: processEnvironment
)
let source: CLISocketPathSource
if let envSocketPath {
source = CLISocketPathResolver.isImplicitDefaultPath(
envSocketPath,
bundleIdentifier: bundleIdentifier,
environment: processEnvironment
) ? .implicitDefault : .environment
} else {
source = .implicitDefault
}
return CLISocketPathResolver.resolve(
requestedPath: requestedSocketPath,
source: source,
environment: processEnvironment,
bundleIdentifier: bundleIdentifier
)
}
func tmuxCompatLaunchContext(
processEnvironment: [String: String],
explicitPassword: String?
) throws -> TmuxCompatLaunchContext? {
// A managed launcher is anchored to the immutable identity injected into its
// terminal. Without an inherited surface there is no caller to validate, so fail
// closed before opening the socket. In particular, never borrow system-wide focus
// from `system.identify`: a command started in Terminal.app must not target an
// unrelated cmux surface merely because that surface happens to be focused.
let ownWorkspace = normalizedTmuxTarget(processEnvironment["CMUX_WORKSPACE_ID"])
let ownSurface = normalizedTmuxTarget(processEnvironment["CMUX_SURFACE_ID"])
guard let ownSurface else { return nil }
let socketPath = try tmuxCompatResolvedSocketPath(processEnvironment: processEnvironment)
let client = SocketClient(path: socketPath)
do {
try client.connect()
try authenticateClientIfNeeded(
client,
explicitPassword: explicitPassword,
socketPath: socketPath
)
defer { client.close() }
func contextFromSurface(
workspaceHandle: String,
surfaceHandle: String,
windowHandle: String?
) throws -> TmuxCompatLaunchContext {
let workspaceId = try resolveWorkspaceId(workspaceHandle, client: client)
let surfaceToken = tmuxTrimIdSigil(surfaceHandle)
let surfaceId = isUUID(surfaceToken)
? surfaceToken
: try tmuxCanonicalSurfaceId(surfaceHandle, workspaceId: workspaceId, client: client)
let payload = try client.sendV2(
method: "surface.list",
params: ["workspace_id": workspaceId]
)
return try contextFromSurfacePayload(
payload,
workspaceId: workspaceId,
surfaceId: surfaceId,
surfaceHandle: surfaceHandle,
windowHandle: windowHandle
)
}
func contextFromSurfacePayload(
_ payload: [String: Any],
workspaceId: String,
surfaceId: String,
surfaceHandle: String,
windowHandle: String?
) throws -> TmuxCompatLaunchContext {
let surfaces = payload["surfaces"] as? [[String: Any]] ?? []
let normalizedSurfaceHandle = tmuxTrimIdSigil(surfaceHandle)
guard let surface = surfaces.first(where: {
($0["id"] as? String) == surfaceId
|| ($0["ref"] as? String) == normalizedSurfaceHandle
}),
let rawPaneHandle = (surface["pane_id"] as? String) ?? (surface["pane_ref"] as? String) else {
throw TmuxCompatLaunchContextError.launchSurfaceHasNoPane
}
let paneHandle = rawPaneHandle.trimmingCharacters(in: .whitespacesAndNewlines)
guard !paneHandle.isEmpty else {
throw TmuxCompatLaunchContextError.launchSurfaceHasNoPane
}
let paneId = try? tmuxCanonicalPaneId(
paneHandle,
workspaceId: workspaceId,
client: client
)
let windowId = (payload["window_id"] as? String)
?? (payload["window_ref"] as? String)
?? windowHandle
return TmuxCompatLaunchContext(
socketPath: socketPath,
workspaceId: workspaceId,
windowId: windowId,
paneHandle: paneHandle,
paneId: paneId,
surfaceId: surfaceId
)
}
// A launcher running inside a cmux terminal inherits that surface's immutable
// workspace/surface pair. Resolve and validate it without consulting global focus, so
// switching or closing the operator's focused pane cannot retarget a running team.
// Once either component is present, the inherited pair is authoritative: an incomplete
// or stale pair fails closed instead of silently changing identity to the focused pane.
if let ownWorkspace {
if let context = try? contextFromSurface(
workspaceHandle: ownWorkspace,
surfaceHandle: ownSurface,
windowHandle: nil
) {
return context
}
}
// A surface's stable UUID survives moves between workspaces. The inherited
// workspace can therefore be stale while the launch surface is still live.
// Relocate that UUID from structured socket state without consulting global
// focus; an inherited identity must never silently retarget to another surface.
let surfaceId = tmuxTrimIdSigil(ownSurface)
guard isUUID(surfaceId) else { return nil }
let payload = try client.sendV2(
method: "surface.list",
params: ["surface_id": surfaceId]
)
guard let currentWorkspaceId = payload["workspace_id"] as? String,
isUUID(currentWorkspaceId) else {
return nil
}
return try? contextFromSurfacePayload(
payload,
workspaceId: currentWorkspaceId,
surfaceId: surfaceId,
surfaceHandle: surfaceId,
windowHandle: nil
)
} catch {
client.close()
return nil
}
}
/// Replaces inherited routing aliases with one socket-validated launch identity.
func canonicalizedTmuxCompatLaunchEnvironment(
_ processEnvironment: [String: String],
launchContext: TmuxCompatLaunchContext?
) -> [String: String] {
var environment = processEnvironment
if let launchContext {
environment["CMUX_WORKSPACE_ID"] = launchContext.workspaceId
environment["CMUX_TAB_ID"] = launchContext.workspaceId
if let surfaceId = launchContext.surfaceId {
environment["CMUX_SURFACE_ID"] = surfaceId
environment["CMUX_PANEL_ID"] = surfaceId
} else {
environment.removeValue(forKey: "CMUX_SURFACE_ID")
environment.removeValue(forKey: "CMUX_PANEL_ID")
}
if let paneId = launchContext.paneId {
environment["CMUX_PANE_ID"] = paneId
} else {
environment.removeValue(forKey: "CMUX_PANE_ID")
}
} else {
for key in [
"CMUX_WORKSPACE_ID",
"CMUX_SURFACE_ID",
"CMUX_PANEL_ID",
"CMUX_TAB_ID",
"CMUX_PANE_ID",
] {
environment.removeValue(forKey: key)
}
}
return environment
}
func createClaudeTeamsShimDirectory(
processEnvironment: [String: String],
commandArgs: [String],
launchContext: TmuxCompatLaunchContext?
) throws -> URL {
let downstreamTmuxMissing = String(
localized: "cli.tmux-compat.error.downstreamTmuxMissing",
defaultValue: "cmux tmux shim: no downstream tmux executable found"
)
let script = """
#!/usr/bin/env bash
set -euo pipefail
if [[ -n "${CMUX_CLAUDE_TEAMS_CMUX_BIN:-}" ]]; then
exec "$CMUX_CLAUDE_TEAMS_CMUX_BIN" __tmux-compat "$@"
fi
# This shim lives in a persistent per-surface PATH directory. Outside the
# claude-teams launch it must behave transparently, even when several cmux
# shim directories are present. Remove every PATH spelling of this shim's
# directory before resolving tmux; the next shim repeats the narrowing.
shim_dir="$(cd -P -- "$(dirname -- "$0")" && pwd)"
IFS=: read -r -a path_entries <<< "${PATH-}"
filtered_path=""
has_filtered_entry=0
for path_entry in "${path_entries[@]}"; do
resolved_dir="$(cd -P -- "${path_entry:-.}" 2>/dev/null && pwd)" || resolved_dir=""
if [[ "$resolved_dir" == "$shim_dir" ]]; then
continue
fi
if (( has_filtered_entry )); then
filtered_path+=":$path_entry"
else
filtered_path="$path_entry"
has_filtered_entry=1
fi
done
PATH="$filtered_path"
export PATH
next_tmux="$(type -P tmux || true)"
if [[ -z "$next_tmux" ]]; then
echo \(tmuxShellQuote(downstreamTmuxMissing)) >&2
exit 127
fi
exec "$next_tmux" "$@"
"""
// Claude Code can replace PATH with a shell snapshot after launch. Its snapshot keeps
// cmux's managed per-surface command-shim directory, so install tmux beside the existing
// claude shim instead of relying on a separate launcher-only PATH entry. The environment
// only identifies the candidate: the write remains bound to cmux's canonical temporary
// root, and neither managed directory component may be a symlink.
if let managedRoot = claudeTeamsManagedShimRoot(
processEnvironment: processEnvironment,
launchContext: launchContext
) {
do {
try writeShimIfChanged(
script,
to: managedRoot.appendingPathComponent("tmux", isDirectory: false)
)
return managedRoot
} catch {
// Informational launches do not create teammates, so they may use the
// launcher-only compatibility directory below. Real Teams sessions must
// keep tmux in Claude's managed snapshot PATH.
}
}
guard claudeTeamsIsNonLaunchInvocation(commandArgs: commandArgs) else {
throw CLIError(message: managedTerminalRequiredMessage(displayName: "Claude Teams"))
}
return try createTmuxCompatShimDirectory(
directoryName: "claude-teams-bin",
tmuxShimScript: script
)
}
private func claudeTeamsManagedShimRoot(
processEnvironment: [String: String],
launchContext: TmuxCompatLaunchContext?,
fileManager: FileManager = .default
) -> URL? {
guard let surfaceId = normalizedTmuxTarget(launchContext?.surfaceId),
isUUID(surfaceId),
let rawRoot = normalizedTmuxTarget(processEnvironment["CMUX_CLAUDE_WRAPPER_SHIM_ROOT"]),
let rawClaudeShim = normalizedTmuxTarget(processEnvironment["CMUX_CLAUDE_WRAPPER_SHIM"]) else {
return nil
}
let managedRoot = URL(fileURLWithPath: rawRoot, isDirectory: true).standardizedFileURL
let claudeShim = URL(fileURLWithPath: rawClaudeShim, isDirectory: false).standardizedFileURL
// The app installs this per-surface root before shell startup. Shell profiles
// are allowed to change TMPDIR, so re-deriving the root here would reject the
// app-installed directory even though its socket-validated surface identity is
// still current. Treat that injected root as canonical, then validate its full
// shape, ownership, permissions, and file relationships before writing to it.
let trustedParent = managedRoot.deletingLastPathComponent().standardizedFileURL
guard trustedParent.lastPathComponent == "cmux-cli-shims",
managedRoot.lastPathComponent == surfaceId,
isOwnedNonSymlinkDirectory(trustedParent, fileManager: fileManager),
isOwnedNonSymlinkDirectory(managedRoot, fileManager: fileManager),
claudeShim == managedRoot.appendingPathComponent("claude", isDirectory: false),
isNonSymlinkExecutableFile(claudeShim, fileManager: fileManager) else {
return nil
}
let tmuxShim = managedRoot.appendingPathComponent("tmux", isDirectory: false)
if let attributes = try? fileManager.attributesOfItem(atPath: tmuxShim.path) {
guard attributes[.type] as? FileAttributeType == .typeRegular,
((attributes[.referenceCount] as? NSNumber)?.intValue ?? 1) <= 1 else {
return nil
}
}
return managedRoot
}
private func isOwnedNonSymlinkDirectory(_ url: URL, fileManager: FileManager) -> Bool {
guard let values = try? url.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) else {
return false
}
guard values.isDirectory == true,
values.isSymbolicLink != true,
let attributes = try? fileManager.attributesOfItem(atPath: url.path),
(attributes[.ownerAccountID] as? NSNumber)?.uint32Value == geteuid() else {
return false
}
let permissions = (attributes[.posixPermissions] as? NSNumber)?.uint16Value ?? 0o777
return permissions & 0o022 == 0
}
private func isNonSymlinkExecutableFile(_ url: URL, fileManager: FileManager) -> Bool {
guard let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]) else {
return false
}
return values.isRegularFile == true
&& values.isSymbolicLink != true
&& fileManager.isExecutableFile(atPath: url.path)
}
}
-8
View File
@@ -415,12 +415,4 @@ extension CMUXCLI {
return ordered.joined(separator: ":")
}
struct TmuxCompatFocusedContext {
let socketPath: String
let workspaceId: String
let windowId: String?
let paneHandle: String
let paneId: String?
let surfaceId: String?
}
}
@@ -0,0 +1,234 @@
import Darwin
import Foundation
extension ClaudeHookSessionStore {
private static let maxSupersededCleanupBatchSize = 4
private static let maxPendingSupersededCleanupRecords = 128
private static let maxSupersededCleanupAttempts = 8
func supersededSessionCleanupCandidates(
in state: inout ClaudeHookSessionStoreFile,
keepingSessionId: String,
owner: ClaudeHookSessionRecord
) -> [ClaudeHookSessionRecord] {
state.pendingSupersededSessionCleanup.removeValue(forKey: keepingSessionId)
guard let pid = owner.pid,
let startSeconds = owner.pidStartSeconds,
let startMicroseconds = owner.pidStartMicroseconds else {
return []
}
// Demote every superseded claimant in the locked store transaction;
// only the external socket cleanup is deliberately batch-limited.
let superseded = state.sessions.values.filter {
$0.sessionId != keepingSessionId
&& $0.pid == pid
&& $0.pidStartSeconds == startSeconds
&& $0.pidStartMicroseconds == startMicroseconds
}
let supersededIDs = Set(superseded.map(\.sessionId))
let enqueuedAt = Date().timeIntervalSince1970
for var record in superseded {
state.sessions.removeValue(forKey: record.sessionId)
record.supersededCleanupEnqueuedAt = enqueuedAt
record.supersededCleanupLastAttemptAt = nil
record.supersededCleanupAttemptCount = 0
state.pendingSupersededSessionCleanup[record.sessionId] = record
}
if !supersededIDs.isEmpty {
state.activeSessionsByWorkspace = state.activeSessionsByWorkspace.filter {
!supersededIDs.contains($0.value.sessionId)
}
state.activeSessionsBySurface = state.activeSessionsBySurface.filter {
!supersededIDs.contains($0.value.sessionId)
}
}
trimPendingSupersededSessionCleanup(in: &state)
return claimPendingSupersededSessionCleanupCandidates(in: &state, owner: owner)
}
func pendingSupersededSessionCleanupCandidates(
for owner: ClaudeHookSessionRecord
) throws -> [ClaudeHookSessionRecord] {
try withLockedState { state in
claimPendingSupersededSessionCleanupCandidates(in: &state, owner: owner)
}
}
private func claimPendingSupersededSessionCleanupCandidates(
in state: inout ClaudeHookSessionStoreFile,
owner: ClaudeHookSessionRecord
) -> [ClaudeHookSessionRecord] {
normalizePendingSupersededSessionCleanupMetadata(in: &state)
trimPendingSupersededSessionCleanup(in: &state)
let orderedRecords = state.pendingSupersededSessionCleanup.values.sorted {
switch ($0.supersededCleanupLastAttemptAt, $1.supersededCleanupLastAttemptAt) {
case (nil, .some):
return true
case (.some, nil):
return false
case let (.some(lhs), .some(rhs)) where lhs != rhs:
return lhs < rhs
default:
break
}
let lhsEnqueuedAt = $0.supersededCleanupEnqueuedAt ?? $0.updatedAt
let rhsEnqueuedAt = $1.supersededCleanupEnqueuedAt ?? $1.updatedAt
if lhsEnqueuedAt != rhsEnqueuedAt {
return lhsEnqueuedAt < rhsEnqueuedAt
}
if $0.startedAt != $1.startedAt {
return $0.startedAt < $1.startedAt
}
return $0.sessionId < $1.sessionId
}
var candidates: [ClaudeHookSessionRecord] = []
for record in orderedRecords {
guard Self.sameProcessGeneration(record, owner)
|| Self.processGenerationIsConfirmedDead(record) else {
continue
}
candidates.append(record)
if candidates.count == Self.maxSupersededCleanupBatchSize {
break
}
}
guard !candidates.isEmpty else { return [] }
// Claiming a batch advances its durable retry order before external
// socket work begins. Failed records therefore rotate behind records
// that have not been tried yet, and concurrent hooks do not repeatedly
// select the same oldest four.
var claimed: [ClaudeHookSessionRecord] = []
var attemptedAt = Date().timeIntervalSince1970
for candidate in candidates {
guard var current = state.pendingSupersededSessionCleanup[candidate.sessionId],
current.pid == candidate.pid,
current.pidStartSeconds == candidate.pidStartSeconds,
current.pidStartMicroseconds == candidate.pidStartMicroseconds,
current.workspaceId == candidate.workspaceId,
current.surfaceId == candidate.surfaceId,
current.updatedAt == candidate.updatedAt,
current.supersededCleanupEnqueuedAt == candidate.supersededCleanupEnqueuedAt,
current.supersededCleanupLastAttemptAt == candidate.supersededCleanupLastAttemptAt,
current.supersededCleanupAttemptCount == candidate.supersededCleanupAttemptCount else {
continue
}
current.supersededCleanupLastAttemptAt = attemptedAt
current.supersededCleanupAttemptCount = (current.supersededCleanupAttemptCount ?? 0) + 1
attemptedAt = attemptedAt.nextUp
state.pendingSupersededSessionCleanup[candidate.sessionId] = current
claimed.append(current)
}
return claimed
}
private func normalizePendingSupersededSessionCleanupMetadata(
in state: inout ClaudeHookSessionStoreFile
) {
for sessionId in Array(state.pendingSupersededSessionCleanup.keys) {
guard var record = state.pendingSupersededSessionCleanup[sessionId] else { continue }
if record.supersededCleanupEnqueuedAt == nil {
record.supersededCleanupEnqueuedAt = record.updatedAt
}
if record.supersededCleanupAttemptCount == nil {
record.supersededCleanupAttemptCount = 0
}
state.pendingSupersededSessionCleanup[sessionId] = record
}
}
private func trimPendingSupersededSessionCleanup(
in state: inout ClaudeHookSessionStoreFile
) {
state.pendingSupersededSessionCleanup = state.pendingSupersededSessionCleanup.filter { _, record in
(record.supersededCleanupAttemptCount ?? 0) < Self.maxSupersededCleanupAttempts
}
guard state.pendingSupersededSessionCleanup.count > Self.maxPendingSupersededCleanupRecords else {
return
}
let keptSessionIDs = Set(
state.pendingSupersededSessionCleanup.values
.sorted {
let lhsEnqueuedAt = $0.supersededCleanupEnqueuedAt ?? $0.updatedAt
let rhsEnqueuedAt = $1.supersededCleanupEnqueuedAt ?? $1.updatedAt
if lhsEnqueuedAt != rhsEnqueuedAt {
return lhsEnqueuedAt > rhsEnqueuedAt
}
return $0.sessionId > $1.sessionId
}
.prefix(Self.maxPendingSupersededCleanupRecords)
.map(\.sessionId)
)
state.pendingSupersededSessionCleanup = state.pendingSupersededSessionCleanup.filter {
keptSessionIDs.contains($0.key)
}
}
private static func sameProcessGeneration(
_ lhs: ClaudeHookSessionRecord,
_ rhs: ClaudeHookSessionRecord
) -> Bool {
guard let pid = lhs.pid,
let startSeconds = lhs.pidStartSeconds,
let startMicroseconds = lhs.pidStartMicroseconds else {
return false
}
return rhs.pid == pid
&& rhs.pidStartSeconds == startSeconds
&& rhs.pidStartMicroseconds == startMicroseconds
}
private static func processGenerationIsConfirmedDead(_ record: ClaudeHookSessionRecord) -> Bool {
guard let pid = record.pid,
pid > 0,
pid <= Int(Int32.max),
let startSeconds = record.pidStartSeconds,
let startMicroseconds = record.pidStartMicroseconds else {
return false
}
var info = proc_bsdinfo()
let expectedSize = MemoryLayout<proc_bsdinfo>.stride
let size = proc_pidinfo(pid_t(pid), PROC_PIDTBSDINFO, 0, &info, Int32(expectedSize))
if size == expectedSize {
return Int64(info.pbi_start_tvsec) != startSeconds
|| Int64(info.pbi_start_tvusec) != startMicroseconds
}
if Darwin.kill(pid_t(pid), 0) == 0 || errno == EPERM {
return false
}
return errno == ESRCH
}
func acknowledgeSupersededSessionCleanup(_ candidates: [ClaudeHookSessionRecord]) throws {
guard !candidates.isEmpty else { return }
let candidatesByID = Dictionary(uniqueKeysWithValues: candidates.map { ($0.sessionId, $0) })
try withLockedState { state in
var acknowledgedIDs: Set<String> = []
for (sessionId, candidate) in candidatesByID {
guard let current = state.pendingSupersededSessionCleanup[sessionId],
current.pid == candidate.pid,
current.pidStartSeconds == candidate.pidStartSeconds,
current.pidStartMicroseconds == candidate.pidStartMicroseconds,
current.workspaceId == candidate.workspaceId,
current.surfaceId == candidate.surfaceId,
current.updatedAt == candidate.updatedAt,
current.supersededCleanupEnqueuedAt == candidate.supersededCleanupEnqueuedAt,
current.supersededCleanupLastAttemptAt == candidate.supersededCleanupLastAttemptAt,
current.supersededCleanupAttemptCount == candidate.supersededCleanupAttemptCount else {
continue
}
state.pendingSupersededSessionCleanup.removeValue(forKey: sessionId)
acknowledgedIDs.insert(sessionId)
}
guard !acknowledgedIDs.isEmpty else { return }
state.activeSessionsByWorkspace = state.activeSessionsByWorkspace.filter {
!acknowledgedIDs.contains($0.value.sessionId)
}
state.activeSessionsBySurface = state.activeSessionsBySurface.filter {
!acknowledgedIDs.contains($0.value.sessionId)
}
}
}
}
+68
View File
@@ -0,0 +1,68 @@
import Foundation
struct ClaudeHookSessionStoreFile: Codable {
var version: Int = 1
var sessions: [String: ClaudeHookSessionRecord] = [:]
// Superseded records stay durable for retry without remaining visible to
// store consumers as simultaneously live session claimants.
var pendingSupersededSessionCleanup: [String: ClaudeHookSessionRecord] = [:]
var activeSessionsByWorkspace: [String: ClaudeHookActiveSessionRecord] = [:]
// The pane-scoped active boundary. The workspace slot only remembers ONE
// active session, so once another pane promotes (e.g. a forked conversation
// in a split), it can no longer prove that a late hook from a superseded
// session in this pane is stale. Keyed by surface id.
// https://github.com/manaflow-ai/cmux/issues/5908
var activeSessionsBySurface: [String: ClaudeHookActiveSessionRecord] = [:]
var agentHookFailureReportTimestamps: [String: TimeInterval] = [:]
enum CodingKeys: String, CodingKey {
case version
case sessions
case pendingSupersededSessionCleanup
case activeSessionsByWorkspace
case activeSessionsBySurface
case agentHookFailureReportTimestamps
}
init() {}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
version = try container.decodeIfPresent(Int.self, forKey: .version) ?? 1
sessions = try container.decodeIfPresent([String: ClaudeHookSessionRecord].self, forKey: .sessions) ?? [:]
pendingSupersededSessionCleanup = try container.decodeIfPresent(
[String: ClaudeHookSessionRecord].self,
forKey: .pendingSupersededSessionCleanup
) ?? [:]
activeSessionsByWorkspace = try container.decodeIfPresent(
[String: ClaudeHookActiveSessionRecord].self,
forKey: .activeSessionsByWorkspace
) ?? [:]
activeSessionsBySurface = try container.decodeIfPresent(
[String: ClaudeHookActiveSessionRecord].self,
forKey: .activeSessionsBySurface
) ?? [:]
agentHookFailureReportTimestamps = try container.decodeIfPresent(
[String: TimeInterval].self,
forKey: .agentHookFailureReportTimestamps
) ?? [:]
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(version, forKey: .version)
try container.encode(sessions, forKey: .sessions)
if !pendingSupersededSessionCleanup.isEmpty {
try container.encode(pendingSupersededSessionCleanup, forKey: .pendingSupersededSessionCleanup)
}
if !activeSessionsByWorkspace.isEmpty {
try container.encode(activeSessionsByWorkspace, forKey: .activeSessionsByWorkspace)
}
if !activeSessionsBySurface.isEmpty {
try container.encode(activeSessionsBySurface, forKey: .activeSessionsBySurface)
}
if !agentHookFailureReportTimestamps.isEmpty {
try container.encode(agentHookFailureReportTimestamps, forKey: .agentHookFailureReportTimestamps)
}
}
}
+6
View File
@@ -0,0 +1,6 @@
struct CodexHookFailureCandidate {
let message: String
let codexErrorInfo: String?
let additionalDetails: String?
let isStreamError: Bool
}
+5
View File
@@ -0,0 +1,5 @@
struct CodexHookFailureSummary {
let statusValue: String
let subtitle: String
let body: String
}
+4
View File
@@ -0,0 +1,4 @@
struct CodexHookUserInputCandidate {
let callId: String
let question: String?
}
+5
View File
@@ -0,0 +1,5 @@
enum CodexMonitorOwnerState {
case alive
case gone
case unknown
}
@@ -0,0 +1,6 @@
enum CodexTranscriptFailureReadResult {
case unavailable
case pending
case healthy(lastAssistantMessage: String?)
case failure(CodexHookFailureCandidate)
}
@@ -0,0 +1,44 @@
import Foundation
/// Replays authoritative rollout completion through the regular Codex Stop path.
struct CodexTranscriptMonitorStopReplay {
let commandArguments: [String]
let payload: String
init?(
sessionId: String,
turnId: String?,
transcriptPath: String?,
workspaceId: String,
surfaceId: String?,
lastAssistantMessage: String?
) {
guard !sessionId.isEmpty, !workspaceId.isEmpty else { return nil }
var object: [String: Any] = [
"session_id": sessionId,
"hook_event_name": "Stop",
"stop_hook_active": false,
]
if let turnId, !turnId.isEmpty {
object["turn_id"] = turnId
}
if let transcriptPath, !transcriptPath.isEmpty {
object["transcript_path"] = transcriptPath
}
if let lastAssistantMessage, !lastAssistantMessage.isEmpty {
object["last_assistant_message"] = lastAssistantMessage
}
guard let data = try? JSONSerialization.data(withJSONObject: object),
let payload = String(data: data, encoding: .utf8) else {
return nil
}
var commandArguments = ["stop", "--workspace", workspaceId]
if let surfaceId, !surfaceId.isEmpty {
commandArguments += ["--surface", surfaceId]
}
self.commandArguments = commandArguments
self.payload = payload
}
}
+4
View File
@@ -0,0 +1,4 @@
struct CodexTranscriptSubagentSignals {
var isSubagentSession = false
var hasSubagentNotificationRelay = false
}
-68
View File
@@ -1,68 +0,0 @@
import Foundation
/// Owns ssh-pty-attach exit-code semantics.
///
/// Exit codes 254 and 255 are the only retryable statuses recognized by the
/// embedded wrapper loops in `SSHPTYAttachStartupCommandBuilder.retryingAttachLines`
/// and `CMUXCLI.sshPTYAttachRetryLoopLines`; keep those shell contracts in sync
/// with this taxonomy. The classifier patterns mirror
/// `userFacingRemotePTYErrorMessage` in `CLI/CMUXCLI+RemotePTYErrors.swift`.
enum SSHPTYAttachExitCode: Int32 {
case fatal = 1
case sessionNotFound = 253
case bridgeClosedSessionRunning = 254
case retryableTransient = 255
/// Statuses the embedded wrapper loops (`case 254|255`) re-run instead of
/// surfacing. Failures exiting with these codes must keep app-side surface
/// tracking intact: the wrapper immediately reattaches on the same surface,
/// and a successful retry never re-tracks a surface that pty_attach_end
/// already untracked.
var isWrapperRetryable: Bool {
self == .bridgeClosedSessionRunning || self == .retryableTransient
}
static func classifyBridgeEstablishmentFailure(_ rawDescription: String) -> SSHPTYAttachExitCode {
classifyNormalized(rawDescription.trimmingCharacters(in: .whitespacesAndNewlines).lowercased())
}
static func classifyBridgeEstablishmentFailure(code: String?, message: String) -> SSHPTYAttachExitCode {
let normalizedCode = code?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if normalizedCode == "pty_session_not_found" {
return .sessionNotFound
}
if normalizedCode == "pty_lifecycle_closed" {
return .fatal
}
let rawDescription = [normalizedCode, message]
.compactMap { $0 }
.joined(separator: " ")
return classifyBridgeEstablishmentFailure(rawDescription)
}
private static func classifyNormalized(_ description: String) -> SSHPTYAttachExitCode {
if description.contains("pty_session_not_found") ||
((description.contains("persistent ssh pty session") ||
description.contains("persistent pty session")) &&
(description.contains("not running") ||
description.contains("no longer running"))) {
return .sessionNotFound
}
if description.contains("timed out") ||
description.contains("timeout") ||
description.contains("did not respond in time") ||
description.contains("remote connection is not active") ||
description.contains("remote daemon is not ready") ||
description.contains("remote daemon tunnel is not ready") ||
description.contains("pty_input_queue_full") ||
description.contains("pty input queue is full") ||
description.contains("input is temporarily backed up") ||
description.contains("connection refused") ||
description.contains("connection reset") {
return .retryableTransient
}
return .fatal
}
}
+94
View File
@@ -0,0 +1,94 @@
import Foundation
struct SSHPTYTerminalReadinessReport: Sendable {
private enum DeliveryOutcome {
case acknowledged
case transientFailure
case permanentRejection
}
private static let permanentV2RejectionCodes: Set<String> = [
"auth_failed",
"auth_required",
"auth_unconfigured",
"forbidden",
"invalid_params",
"invalid_request",
"invalid_utf8",
"method_not_found",
"not_found",
"not_supported",
"permission_denied",
"pty_lifecycle_closed",
"stale_state",
"unauthorized",
"unsupported",
"validation_failed",
"workspace_not_found",
]
let socketPath: String
let explicitPassword: String?
let params: [String: String]
let attemptTimeout: TimeInterval
let retryDelay: TimeInterval
let maximumRetryDelay: TimeInterval
func deliverUntilAcknowledged() async {
let initialRetryDelay = max(0, retryDelay)
let retryDelayCap = max(initialRetryDelay, maximumRetryDelay)
var nextRetryDelay = initialRetryDelay
let clock = ContinuousClock()
while !Task.isCancelled {
switch deliverOnce() {
case .acknowledged, .permanentRejection:
return
case .transientFailure:
break
}
do {
try await clock.sleep(for: .seconds(nextRetryDelay))
} catch {
return
}
nextRetryDelay = min(retryDelayCap, nextRetryDelay * 2)
}
}
private func deliverOnce() -> DeliveryOutcome {
let deadline = Date.now.addingTimeInterval(attemptTimeout)
let reportingClient = SocketClient(path: socketPath)
defer { reportingClient.close() }
do {
try reportingClient.connectWithoutRetry(responseTimeout: attemptTimeout)
let authenticationTimeout = deadline.timeIntervalSinceNow
guard authenticationTimeout > 0 else { return .transientFailure }
try CMUXCLI.authenticateSocketClientIfNeeded(
reportingClient,
explicitPassword: explicitPassword,
socketPath: socketPath,
responseTimeout: authenticationTimeout,
deadline: deadline
)
let reportTimeout = deadline.timeIntervalSinceNow
guard reportTimeout > 0 else { return .transientFailure }
let jsonParams = params.reduce(into: [String: Any]()) {
$0[$1.key] = $1.value
}
_ = try reportingClient.sendV2(
method: "workspace.remote.terminal_session_connected",
params: jsonParams,
responseTimeout: reportTimeout
)
return .acknowledged
} catch let error as CLIError {
if error.message.hasPrefix("ERROR:") ||
error.v2Code.map(Self.permanentV2RejectionCodes.contains) == true {
return .permanentRejection
}
return .transientFailure
} catch {
return .transientFailure
}
}
}
+3
View File
@@ -0,0 +1,3 @@
enum TmuxCompatLaunchContextError: Error {
case launchSurfaceHasNoPane
}
+1069 -510
View File
File diff suppressed because it is too large Load Diff
@@ -5,8 +5,8 @@ extension CMUXAuthUser {
/// when no fixture was requested.
///
/// UI tests opt in with `CMUX_UITEST_AUTH_FIXTURE=1` and may override the
/// id/email/name fields; a cleared-auth or mock-data launch always wins
/// over a fixture.
/// id/email/name/profile-image fields; a cleared-auth or mock-data launch
/// always wins over a fixture.
/// - Parameters:
/// - environment: The process launch environment.
/// - clearAuth: Whether the launch requested a cleared auth state.
@@ -25,7 +25,8 @@ extension CMUXAuthUser {
self.init(
id: environment["CMUX_UITEST_AUTH_USER_ID"] ?? "uitest_user",
primaryEmail: environment["CMUX_UITEST_AUTH_EMAIL"] ?? "[email protected]",
displayName: environment["CMUX_UITEST_AUTH_NAME"] ?? "UI Test"
displayName: environment["CMUX_UITEST_AUTH_NAME"] ?? "UI Test",
profileImageURL: environment["CMUX_UITEST_AUTH_PROFILE_IMAGE_URL"]
)
}
}
@@ -12,15 +12,24 @@ public struct CMUXAuthUser: Codable, Equatable, Sendable {
public let primaryEmail: String?
/// The user's display name, if one is set.
public let displayName: String?
/// The user's Stack Auth profile image URL, if one is set.
public let profileImageURL: String?
/// Creates a user value.
/// - Parameters:
/// - id: The Stack Auth user id.
/// - primaryEmail: The user's primary email, if any.
/// - displayName: The user's display name, if any.
public init(id: String, primaryEmail: String?, displayName: String?) {
/// - profileImageURL: The user's profile image URL, if any.
public init(
id: String,
primaryEmail: String?,
displayName: String?,
profileImageURL: String? = nil
) {
self.id = id
self.primaryEmail = primaryEmail
self.displayName = displayName
self.profileImageURL = profileImageURL
}
}
@@ -91,6 +91,7 @@ struct CMUXAuthStateTests {
"CMUX_UITEST_AUTH_USER_ID": "fixture-user",
"CMUX_UITEST_AUTH_EMAIL": "[email protected]",
"CMUX_UITEST_AUTH_NAME": "Fixture User",
"CMUX_UITEST_AUTH_PROFILE_IMAGE_URL": "https://example.com/fixture-avatar.png",
]
#expect(
@@ -101,7 +102,8 @@ struct CMUXAuthStateTests {
) == CMUXAuthUser(
id: "fixture-user",
primaryEmail: "[email protected]",
displayName: "Fixture User"
displayName: "Fixture User",
profileImageURL: "https://example.com/fixture-avatar.png"
)
)
#expect(
@@ -122,7 +124,12 @@ struct CMUXAuthStateTests {
@Test("Primed cached user remains restoring while validating tokens")
func primedCachedUserRemainsRestoringWhileValidatingTokens() {
let user = CMUXAuthUser(id: "user_123", primaryEmail: "[email protected]", displayName: "Test User")
let user = CMUXAuthUser(
id: "user_123",
primaryEmail: "[email protected]",
displayName: "Test User",
profileImageURL: "https://example.com/avatar.png"
)
let state = CMUXAuthState.primed(
clearAuthRequested: false,
mockDataEnabled: false,
+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 }
}
@@ -5,18 +5,28 @@ public enum CmxTransportAuthorizationMode: Equatable, Sendable {
/// A pairing established before Iroh may send a Stack bearer only to the
/// exact Tailscale peer captured by this persisted compatibility grant.
case legacyTailscaleBearer(CmxLegacyTailscaleAuthorizationEvidence)
/// A user-entered compatibility code may send a Stack bearer only to the
/// exact Tailscale peer address it named. The device identity a code may
/// claim is self-reported and adds no authority; the entry of the code in
/// the pairing UI is the authorization event, and the value never outlives
/// that pairing dial.
case userAuthorizedTailscalePairing(CmxUserTailscalePairingAuthorization)
/// The transport handshake admitted this exact peer and account binding.
case transportAdmission
}
/// Route plus peer intent required to build a transport without substitution.
public struct CmxByteTransportRequest: Equatable, Sendable {
/// The route the transport must dial without substituting another peer.
public let route: CmxAttachRoute
/// The authenticated peer device expected on the route, when known.
public let expectedPeerDeviceID: String?
/// The authorization evidence permitted on the transport.
public let authorizationMode: CmxTransportAuthorizationMode
/// The local owner whose network path this request represents.
public let sessionPurpose: CmxTransportSessionPurpose
/// Creates a route-bound transport request with explicit peer authority.
public init(
route: CmxAttachRoute,
expectedPeerDeviceID: String?,
@@ -28,4 +38,16 @@ public struct CmxByteTransportRequest: Equatable, Sendable {
self.authorizationMode = authorizationMode
self.sessionPurpose = sessionPurpose
}
/// Returns the same route and authority with a different local owner role.
public func withSessionPurpose(
_ sessionPurpose: CmxTransportSessionPurpose
) -> Self {
Self(
route: route,
expectedPeerDeviceID: expectedPeerDeviceID,
authorizationMode: authorizationMode,
sessionPurpose: sessionPurpose
)
}
}
@@ -0,0 +1,6 @@
/// Optional role update for a connected transport whose underlying peer
/// session stays live while ownership moves between foreground and background.
public protocol CmxByteTransportSessionPurposeUpdating: CmxByteTransport {
/// Reclassifies the live transport for foreground or background tuning.
func updateSessionPurpose(_ purpose: CmxTransportSessionPurpose) async
}
@@ -1,14 +1,22 @@
import Foundation
/// The minimal pairing-QR grammar: expected Mac account/build metadata plus
/// plain `host:port` routes in the URL query.
/// The minimal pairing-QR grammars for Iroh identity and Tailscale routes.
///
/// `cmux-ios://attach?v=2&ub=<stack-user-id>&pc=<compat>&av=<version>&ab=<build>&r=<host>:<port>[&r=<host>:<port>...]`
/// Retained Iroh codes carry only the stable EndpointID:
/// `cmux-ios://attach?v=3&i=<endpoint-id>`.
///
/// A pairing QR needs to tell the phone where to dial and which non-secret
/// account/build context to check before dialing. The account value is the
/// opaque Stack user id, never the email itself. Everything else the earlier
/// grammars carried has a better channel or no reason to exist:
/// The EndpointID is the only value the phone needs before dialing. The
/// signed-in trust broker verifies same-account ownership while minting the
/// pair grant, and the authenticated `mobile.host.status` response supplies
/// the Mac's device id, display name, and build metadata after connection.
/// Omitting those duplicate fields avoids JSON and base64 overhead, lowering
/// the QR version while its displayed size stays unchanged.
///
/// Tailscale compatibility codes keep the v2 grammar so already-released
/// clients can still scan them:
/// `cmux-ios://attach?v=2&ub=<stack-user-id>&pc=<compat>&av=<version>&ab=<build>&r=<host>:<port>[&r=<host>:<port>...]`.
///
/// Both grammars share these properties:
/// - **No auth token.** The owner's Stack access token is the host's sole
/// authorization gate; a token in the QR authorized nothing and made the
/// code look like a leaked credential.
@@ -17,7 +25,7 @@ import Foundation
/// - **No display name, no device id.** Both arrive post-handshake from
/// `mobile.host.status`; the decoder leaves `macDeviceID` empty and the
/// shell adopts the host-reported identity once connected.
/// - **No loopback, ever.** Routes are Tailscale `host:port` only: the
/// - **No loopback, ever.** v2 routes are Tailscale `host:port` only: the
/// encoder drops a DEBUG Mac's dev loopback route instead of encoding it,
/// the Mac refuses to mint a QR without a Tailscale route (it shows the
/// set-up-Tailscale guidance instead), and the decoder rejects loopback
@@ -28,20 +36,27 @@ import Foundation
/// into an `NWConnection` `.waiting` black hole for the full request
/// timeout before the Tailscale route was ever tried.
///
/// The payload is deliberately *not* wrapped in base64 JSON: anyone can read
/// The payloads are deliberately *not* wrapped in base64 JSON: anyone can read
/// the URL off the QR and see for themselves that it carries only an address.
/// Plain text is also smaller, which lowers the QR version (fewer, larger
/// modules) and makes the code scan faster from a Mac screen.
///
/// Compatibility: this grammar only ever appears in the Mac's pairing QR.
/// 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 grammar version carried in the URL's `v` query item. Distinct from
/// ``CmxAttachTicket/currentVersion`` (the ticket *structure* version):
/// `v=1` URLs carry a base64 JSON `payload`, `v=2` URLs carry bare routes.
public static let version = 2
/// The newest grammar version this build can decode.
///
/// Distinct from ``CmxAttachTicket/currentVersion`` (the ticket structure
/// version): v1 URLs carry base64 JSON, v2 carries Tailscale routes, and
/// v3 carries one bare Iroh EndpointID.
public static let version = 3
private static let tailscaleVersion = 2
private static let irohVersion = 3
/// Defensive cap on routes accepted from a scanned code. The Mac's route
/// resolver emits at most a couple (MagicDNS name + Tailscale IP); a QR
@@ -53,41 +68,53 @@ public struct CmxPairingQRCode: Sendable {
/// site; every instance speaks the same grammar version.
public init() {}
/// Encode `ticket` as a v2 pairing URL, or `nil` when the ticket does not
/// Encode `ticket` as a minimal pairing URL, or `nil` when it does not
/// qualify (see ``canEncode(_:routeDisclosureMode:)``); callers fall back
/// to the compact v1 payload so every ticket still has an attach URL.
///
/// Only the ticket's Tailscale routes are encoded: a DEBUG Mac's dev
/// loopback route is dropped, never written into a scannable code.
/// Iroh mode writes one EndpointID and nothing else. Compatibility mode
/// writes only the ticket's Tailscale routes; a DEBUG Mac's dev loopback
/// route is dropped, never written into a scannable code.
public func encode(
_ ticket: CmxAttachTicket,
routeDisclosureMode: CmxPairingRouteDisclosureMode
) -> String? {
guard routeDisclosureMode == .legacyPrivateNetworkCompatibility,
let routes = encodableRoutes(of: ticket) else {
return nil
}
var items: [String] = ["v=\(Self.version)"]
if let userID = normalizedNonEmpty(ticket.macUserID) {
items.append("ub=\(percentEncodeQueryValue(userID))")
}
if let compatibilityVersion = ticket.macPairingCompatibilityVersion {
items.append("pc=\(compatibilityVersion)")
}
if let version = normalizedNonEmpty(ticket.macAppVersion) {
items.append("av=\(percentEncodeQueryValue(version))")
}
if let build = normalizedNonEmpty(ticket.macAppBuild) {
items.append("ab=\(percentEncodeQueryValue(build))")
}
let routeItems = routes.map { route -> String in
guard case let .hostPort(host, port) = route.endpoint else {
// Unreachable: `encodableRoutes` admits host/port endpoints only.
return ""
let items: [String]
switch routeDisclosureMode {
case .irohIdentityOnly:
guard let identity = encodableIrohIdentity(of: ticket) else {
return nil
}
return "r=\(hostPortString(host: host, port: port))"
items = [
"v=\(Self.irohVersion)",
"i=\(identity.endpointID)"
]
case .legacyPrivateNetworkCompatibility:
guard let routes = encodableTailscaleRoutes(of: ticket) else {
return nil
}
var compatibilityItems: [String] = ["v=\(Self.tailscaleVersion)"]
if let userID = normalizedNonEmpty(ticket.macUserID) {
compatibilityItems.append("ub=\(percentEncodeQueryValue(userID))")
}
if let compatibilityVersion = ticket.macPairingCompatibilityVersion {
compatibilityItems.append("pc=\(compatibilityVersion)")
}
if let version = normalizedNonEmpty(ticket.macAppVersion) {
compatibilityItems.append("av=\(percentEncodeQueryValue(version))")
}
if let build = normalizedNonEmpty(ticket.macAppBuild) {
compatibilityItems.append("ab=\(percentEncodeQueryValue(build))")
}
compatibilityItems.append(contentsOf: routes.map { route -> String in
guard case let .hostPort(host, port) = route.endpoint else {
// Unreachable: the selector admits host/port endpoints only.
return ""
}
return "r=\(hostPortString(host: host, port: port))"
})
items = compatibilityItems
}
items.append(contentsOf: routeItems)
// The scheme is channel-specific (see ``CmxPairingURLScheme``): a dev
// Mac's QR opens the dev iOS build, a release Mac's QR opens the
// release build, and the system camera can no longer hand a beta/prod
@@ -95,15 +122,17 @@ public struct CmxPairingQRCode: Sendable {
return "\(CmxPairingURLScheme.current)://attach?" + items.joined(separator: "&")
}
/// Whether `ticket` is expressible in the minimal grammar under the
/// explicitly selected disclosure mode; see ``encodableRoutes(of:)`` for
/// the rules.
/// Whether `ticket` is expressible in the selected minimal grammar.
public func canEncode(
_ ticket: CmxAttachTicket,
routeDisclosureMode: CmxPairingRouteDisclosureMode
) -> Bool {
routeDisclosureMode == .legacyPrivateNetworkCompatibility
&& encodableRoutes(of: ticket) != nil
switch routeDisclosureMode {
case .irohIdentityOnly:
encodableIrohIdentity(of: ticket) != nil
case .legacyPrivateNetworkCompatibility:
encodableTailscaleRoutes(of: ticket) != nil
}
}
/// The route subsequence a v2 pairing URL would carry for `ticket`, or
@@ -119,7 +148,7 @@ public struct CmxPairingQRCode: Sendable {
/// Tailscale route at all, or a non-Tailscale fallback route such as an
/// iroh peer that the bare `host:port` grammar cannot express) keeps the
/// compact v1 payload so the mapping stays lossless.
private func encodableRoutes(of ticket: CmxAttachTicket) -> [CmxAttachRoute]? {
private func encodableTailscaleRoutes(of ticket: CmxAttachTicket) -> [CmxAttachRoute]? {
guard ticket.version == CmxAttachTicket.currentVersion,
ticket.workspaceID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
ticket.terminalID?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false else {
@@ -144,10 +173,37 @@ public struct CmxPairingQRCode: Sendable {
return routes
}
/// Whether `components` (an already-parsed `cmux-ios://attach` URL) speaks
/// this grammar. v1 URLs carry the base64 `payload` item instead.
/// The single canonical Iroh identity a v3 code can carry.
///
/// Other route kinds and Iroh path hints are deliberately discarded under
/// identity-only disclosure. The decoder reconstructs the sole route with
/// canonical id `iroh` and priority zero; neither value affects selection
/// when the ticket has exactly one disclosed route.
private func encodableIrohIdentity(
of ticket: CmxAttachTicket
) -> CmxIrohPeerIdentity? {
guard ticket.version == CmxAttachTicket.currentVersion,
ticket.workspaceID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
ticket.terminalID?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false else {
return nil
}
let irohRoutes = ticket.routes.filter { $0.kind == .iroh }
guard irohRoutes.count == 1,
let route = irohRoutes.first,
case let .peer(identity, _) = route.endpoint else {
return nil
}
return identity
}
/// Whether `components` speaks a supported plain pairing-code grammar.
///
/// v1 URLs carry a base64 JSON `payload` item instead.
public func isPairingCodeURL(_ components: URLComponents) -> Bool {
components.queryItems?.first(where: { $0.name == "v" })?.value == "\(Self.version)"
guard let version = Self.attachURLVersion(components) else {
return false
}
return version == Self.tailscaleVersion || version == Self.irohVersion
}
/// The integer grammar version declared by an attach URL's `v` query item,
@@ -161,9 +217,11 @@ public struct CmxPairingQRCode: Sendable {
return Int(raw)
}
/// Whether `rawValue` is a v2 pairing URL. String-level convenience for
/// callers that hold the encoded URL (the Mac's pairing window asserting
/// the code it is about to display speaks the minimal grammar).
/// Whether `rawValue` is a supported plain pairing URL.
///
/// String-level convenience for callers that hold the encoded URL (the
/// Mac's pairing window asserting the code it is about to display speaks
/// the minimal grammar).
public func isPairingCodeURLString(_ rawValue: String) -> Bool {
guard let url = URL(string: rawValue),
CmxPairingURLScheme.isPairingScheme(url.scheme),
@@ -174,19 +232,33 @@ public struct CmxPairingQRCode: Sendable {
return isPairingCodeURL(components)
}
/// Decode a v2 pairing URL into a validated ``CmxAttachTicket``.
/// Decode a supported plain pairing URL into a validated ticket.
///
/// The ticket comes back unscoped with an empty `macDeviceID`; the shell
/// recovers the Mac's identity post-handshake from `mobile.host.status`.
/// - Parameter components: The parsed `cmux-ios://attach?v=2&...` URL.
/// - Parameter components: A parsed v2 or v3 attach URL.
/// - Throws: ``MobileSyncPairingPayloadError/invalidURL`` for malformed
/// input and ``MobileSyncPairingPayloadError/loopbackRouteRejected``
/// when any route names a loopback host (a scanned code must never
/// point the phone at itself).
public func decode(_ components: URLComponents) throws -> CmxAttachTicket {
guard isPairingCodeURL(components) else {
guard let version = Self.attachURLVersion(components) else {
throw MobileSyncPairingPayloadError.invalidURL
}
switch version {
case Self.tailscaleVersion:
return try decodeTailscale(components)
case Self.irohVersion:
return try decodeIroh(components)
default:
throw MobileSyncPairingPayloadError.invalidURL
}
}
}
private extension CmxPairingQRCode {
/// Decode the v2 Tailscale compatibility grammar.
func decodeTailscale(_ components: URLComponents) throws -> CmxAttachTicket {
let rawRoutes = (components.queryItems ?? [])
.filter { $0.name == "r" }
.compactMap(\.value)
@@ -222,8 +294,37 @@ public struct CmxPairingQRCode: Sendable {
try ticket.validate()
return ticket
}
}
private extension CmxPairingQRCode {
/// Decode the v3 endpoint-only Iroh grammar.
func decodeIroh(_ components: URLComponents) throws -> CmxAttachTicket {
let items = components.queryItems ?? []
guard items.count == 2,
items.filter({ $0.name == "v" }).count == 1,
let endpointID = items.first(where: { $0.name == "i" })?.value,
items.filter({ $0.name == "i" }).count == 1,
let identity = try? CmxIrohPeerIdentity(endpointID: endpointID) else {
throw MobileSyncPairingPayloadError.invalidURL
}
let route = try CmxAttachRoute(
id: CmxAttachTransportKind.iroh.rawValue,
kind: .iroh,
endpoint: .peer(identity: identity, pathHints: []),
priority: 0
)
let ticket = try CmxAttachTicket(
workspaceID: "",
terminalID: nil,
macDeviceID: "",
macDisplayName: nil,
macPairingCompatibilityVersion: 0,
routes: [route],
expiresAt: nil,
authToken: nil
)
try ticket.validate()
return ticket
}
/// The route id the Mac's route resolver mints for the route at `index`
/// (`tailscale` for the first, `tailscale_N` after).
func synthesizedRouteID(index: Int) -> String {
@@ -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
}
@@ -0,0 +1,46 @@
import Foundation
/// Invalid input for a user-entered Tailscale compatibility pairing code.
public enum CmxUserTailscalePairingAuthorizationError: Error, Equatable, Sendable {
/// The host was not a numeric Tailscale peer address.
case invalidHost
/// The port fell outside `1...65535`.
case invalidPort(Int)
}
/// A narrow capability allowing one user-entered Tailscale compatibility code
/// to dial the exact peer address it named.
///
/// The authorization event is the user reading the code off their Mac's
/// pairing window (QR scan or pasted text) in this app session. Unlike
/// ``CmxLegacyTailscaleAuthorizationEvidence`` there is no Mac device binding:
/// any identity a code claims is self-reported and carries no authority, so
/// this value anchors on the exact destination alone and never persists. Once
/// the host authenticates, the shell records a device-bound grant and later
/// dials use the evidence path.
public struct CmxUserTailscalePairingAuthorization: Equatable, Sendable {
/// The canonical numeric Tailscale peer address from the entered code.
public let host: String
/// The exact legacy mobile listener port from the entered code.
public let port: Int
/// Validates and canonicalizes one user-entered compatibility destination.
public init(host: String, port: Int) throws {
guard let peerAddress = CmxTailscalePeerAddress(host) else {
throw CmxUserTailscalePairingAuthorizationError.invalidHost
}
guard (1 ... 65_535).contains(port) else {
throw CmxUserTailscalePairingAuthorizationError.invalidPort(port)
}
self.host = peerAddress.value
self.port = port
}
/// Whether a dial still names the exact peer the user entered.
public func authorizes(host: String, port: Int) -> Bool {
guard let peerAddress = CmxTailscalePeerAddress(host) else {
return false
}
return peerAddress.value == self.host && port == self.port
}
}
@@ -7,8 +7,10 @@ import Foundation
/// grammar revision that still carry `e` (expiry) and `n` (display name)
/// decode here with both intentionally dropped: a pairing QR never expires,
/// and the Mac's name is read post-handshake from `mobile.host.status`.
/// New Iroh pairing payloads disclose only EndpointID identity. The explicit
/// compatibility mode temporarily retains released clients' legacy routes.
/// Compact Iroh fallbacks disclose only EndpointID identity. The primary
/// 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?
@@ -16,7 +16,9 @@ public enum DiagnosticEventCode: UInt16, Sendable, Codable, CaseIterable {
case connect = 1
/// Pairing / attach completed successfully.
case pairOk = 2
/// Pairing / attach failed.
/// Pairing / attach failed. `a`, when present, is
/// ``DiagnosticTransportKind``; `b`, when present, is
/// ``DiagnosticFailureKind``.
case pairFail = 3
/// The render-grid stream lagged behind (a bounded render-lag counter tick).
///
@@ -180,6 +182,17 @@ public enum DiagnosticEventCode: UInt16, Sendable, Codable, CaseIterable {
/// Device reachability changed. `a` is 1 when a usable network path
/// exists, else 0. Correlates drops with WiFi/cellular transitions.
case reachabilityChanged = 53
/// The Iroh boundary reported why a shared QUIC connection closed. `a` is
/// the stable close-initiator kind (0 unknown, 1 local, 2 remote, 3 timed
/// out), `b` is ``DiagnosticFailureKind``, `ms` is the application error
/// code clamped to the nonnegative `Int32` range when parseable, and `c` is
/// the matching positive, process-local session correlation ID.
case transportCloseAttribution = 54
/// One Iroh path opened, closed, became selected, or reported lag. `a` is
/// the stable path-event kind (1 opened, 2 closed, 3 selected, 4 lagged),
/// `b` is ``DiagnosticPathKind`` for the affected path, and `c` is the
/// matching positive, process-local session correlation ID.
case transportPathEvent = 55
}
/// Scene phase carried by ``DiagnosticEventCode/appLifecycleChanged``.
@@ -0,0 +1,205 @@
import Foundation
/// Decodes a ``DiagnosticEvent`` into stable, human-readable names and fields
/// for telemetry sinks (Sentry breadcrumbs, structured logs) and debug UI.
///
/// The compact ring export stays integer-only; this presentation layer is for
/// consumers that ship or display individual events and want them legible
/// without the offline decoder. Everything here is derived from the fixed
/// integer taxonomy, so the output is privacy-safe by construction: no free
/// text from errors, peers, accounts, or terminal content can appear.
///
/// Case names are part of the telemetry vocabulary (Sentry issue grouping and
/// search keys use them), so renaming a taxonomy case is a breaking telemetry
/// change; ``DiagnosticEventPresentationTests`` pins the names.
public enum DiagnosticEventPresentation {
/// One decoded key/value pair of a described event.
public struct Field: Sendable, Equatable {
public let key: String
public let value: String
public init(key: String, value: String) {
self.key = key
self.value = value
}
}
/// A described event: a stable dotted name plus decoded payload fields.
public struct DescribedEvent: Sendable, Equatable {
/// The stable event name, e.g. `transportDialFailed`.
public let name: String
/// Decoded payload fields in a stable order.
public let fields: [Field]
public init(name: String, fields: [Field]) {
self.name = name
self.fields = fields
}
}
/// The stable name of an event code (its case name).
public static func name(_ code: DiagnosticEventCode) -> String {
String(describing: code)
}
/// The stable name of a failure kind (its case name).
public static func name(_ kind: DiagnosticFailureKind) -> String {
String(describing: kind)
}
/// The stable name of a transport kind (its case name).
public static func name(_ kind: DiagnosticTransportKind) -> String {
String(describing: kind)
}
/// The stable name of a path kind (its case name).
public static func name(_ kind: DiagnosticPathKind) -> String {
String(describing: kind)
}
/// The stable name of a session lifecycle kind (its case name).
public static func name(_ kind: DiagnosticSessionLifecycleKind) -> String {
String(describing: kind)
}
/// The stable name of an app lifecycle phase (its case name).
public static func name(_ phase: DiagnosticAppLifecyclePhase) -> String {
String(describing: phase)
}
/// The stable name of a runtime role (its case name).
public static func name(_ role: DiagnosticRuntimeRole) -> String {
String(describing: role)
}
/// Decodes an event's payload slots per its code's documented semantics.
///
/// Unknown raw values render as their integer so a newer writer's event
/// still describes usefully on an older reader.
public static func describe(_ event: DiagnosticEvent) -> DescribedEvent {
var fields: [Field] = []
if let surface = event.surface {
fields.append(Field(key: "surface", value: String(surface)))
}
if let ms = event.ms {
fields.append(Field(key: msKey(for: event.code), value: String(ms)))
}
if let a = event.a {
fields.append(decodeA(a, code: event.code))
}
if let b = event.b {
fields.append(decodeB(b, code: event.code))
}
if let c = event.c {
fields.append(Field(key: cKey(for: event.code), value: String(c)))
}
return DescribedEvent(name: name(event.code), fields: fields)
}
/// The failure kind carried in an event's `b` slot, when its code uses `b`
/// for ``DiagnosticFailureKind``.
public static func failureKind(of event: DiagnosticEvent) -> DiagnosticFailureKind? {
guard codesWithFailureB.contains(event.code), let b = event.b else { return nil }
return DiagnosticFailureKind(rawValue: b)
}
/// The transport kind carried in an event's `a` slot, when its code uses
/// `a` for ``DiagnosticTransportKind``.
public static func transportKind(of event: DiagnosticEvent) -> DiagnosticTransportKind? {
guard codesWithTransportA.contains(event.code), let a = event.a else { return nil }
return DiagnosticTransportKind(rawValue: a)
}
/// Event codes whose `b` slot carries a ``DiagnosticFailureKind``.
static let codesWithFailureB: Set<DiagnosticEventCode> = [
.pairFail, .transportDialFailed, .recoveryFailed, .endpointFailed,
.relayPolicyRefreshFailed, .sessionClosed, .routeUnavailable,
.discoveryFailed, .admissionFailed, .hostAuthenticationFailed,
.rpcFailed, .transportCloseAttribution,
]
/// Event codes whose `a` slot carries a ``DiagnosticTransportKind``.
static let codesWithTransportA: Set<DiagnosticEventCode> = [
.pairFail, .transportDialStarted, .transportDialConnected,
.transportDialFailed, .sessionClosed, .routeUnavailable,
]
private static func msKey(for code: DiagnosticEventCode) -> String {
switch code {
case .retryScheduled:
return "delay_ms"
case .transportCloseAttribution:
return "app_error_code"
case .composerActiveTransition:
return "keyboard_height"
default:
return "ms"
}
}
private static func cKey(for code: DiagnosticEventCode) -> String {
switch code {
case .transportDialStarted, .transportDialConnected, .transportDialFailed:
return "attempt_id"
case .sessionClosed, .transportSessionLifecycle,
.transportCloseAttribution, .transportPathEvent:
return "session_id"
default:
return "c"
}
}
private static func decodeA(_ a: Int, code: DiagnosticEventCode) -> Field {
switch code {
case .pairFail, .transportDialStarted, .transportDialConnected,
.transportDialFailed, .sessionClosed, .routeUnavailable:
return enumField(key: "transport", raw: a) { DiagnosticTransportKind(rawValue: $0).map(name) }
case .selectedPathChanged:
return enumField(key: "path", raw: a) { DiagnosticPathKind(rawValue: $0).map(name) }
case .transportSessionLifecycle:
return enumField(key: "lifecycle", raw: a) { DiagnosticSessionLifecycleKind(rawValue: $0).map(name) }
case .appLifecycleChanged:
return enumField(key: "phase", raw: a) { DiagnosticAppLifecyclePhase(rawValue: $0).map(name) }
case .reachabilityChanged:
return Field(key: "reachable", value: a == 1 ? "true" : "false")
case .transportCloseAttribution:
return enumField(key: "initiator", raw: a) { closeInitiatorNames[$0] }
case .transportPathEvent:
return enumField(key: "path_event", raw: a) { pathEventNames[$0] }
default:
return Field(key: "a", value: String(a))
}
}
private static func decodeB(_ b: Int, code: DiagnosticEventCode) -> Field {
if codesWithFailureB.contains(code) {
return enumField(key: "failure", raw: b) { DiagnosticFailureKind(rawValue: $0).map(name) }
}
switch code {
case .transportSessionLifecycle:
return Field(key: "purpose", value: String(b))
case .transportPathEvent:
return enumField(key: "path", raw: b) { DiagnosticPathKind(rawValue: $0).map(name) }
default:
return Field(key: "b", value: String(b))
}
}
private static func enumField(
key: String,
raw: Int,
name: (Int) -> String?
) -> Field {
Field(key: key, value: name(raw) ?? String(raw))
}
/// Close-initiator names for ``DiagnosticEventCode/transportCloseAttribution``'s `a`.
private static let closeInitiatorNames: [Int: String] = [
0: "unknown", 1: "local", 2: "remote", 3: "timedOut",
]
/// Path-event names for ``DiagnosticEventCode/transportPathEvent``'s `a`.
private static let pathEventNames: [Int: String] = [
1: "opened", 2: "closed", 3: "selected", 4: "lagged",
]
}
@@ -45,6 +45,9 @@ public final class DiagnosticLog: Sendable {
/// The inner actor owning the ring buffer and the wall-clock anchor.
private let store: Store
/// The optional live observer, delivered retained events on the drain task.
private let tap: TapBox
/// The drain task. Its closure captures only local stream/store values, so
/// deinitialization can finish ingress and let accepted clear commands drain
/// to their acknowledgements without retaining this log.
@@ -94,12 +97,16 @@ public final class DiagnosticLog: Sendable {
commandContinuation: commandContinuation
)
self.ingress = ingress
let tap = TapBox()
self.tap = tap
self.drainTask = Task {
for await command in commandStream {
switch command {
case let .events(events):
for await event in events {
await store.append(event)
for await sequenced in events {
if await store.append(sequenced.event) {
tap.deliver(sequenced)
}
}
case let .clear(
anchorWallNanos,
@@ -112,8 +119,10 @@ public final class DiagnosticLog: Sendable {
anchorMonotonicNanos: anchorMonotonicNanos
)
acknowledgement.resume()
for await event in nextEvents {
await store.append(event)
for await sequenced in nextEvents {
if await store.append(sequenced.event) {
tap.deliver(sequenced)
}
}
}
}
@@ -124,6 +133,26 @@ public final class DiagnosticLog: Sendable {
ingress.finish()
}
/// Sets the single live event observer, replacing any previous one.
///
/// The observer runs on the internal drain task, after the event is retained
/// in the ring, so it adds no work to the hot-path ``record(_:)`` call and
/// sees events in ring order. Events consumed but not retained (the repeated
/// ``DiagnosticEventCode/selectedPathChanged`` dedup) are not delivered.
/// Events recorded before the observer is set are never delivered, even
/// when they are still queued on the drain task at install time (each
/// event carries an ingress admission sequence, and only events admitted
/// after installation pass the tap). A consumer that needs history
/// snapshots the ring via ``export()`` or ``snapshot(generatedAt:)``.
///
/// The observer must be fast and must not block: it shares the drain task
/// with ring appends. Forward into your own queue or task for slow work.
///
/// - Parameter observer: The observer, or `nil` to remove the current one.
public func setEventTap(_ observer: (@Sendable (DiagnosticEvent) -> Void)?) {
tap.set(observer, notBefore: ingress.lastAdmittedSeq())
}
/// Record one event. Non-blocking and safe from any thread.
///
/// This is the hot-path API. It only yields the value onto the buffered
@@ -207,15 +236,53 @@ public final class DiagnosticLog: Sendable {
/// once full and would starve the drain task during the exact lag bursts this
/// log captures).
private enum DrainCommand: Sendable {
case events(AsyncStream<DiagnosticEvent>)
case events(AsyncStream<SequencedEvent>)
case clear(
anchorWallNanos: UInt64,
anchorMonotonicNanos: UInt64,
nextEvents: AsyncStream<DiagnosticEvent>,
nextEvents: AsyncStream<SequencedEvent>,
acknowledgement: CheckedContinuation<Void, Never>
)
}
/// One admitted event with its ingress admission sequence number. The tap
/// compares the number against its activation floor so an observer never
/// receives an event that was admitted (recorded) before it was installed,
/// even when that event is still queued on the drain task at install time.
private struct SequencedEvent: Sendable {
let seq: UInt64
let event: DiagnosticEvent
}
/// Holds the settable live observer without retaining the log, so the drain
/// task can capture it while ``DiagnosticLog/deinit`` stays reachable.
private final class TapBox: Sendable {
private struct State: Sendable {
var observer: (@Sendable (DiagnosticEvent) -> Void)?
/// Only events admitted after this ingress sequence are delivered.
var notBefore: UInt64 = 0
}
// lint:allow lock - deliver runs on the drain task and set is rare; the
// critical region only reads or writes one closure reference + floor.
private let state = OSAllocatedUnfairLock<State>(initialState: State())
func set(_ newObserver: (@Sendable (DiagnosticEvent) -> Void)?, notBefore: UInt64) {
state.withLock {
$0.observer = newObserver
$0.notBefore = notBefore
}
}
func deliver(_ sequenced: SequencedEvent) {
let current = state.withLock { state -> (@Sendable (DiagnosticEvent) -> Void)? in
guard sequenced.seq > state.notBefore else { return nil }
return state.observer
}
current?(sequenced.event)
}
}
/// Serializes event-segment rotation without suspending callers. Event
/// segments use `.bufferingNewest(capacity)` and therefore stay bounded;
/// the command stream is unbounded only for rare clear controls, which must
@@ -224,15 +291,18 @@ public final class DiagnosticLog: Sendable {
private struct State: Sendable {
let capacity: Int
let commandContinuation: AsyncStream<DrainCommand>.Continuation
var eventContinuation: AsyncStream<DiagnosticEvent>.Continuation?
var eventContinuation: AsyncStream<SequencedEvent>.Continuation?
var isFinished = false
/// Monotonic admission counter; the last value handed to a
/// recorded event. Read at tap install time as the delivery floor.
var lastAdmittedSeq: UInt64 = 0
}
private enum ClearEnqueueResult: Sendable {
case enqueued(previous: AsyncStream<DiagnosticEvent>.Continuation?)
case enqueued(previous: AsyncStream<SequencedEvent>.Continuation?)
case terminated(
previous: AsyncStream<DiagnosticEvent>.Continuation?,
next: AsyncStream<DiagnosticEvent>.Continuation
previous: AsyncStream<SequencedEvent>.Continuation?,
next: AsyncStream<SequencedEvent>.Continuation
)
}
@@ -257,10 +327,21 @@ public final class DiagnosticLog: Sendable {
func record(_ event: DiagnosticEvent) {
state.withLock { state in
guard !state.isFinished else { return }
state.eventContinuation?.yield(event)
state.lastAdmittedSeq += 1
state.eventContinuation?.yield(SequencedEvent(
seq: state.lastAdmittedSeq,
event: event
))
}
}
/// The admission sequence of the most recently recorded event, used as
/// the tap's activation floor so already-admitted events are never
/// delivered to a newly installed observer.
func lastAdmittedSeq() -> UInt64 {
state.withLock { $0.lastAdmittedSeq }
}
func clear(
anchorWallNanos: UInt64,
anchorMonotonicNanos: UInt64,
@@ -306,7 +387,7 @@ public final class DiagnosticLog: Sendable {
func finish() {
let continuations: (
AsyncStream<DiagnosticEvent>.Continuation?,
AsyncStream<SequencedEvent>.Continuation?,
AsyncStream<DrainCommand>.Continuation
)? = state.withLock { state in
guard !state.isFinished else { return nil }
@@ -321,7 +402,7 @@ public final class DiagnosticLog: Sendable {
private static func makeEventSegment(
capacity: Int
) -> (AsyncStream<DiagnosticEvent>, AsyncStream<DiagnosticEvent>.Continuation) {
) -> (AsyncStream<SequencedEvent>, AsyncStream<SequencedEvent>.Continuation) {
AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(capacity))
}
}
@@ -356,10 +437,14 @@ public final class DiagnosticLog: Sendable {
self.slots = Array(repeating: nil, count: clamped)
}
func append(_ event: DiagnosticEvent) {
/// Appends one event, returning whether it was retained (`false` for the
/// repeated selected-path dedup) so the drain task can skip observer
/// delivery for events the ring itself discards.
@discardableResult
func append(_ event: DiagnosticEvent) -> Bool {
totalProcessed += 1
if let nextPathKind = event.diagnosticPathKind {
guard nextPathKind != selectedPathKind else { return }
guard nextPathKind != selectedPathKind else { return false }
selectedPathKind = nextPathKind
}
slots[head] = event
@@ -367,6 +452,7 @@ public final class DiagnosticLog: Sendable {
if filled < capacity {
filled += 1
}
return true
}
func count() -> Int {
@@ -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
})
}
@@ -256,12 +261,13 @@ public extension DiagnosticEvent {
return c
}
/// Redacted path class carried by ``DiagnosticEventCode/selectedPathChanged``.
/// Redacted path class carried by a selected-path or path-lifecycle event.
var diagnosticPathKind: DiagnosticPathKind? {
guard code == .selectedPathChanged, let a else {
guard code == .selectedPathChanged || code == .transportPathEvent,
let rawValue = code == .transportPathEvent ? b : a else {
return nil
}
return DiagnosticPathKind(rawValue: a)
return DiagnosticPathKind(rawValue: rawValue)
}
/// Privacy-safe pool transition carried by
@@ -282,7 +288,10 @@ public extension DiagnosticEvent {
/// Positive process-local session correlation ID. This value is not stable
/// across app launches or devices.
var diagnosticSessionID: Int? {
guard code == .transportSessionLifecycle || code == .sessionClosed,
guard code == .transportSessionLifecycle
|| code == .sessionClosed
|| code == .transportCloseAttribution
|| code == .transportPathEvent,
let c,
c > 0 else { return nil }
return c
@@ -344,6 +353,7 @@ public extension DiagnosticEventCode {
.endpointFailed,
.relayPolicyRefreshFailed,
.sessionClosed,
.transportCloseAttribution,
.routeUnavailable,
.discoveryFailed,
.admissionFailed,
@@ -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.
@@ -201,6 +206,8 @@ public enum DiagnosticSessionLifecycleKind: Int, Sendable, Codable, CaseIterable
case runtimeReconfigured = 9
/// A caller explicitly invalidated one exact peer session.
case explicitlyInvalidated = 10
/// Every usable transport path disappeared from an admitted session.
case allPathsClosed = 11
}
/// Which component produced a diagnostic report.
@@ -0,0 +1,16 @@
import Foundation
/// Payload pushed on the `browser.closed` topic.
public struct MobileBrowserClosedEvent: Codable, Equatable, Sendable {
/// Browser panel UUID string.
public let panelID: String
/// Creates a browser closed event.
public init(panelID: String) {
self.panelID = panelID
}
private enum CodingKeys: String, CodingKey {
case panelID = "panel_id"
}
}
@@ -0,0 +1,20 @@
/// One action offered by a mirrored browser dialog.
public struct MobileBrowserDialogButton: Codable, Equatable, Sendable {
/// Stable identifier returned in a dialog response.
public let id: String
/// Mac-provided button label displayed verbatim on the phone.
public let label: String
/// Visual and semantic role of the action.
public let role: MobileBrowserDialogButtonRole
/// Creates a mirrored browser dialog action.
/// - Parameters:
/// - id: Stable identifier returned when the action is selected.
/// - label: Mac-provided label displayed verbatim.
/// - role: Visual and semantic role of the action.
public init(id: String, label: String, role: MobileBrowserDialogButtonRole) {
self.id = id
self.label = label
self.role = role
}
}
@@ -0,0 +1,9 @@
/// The visual and semantic role of a mirrored browser dialog button.
public enum MobileBrowserDialogButtonRole: String, Codable, Equatable, Sendable {
/// The dialog's preferred affirmative action.
case `default`
/// An action that cancels or declines the request.
case cancel
/// An irreversible or security-sensitive action.
case destructive
}
@@ -0,0 +1,66 @@
/// A native Mac browser dialog mirrored as data to a phone.
public struct MobileBrowserDialogEvent: Codable, Equatable, Sendable {
/// Browser panel UUID string.
public let panelID: String
/// Dialog UUID string used for exactly-once resolution.
public let dialogID: String
/// Native browser interaction represented by this dialog.
public let kind: MobileBrowserDialogKind
/// Mac-provided title displayed verbatim, when present.
public let title: String?
/// Mac-provided message displayed verbatim, when present.
public let message: String?
/// Origin host associated with the request, when present.
public let host: String?
/// Actions offered by the dialog.
public let buttons: [MobileBrowserDialogButton]
/// Text-entry metadata, when the dialog accepts text.
public let textField: MobileBrowserDialogTextField?
/// Whether the phone can only cancel while the interaction remains Mac-only.
public let informational: Bool
/// Creates a native browser dialog event.
/// - Parameters:
/// - panelID: Browser panel UUID string.
/// - dialogID: Dialog UUID string.
/// - kind: Native browser interaction represented by the dialog.
/// - title: Mac-provided title displayed verbatim.
/// - message: Mac-provided message displayed verbatim.
/// - host: Origin host associated with the request.
/// - buttons: Actions offered by the dialog.
/// - textField: Text-entry metadata, when applicable.
/// - informational: Whether the interaction must be completed on the Mac.
public init(
panelID: String,
dialogID: String,
kind: MobileBrowserDialogKind,
title: String?,
message: String?,
host: String?,
buttons: [MobileBrowserDialogButton],
textField: MobileBrowserDialogTextField?,
informational: Bool
) {
self.panelID = panelID
self.dialogID = dialogID
self.kind = kind
self.title = title
self.message = message
self.host = host
self.buttons = buttons
self.textField = textField
self.informational = informational
}
private enum CodingKeys: String, CodingKey {
case panelID = "panel_id"
case dialogID = "dialog_id"
case kind
case title
case message
case host
case buttons
case textField = "text_field"
case informational
}
}

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