Compare commits

...
Author SHA1 Message Date
Lawrence Chen 3fad5abf1d Merge pull request #10424 from manaflow-ai/feat/coderouter-status-session-counts
Expose per-account session counts and cooldowns in account listing
2026-08-19 04:13:14 -07:00
Lawrence Chen 34f0e1eeca Expose per-account session counts and cooldowns in account listing
The status endpoint now reports, per account, how many sessions are
bound with traffic inside the routing load window, plus the account's
cooldown deadline. The coderouter CLI uses both to make sticky routing
and rate-limit cooldowns visible. The session-count read is display
only and fails to zero so it can never take the status endpoint down.
2026-08-19 03:59:50 -07:00
Lawrence Chen 882ab10e75 Merge pull request #10402 from manaflow-ai/feat/coderouter-free-tier
Make hosted coderouter free for up to 3 accounts
2026-08-18 23:44:59 -07:00
Lawrence Chen 9ad361ed37 Make hosted coderouter free for up to 3 accounts
Pricing change: a team may connect and route up to 3 provider
accounts (subscriptions) for free. More than 3 requires an active
cmux Pro or Team subscription.

- New services/coderouter/entitlement.ts: free tier checked first
  (one indexed count read); the Stripe read runs only for teams over
  the limit. Both checks fail closed.
- Session issuance: replaces the flat Pro gate. Over-limit teams
  without a subscription get 402 with the count in the message.
- Account add: connecting an account beyond the limit without a
  subscription returns 402 before anything is stored. Re-importing
  an account the team already has is always allowed, so a broken
  account stays repairable on the free tier. The accounts POST
  handler is now factory-built for dependency-injected tests.
- Billing-lapse token revocation stays unchanged: after a lapse the
  CLI renews and the free tier re-qualifies teams with <= 3 accounts.
- New analytics: entitlement_basis on route_session_issued and a
  coderouter_account_limit_reached event, both schema-whitelisted.
- CODEROUTER_HOSTED_PRO_REQUIRED=0 still disables all gating.

Boundary: exactly 3 accounts is free; the 4th needs Pro/Team.
2026-08-18 23:21:33 -07:00
Lawrence Chen ae7d761e03 Merge pull request #10387 from manaflow-ai/fix/coderouter-codex-session-spread
Spread Codex placements and pin sessions to accounts
2026-08-18 21:57:20 -07:00
Austin Wang c15d665f65 Merge pull request #10363 from manaflow-ai/issue-10103-copy-mode-selection-bleed
Fix terminal Copy Mode selection scope across split panes
2026-08-18 21:46:09 -07:00
Lawrence Chen 425f2d7efe Keep sticky sessions through in-flight credential refreshes
A bound account in state 'refreshing' is healthy; its refresh lease
resolves in seconds. Treat it as usable for sticky reuse, and give a
sticky session a short bounded wait (4 x 500ms) when the refresh lease
is busy, instead of instantly moving the session and discarding its
prompt cache. Non-sticky requests keep the fail-fast move.
2026-08-18 21:43:42 -07:00
Lawrence Chen 7d79a858f6 Degrade to legacy routing while the session table migration is pending
If the code deploys before the additive migration is applied, the
session-stickiness statements hit undefined_table (42P01). Treat that
as: no binding found, claim without the session-load ordering term, and
skip the pin. Routing behaves exactly like the pre-change code until
the migration lands, instead of erroring every Codex request. Covered
by a DB behavior test that renames the table away and back.
2026-08-18 21:38:20 -07:00
Lawrence Chen 18ed976a1b Spread Codex placements and pin sessions to accounts
Port of subrouter PR #228 to the coderouter TypeScript data plane.

Problems in the current routing:
- selectAccountForRequest picks an account per request with three
  independent statements (sweep, select, update). Nothing spans the
  read-pick-write sequence, so parallel session starts read the same
  snapshot and herd onto one account. That account burns quota first,
  then every session reroutes to the next account at once.
- No session stickiness exists. Each request rotates to the
  least-recently-used account, so the provider prompt cache is
  discarded on nearly every turn and the whole prefix is re-billed
  as uncached input across all accounts.

Fix:
- New coderouter_session_accounts table pins one agent session (the
  Codex CLI session_id header) to one account.
- selectAccountForSession honors a usable binding first (sticky), and
  moves a session only when its account is broken, cooling down,
  removed, or already attempted in this request.
- New placements claim an account atomically in one statement with
  FOR UPDATE SKIP LOCKED, ordered by fewest recently-active bound
  sessions, then least-recently-used. Concurrent claims take
  different accounts instead of the same snapshot argmax. When every
  candidate row is locked, a blocking fallback claim accepts a
  collision instead of failing the request.
- selectAccountForRequest (models, opencode) now uses the same atomic
  claim, which closes its read-pick-write race too.

Tests: unit tests for the selector and the responses proxy, and
CMUX_DB_TEST-gated behavior tests that prove sequential spread,
concurrent non-herding, stickiness, move-on-cooldown, exclusion,
and last-write-wins binding against real Postgres.
2026-08-18 21:34:14 -07:00
Abdulaziz Albahar 9d815638c7 Merge pull request #10381 from manaflow-ai/fix-legacy-beta-namespace-fallback
fix(iroh): let pre-namespace legacy iOS bindings pair with default and nightly Macs
2026-08-18 20:59:38 -07:00
Lawrence Chen 34483ba969 Merge pull request #10382 from manaflow-ai/chore-testbox-skill-moved
chore: move the Testbox skill to cmuxterm-hq
2026-08-18 20:43:39 -07:00
Lawrence Chen bc0dcaa8b9 chore: move the Testbox skill to cmuxterm-hq
The lane is fleet infrastructure we operate, not contributor guidance for this
codebase, so the prose now lives in cmuxterm-hq at
skills/infra/blacksmith-testbox/ beside macfleet and cloud-vm-ops. See
manaflow-ai/cmuxterm-hq#306.

Everything the box actually executes stays here: both workflows, the five
scripts/blacksmith-*.sh helpers, and the two guards that validate them. Only
tests/test_testbox_doc_blocks.sh follows the skill, because it validates that
prose, and hq gained a workflow so it keeps gating rather than quietly stopping.

CLAUDE.md keeps a pointer rather than dropping the subject, because an agent
starting cmux-tui Rust work reads this file and must still learn not to compile
on the Mac. Worth stating plainly: all nine trial agents found this skill from a
local file in this repo, and a pointer to another checkout is weaker
discoverability, particularly from a plain cmux clone.
2026-08-18 20:40:08 -07:00
Abdulaziz Albahar 540bf2b9e4 Merge pull request #10310 from manaflow-ai/feat-conn-method-diagnostics
feat(ios): state connection method and live transport in diagnostics reports
2026-08-18 20:29:53 -07:00
Abdulaziz Albahar 2048eebfc9 Merge pull request #10294 from manaflow-ai/feat-tsonly-secondary-gate
fix(ios): Tailscale-only must gate secondary-Mac and discovery Iroh dials
2026-08-18 20:29:44 -07:00
Abdulaziz Albahar 65d4870c00 Merge pull request #10313 from manaflow-ai/fix-gallery-picker-simstream
fix(ios): pass selectSimulatorStream in the surface gallery preview
2026-08-18 20:29:39 -07:00
Abdulaziz AlbaharandClaude Fable 5 3f2b88f307 refactor(ios): map connection method to its diagnostics enum exhaustively
Review follow-up: replace the 0/1 ternaries with an exhaustive switch into
DiagnosticConnectionMethod so a future third method becomes a compile error
instead of silently reporting as Tailscale.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-18 20:29:20 -07:00
Abdulaziz AlbaharandClaude Fable 5 58416dcbd4 test(ios): pin the runtime clock in the Tailscale-only pool tests
Review follow-up: inject a fixed date instead of wall-clock Date() so
route-selection behavior in these tests is deterministic.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-18 20:26:47 -07:00
Lawrence Chen bee0e6c546 Merge pull request #10304 from manaflow-ai/feat-tab-identity-single-owner
Make the topology own tab identity
2026-08-18 20:25:19 -07:00
Lawrence Chen 76418e4592 Merge pull request #10299 from manaflow-ai/feat-startup-orphan-terminal-projection
Fix startup wedge when several terminal hosts are unadoptable
2026-08-18 20:25:01 -07:00
Abdulaziz AlbaharandClaude Fable 5 5bc3fee2c8 fix(iroh): let pre-namespace legacy iOS bindings pair with default and nightly Macs
The bundle-isolation rollout (#9183) records iOS builds without the
X-Cmux-App-Namespace header as legacy. Those are the shipped pre-namespace
Beta binaries on the default lane, and they lost the official-namespace
default->{default,nightly} exception: discovery still listed a Nightly Mac,
but issuePairGrant denied it with target_not_pairable, surfacing on the
phone as Authorization failed while relay policy and reachability passed.

Legacy callers on the default lane now get the same default+nightly Mac
reach as official namespaced apps, applied through the single
canIOSBindingUseMac choke point so pair grants and proofed discovery agree.
Non-default legacy lanes keep exact tag matching, and canIOSBindingForgetMac
is split off the alias so the destructive forget path is not broadened.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-18 20:15:24 -07:00
Abdulaziz AlbaharandClaude Fable 5 d856159ce8 test(iroh): legacy default-lane iOS bindings must reach default and nightly Macs
Old Beta builds predate the X-Cmux-App-Namespace header and binding request
proofs, so the broker records them as legacy/default. They currently lose the
official-namespace default->nightly exception and get target_not_pairable
against Nightly Macs. These tests pin the intended compatibility: pairing and
discovery gain the fallback, non-default legacy lanes keep exact matching, and
the destructive forget_mac path is not broadened.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-18 20:08:29 -07:00
Austin Wang 11684e5064 Merge pull request #10359 from manaflow-ai/issue-10102-drop-ghosted-regression
Fix terminal file drops being ghosted after pane teardown
2026-08-18 18:12:07 -07:00
austinpower1258 485d627042 fix: complete drop result and isolate fixtures 2026-08-18 16:43:22 -07:00
austinpower1258 d4a2021b1f test: cover multiple promised image items 2026-08-18 16:40:01 -07:00
austinpower1258 94f35f8e5a fix: read each promised drop item 2026-08-18 16:39:08 -07:00
austinpower1258 b7d1c230e2 fix: handle optional drag pasteboard types 2026-08-18 16:35:24 -07:00
austinpower1258 5129950f7e fix: make transient image drops durable 2026-08-18 16:34:02 -07:00
austinpower1258 b372e09f9f fix: avoid redundant pasteboard type fallbacks 2026-08-18 15:04:32 -07:00
austinpower1258 167eb677e6 fix: expose pasteboard temporary root to drop planner 2026-08-18 14:57:53 -07:00
austinpower1258 c8f202201b fix: preserve promised image drop files 2026-08-18 14:19:45 -07:00
austinpower1258 6e56e6ff14 fix: cancel copy mode when pane deactivates 2026-08-18 14:16:33 -07:00
austinpower1258 58a0400a6e fix: recognize unix temporary drop paths 2026-08-18 14:14:07 -07:00
austinpower1258 a1a5ef6c39 fix: restore terminal file drops and retain transient images 2026-08-18 14:12:16 -07:00
austinpower1258 b0948c0341 fix: cancel copy mode on responder loss 2026-08-18 14:11:59 -07:00
austinpower1258 d3e3abd367 fix: scope terminal copy mode to focused pane 2026-08-18 14:01:01 -07:00
austinpower1258 9f556f118e test: require copy mode to end on focus loss 2026-08-18 14:00:14 -07:00
Abdulaziz Albahar 786a35d099 Show model loading and model-specific effort controls (#10098)
* test: cover pending model catalogs in shared picker

* Show model catalog loading in shared picker

* test: expose buried model loading state

* Make pending model picker unmistakable

* test: require model-specific effort picker

* Use model-specific effort metadata

* test: require native model effort picker

* test: isolate native effort picker regression

* test: isolate iOS UI test plan

* feat: add native model effort picker

* test: verify native effort choices

* test: cover all native effort choices

* test: require shared readable picker scroller

* fix: keep task pickers readable in one scroller

* test: measure visible picker labels accurately

* test: cover default model effort picker

* fix: source efforts for the default model

* fix: keep default efforts through draft restore

* test: cover unpinned default model efforts

* test: define semantic OpenCode effort ordering

* fix: order OpenCode effort variants semantically

* test: cover task model discovery failures

* fix: surface task model discovery errors

* fix: keep model discovery errors readable

* fix: resolve provider loading review findings
2026-08-18 13:58:00 -07:00
austinpower1258 c7e3d94b66 test: prevent inactive terminal drop target shadowing 2026-08-18 13:50:10 -07:00
Abdulaziz AlbaharandClaude Fable 5 7589f52b81 Isolate every iOS build by bundle identity (#9183)
* test: require iOS build isolation

* fix: isolate iOS builds by bundle identity

* fix: compile namespaced Mac backup publisher

* chore: remove shared app group example

* test: require isolated iOS OAuth cookies

* fix: isolate iOS OAuth browser cookies

* fix: close autoreview namespace gaps

* fix: complete iOS namespace isolation

* refactor: satisfy namespace isolation policy

* fix: close namespace migration review gaps

* fix: preserve isolated Iroh and OAuth admission

* test: cover isolated iOS rollout paths

* fix: authenticate isolated iOS rollout paths

* test: cover authenticated management recovery

* fix: authorize isolated management operations

* test: cover cached proof and Mac forget

* fix: preserve proof across isolated lifecycle paths

* fix: satisfy Iroh API package policy

* test: cover autoreview isolation regressions

* fix: close isolated rollout review gaps

* test: cover target and trust boundary regressions

* fix: enforce isolated pairing boundaries

* test: cover legacy tombstone migration

* fix: preserve tombstones across backup migration

* test: cover migration precedence and discovery privacy

* fix: enforce migration and discovery precedence

* test: cover push targeting and migration bounds

* fix: close push and migration isolation gaps

* test: prevent entitlement dumps on signing failures

* fix: redact signed entitlements on upload failure

* fix: make backup scope provider sendable

* fix: return localized pairing target names

* fix: drain legacy namespaced revocations

* test: track push token policy limits

* test: cover post-revocation fallback paths

* fix: refresh authority after binding revocations

* test: cover paired-Mac migration boundaries

* fix: bound and scope legacy paired-Mac migration

* fix: capture migration account without async coalescing

* fix: keep legacy migration and keychain deletion safe

* chore: refresh pull request head

* fix: bound legacy backup reconciliation

* test: cover conditional paired Mac migration

* fix: make paired Mac migration conditional

* test: cover pairing migration recovery races

* fix: serialize identity persistence recovery

* test: cover migration team and pairing lanes

* fix: pin migration team and pairing channel

* test: await development identity storage

* test: cover pairing emission and keychain scope

* fix: fail closed on app and keychain scope

* test: cover bundle-derived Mac namespaces

* fix: namespace Mac bindings by bundle

* test: cover legacy sign-out binding namespaces

* fix: revoke bindings in their stored namespace

* test: expect APNs bundle in send outcome

* test: cover untagged debug pairing identity

* fix: preserve untagged debug pairing identity

* test: cover rate-limited pending revocations

* fix: drain revocations after rate-limited registration

* test: cover safe pending revocation reconciliation

* fix: preserve active binding during revocation recovery

* fix: preserve broker proof through client wrappers

* test: retain authorization during rate-limited recovery

* fix: preserve retained binding during rate-limit recovery

* test: cover stale binding cleanup authorization

* fix: authorize stale binding cleanup

* docs: document binding authorization helpers

* test: type stale cleanup response

* fix(macos): restore compilable cleanupSurfaceState call

https://github.com/manaflow-ai/cmux/pull/10072 changed this call site to
pass workspaceID: but never landed that overload, so the macOS target
has not compiled since it merged (CI is dispatch-only and did not catch
it). Restore the existing signature; the native-mobile-surface
preservation intent needs to re-land together with its implementation.

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

* fix: wire mobile surface artifact integration

* fix: require explicit trust broker namespace

* fix: require explicit management revocation routes

* fix: reject ambiguous iOS bundle namespaces

* fix: bound identity waits and scope legacy auth

* fix: cancel queued identity operations

* fix: modernize browser change handlers

* fix: scope legacy token deletion

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-08-18 13:37:30 -07:00
Abdulaziz Albahar 029d652972 docs: define shared fleet capacity (#10312)
* docs: define shared fleet and messaging policy

* docs: keep messaging policy global
2026-08-18 12:34:01 -07:00
Lawrence Chen c821be8dfc Merge pull request #10306 from manaflow-ai/fix-testbox-push-requirement
fix: require a pushed commit for Testbox benchmarks, and lead the skill with the workflow
2026-08-17 23:31:24 -07:00
Lawrence Chen 0c18281d4e fix: the demo script leaked the runner it told you to worry about
Revalidating the demo after the set-difference rewrite passed the build (2m13s
then 0.14s, box stopped, exit 0), but the org check afterwards showed its warmup
run still in_progress, holding a 32 vCPU runner. The skill calls cancelling a
required step and the script did not do it, so the one entry point most people
will run was the one leaking.

It records the run it approves and cancels it from the same EXIT trap that stops
the box, so a Ctrl-C cleans up both.
2026-08-17 23:24:28 -07:00
Lawrence Chen 8dca3436f8 fix: cargo test needs umask 022 on a Testbox, and I shipped it without
I added the cargo test line to the skill without running it. A concurrency
trial ran it and it fails on a fresh box: 104 failures, exit 101, all one root
cause. `blacksmith testbox run` gives a shell at umask 0002, so test directories
are created group-writable, and cmux-remote's secure-directory check correctly
rejects an ancestor writable by other users without the sticky bit.

Hosted CI runs at umask 022, so the suite passes there and fails here. The suite
is not umask-independent, which means this lane does not reproduce CI unless the
command says so. With `umask 022` prepended, 3504 tests pass in about 88s.
Builds and clippy are unaffected.
2026-08-17 23:17:11 -07:00
Lawrence Chen 39410cd265 fix: identify your run by set difference, not a time window
Two agents dispatching nine seconds apart deadlocked. The guard correctly
refused to guess between two identical waiting gates, then told the operator to
"approve yours in the UI". An agent has no UI, and nothing in the REST API binds
a run to a Testbox ID, so the recovery was unusable by the caller the lane is
built for. One trial burned 11 minutes and an extra box getting out of it.

Snapshot the waiting gates before dispatching and take the set difference after.
That is exact where a 120 second window is not, since another operator lands
inside any window you pick. On the rare true tie the instruction is now
deterministic and needs no browser: stop your box, re-snapshot, dispatch again.

Also record the trap the same trial nearly fell into. Correlating a box to a run
by timestamp looks right and is wrong: Blacksmith rewrites a box's CREATED value
as it hydrates, measured moving 05:54:43 to 05:55:03 to 05:58:48 on one box. The
authoritative binding is the RUN URL column, which appears only once the box is
ready.

SKILL.md, benchmark.md, and the demo script now share the one algorithm.
2026-08-17 23:08:35 -07:00
Lawrence Chen 2765225598 docs: cover tests and lints, and move the threat model out of the way
Every trial exercised the evidence path, so the skill only ever showed
cargo build. An agent's real task is usually cargo test or clippy, which use the
same warm target/ and need no stage helper; show them.

Move the trust-boundary reasoning to references/trust-boundary.md and keep the
operative summary inline. An agent about to build needs the rule and the
enforcement, not the full argument. SKILL.md is 11.7 KB, still above the 10 KB
guideline; the remaining bulk is the approval block, and the eighth trial proved
that moving an executable step away from the file that needs it strands a live
box, so it stays.
2026-08-17 22:42:28 -07:00
Lawrence Chen b6a6403506 docs: stop the exit trap crying wolf on a clean run
Ninth trial ran all eight documented blocks literally, in order, each exiting 0,
repairing nothing. Two cosmetic inaccuracies left, both mine.

The EXIT trap printed "Testbox <id> is still running; no stop was authorized"
at the end of a run whose ceremony had already stopped the box, because it
inferred liveness from CONFIRM_TESTBOX_STOP_SHA being unset rather than from
the inventory it had just captured. On a clean run that reads as a failure. It
checks list-at-exit.log now.

And I cited the changed-file overhead gap as "several times larger, 8.1s against
1.2s". It measured 1.48s this run with no change to the lane. The guidance not
to read that gap as sync overhead stands; the magnitude does not, so the text no
longer promises one.
2026-08-17 22:38:59 -07:00
Lawrence Chen 93cba89d64 fix: the poll guard I added never ran, and stranded a box
Eighth trial ran the documented blocks literally, as instructed, and the
warmup block stranded a live 32 vCPU box. `grep -c .` exits 1 when the count is
zero, and the plan sets `set -euo pipefail`, so the assignment
`waiting_count="$(... | grep -c .)"` ended the script on the first poll. Zero
waiting runs is the normal state right after warmup returns, so the 150s poll
loop was unreachable dead code, and the exact failure commit 8d3edf5b28 set out
to fix happened again, one round later, in the fix itself. There is no error
text; the script simply stops, leaving a warmed box, an un-approved run, and a
receipt the trap correctly refuses to act on.

Guarded every count with `|| true` in both files.

tests/test_testbox_doc_blocks.sh now treats the plan as executable
documentation: it parses all 21 fenced bash blocks under the shell the plan
mandates, then executes each counting construct against an empty result to prove
it survives its normal first state. Verified red against the unguarded line and
green after. Syntax checking could never have caught this, which is why two
rounds of review missed it.

Two more from the same trial. benchmark.md never cancelled the warmup run, so
following it alone leaves the keepalive holding a runner until the 120 minute
timeout; it now cancels and polls to terminal state. And the setup artifact is
required evidence whose command sat in prose rather than a fenced block, so a
literal run never captured it.
2026-08-17 22:21:26 -07:00
Lawrence Chen 9ebfec987c fix: put the approval in the code block that needs it
Seventh trial ran smooth end to end, zero unexpected failures, and still found
that benchmark.md's warmup block goes straight from the receipt to
`status --wait`. The approval existed only in the surrounding prose, so anyone
copying the block parks at the environment gate, burns the full 15 minute
timeout, and strands a warmed 32 vCPU box. Prose next to a code block does not
protect someone running the code block. The approval, with the polling guard and
its own attempt transcript, is now in the code, along with the DISPATCH_EPOCH
capture it binds to. Removed the unreachable second warmup_status check while
there.

Also qualified the overhead column. The changed-file gap measured 8.1s against
1.2s for the other stages while all three synced strategy=skip, because that
stage's backup, edit, restore, and re-verify work runs inside the CLI call but
outside wall_seconds. It is real, it is not sync and transport, and
operations.md now says to compare overhead only across first-clean and
incremental-noop.

Every bash block in the plan is now syntax-checked as a unit.
2026-08-17 21:58:45 -07:00
Lawrence Chen 8d3edf5b28 fix: stop the approval guard from aborting a warmup that was fine
Sixth trial reported the run as not smooth, and it was right. The guard I wrote
in SKILL.md Step 3 queries for waiting runs the instant warmup returns. GitHub
had not surfaced the run yet, so zero matched, and the guard printed "more than
one run waiting; approve yours in the UI" and exited. Zero runs is not
ambiguity, it is "not yet". Under benchmark.md's set -e that abort left a live,
un-approved box on the org inventory with its receipt already written, and the
plan's own trap is designed to refuse to stop it.

The guard now polls for 150s, distinguishes zero from two-or-more, and says
which happened. It also tells you that an abort means you already own a running
box, so stop it before retrying or the next dispatch leaves two boxes and one
receipt.

The trap also lied about why it gave up: it printed "warmup returned no owned
Testbox receipt" when the receipt existed and the real cause was
CONFIRM_TESTBOX_STOP_SHA being unset, which is the normal state. It now reports
the condition that actually held, and names the box you must stop yourself. Its
inventory file was called list-after-warmup-failure.log even on the success
path; it is list-at-exit.log now.

Two smaller ones. Record every approval attempt separately, because a retry that
overwrites the first hides a live unapproved box from the evidence pack. And
warm-before-reading has an exception for evidence runs, whose preflight and
receipt must exist before warmup; three agents in a row hit that ordering.
Finally, changed-file rebuilds cmux-remote as well as cmux-tui, so its ~8s is a
small-edit figure, not a single-crate floor.
2026-08-17 21:37:36 -07:00
Lawrence Chen f41a1ee183 Verify R2 nightly bytes before redirect (#10317) 2026-08-17 21:32:22 -07:00
Lawrence Chen 307a2acb28 docs: fix the last three defects, including a second clock that was never captured
Fifth trial ran the lane smoothly end to end: every documented command exited
as documented, no retries, no procedural guessing, and the only non-zero exits
were the two the docs predict. 132.25s clean, 0.17s no-op, 8.03s changed-file,
box and runner both proven gone. The artifact check added last round worked: it
recorded a 746 MB binary, 48 bytes larger in the changed-file stage.

Two defects were mine from the previous commit. The artifact prose said
cmux-tui-testbox-<run-id> while the command beside it correctly said
cmux-tui-testbox-setup-<run-id>, so following the prose downloads nothing. And
required field 3 asked for an "identity run ID" for a run the same document
forbids issuing; it now says there is no such run and names the real transcripts.

The third is older and worse. operations.md promised two clocks and described
the second as the local CLI transcript covering sync, transport, and queueing.
No pack ever contained it: stage `wall_seconds` is measured on the box around
cargo, which is why it sits milliseconds above `/usr/bin/time -p` real rather
than well above it, and three separate agents nearly cited it as CLI wall time.
The plan now records the real local clock per stage in <stage>.cli-wall.txt, and
operations.md says plainly that a pack without it cannot measure Testbox
overhead at all.
2026-08-17 21:17:43 -07:00
Lawrence Chen 0a0d41243d Support signed R2 nightly assets on web downloads (#10309) 2026-08-17 21:06:57 -07:00
Lawrence Chen ae6a0f143e fix: pin before the stages, and prove a binary came out
Fourth trial agent ran the plan with zero non-zero exits and no retries
(127.03s clean, 0.17s no-op, 8.02s changed-file, box and runner proven gone),
then found the ordering defect that matters most: benchmark.md printed the
three-stage loop BEFORE the pin instruction, so following that file top to
bottom benchmarks main rather than your branch. SKILL.md had the right order,
so the two documents disagreed. The pin now has its own section ahead of the
loop, and says plainly that running the loop first measures main.

The stage helper now verifies the build produced cmux-tui/target/debug/cmux-tui
and records its size in each stage JSON. A zero exit proves cargo was happy, not
that anything was produced, and the box is destroyed before anyone can check.

Also: name the setup artifact and give the gh run download command, instead of
"keep the artifact URL" with no artifact name; document that gh run cancel takes
about five minutes to land, so in_progress right after cancelling is not a
failure; and explain the two surfaces that look contradictory after a stop, that
status still prints a completed row while list shows nothing, and that
setup-identity.json says X64 where stage records say x86_64 for the same host.
2026-08-17 20:57:34 -07:00
Abdulaziz AlbaharandClaude Fable 5 bd8d313383 fix(ios): pass selectSimulatorStream in the surface gallery preview
MacSurfaceGalleryPreviewView (#if DEBUG) builds TerminalPickerMenuActions
without the required selectSimulatorStream closure, so every Debug iOS
build from main fails compiling CmuxMobileShellUI while Release archives
skip the file and stay green. Missed in the PR 10072 partial merge, same
family as #10287/#10290/#10295.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-17 20:40:47 -07:00
Lawrence Chen 4e54c420a2 docs: fix the evidence-pack defects a third trial agent found
Third agent ran the whole plan with no failure, no retry, and every exit code
the docs predict, including the intended 75 from PREVIEW: 129.97s clean, 0.18s
no-op, 9.28s changed-file, box and runner both proven gone. What it found was
that the evidence pack the plan produces cannot be fully trusted.

`blacksmith auth whoami` writes to stderr, so the plan's bare invocation put no
org identity in the pack while the field list implied one; it captures stderr
now. `timings.json` hardcoded schema 2 while stage records self-report schema 3,
and operations.md still described stage records as schema 2, so the wrapper now
carries stage_record_schema and the prose matches the code.

The guard naming a historical evidence directory pointed at a path that does not
exist and could not: `blacksmith-testbox-e40704611ac35f4ffa153` is the first 13
characters of that SHA glued to its last 8, and it lacks the mandatory suffix. A
guard you cannot resolve protects nothing, so both files now state the rule
itself: never write into an evidence directory you did not create this run. The
superseded 161.47s/8.28s/9.13s numbers go with it; three trials have since
measured this lane directly.

The collision-avoidance branch was dead code. It tested for
blacksmith-testbox-<sha> while every directory the plan creates is
blacksmith-testbox-<sha>-<suffix>, so the timestamp path never fired and two
runs of one SHA from one PID collided. It globs the suffix now.

Finally, Blacksmith's CREATED column is a last-transition time, not a creation
time; the same box reported three different values across hydrating, ready, and
stopped. Say so, and point at the status --wait transcript for elapsed time.
2026-08-17 20:38:01 -07:00
Lawrence Chen 9e841fc193 fix: repair the five defects a second trial agent hit
Second independent agent, given only "produce defensible benchmark evidence",
found the skill from CLAUDE.md and ran the full plan first try: 129.37s clean,
0.17s no-op, 9.69s changed-file, box and runner both confirmed gone. The
receipt-bound cleanup fixed in the previous commit worked for it. Five things
still failed it.

Two were code. The post-stop poll wrote to status-before-stop.log, so the
evidence pack's own pre-stop record was overwritten with post-stop state and an
auditor would misread when the box existed; it writes status-after-stop.log now.
And cleanup-preview.json paired warmup_ref with the benchmarked source_sha,
so the destruction record named a SHA that is not on the ref beside it; it
records both refs now, schema 2, and the regression test asserts it.

Three were documentation. benchmark.md bills itself as the full plan but never
mentioned the deployment gate, so its warmup block deadlocks for 15 minutes on
`status --wait`; it now points at the guarded approval and warns against
approving workflow_runs[0]. The PREVIEW cleanup exits 75 on success, which the
same plan's mandated `set -e` turns into an abort right before cleanup; both
call sites say so and show the `set +e` wrapper. And SKILL.md claimed stopping
the box ends the warmup run, which is false: the measured run sat in_progress
for four minutes afterwards holding a 32 vCPU runner, so cancelling is now a
required step rather than a hedge.
2026-08-17 20:20:13 -07:00
Abdulaziz AlbaharandClaude Fable 5 3a15a3e053 fix(ios): honor Tailscale-only for secondary Macs and Iroh discovery
makeSecondaryClient picked routes with a bare storedReconnectRoutes call,
so with Connection Method = Tailscale the multi-Mac aggregation pool still
pinned stored Iroh routes and dialed background-control sessions over
public paths and managed relays; broker-discovered secondaries took the
same path. Route through orderedReconnectRoutes, which applies the same
strict Tailscale requirement as the foreground dial: grant-authorized
Tailscale routes still aggregate, everything else fails closed and waits
for a new external edge.

Zero-touch Iroh discovery now returns no candidates while Tailscale is
selected, skipping broker round-trips whose iroh-only results could never
be dialed.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-17 19:56:09 -07:00
Abdulaziz AlbaharandClaude Fable 5 6e27c776ac test(ios): Tailscale-only must not dial Iroh for secondary Macs
With Connection Method = Tailscale, background multi-Mac aggregation still
pins a stored Iroh route and opens an Iroh background-control session over
public paths and managed relays. makeSecondaryClient builds its routes
without the Tailscale requirement, bypassing the strict filter every other
dial path applies. Failing test only; the fix lands in the next commit.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-17 19:56:09 -07:00
Abdulaziz AlbaharandClaude Fable 5 a0c9ba476c feat(ios): state connection method and live transport in diagnostics
A transport report today only implies the connection method and carrier
transport through whichever dial events survived the bounded ring; reading
one still needs a Settings screenshot. Record the configured method
(connectionMethodConfigured) at store construction and on every foreground
so any report window states it, decode the existing
connectionMethodPreferenceChanged value into the same readable method name,
and record foregroundTransportSelected with the active route's transport on
connect and on every route change, covering both state-then-route and
route-then-state connect flows plus mid-connection promotions.

Reports now carry lines like:
  App feature event (Operation: connectionMethodConfigured, Method: Tailscale Only)
  App feature event (Operation: foregroundTransportSelected, Transport: Iroh)

en+ja catalog entries for the new method and field names.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-17 19:54:11 -07:00
Lawrence Chen 1e4592dd90 fix: unbreak receipt-bound Testbox cleanup, and close the doc gaps a test agent hit
A subagent given only "compile cmux-tui on Linux" found the skill from
CLAUDE.md and followed it to real numbers, then reported what broke.

The receipt-bound cleanup could never succeed. #10303 pinned warmup to
--ref main, so the inventory REF column is always main, while the receipt
recorded source_ref, the branch being benchmarked. cleanup.sh compared the two
and exited 66 with "inventory ownership context differs from the warmup
receipt", for every legitimate user. That pushed operators toward a bare stop
and away from the ownership check the docs spend a page justifying. The receipt
now carries warmup_ref and source_ref separately and cleanup compares the
inventory against warmup_ref. tests/test_testbox_cleanup_receipt_ref.sh drives
the real script against a stubbed CLI; it reproduces the old exit 66 and passes
on the fix.

The approval snippet took workflow_runs[0]. Every run in this lane shares a
title and a main head branch, and the agent found a stranger's run waiting two
minutes ahead of its own, so the snippet would have approved someone else's
deployment gate. It now binds to a run created after your own dispatch and
refuses when more than one is waiting, matching the demo script's guard.

Also: `list --all` is not proof nothing is burning, because the keepalive holds
a 32 vCPU runner after the box is gone, so step 7 checks the run too and shows
`gh run cancel`; say which of the two cleanup paths is authoritative and when;
state that warmup does not block; put the --idle-timeout unit on the command;
and note that the aggregator is all-three-stages or nothing.
2026-08-17 19:27:31 -07:00
Lawrence Chen 8238cecf5c feat: drop the demo script's confirmation prompt
Running a script named "testbox demo" is the consent. The spend is bounded by
--idle-timeout and by the EXIT trap that stops the box, so a prompt whose answer
is always yes was noise. It also aborted confusingly with no TTY, because `read`
returns non-zero under `set -e`. Print what is about to happen instead.
2026-08-17 19:10:24 -07:00
Lawrence Chen f9aaa2c77d docs: point the skill at the tour script and explain the cancelled run state 2026-08-17 18:51:56 -07:00
Lawrence Chen 57984fa578 feat: add a runnable Testbox tour script
Reading a runbook does not tell you what a persistent build box feels like.
scripts/blacksmith-testbox-demo.sh warms one, pins it to your pushed HEAD,
builds cmux-tui twice, and prints both times so the second build makes the
point the prose cannot.

It prints every remote command before running it, so the tour doubles as the
documentation. It stops the box it created from an EXIT trap, including on
Ctrl-C, and it only ever stops that box. Deployment approval matches runs
dispatched within two minutes of its own warmup and refuses to guess when more
than one is waiting, so it cannot approve someone else's run. --stages swaps
the two plain builds for the three measured stages.
2026-08-17 18:43:46 -07:00
Lawrence Chen d0f14053a2 docs: make blacksmith-testbox a skill an agent acts on, not a manual
The skill opened with its threat model and buried the commands, so an agent
about to compile cmux-tui learned the lane existed only after it had already
waited. Lead with the operating rule instead: warm your own box in the first
minute, before reading code, because the four minutes of hydration are four
minutes you can spend reading.

Then a seven-step CLI walkthrough covering warmup, deployment approval, pinning
the box to your commit, building or benchmarking, download, and stop. One box
per worktree and per agent, since `testbox run` synchronizes with
`rsync --delete` and two agents on one ID overwrite each other.

SKILL.md drops from 19.7 KB to 8 KB. Stage orchestration, the cleanup ceremony,
and how to read the two clocks move to references/operations.md; the full
evidence plan stays in benchmark.md. Index the skill in CLAUDE.md, which #10303
missed, so agents can find it at all.
2026-08-17 18:39:51 -07:00
Lawrence Chen 3e377bba84 fix: require and enforce a pushed commit for Testbox benchmarks
Testing the broker's cross-commit path on a live box disproved a claim I wrote
into the skill. `blacksmith testbox run` synchronizes file contents, not
history: it makes one opportunistic `git fetch` of the benchmarked commit, falls
back to copying changed files, and skips even that once fingerprints match. A
local-only commit fails on the box with `upload-pack: not our ref`, leaving the
box on the `main` checkout the warmup job made, with candidate file contents
written over it. The stage helper then died on a raw `git rev-parse
<sha>^{tree}` error.

Require the pushed commit, and pin the box to it once per box with fetch plus
`git reset --hard` before the first stage. That makes the box an exact checkout
of the benchmarked revision, so a timing always names something anyone can
fetch, and an uncommitted edit can never be what got built. The helper now
verifies the commit is present and checked out, and prints the exact push and
pin commands instead of a git internals error.
2026-08-17 18:19:07 -07:00
Lawrence Chen c8d2cac0eb Merge pull request #10305 from manaflow-ai/feat-testbox-guard-workflow
ci: run the Testbox broker guard on every pull request
2026-08-17 18:12:07 -07:00
Lawrence Chen 86f14c0997 ci: name both shellcheck codes for the trap-invoked helpers
Ubuntu 24.04 ships shellcheck 0.9, which reports trap-only functions as
SC2317; 0.10 renamed that to SC2329, which is what the file disabled. The new
lint job runs on the runner's shellcheck, so it flagged all 15 lines of the two
trap handlers. Name both codes.
2026-08-17 18:10:36 -07:00
Lawrence Chen 8ebd307452 ci: run the Testbox broker guard on every pull request
The main CI suite is dispatch-only while CI is paused, so tests/test_ci_testbox_broker_guard.py
would not have run on a pull request that weakened the lane it guards. Give it
a small always-on workflow that also shellchecks the four helper scripts and
actionlints the warmup workflow with a checksum-pinned actionlint.

No path filter, deliberately. A change that moves or renames the guard is
exactly the change a path filter would let through.
2026-08-17 18:02:25 -07:00
Lawrence Chen 2562c5ecfe Merge pull request #10303 from manaflow-ai/feat-testbox-main-broker
ci: warm cmux-tui Testboxes from a main-controlled broker
2026-08-17 18:00:08 -07:00
Lawrence Chen d28e3199fe docs: describe the broker ref guard instead of the removed reviewed pins 2026-08-17 17:21:50 -07:00
Lawrence Chen 7105bc46e4 test: guard the Testbox broker trust boundary in CI
The property that keeps candidate code out of the token-bearing job is a
workflow shape, and no runtime check can observe it without spending a 32 vCPU
box. Assert it statically: manual dispatch only, no input that selects a
source revision, a refs/heads/main guard ahead of begin-testbox, no repository
script or local composite action before the token, every checkout pinned to
github.sha, SHA-pinned actions, and a keepalive that runs from the checked-in
main script.

Verified red against the previous candidate-controlled workflow (two failures:
the candidate-selecting inputs and the missing main guard) and green here.
2026-08-17 17:20:37 -07:00
Lawrence Chen f4222dec61 ci: warm cmux-tui Testboxes from a main-controlled broker
blacksmith testbox warmup resolves the workflow definition and the hydrated
source from one --ref, so a lane that warms a candidate branch runs that
branch's copy of the workflow before begin-testbox writes the Testbox auth
token into the job. A candidate could therefore delete its own guards.

Hydrate main only. The first step refuses any ref except refs/heads/main, and
no repository code runs before the token. A candidate revision reaches the box
afterwards through blacksmith testbox run, which syncs a maintainer's worktree
onto the warm VM and needs Blacksmith org credentials that already grant box
access, so it moves no trust boundary.

The hydrated commit and the benchmarked commit are now deliberately different.
The stage helper checks the setup marker for VM identity, runner class, and
toolchain completeness instead of source equality, records the hydrated ref and
SHA under a new "hydration" block, and still fails closed when the active Rust,
Cargo, or Zig differs from what warmed the caches.

The lane no longer needs BLACKSMITH_TESTBOX_REVIEWED_REF or
BLACKSMITH_TESTBOX_REVIEWED_SHA; the environment needs a deployment branch rule
of exactly main.
2026-08-17 17:20:31 -07:00
Lawrence Chen 70f2e14f44 Make the topology own tab identity
Tab identity was re-derived on every read from either the live surface or
the slot indexes, so each reader invented its own missing-data policy: the
projection failed hard, `rebuild_resource_indexes` silently dropped the tab,
and the browser branch required a live surface. A silent drop is the worst
of the three, because the next projection tombstones durable rows that are
still live.

`State::register_tab_identity` is now the writer every placement path uses,
including `insert_surface_checked` and the browser attach path, which
previously left identity to be harvested from the surface at the next index
rebuild. The reserved-placement install in `resource_project_terminal_selected`
still writes its own placement order, which `register_tab_identity` must not
reorder; it writes the same identity fields.

Every reader now takes identity from the topology: the index rebuild no
longer consults surfaces, the projection resolver reads the owner, and the
layout-undo token and active-tab lookup drop their surface fallbacks.
`State::ensure_tab_identity_coverage` runs at the projection boundary, so a
missing identity fails that mutation by name instead of erasing durable rows.

Browser tabs now project from their durable row when their runtime is gone,
matching terminals. That closes the same failure for a browser view whose
surface never materialized.
2026-08-17 17:15:07 -07:00
Austin Wang 0fc34d7067 Merge pull request #10198 from manaflow-ai/issue-10189-claude-teams-panel-path-title
Fix Claude Teams panel PATH and agent names
2026-08-17 16:39:22 -07:00
Austin Wang 33a43031e2 Merge pull request #10211 from manaflow-ai/issue-10204-reload-build-perf
Speed up Blacksmith reload builds with warm caches
2026-08-17 16:39:09 -07:00
Lawrence Chen aa046ab0c7 Project restored tabs from durable identity, not live surfaces
`ordered_terminal_tab_ids` demanded a live surface for every pane tab, while
the projection loop right below it already accepted a restored tab that had
no surface yet. Startup restores tabs before adoption, so an unadoptable host
leaves a tab with no surface, and the ordering pre-pass aborted the whole
projection with "pane references missing surface <slot>".

The abort happened inside the exit-detach commit that was supposed to prune
that terminal, so nothing was written and every later start repeated it. One
orphan host recovered, because removing it made the tree consistent; two or
more behind the same pane wedged the session forever.

Both call sites now resolve tab identity through one helper that prefers the
live surface and falls back to the durable indexes.

Also commit the durable exit before deleting the host record in the two
startup paths that proved a host dead. The record is the only evidence that
the host existed, so a failed commit must not erase it first.
2026-08-17 16:33:18 -07:00
Lawrence Chen e276c42ede Add failing test for two dead hosts behind one pane
A daemon restart that finds several unadoptable terminal hosts in the same
pane must still start. Today the first exit-detach projects the whole tree
while the other restored tab still has no surface, so startup aborts with
"pane references missing surface <slot>" and writes nothing, which makes the
session permanently unopenable.
2026-08-17 16:25:27 -07:00
austinpower1258 04576b88f5 Bound cached products and preserve cmuxd behavior 2026-08-17 16:17:49 -07:00
Austin Wang 11c4556b1d Merge pull request #10224 from manaflow-ai/issue-10210-light-chrome-white-controls
Fix browser chrome appearance authority across focus transitions
2026-08-17 15:58:00 -07:00
austinpower1258 3950e833ad Fix shim tilde expansion and OS cache isolation 2026-08-17 15:56:48 -07:00
Austin Wang af1788ca18 Merge pull request #10234 from manaflow-ai/fix-nightly-main-compile
Fix nightly macOS Release compile on main
2026-08-17 15:52:27 -07:00
Abdulaziz Albahar 12b646bf56 iOS: remove New Task Composer beta toggle (#10291)
* test(ios): cover task composer beta toggle removal

* fix(ios): remove task composer beta setting

* test(ios): use stable settings scroll anchor

* test(ios): retain retired toggle coverage

* test(ios): narrow retired beta toggle coverage
2026-08-17 15:47:00 -07:00
austinpower1258 0274b638d4 Harden Ghostty fallback and cache architecture keys 2026-08-17 15:44:30 -07:00
Abdulaziz AlbaharandClaude Fable 5 e27b734c53 fix(ios): implement selectedMacSurfaceID store selection, unhide effectiveConnectionStatus (#10295)
Fourth and fifth Release-archive breaks from 04ff18eea6: WorkspaceDetailView
and its extensions reference store.selectedMacSurfaceID and
store.selectMacSurface(_:), which were never implemented on
MobileShellComposite (MobileShellCompositePreviewTests already encodes the
contract: starts nil, selectMacSurface sets it without touching
selectedTerminalID, workspace switch clears it). effectiveConnectionStatus
was private in WorkspaceDetailView.swift but used from
WorkspaceDetailView+Surfaces.swift, a different file.

Adds the stored property plus the clearing hook in selectedWorkspaceID.didSet,
the selectMacSurface mutator in MobileShellComposite+SurfaceFocus, and drops
the private modifier.

Co-authored-by: Claude Fable 5 <[email protected]>
2026-08-17 15:39:24 -07:00
austinpower1258 dd954ccfc1 Harden reload timing and Ghostty download budgets 2026-08-17 15:27:56 -07:00
Abdulaziz AlbaharandClaude Fable 5 240c978340 fix(ios): public import for MobileWorkspacePreview.ID in capabilities API (#10290)
supportsPanelArtifacts(in:) exposes MobileWorkspacePreview.ID publicly, but
the file imported CmuxMobileShellModel with a plain import, which is internal
under InternalImportsByDefault. The Release device archive rejects it (method
cannot be declared public because its parameter uses an internal type); Debug
simulator builds do not, which is why test-ios stayed green. Matches the 24
sibling files in this package that already use public import.

Co-authored-by: Claude Fable 5 <[email protected]>
2026-08-17 15:15:10 -07:00
austinpower1258 aab8ceccb7 fix(macOS): forward workspace identity during surface cleanup 2026-08-17 14:57:53 -07:00
austinpower1258 4113149d01 fix(macOS): reconcile mobile surface APIs on main 2026-08-17 14:57:53 -07:00
Abdulaziz AlbaharandClaude Fable 5 21c48914dd fix(ios): make artifact failure and scope switches exhaustive (#10287)
* fix(ios): make artifact failure and scope switches exhaustive

04ff18eea6 added ChatArtifactError.unknown(code:) and the .panel loader
scope but missed two switches, so the cmux-ios scheme has not compiled
since Aug 14 and every iOS TestFlight upload since build 20260814211222
has failed. Adds the .unknown failure presentation (localized en/ja,
never blames connectivity) and routes .panel loader scope to the .panel
viewer scope.

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

* fix(ios): map unknown artifact errors in MarkdownSurfaceModel

The same 04ff18eea6 change left a third non-exhaustive switch in
MarkdownSurfaceModel.failure(for:), hidden behind the CmuxAgentChatUI
compile failure because CmuxMobileShellUI builds after it. Map
.unknown(code:) to .loadFailed(code:), whose doc contract is exactly
this case (the Mac answered with an unrecognized error), preserving the
code for display.

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

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-08-17 14:44:11 -07:00
Abdulaziz Albahar e2fedffd0f Merge pull request #10112 from manaflow-ai/fix-ios-push-toggle-off
Fix iOS push alerts opt-out toggle
2026-08-17 11:49:17 -07:00
Abdulaziz Albahar 9c5f7d072f fix(ios): stop replay scroll drain watchdog loop (#10186)
* test(ios): bound verified replay scroll drain ownership

* fix(ios): bound verified replay scroll ownership
2026-08-17 11:27:31 -07:00
austinpower1258 f0da2a5262 fix: preserve inherited appearance for WebKit refresh 2026-08-15 19:21:17 -07:00
austinpower1258 f3a2eb940c fix: keep browser chrome on resolved surface appearance 2026-08-15 19:15:41 -07:00
austinpower1258 d93e73b3d7 Scope reload caches to checkout paths 2026-08-15 19:03:44 -07:00
austinpower1258 9d67c5c624 test: cover browser chrome authority across focus 2026-08-15 19:00:25 -07:00
austinpower1258 3fd79f1928 Reuse exact SPM state on warm reloads 2026-08-15 18:57:44 -07:00
Abdulaziz Albahar 52e1b9c9a1 Merge remote-tracking branch 'origin/main' into fix-ios-push-toggle-off 2026-08-14 21:27:21 -07:00
austinpower1258 843a9ac2be Harden reload cache fallbacks 2026-08-14 21:20:48 -07:00
austinpower1258 152ec20140 Make reload DerivedData cache reusable 2026-08-14 20:54:51 -07:00
Abdulaziz Albahar 8677b43be3 fix(push): await bounded opt-out cleanup 2026-08-14 20:53:58 -07:00
austinpower1258 4407564458 fix: retain Claude launch path dependency 2026-08-14 20:43:02 -07:00
austinpower1258 14f33ab531 fix: validate versioned Claude title metadata 2026-08-14 20:34:22 -07:00
austinpower1258 1a9453a3bc fix: initialize Claude teammate panel titles 2026-08-14 20:33:11 -07:00
Abdulaziz Albahar 869b9929c5 fix(push): preserve ambiguous cleanup state 2026-08-14 20:29:37 -07:00
austinpower1258 5a6d422034 Speed up Blacksmith reload builds 2026-08-14 20:28:00 -07:00
Abdulaziz Albahar 8fa502120b fix(push): bound recovery workers 2026-08-14 20:15:33 -07:00
austinpower1258 67d408bfca test: cover initial Claude teammate panel title 2026-08-14 20:10:16 -07:00
Abdulaziz Albahar b4ff55127a fix(push): close notification intent races 2026-08-14 19:51:57 -07:00
austinpower1258 f438cdbd85 fix: recognize versioned Claude teammate launches 2026-08-14 19:47:07 -07:00
austinpower1258 564541f02b test: cover versioned Claude teammate executables 2026-08-14 19:45:59 -07:00
Abdulaziz Albahar d1ef41b535 fix(push): make cleanup migration transactional 2026-08-14 19:36:47 -07:00
austinpower1258 9e920b4373 fix: preserve agent title through spinner frames 2026-08-14 19:20:57 -07:00
austinpower1258 d6b75fe6ee fix: snapshot agent titles at runtime callback boundary 2026-08-14 19:15:36 -07:00
Abdulaziz Albahar d212b57054 fix(push): index durable cleanup obligations 2026-08-14 19:13:06 -07:00
Abdulaziz Albahar 514684743a fix(push): preserve generation through cleanup recovery 2026-08-14 18:58:21 -07:00
austinpower1258 6b5c869114 fix: restore login PATH and names for agent panels 2026-08-14 18:55:57 -07:00
austinpower1258 7d0687b431 test: cover claude teams panel shell and title regressions 2026-08-14 18:55:57 -07:00
Abdulaziz Albahar 8dc9926c38 fix(push): preserve cleanup intent across retries 2026-08-14 18:48:30 -07:00
Abdulaziz Albahar d3841ca493 fix(push): supersede direct cleanup retries 2026-08-14 18:39:34 -07:00
Abdulaziz Albahar 3a25ecf3d1 fix(push): continue paged cleanup drains 2026-08-14 18:33:45 -07:00
Abdulaziz Albahar 0c443b5520 fix(push): discard stale cleanup index entries 2026-08-14 18:25:39 -07:00
Abdulaziz Albahar 3f6a681ba2 fix(push): page durable cleanup overflow 2026-08-14 18:19:38 -07:00
Abdulaziz Albahar 48730ca18d Merge origin/main into fix-ios-push-toggle-off 2026-08-14 18:08:05 -07:00
Abdulaziz Albahar 708bc6b89b fix(push): preserve overflow cleanup obligations 2026-08-14 18:05:56 -07:00
Abdulaziz Albahar 4364c2d23f fix(push): bound pending cleanup state 2026-08-14 03:15:56 -07:00
Abdulaziz Albahar c32c6311b7 test(push): bound pending cleanup storage 2026-08-14 03:14:54 -07:00
Abdulaziz Albahar d9a4ea5747 fix(push): authorize before backend reconciliation 2026-08-14 03:08:06 -07:00
Abdulaziz Albahar d34c247866 fix(push): let opt-outs bypass stale work 2026-08-14 02:54:04 -07:00
Abdulaziz Albahar 9800bddc17 test(push): require immediate opt-out cleanup 2026-08-14 02:52:47 -07:00
Abdulaziz Albahar 1fa42306cf refactor(push): own intent reconciliation 2026-08-14 02:37:40 -07:00
Abdulaziz Albahar 3607cc8f69 fix(ios): reconcile started push mutations 2026-08-14 02:21:29 -07:00
Abdulaziz Albahar c9be5246a7 fix(ios): supersede stale push intents 2026-08-14 02:15:50 -07:00
Abdulaziz Albahar 9aae5b34b8 test(ios): cover superseded push intent 2026-08-14 02:08:49 -07:00
Abdulaziz Albahar 38ffb32c68 fix(ios): honor resolved push state 2026-08-14 01:53:47 -07:00
Abdulaziz Albahar e6d722d5a8 fix(ios): preserve queued push intent 2026-08-14 01:49:42 -07:00
Abdulaziz Albahar 71818d7367 fix(ios): serialize push toggle mutations 2026-08-14 01:40:58 -07:00
Abdulaziz Albahar 1834813593 test(ios): isolate push fixture state 2026-08-14 01:36:12 -07:00
Abdulaziz Albahar 2f0e084e10 test(ios): bound pending push fixture 2026-08-14 01:35:46 -07:00
Abdulaziz Albahar 253e6707fd fix(ios): keep push toggle interactive 2026-08-14 01:34:57 -07:00
Abdulaziz Albahar dc379323d2 test(ios): signal pending push mutation 2026-08-14 01:29:30 -07:00
Abdulaziz Albahar ed6e5e0297 fix(ios): update push toggle optimistically 2026-08-14 01:20:18 -07:00
Abdulaziz Albahar 4666aa7b75 test(ios): cover pending push opt-out 2026-08-14 01:20:09 -07:00
Abdulaziz Albahar c9cc7c1309 refactor(ios): reduce push toggle fix scope 2026-08-14 01:18:46 -07:00
Abdulaziz Albahar 8a982c134a Merge remote-tracking branch 'origin/main' into fix-ios-push-toggle-off 2026-08-14 01:18:32 -07:00
Abdulaziz Albahar 8a8fb0b835 fix(ios): recover timed-out push cleanup 2026-08-14 01:09:28 -07:00
Abdulaziz Albahar d7ebaae630 test(ios): recover timed-out push opt-out 2026-08-14 01:03:57 -07:00
Abdulaziz Albahar c33317abdf refactor(push): use one reconciliation worker 2026-08-14 00:52:45 -07:00
Abdulaziz Albahar e599af99e5 fix(push): order sign-out with newer registration 2026-08-14 00:14:36 -07:00
Abdulaziz Albahar 86c89f5515 test(push): protect newer registration from stale sign-out 2026-08-14 00:11:59 -07:00
Abdulaziz Albahar 890b6b9531 refactor(push): isolate intent worker type 2026-08-13 23:51:33 -07:00
Abdulaziz Albahar 9b0f659a9e fix(push): bound cached registration recovery 2026-08-13 23:50:29 -07:00
Abdulaziz Albahar 61b1afca7a test(push): require fresh bounded recovery after timeout 2026-08-13 23:47:52 -07:00
Abdulaziz Albahar 601904189a test(push): wait for same-lane recovery state 2026-08-13 23:39:00 -07:00
Abdulaziz Albahar 01fdd8ef3a fix(push): bound same-lane recovery and startup cleanup 2026-08-13 23:33:37 -07:00
Abdulaziz Albahar 5528198cc1 test(push): cover startup drain and same-lane recovery 2026-08-13 23:30:29 -07:00
Abdulaziz Albahar 6832304b12 fix(push): fence late intents and timeout races 2026-08-13 23:18:38 -07:00
Abdulaziz Albahar 0cfa6daac2 test(push): cover late intent and timeout winner races 2026-08-13 23:11:01 -07:00
Abdulaziz Albahar 6e98cee5e1 fix(push): bound timeout recovery lanes 2026-08-13 23:02:12 -07:00
Abdulaziz Albahar 1f9c96da50 test(push): require bounded timeout recovery 2026-08-13 23:00:13 -07:00
Abdulaziz Albahar faa340e657 fix(push): commit intents before bounded reconciliation 2026-08-13 22:50:16 -07:00
Abdulaziz Albahar f3b831f81d test(push): require timed-out intent to reach service 2026-08-13 22:44:01 -07:00
Abdulaziz Albahar 1130e64a88 refactor(push): keep intent lane state flat 2026-08-13 22:29:36 -07:00
Abdulaziz Albahar 25ba6fd6a6 fix(push): retain bounded mutation lanes until completion 2026-08-13 22:27:09 -07:00
Abdulaziz Albahar 87c0033eb3 test(push): cover stalled intent and timeout worker ownership 2026-08-13 22:18:33 -07:00
Abdulaziz Albahar 75937e4297 fix(push): store boolean settings mutation task 2026-08-13 22:02:49 -07:00
Abdulaziz Albahar 8546b3dbd7 fix(push): reject unproven legacy cleanup credentials 2026-08-13 21:59:22 -07:00
Abdulaziz Albahar eb29f93ebb test(push): seed cleanup owner in queued sign-out 2026-08-13 21:58:22 -07:00
Abdulaziz Albahar a441e85c73 fix(push): require persisted owner for opt-out cleanup 2026-08-13 21:54:15 -07:00
Abdulaziz Albahar 015f35393b test(push): fence opt-out cleanup to persisted owner 2026-08-13 21:52:18 -07:00
Abdulaziz Albahar 197fb04942 fix(push): fence direct registration commits and bound enables 2026-08-13 21:44:11 -07:00
Abdulaziz Albahar 62a72a7f3c test(push): cover stale registration and public enable timeout 2026-08-13 21:37:27 -07:00
Abdulaziz Albahar 84bd0cc51f fix(push): persist sign-out cleanup before mutation gate 2026-08-13 21:24:48 -07:00
Abdulaziz Albahar f1582d5c35 test(push): preserve queued sign-out cleanup on cancellation 2026-08-13 21:21:56 -07:00
Abdulaziz Albahar 7c6ef3c047 test(push): isolate mutation timeout generations 2026-08-13 21:05:23 -07:00
Abdulaziz Albahar 72d7ee68b4 fix(push): reconcile late authorization and cancel workers 2026-08-13 21:02:17 -07:00
Abdulaziz Albahar 81f03ee161 test(push): cover late prompt and worker cancellation 2026-08-13 21:02:08 -07:00
Abdulaziz Albahar 15f23ddb43 fix(push): permit retry after settings timeout 2026-08-13 20:38:25 -07:00
Abdulaziz Albahar fa2d641624 test(push): retry timed out enable intent 2026-08-13 20:37:04 -07:00
Abdulaziz Albahar 86311258d8 chore(push): isolate timeout outcome type 2026-08-13 20:28:36 -07:00
Abdulaziz Albahar cb1914233c fix(push): bound settings mutation lifetime 2026-08-13 20:25:39 -07:00
Abdulaziz Albahar 59ded957b5 test(push): bound stalled settings mutation 2026-08-13 20:25:05 -07:00
Abdulaziz Albahar c2c06d7b2f test(push): fix cancellation regression setup 2026-08-13 20:14:14 -07:00
Abdulaziz Albahar a9d3eecc7a fix(push): recover cancelled registration attempts 2026-08-13 20:13:58 -07:00
Abdulaziz Albahar bf861abda8 test(push): recover cancelled queued registration 2026-08-13 20:12:17 -07:00
Abdulaziz Albahar be494245e2 fix(push): recheck cancellation at gate handoff 2026-08-13 20:05:37 -07:00
Abdulaziz Albahar abe6ac92a5 fix(push): accept generation zero startup intent 2026-08-13 19:56:12 -07:00
Abdulaziz Albahar 18c558420d test(push): accept initial coordinator generation 2026-08-13 19:55:20 -07:00
Abdulaziz Albahar 886a28cdb2 fix(push): drop cancelled mutation waiters 2026-08-13 19:48:03 -07:00
Abdulaziz Albahar 845883cac1 test(push): cancel queued mutation waiter 2026-08-13 19:47:01 -07:00
Abdulaziz Albahar e8a3df177b chore(push): isolate intent kind type 2026-08-13 19:40:53 -07:00
Abdulaziz Albahar b93b7e06a4 fix(push): unify preference mutation ordering 2026-08-13 19:33:51 -07:00
Abdulaziz Albahar 6bdd586e0d fix(push): propagate denied enable generation 2026-08-13 19:16:59 -07:00
Abdulaziz Albahar f5c966427e test(push): preserve denied reenable generation 2026-08-13 19:13:52 -07:00
Abdulaziz Albahar 9c94426350 fix(push): require explicit opt-out for startup cleanup 2026-08-13 19:07:45 -07:00
Abdulaziz Albahar 66b9763e37 test(push): preserve absent preference on startup 2026-08-13 19:06:07 -07:00
Abdulaziz Albahar 5f2c42b6d3 fix(push): let intent drains outlive caller cancellation 2026-08-13 18:58:49 -07:00
Abdulaziz Albahar 15270dcd88 test(push): preserve enable after caller cancellation 2026-08-13 18:56:39 -07:00
Abdulaziz Albahar cdffcba67e fix(push): submit every current enable generation 2026-08-13 18:48:07 -07:00
Abdulaziz Albahar d5818e9124 test(push): cover reenable generation during cleanup 2026-08-13 18:46:15 -07:00
Abdulaziz Albahar 0e9a3fa33e fix(push): fence in-flight cleanup and stale snapshots 2026-08-13 18:33:20 -07:00
Abdulaziz Albahar 7c7f86f967 test(push): cover startup ownership and stale snapshots 2026-08-13 18:29:46 -07:00
Abdulaziz Albahar 87da95bb3a refactor(push): align service package conventions 2026-08-13 18:20:48 -07:00
Abdulaziz Albahar 9177de8354 fix(push): atomically commit coordinator intent 2026-08-13 18:11:59 -07:00
Abdulaziz Albahar 0b2802b5e4 test(push): persist opt-out before cleanup 2026-08-13 18:11:23 -07:00
Abdulaziz Albahar ebcdf1ca61 fix(push): recover interrupted opt-out cleanup 2026-08-13 18:03:13 -07:00
Abdulaziz Albahar 88b75b085b test(push): recover disabled startup cleanup 2026-08-13 18:02:01 -07:00
Abdulaziz Albahar d86f774bf5 fix(push): coalesce pending notification intents 2026-08-13 17:52:26 -07:00
Abdulaziz Albahar 4379d55394 fix(push): coalesce repeated registration intents 2026-08-13 17:41:30 -07:00
Abdulaziz Albahar 9f86b86b51 test(push): deduplicate same-generation activation 2026-08-13 17:40:16 -07:00
Abdulaziz Albahar 63007514af fix(push): make latest notification intent authoritative 2026-08-13 17:32:24 -07:00
Abdulaziz Albahar 8bde795686 test(push): cover latest intent ordering 2026-08-13 17:32:09 -07:00
Abdulaziz Albahar 90a97c1921 fix(push): serialize notification intent mutations 2026-08-13 17:05:23 -07:00
Abdulaziz Albahar 4a5301de45 test(push): cover ordered opt-out intents 2026-08-13 17:05:18 -07:00
Abdulaziz Albahar 09868c8c42 test(push): require serialized opt-out mutation 2026-08-13 16:46:53 -07:00
Abdulaziz Albahar 5bc3c10d76 fix(ios): preempt stale push registration work 2026-08-13 16:36:49 -07:00
Abdulaziz Albahar e679e16b52 fix(ios): own push preference mutation in coordinator 2026-08-13 16:12:57 -07:00
Abdulaziz Albahar 57bef7d12b fix(ios): serialize push retries after reconciliation 2026-08-13 15:49:15 -07:00
Abdulaziz Albahar 4492cf1eb3 fix(ios): serialize timed out push mutations 2026-08-13 15:37:38 -07:00
Abdulaziz Albahar 5f4c064122 fix(ios): bound push readiness reconciliation 2026-08-13 15:29:03 -07:00
Abdulaziz Albahar 7f4f7d3526 Merge remote-tracking branch 'origin/main' into fix-ios-push-toggle-off
# Conflicts:
#	Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift
2026-08-13 15:22:58 -07:00
Abdulaziz Albahar 6b698958e7 fix(ios): surface uncertain push toggle outcomes 2026-08-13 15:22:01 -07:00
Abdulaziz Albahar a162e51119 refactor(ios): isolate push preview mutation gate 2026-08-13 15:09:33 -07:00
Abdulaziz Albahar 653a43d98a fix(ios): bound push toggle mutations 2026-08-13 15:04:00 -07:00
Abdulaziz Albahar 12cf0c540b fix(ios): tie push toggle mutation to view lifecycle 2026-08-13 14:55:45 -07:00
Abdulaziz Albahar 63ed21a31a fix(ios): initialize task composer setting 2026-08-13 13:25:11 -07:00
Abdulaziz Albahar 207491f365 fix(ios): remove retired toast settings toggle 2026-08-13 13:11:05 -07:00
Abdulaziz Albahar 952586ecc5 fix(ios): restore toast settings binding 2026-08-13 13:08:01 -07:00
Abdulaziz Albahar d2d527a54e test(ios): make push toggle timing assertion deterministic 2026-08-13 09:44:13 -07:00
Abdulaziz Albahar e7c9a6a7b4 fix(ios): update push toggle optimistically 2026-08-13 09:08:27 -07:00
Abdulaziz Albahar b4625c423e test(ios): cover pending push opt-out 2026-08-13 09:08:19 -07:00
327 changed files with 18777 additions and 1766 deletions
+1
View File
@@ -19,4 +19,5 @@ self-hosted-runner:
# Linux: Blacksmith primary (LINUX_RUNNER), WarpBuild overflow fallback.
- blacksmith-4vcpu-ubuntu-2404
- blacksmith-8vcpu-ubuntu-2404
- blacksmith-32vcpu-ubuntu-2404
- warp-ubuntu-latest-x64-4x
+3
View File
@@ -124,6 +124,9 @@ jobs:
- 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 Blacksmith Testbox broker trust boundary
run: python3 tests/test_ci_testbox_broker_guard.py
- name: Validate nightly prune Python compatibility
run: PYTHON_BIN=python3.9 bash ./tests/test_ci_nightly_prune_python_compat.sh
@@ -0,0 +1,366 @@
name: cmux-tui Rust Testbox setup
# Main-controlled broker. This workflow runs only from refs/heads/main and
# hydrates only main, so no candidate branch can edit the guards that run
# before begin-testbox exposes its auth token, and no candidate build script
# executes inside this token-bearing job. A candidate revision reaches the
# Testbox later, through `blacksmith testbox run`, which synchronizes a
# maintainer's local worktree onto the already-warm VM.
on:
workflow_dispatch:
inputs:
testbox_id:
description: "Testbox session ID supplied by blacksmith testbox warmup"
required: true
type: string
permissions: {}
concurrency:
# A Testbox is a mutable shared workspace. Serialize every request for the
# same ID so two dispatches cannot corrupt one cache.
group: cmux-tui-testbox-${{ inputs.testbox_id }}
cancel-in-progress: false
jobs:
cmux-tui-rust:
name: cmux-tui Rust setup
runs-on: blacksmith-32vcpu-ubuntu-2404
environment:
# Configure this environment with required reviewers, no secrets, and a
# deployment branch rule of exactly `main`. Approval is evaluated before
# the first step, so it precedes begin-testbox.
name: blacksmith-testbox-trusted
permissions:
contents: read
# Hydration plus three sequential 20-minute bounded remote builds happen
# after setup. Keep the GitHub job alive long enough for all stages and
# cleanup; the Testbox itself still has its separate idle timeout.
timeout-minutes: 120
steps:
# Every check here is main-controlled. `blacksmith testbox warmup`
# resolves both this file and the hydrated source from the same --ref, so
# refusing any ref other than refs/heads/main is what keeps a candidate
# branch outside the trust boundary.
- name: Validate broker ref
env:
DISPATCH_REF: ${{ github.ref }}
EVENT_NAME: ${{ github.event_name }}
REPOSITORY: ${{ github.repository }}
TESTBOX_ID: ${{ inputs.testbox_id }}
shell: bash
run: |
set -euo pipefail
[[ "$REPOSITORY" == "manaflow-ai/cmux" ]] || {
echo "::error::this Testbox lane is only valid for manaflow-ai/cmux" >&2
exit 1
}
[[ "$EVENT_NAME" == "workflow_dispatch" ]] || {
echo "::error::Testbox setup must be dispatched manually, never from a PR event" >&2
exit 1
}
[[ "$DISPATCH_REF" == "refs/heads/main" ]] || {
echo "::error::this broker lane runs only from refs/heads/main, got $DISPATCH_REF; warm up with --ref main and sync the candidate through blacksmith testbox run" >&2
exit 1
}
[[ "$TESTBOX_ID" =~ ^tbx_[A-Za-z0-9_-]+$ ]] || {
echo "::error::malformed Testbox ID" >&2
exit 1
}
- name: Begin Testbox
uses: useblacksmith/begin-testbox@233448af4bfdc6fca509a7f0974411ac6d8a8043 # v2
with:
testbox_id: ${{ inputs.testbox_id }}
# main can move while a reviewer approves the deployment. Record which
# commit this job actually hydrates and fail closed if the dispatch ref
# changed under it.
- name: Revalidate broker identity after token exposure
env:
DISPATCH_REF: ${{ github.ref }}
DISPATCH_SHA: ${{ github.sha }}
shell: bash
run: |
set -euo pipefail
[[ "$DISPATCH_REF" == "refs/heads/main" ]] || {
echo "::error::broker ref changed during setup" >&2
exit 1
}
[[ "$DISPATCH_SHA" =~ ^[0-9a-f]{40}$ ]] || {
echo "::error::dispatch SHA is malformed" >&2
exit 1
}
printf 'hydrating main at %s\n' "$DISPATCH_SHA"
- name: Checkout hydration commit
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
persist-credentials: false
ref: ${{ github.sha }}
- name: Require exact checkout and clean source
env:
EXPECTED_SHA: ${{ github.sha }}
shell: bash
run: |
set -euo pipefail
actual_sha="$(git rev-parse HEAD)"
[[ "$actual_sha" == "$EXPECTED_SHA" ]] || {
echo "::error::checked out $actual_sha, expected hydration SHA $EXPECTED_SHA" >&2
exit 1
}
[[ -z "$(git status --porcelain=v1 --untracked-files=normal)" ]] || {
echo "::error::source checkout is dirty before hydration" >&2
git status --short >&2
exit 1
}
source_tree_sha="$(git rev-parse 'HEAD^{tree}')"
ghostty_entry="$(git ls-tree HEAD ghostty)"
[[ "$ghostty_entry" =~ ^160000[[:space:]]commit[[:space:]][0-9a-f]{40}[[:space:]]ghostty$ ]] || {
echo "::error::HEAD:ghostty is not a gitlink" >&2
exit 1
}
ghostty_gitlink_sha="$(git rev-parse 'HEAD:ghostty')"
printf 'source_sha=%s\nsource_tree_sha=%s\nghostty_gitlink_sha=%s\n' \
"$actual_sha" "$source_tree_sha" "$ghostty_gitlink_sha"
- name: Initialize Ghostty source submodule
shell: bash
run: |
set -euo pipefail
git submodule update --init --depth 1 ghostty
[[ "$(git -C ghostty rev-parse --show-toplevel)" == "$GITHUB_WORKSPACE/ghostty" ]] || {
echo "::error::ghostty did not initialize as its own submodule checkout" >&2
exit 1
}
ghostty_entry="$(git ls-tree HEAD ghostty)"
[[ "$ghostty_entry" =~ ^160000[[:space:]]commit[[:space:]][0-9a-f]{40}[[:space:]]ghostty$ ]] || {
echo "::error::HEAD:ghostty is not a gitlink" >&2
exit 1
}
expected_ghostty_sha="$(git rev-parse HEAD:ghostty)"
actual_ghostty_sha="$(git -C ghostty rev-parse HEAD)"
[[ "$actual_ghostty_sha" == "$expected_ghostty_sha" ]] || {
echo "::error::Ghostty checkout $actual_ghostty_sha does not match gitlink $expected_ghostty_sha" >&2
exit 1
}
[[ -z "$(git -C ghostty status --porcelain=v1 --untracked-files=normal)" ]] || {
echo "::error::Ghostty submodule is dirty after initialization" >&2
git -C ghostty status --short >&2
exit 1
}
test -f ghostty/build.zig.zon
- name: Install Linux build dependencies
shell: bash
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y clang libclang-dev pkg-config
- name: Cache Zig package downloads
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.cache/zig
key: cmux-tui-zig-${{ hashFiles('ghostty/build.zig.zon', 'ghostty/build.zig.zon.json') }}
restore-keys: |
cmux-tui-zig-
- name: Install repository-pinned Zig
shell: bash
run: ./scripts/install-zig-ci.sh
- name: Fetch Ghostty Zig dependencies without compiling
working-directory: ghostty
shell: bash
run: |
set -euo pipefail
# `--fetch` hydrates the package cache and exits before a build.
"$CMUX_ZIG" build --fetch
- name: Cache Cargo registry and git dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.cargo/registry
~/.cargo/git
key: cmux-tui-cargo-${{ runner.os }}-${{ hashFiles('cmux-tui/Cargo.lock', 'cmux-tui/rust-toolchain.toml') }}
restore-keys: |
cmux-tui-cargo-${{ runner.os }}-
- name: Set up repository-pinned cmux-tui Rust
uses: ./.github/actions/setup-cmux-tui-rust
- name: Fetch Cargo dependencies without compiling
working-directory: cmux-tui
shell: bash
run: |
set -euo pipefail
cargo fetch --locked
- name: Record runner, toolchain, and Ghostty identity
env:
SOURCE_SHA: ${{ github.sha }}
SOURCE_REF: ${{ github.ref }}
TESTBOX_ID: ${{ inputs.testbox_id }}
RUNNER_LABEL: blacksmith-32vcpu-ubuntu-2404
shell: bash
run: |
set -euo pipefail
source_tree_sha="$(git rev-parse 'HEAD^{tree}')"
ghostty_entry="$(git ls-tree HEAD ghostty)"
[[ "$ghostty_entry" =~ ^160000[[:space:]]commit[[:space:]][0-9a-f]{40}[[:space:]]ghostty$ ]] || exit 1
ghostty_gitlink_sha="$(git rev-parse 'HEAD:ghostty')"
ghostty_head_sha="$(git -C ghostty rev-parse HEAD)"
[[ "$SOURCE_SHA" == "$(git rev-parse HEAD)" ]] || exit 1
[[ "$ghostty_gitlink_sha" == "$ghostty_head_sha" ]] || exit 1
[[ -z "$(git status --porcelain=v1 --untracked-files=normal)" ]] || exit 1
[[ -z "$(git -C ghostty status --porcelain=v1 --untracked-files=normal)" ]] || exit 1
pushd cmux-tui >/dev/null
cargo metadata --locked --no-deps --format-version 1 > "$RUNNER_TEMP/cmux-tui-cargo-metadata.json"
test -s "$RUNNER_TEMP/cmux-tui-cargo-metadata.json"
RUST_TOOLCHAIN="$(rustup show active-toolchain)"
RUSTC_VERSION="$(rustc --version)"
CARGO_VERSION="$(cargo --version)"
popd >/dev/null
mkdir -p testbox-benchmark
SOURCE_TREE_SHA="$source_tree_sha"
GHOSTTY_GITLINK_SHA="$ghostty_gitlink_sha"
GHOSTTY_HEAD_SHA="$ghostty_head_sha"
ZIG_VERSION="$("$CMUX_ZIG" version)"
ZIG_PATH="$CMUX_ZIG"
RUNNER_UNAME="$(uname -a)"
RUNNER_CPU_COUNT="$(nproc)"
[[ "$RUNNER_ARCH" == "X64" && "$RUNNER_CPU_COUNT" == "32" ]] || {
echo "::error::expected x64 32-vCPU runner, got arch=$RUNNER_ARCH cpu_count=$RUNNER_CPU_COUNT" >&2
exit 1
}
CARGO_METADATA_SHA256="$(sha256sum "$RUNNER_TEMP/cmux-tui-cargo-metadata.json" | cut -d ' ' -f 1)"
RUST_TOOLCHAIN_FILE_SHA256="$(sha256sum cmux-tui/rust-toolchain.toml | cut -d ' ' -f 1)"
CARGO_LOCK_SHA256="$(sha256sum cmux-tui/Cargo.lock | cut -d ' ' -f 1)"
GHOSTTY_ZON_SHA256="$(sha256sum ghostty/build.zig.zon | cut -d ' ' -f 1)"
export SOURCE_TREE_SHA GHOSTTY_GITLINK_SHA GHOSTTY_HEAD_SHA RUST_TOOLCHAIN RUSTC_VERSION CARGO_VERSION ZIG_VERSION ZIG_PATH RUNNER_UNAME RUNNER_CPU_COUNT CARGO_METADATA_SHA256 RUST_TOOLCHAIN_FILE_SHA256 CARGO_LOCK_SHA256 GHOSTTY_ZON_SHA256
python3 - <<'PY' > testbox-benchmark/setup-identity.json
import json
import os
import platform
print(json.dumps({
"schema": 3,
"source": {
"ref": os.environ["SOURCE_REF"],
"commit_sha": os.environ["SOURCE_SHA"],
"tree_sha": os.environ["SOURCE_TREE_SHA"],
"ghostty_gitlink_sha": os.environ["GHOSTTY_GITLINK_SHA"],
"ghostty_head_sha": os.environ["GHOSTTY_HEAD_SHA"],
},
"broker": {
"workflow_ref": os.environ["GITHUB_REF"],
"workflow_sha": os.environ["SOURCE_SHA"],
},
"testbox": {
"id": os.environ["TESTBOX_ID"],
"setup_workflow_run_id": os.environ["GITHUB_RUN_ID"],
},
"runner": {
"label": os.environ["RUNNER_LABEL"],
"name": os.environ.get("RUNNER_NAME"),
"os": os.environ.get("RUNNER_OS"),
"arch": os.environ.get("RUNNER_ARCH"),
"hostname": platform.node(),
"uname": os.environ["RUNNER_UNAME"],
"cpu_count": int(os.environ["RUNNER_CPU_COUNT"]),
},
"toolchain": {
"rust_toolchain": os.environ["RUST_TOOLCHAIN"],
"rustc": os.environ["RUSTC_VERSION"],
"cargo": os.environ["CARGO_VERSION"],
"rust_toolchain_file_sha256": os.environ["RUST_TOOLCHAIN_FILE_SHA256"],
"cargo_lock_sha256": os.environ["CARGO_LOCK_SHA256"],
"cargo_metadata_sha256": os.environ["CARGO_METADATA_SHA256"],
"zig_path": os.environ["ZIG_PATH"],
"zig": os.environ["ZIG_VERSION"],
"ghostty_build_zig_zon_sha256": os.environ["GHOSTTY_ZON_SHA256"],
},
}, sort_keys=True, indent=2))
PY
test -s testbox-benchmark/setup-identity.json
cat testbox-benchmark/setup-identity.json
- name: Require clean hydrated source
shell: bash
run: |
set -euo pipefail
[[ -z "$(git status --porcelain=v1 --untracked-files=normal)" ]] || {
echo "::error::source became dirty during hydration" >&2
git status --short >&2
exit 1
}
[[ -z "$(git -C ghostty status --porcelain=v1 --untracked-files=normal)" ]] || {
echo "::error::Ghostty became dirty during hydration" >&2
git -C ghostty status --short >&2
exit 1
}
- name: Upload setup identity JSON
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: cmux-tui-testbox-setup-${{ github.run_id }}
path: testbox-benchmark/setup-identity.json
if-no-files-found: warn
retention-days: 14
# Missing `/tmp/.testbox` means begin-testbox could not register the VM.
# No auth token or API URL exists, so neither this workflow nor the pinned
# action can report hydration_failed. Fail explicitly; the CLI's bounded
# readiness wait and receipt-aware cleanup remain the only safe recovery.
- name: Require Testbox registration state
if: always()
shell: bash
run: |
set -euo pipefail
if [[ ! -d /tmp/.testbox ]]; then
echo "::error::begin-testbox produced no registration state; hydration failure cannot be reported from this runner" >&2
exit 1
fi
- name: Confirm Testbox registration and mark setup ready
if: success()
env:
EXPECTED_TESTBOX_ID: ${{ inputs.testbox_id }}
shell: bash
run: |
set -euo pipefail
state=/tmp/.testbox
rm -f /tmp/.testbox/cmux-tui-rust-setup-identity.json
for required_file in testbox_id installation_model_id auth_token api_url runner_host runner_ssh_port adopted_run_id ssh_public_key; do
test -s "$state/$required_file"
done
test -s "$state/working_directory"
test "$(<"$state/testbox_id")" = "$EXPECTED_TESTBOX_ID"
installation_model_id="$(<"$state/installation_model_id")"
[[ "$installation_model_id" =~ ^[0-9]+$ ]]
test -s "$state/ssh_public_key"
# begin-testbox deliberately continues after degraded phone-home.
# Validate the SSH handoff and install the identity marker before the
# pinned run-testbox action publishes ready. That action remains the
# sole readiness publisher, so a CLI run cannot race this marker.
marker="$state/cmux-tui-rust-setup-identity.json"
rm -f "$marker"
install -m 600 testbox-benchmark/setup-identity.json "$marker"
test -s "$marker"
# Keepalive is checked into this main-controlled workflow instead of
# importing an upstream composite that performs an unbounded duplicate
# ready curl. It reports
# hydration_failed on any failed setup or readiness path and bounds every
# phone-home request before entering its idle loop.
- name: Run trusted Testbox keepalive
if: always()
env:
JOB_STATUS: ${{ job.status }}
shell: bash
run: ./scripts/blacksmith-testbox-keepalive.sh
+619 -24
View File
@@ -63,6 +63,9 @@ jobs:
env:
# The ghostty CLI helper zig build is skipped; GhosttyKit comes prebuilt.
CMUX_SKIP_ZIG_BUILD: "1"
# A degraded cache must lose to a cold build instead of stalling this
# latency-sensitive lane for actions/cache's ten-minute default.
SEGMENT_DOWNLOAD_TIMEOUT_MINS: "2"
SWIFT_BACKTRACE: "interactive=no,timeout=0s,symbolicate=off,color=no"
steps:
- name: Checkout source ref
@@ -80,12 +83,333 @@ jobs:
set -euo pipefail
./scripts/select-ci-xcode.sh
- name: Install zig
run: ./scripts/install-zig-ci.sh
- name: Provision GhosttyKit (macOS)
- name: Prepare macOS cache metadata
if: ${{ inputs.platform == 'macos' }}
run: ./scripts/download-prebuilt-ghosttykit.sh || ./scripts/ensure-ghosttykit.sh
id: cache_meta
env:
SOURCE_REF: ${{ inputs.ref || github.ref_name }}
run: |
set -euo pipefail
xcode_version="$(xcodebuild -version | awk 'NR == 1 { print $2 }')"
xcode_build="$(xcodebuild -version | awk 'NR == 2 { print $3 }')"
xcode_key="$(printf 'xcode-%s-%s' "$xcode_version" "$xcode_build" | tr -c 'A-Za-z0-9._-' '-')"
branch_name="${SOURCE_REF#refs/heads/}"
branch_name="${branch_name#refs/tags/}"
if [ -z "$branch_name" ]; then
branch_name="$(git branch --show-current)"
fi
if [ -z "$branch_name" ]; then
branch_name="detached"
fi
branch_slug="$(printf '%s' "$branch_name" | tr -c 'A-Za-z0-9._-' '-' | sed 's/-\{2,\}/-/g; s/^-//; s/-$//')"
branch_digest="$(printf '%s' "$branch_name" | shasum -a 256 | awk '{ print substr($1, 1, 12) }')"
if [ "$branch_name" = "main" ]; then
branch_key="main"
else
branch_key="${branch_slug:0:48}-${branch_digest}"
fi
# Xcode's SourcePackages workspace state and DerivedData build
# database contain absolute checkout paths. Keep exact-state caches
# tied to the path contract; a different runner workspace must cold
# resolve/build instead of consuming stale absolute paths.
workspace_digest="$(printf '%s' "$GITHUB_WORKSPACE" | shasum -a 256 | awk '{ print substr($1, 1, 12) }')"
workspace_key="workspace-${workspace_digest}"
runner_os="$(printf '%s' "$(uname -s)" | tr -c 'A-Za-z0-9._-' '-')"
runner_os_version="$(sw_vers -productVersion 2>/dev/null || uname -r)"
runner_os_version="$(printf '%s' "$runner_os_version" | tr -c 'A-Za-z0-9._-' '-')"
runner_arch="$(printf '%s' "$(uname -m)" | tr -c 'A-Za-z0-9._-' '-')"
runner_key="${runner_os}-${runner_os_version}-${runner_arch}"
resolved_file="cmux.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved"
source_sha="$(git rev-parse HEAD)"
# Cache entries are immutable. A run-scoped save key plus a stable
# source prefix lets a cold retry publish a newer repaired entry.
cache_run_key="${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
spm_prefix="reload-build-macos-spm-v2-${xcode_key}-${workspace_key}-"
# DerivedData contains ONLY_ACTIVE_ARCH products and object files;
# never restore those across OS/architecture changes.
derived_prefix="reload-build-macos-deriveddata-v3-${xcode_key}-${runner_key}-${workspace_key}-"
if [ -f "$resolved_file" ]; then
resolved_hash="$(shasum -a 256 "$resolved_file" | awk '{ print $1 }')"
spm_cache_enabled=true
spm_key_base="${spm_prefix}${resolved_hash}"
spm_key="${spm_key_base}-run-${cache_run_key}"
spm_source_prefix="${spm_key_base}-run-"
else
echo "::warning::$resolved_file absent in source ref; disabling the SPM cache"
spm_cache_enabled=false
spm_key_base=""
spm_key=""
spm_source_prefix=""
fi
derived_data_key_base="${derived_prefix}${branch_key}-${source_sha}"
{
echo "xcode_key=$xcode_key"
echo "branch_key=$branch_key"
echo "workspace_key=$workspace_key"
echo "runner_key=$runner_key"
echo "source_sha=$source_sha"
echo "spm_cache_enabled=$spm_cache_enabled"
echo "spm_prefix=$spm_prefix"
echo "spm_key_base=$spm_key_base"
echo "spm_key=$spm_key"
echo "spm_source_prefix=$spm_source_prefix"
echo "derived_data_key_base=$derived_data_key_base"
echo "derived_data_key=${derived_data_key_base}-run-${cache_run_key}"
echo "derived_data_source_prefix=${derived_data_key_base}-run-"
echo "derived_data_branch_prefix=${derived_prefix}${branch_key}-"
echo "derived_data_main_prefix=${derived_prefix}main-"
} >> "$GITHUB_OUTPUT"
# The verified prebuilt is the happy path for both platforms. Zig is
# installed only after this attempt fails, immediately before the local
# ensure-ghosttykit.sh fallback that needs it.
- name: Download prebuilt GhosttyKit
id: ghosttykit_prebuilt
run: |
set -euo pipefail
start=$(date +%s)
# Keep a transient release outage from consuming the whole 60-minute
# job before the Zig-backed fallback gets a chance to run. The script
# still owns checksum/archive validation and preserves its normal
# defaults for local and fleet callers.
if GHOSTTYKIT_DOWNLOAD_RETRIES=2 \
GHOSTTYKIT_DOWNLOAD_RETRY_DELAY=5 \
GHOSTTYKIT_DOWNLOAD_MAX_TIME=60 \
./scripts/download-prebuilt-ghosttykit.sh; then
available=true
else
available=false
echo "::warning::Prebuilt GhosttyKit download failed; enabling the Zig-backed fallback"
fi
end=$(date +%s)
echo "available=$available" >> "$GITHUB_OUTPUT"
echo "seconds=$(( end - start ))" >> "$GITHUB_OUTPUT"
- name: Install zig for GhosttyKit fallback
if: ${{ steps.ghosttykit_prebuilt.outputs.available != 'true' }}
id: zig_fallback
run: |
set -euo pipefail
start=$(date +%s)
./scripts/install-zig-ci.sh
end=$(date +%s)
seconds="$(awk -v start="$start" -v end="$end" 'BEGIN { print end - start }')"
echo "seconds=$seconds" >> "$GITHUB_OUTPUT"
- name: Build fallback GhosttyKit
if: ${{ steps.ghosttykit_prebuilt.outputs.available != 'true' }}
id: ghosttykit_fallback
env:
# The bounded download step already proved the prebuilt path failed;
# do not let ensure-ghosttykit retry that URL with its long defaults.
CMUX_GHOSTTYKIT_NO_PREBUILT: "1"
run: |
set -euo pipefail
start=$(date +%s)
./scripts/ensure-ghosttykit.sh
end=$(date +%s)
echo "seconds=$(( end - start ))" >> "$GITHUB_OUTPUT"
- name: Verify provisioned GhosttyKit
run: test -d GhosttyKit.xcframework
- name: Start SPM cache restore timer
if: ${{ inputs.platform == 'macos' && steps.cache_meta.outputs.spm_cache_enabled == 'true' }}
id: spm_restore_start
run: echo "epoch=$(date +%s)" >> "$GITHUB_OUTPUT"
- name: Restore SPM SourcePackages cache
if: ${{ inputs.platform == 'macos' && steps.cache_meta.outputs.spm_cache_enabled == 'true' }}
id: restore_spm
continue-on-error: true
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: .ci-source-packages
key: ${{ steps.cache_meta.outputs.spm_key }}
restore-keys: ${{ steps.cache_meta.outputs.spm_source_prefix }}
- name: Finish SPM cache restore
if: ${{ always() && inputs.platform == 'macos' }}
id: spm_restore
env:
CACHE_ENABLED: ${{ steps.cache_meta.outputs.spm_cache_enabled }}
CACHE_SOURCE_PREFIX: ${{ steps.cache_meta.outputs.spm_source_prefix }}
START_EPOCH: ${{ steps.spm_restore_start.outputs.epoch }}
CACHE_OUTCOME: ${{ steps.restore_spm.outcome }}
CACHE_HIT: ${{ steps.restore_spm.outputs.cache-hit }}
CACHE_MATCHED_KEY: ${{ steps.restore_spm.outputs.cache-matched-key }}
run: |
set -euo pipefail
now=$(date +%s)
start=${START_EPOCH:-0}
if ! [[ "$start" =~ ^[0-9]+$ ]] || [ "$start" -eq 0 ]; then
start=$now
fi
cache_hit=${CACHE_HIT:-false}
matched_key=${CACHE_MATCHED_KEY:-}
exact_hit=false
if [ "${CACHE_ENABLED:-false}" != "true" ]; then
status=disabled_missing_resolved
rm -rf "$GITHUB_WORKSPACE/.ci-source-packages"
elif [ "${CACHE_OUTCOME:-failure}" != "success" ]; then
status=restore_error
rm -rf "$GITHUB_WORKSPACE/.ci-source-packages"
elif [ "$cache_hit" = "true" ] || { [ -n "${CACHE_SOURCE_PREFIX:-}" ] && [[ "$matched_key" == "$CACHE_SOURCE_PREFIX"* ]]; }; then
status=exact_hit
exact_hit=true
elif [ -n "$matched_key" ]; then
status=fallback_hit
else
status=miss
fi
mkdir -p "$GITHUB_WORKSPACE/.ci-source-packages"
{
echo "seconds=$(( now - start ))"
echo "status=$status"
echo "exact_hit=$exact_hit"
echo "matched_key=$matched_key"
} >> "$GITHUB_OUTPUT"
- name: Sanitize restored SPM cache
if: ${{ inputs.platform == 'macos' }}
id: spm_sanitize
env:
CACHE_ENABLED: ${{ steps.cache_meta.outputs.spm_cache_enabled }}
CACHE_STATUS: ${{ steps.spm_restore.outputs.status }}
run: |
set -euo pipefail
status=${CACHE_STATUS:-restore_error}
discarded=false
sanitizer=scripts/ci/sanitize-xcode-source-packages-cache.py
if [ "${CACHE_ENABLED:-false}" != "true" ]; then
status=disabled_missing_resolved
elif [ "$status" = "exact_hit" ]; then
# The exact key is tied to Package.resolved, Xcode, source SHA, and
# the fixed Blacksmith checkout path. Preserve its workspace state
# so Xcode can reuse the resolved package graph; a bad state still
# trips the guarded cold retry below.
echo "preserving exact-hit SourcePackages workspace state"
elif [ ! -f "$sanitizer" ]; then
echo "::warning::$sanitizer absent in source ref; discarding the restored SPM cache"
status=sanitize_error
discarded=true
elif ! python3 "$sanitizer" .ci-source-packages; then
echo "::warning::SPM cache sanitize failed; discarding it and building cold"
status=sanitize_error
discarded=true
fi
if [ "$discarded" = "true" ]; then
rm -rf "$GITHUB_WORKSPACE/.ci-source-packages"
mkdir -p "$GITHUB_WORKSPACE/.ci-source-packages"
fi
echo "status=$status" >> "$GITHUB_OUTPUT"
echo "discarded=$discarded" >> "$GITHUB_OUTPUT"
- name: Start DerivedData cache restore timer
if: ${{ inputs.platform == 'macos' }}
id: derived_data_restore_start
run: echo "epoch=$(date +%s)" >> "$GITHUB_OUTPUT"
- name: Restore DerivedData cache
if: ${{ inputs.platform == 'macos' }}
id: restore_derived_data
continue-on-error: true
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
# Keep the build database, compiler outputs, and Debug products that
# Xcode needs for a true incremental build. Archives, indexes, logs,
# SourcePackages, and every non-Debug product remain excluded.
path: |
.ci-reload-derived-data/Build/Intermediates.noindex
.ci-reload-derived-data/Build/Products/Debug
.ci-reload-derived-data/ModuleCache.noindex
.ci-reload-derived-data/SDKStatCaches.noindex
key: ${{ steps.cache_meta.outputs.derived_data_key }}
restore-keys: |
${{ steps.cache_meta.outputs.derived_data_source_prefix }}
${{ steps.cache_meta.outputs.derived_data_branch_prefix }}
${{ steps.cache_meta.outputs.derived_data_main_prefix }}
- name: Finish DerivedData cache restore
if: ${{ always() && inputs.platform == 'macos' }}
id: derived_data_restore
env:
CACHE_SOURCE_PREFIX: ${{ steps.cache_meta.outputs.derived_data_source_prefix }}
START_EPOCH: ${{ steps.derived_data_restore_start.outputs.epoch }}
CACHE_OUTCOME: ${{ steps.restore_derived_data.outcome }}
CACHE_HIT: ${{ steps.restore_derived_data.outputs.cache-hit }}
CACHE_MATCHED_KEY: ${{ steps.restore_derived_data.outputs.cache-matched-key }}
run: |
set -euo pipefail
now=$(date +%s)
start=${START_EPOCH:-0}
if ! [[ "$start" =~ ^[0-9]+$ ]] || [ "$start" -eq 0 ]; then
start=$now
fi
cache_hit=${CACHE_HIT:-false}
matched_key=${CACHE_MATCHED_KEY:-}
exact_hit=false
if [ "${CACHE_OUTCOME:-failure}" != "success" ]; then
status=restore_error
rm -rf "$GITHUB_WORKSPACE/.ci-reload-derived-data"
elif [ "$cache_hit" = "true" ] || { [ -n "${CACHE_SOURCE_PREFIX:-}" ] && [[ "$matched_key" == "$CACHE_SOURCE_PREFIX"* ]]; }; then
status=exact_hit
exact_hit=true
elif [ -n "$matched_key" ]; then
status=fallback_hit
else
status=miss
fi
mkdir -p "$GITHUB_WORKSPACE/.ci-reload-derived-data"
{
echo "seconds=$(( now - start ))"
echo "status=$status"
echo "exact_hit=$exact_hit"
echo "matched_key=$matched_key"
} >> "$GITHUB_OUTPUT"
# actions/checkout gives every tracked source a fresh mtime, newer than
# restored compiler outputs. Xcode then recompiles an exact cache hit as
# if it were cold. Derive a stable, pre-2020 mtime from each Git blob id:
# unchanged files stay unchanged across runs, while changed blobs receive
# a different file signature and are still invalidated correctly.
- name: Normalize tracked source mtimes for Xcode
if: ${{ inputs.platform == 'macos' }}
run: |
set -euo pipefail
python3 - <<'PY'
import os
import subprocess
# 2001-01-01 plus at most fifteen years keeps source mtimes safely
# behind newly generated outputs while preserving nanosecond entropy.
base_seconds = 978_307_200
span_seconds = 15 * 365 * 24 * 60 * 60
records = subprocess.check_output(
["git", "ls-files", "--recurse-submodules", "--stage", "-z"]
).split(b"\0")
normalized = 0
for record in records:
if not record:
continue
metadata, raw_path = record.split(b"\t", 1)
mode, object_id, stage = metadata.split()
if mode == b"160000" or stage != b"0":
continue
path = os.fsdecode(raw_path)
if not os.path.lexists(path):
continue
digest = object_id.decode("ascii")
seconds = base_seconds + int(digest[:12], 16) % span_seconds
nanoseconds = int(digest[12:20], 16) % 1_000_000_000
mtime_ns = seconds * 1_000_000_000 + nanoseconds
os.utime(path, ns=(mtime_ns, mtime_ns), follow_symlinks=False)
normalized += 1
print(f"normalized {normalized} Git-tracked source mtimes")
PY
- name: Mark deps-ready
id: t1
@@ -95,12 +419,64 @@ jobs:
- name: Build tagged macOS app
if: ${{ inputs.platform == 'macos' }}
id: build_macos
env:
BUILD_TAG: ${{ inputs.tag }}
CMUX_GHOSTTYKIT_PREPROVISIONED: "1"
CMUX_SOURCE_PACKAGES_DIR: ${{ github.workspace }}/.ci-source-packages
SPM_CACHE_STATUS: ${{ steps.spm_sanitize.outputs.status }}
DERIVED_DATA_CACHE_STATUS: ${{ steps.derived_data_restore.outputs.status }}
run: |
set -euo pipefail
# Per-run log path: /tmp/reload.log collides across tenants on the
# multi-tenant self-hosted fleet Macs (tee: Permission denied).
log="$RUNNER_TEMP/reload.log"
./scripts/reload.sh --tag "${{ inputs.tag }}" --swift-frontend-workaround 2>&1 | tee "$log"
derived_data="$GITHUB_WORKSPACE/.ci-reload-derived-data"
echo "cache_retry=false" >> "$GITHUB_OUTPUT"
run_reload() {
if [ "$SPM_CACHE_STATUS" = "exact_hit" ]; then
# The exact Package.resolved/state pair is safe to reuse. If it
# is stale or corrupt, the caller clears both caches and changes
# this marker before retrying from a normal cold resolve.
export CMUX_DISABLE_AUTOMATIC_PACKAGE_RESOLUTION=1
else
export CMUX_DISABLE_AUTOMATIC_PACKAGE_RESOLUTION=0
fi
./scripts/reload.sh \
--tag "$BUILD_TAG" \
--derived-data "$derived_data" \
--swift-frontend-workaround
}
set +e
run_reload 2>&1 | tee "$log"
build_status=${PIPESTATUS[0]}
set -e
# A successfully restored but stale/corrupt cache must cost at most
# one failed attempt: clear both cache roots and retry once from cold.
if [ "$build_status" -ne 0 ] && {
[ "$SPM_CACHE_STATUS" = "exact_hit" ] ||
[ "$SPM_CACHE_STATUS" = "fallback_hit" ] ||
[ "$DERIVED_DATA_CACHE_STATUS" = "exact_hit" ] ||
[ "$DERIVED_DATA_CACHE_STATUS" = "fallback_hit" ];
}; then
echo "::warning::Cached macOS build failed; clearing SPM and DerivedData caches and retrying cold"
rm -rf "$CMUX_SOURCE_PACKAGES_DIR" "$derived_data"
mkdir -p "$CMUX_SOURCE_PACKAGES_DIR" "$derived_data"
SPM_CACHE_STATUS=miss
DERIVED_DATA_CACHE_STATUS=miss
: > "$log"
set +e
run_reload 2>&1 | tee "$log"
build_status=${PIPESTATUS[0]}
set -e
echo "cache_retry=true" >> "$GITHUB_OUTPUT"
fi
if [ "$build_status" -ne 0 ]; then
exit "$build_status"
fi
app_path="$(awk '/^App path:/{getline; sub(/^ /,""); print; exit}' "$log")"
[ -n "$app_path" ] && [ -d "$app_path" ] || { echo "could not locate built app" >&2; exit 1; }
echo "app_path=$app_path" >> "$GITHUB_OUTPUT"
@@ -147,6 +523,7 @@ jobs:
# Match ios/scripts/reload.sh --swift-frontend-workaround. Optimized
# Debug builds need this Xcode 26 codegen path disabled on every lane.
# shellcheck disable=SC2016 # Xcode expands $(inherited), not this shell.
swift_workaround_args=(
SWIFT_ENABLE_BATCH_MODE=NO
DEBUG_INFORMATION_FORMAT=
@@ -162,7 +539,7 @@ jobs:
ios_ready || { echo "iOS platform still not registered; archive would fail" >&2; exit 1; }
fi
./scripts/ensure-ghosttykit.sh
test -d GhosttyKit.xcframework
out="$GITHUB_WORKSPACE/build"
mkdir -p "$out"
archive="$out/cmux-ios-$slug.xcarchive"
@@ -227,36 +604,254 @@ jobs:
ditto "$sim_app" "$pkg/simulator/cmux.app"
ditto -c -k "$pkg" "$GITHUB_WORKSPACE/artifact/archive.zip"
- name: Mark build-ready
id: t2
run: echo "epoch=$(date +%s)" >> "$GITHUB_OUTPUT"
- name: Trim tag-specific products before DerivedData cache save
if: ${{ success() && inputs.platform == 'macos' }}
run: |
set -euo pipefail
products="$GITHUB_WORKSPACE/.ci-reload-derived-data/Build/Products/Debug"
[ -d "$products" ] || exit 0
# reload.sh stages the requested tagged app beside the stable base
# product. Keep only the base product in the cache; otherwise every
# tag accumulates another app bundle under the branch/SHA key.
for product in "$products"/cmux\ DEV\ *.app "$products"/.cmux\ DEV\ *.reload-*.app; do
[ -d "$product" ] || continue
[ "$(basename "$product")" = "cmux DEV.app" ] || rm -rf -- "$product"
done
- name: Start SPM cache save timer
if: ${{ success() && inputs.platform == 'macos' && steps.cache_meta.outputs.spm_cache_enabled == 'true' && (steps.spm_restore.outputs.exact_hit != 'true' || steps.spm_sanitize.outputs.discarded == 'true' || steps.build_macos.outputs.cache_retry == 'true') }}
id: spm_save_start
run: echo "epoch=$(date +%s)" >> "$GITHUB_OUTPUT"
- name: Save SPM SourcePackages cache
if: ${{ success() && inputs.platform == 'macos' && steps.cache_meta.outputs.spm_cache_enabled == 'true' && (steps.spm_restore.outputs.exact_hit != 'true' || steps.spm_sanitize.outputs.discarded == 'true' || steps.build_macos.outputs.cache_retry == 'true') }}
id: save_spm
continue-on-error: true
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: .ci-source-packages
key: ${{ steps.cache_meta.outputs.spm_key }}
- name: Finish SPM cache save
if: ${{ always() && inputs.platform == 'macos' }}
id: spm_save
env:
CACHE_ENABLED: ${{ steps.cache_meta.outputs.spm_cache_enabled }}
START_EPOCH: ${{ steps.spm_save_start.outputs.epoch }}
SAVE_OUTCOME: ${{ steps.save_spm.outcome }}
RESTORE_EXACT_HIT: ${{ steps.spm_restore.outputs.exact_hit }}
run: |
set -euo pipefail
now=$(date +%s)
start=${START_EPOCH:-0}
if [[ "$start" =~ ^[0-9]+$ ]] && [ "$start" -gt 0 ]; then
seconds=$(( now - start ))
else
seconds=0
fi
case "${SAVE_OUTCOME:-skipped}" in
success) status=saved ;;
failure) status=save_error ;;
*)
if [ "${CACHE_ENABLED:-false}" != "true" ]; then
status=skipped_disabled
elif [ "${RESTORE_EXACT_HIT:-false}" = "true" ]; then
status=skipped_exact_hit
else
status=skipped
fi
;;
esac
echo "seconds=$seconds" >> "$GITHUB_OUTPUT"
echo "status=$status" >> "$GITHUB_OUTPUT"
- name: Start DerivedData cache save timer
if: ${{ success() && inputs.platform == 'macos' && (steps.derived_data_restore.outputs.exact_hit != 'true' || steps.build_macos.outputs.cache_retry == 'true') }}
id: derived_data_save_start
run: echo "epoch=$(date +%s)" >> "$GITHUB_OUTPUT"
- name: Save DerivedData cache
if: ${{ success() && inputs.platform == 'macos' && (steps.derived_data_restore.outputs.exact_hit != 'true' || steps.build_macos.outputs.cache_retry == 'true') }}
id: save_derived_data
continue-on-error: true
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
# Match the bounded restore set; only Debug products are included.
path: |
.ci-reload-derived-data/Build/Intermediates.noindex
.ci-reload-derived-data/Build/Products/Debug
.ci-reload-derived-data/ModuleCache.noindex
.ci-reload-derived-data/SDKStatCaches.noindex
key: ${{ steps.cache_meta.outputs.derived_data_key }}
- name: Finish DerivedData cache save
if: ${{ always() && inputs.platform == 'macos' }}
id: derived_data_save
env:
START_EPOCH: ${{ steps.derived_data_save_start.outputs.epoch }}
SAVE_OUTCOME: ${{ steps.save_derived_data.outcome }}
RESTORE_EXACT_HIT: ${{ steps.derived_data_restore.outputs.exact_hit }}
run: |
set -euo pipefail
now=$(date +%s)
start=${START_EPOCH:-0}
if [[ "$start" =~ ^[0-9]+$ ]] && [ "$start" -gt 0 ]; then
seconds=$(( now - start ))
else
seconds=0
fi
case "${SAVE_OUTCOME:-skipped}" in
success) status=saved ;;
failure) status=save_error ;;
*)
if [ "${RESTORE_EXACT_HIT:-false}" = "true" ]; then
status=skipped_exact_hit
else
status=skipped
fi
;;
esac
echo "seconds=$seconds" >> "$GITHUB_OUTPUT"
echo "status=$status" >> "$GITHUB_OUTPUT"
- name: Write timings.json
if: ${{ always() }}
env:
TIMING_TAG: ${{ inputs.tag }}
TIMING_PLATFORM: ${{ inputs.platform }}
TIMING_RUNNER: ${{ inputs.runner }}
TIMING_REF: ${{ inputs.ref || github.ref }}
TIMING_T0: ${{ steps.t0.outputs.epoch || '0' }}
TIMING_T1: ${{ steps.t1.outputs.epoch || '0' }}
TIMING_T2: ${{ steps.t2.outputs.epoch || '0' }}
GHOSTTYKIT_PREBUILT: ${{ steps.ghosttykit_prebuilt.outputs.available || 'false' }}
GHOSTTYKIT_DOWNLOAD_SECONDS: ${{ steps.ghosttykit_prebuilt.outputs.seconds || '0' }}
ZIG_INSTALL_SECONDS: ${{ steps.zig_fallback.outputs.seconds || '0' }}
GHOSTTYKIT_FALLBACK_SECONDS: ${{ steps.ghosttykit_fallback.outputs.seconds || '0' }}
CACHE_XCODE_KEY: ${{ steps.cache_meta.outputs.xcode_key }}
CACHE_BRANCH_KEY: ${{ steps.cache_meta.outputs.branch_key }}
CACHE_WORKSPACE_KEY: ${{ steps.cache_meta.outputs.workspace_key }}
CACHE_RUNNER_KEY: ${{ steps.cache_meta.outputs.runner_key }}
SPM_CACHE_PRIMARY_KEY: ${{ steps.cache_meta.outputs.spm_key }}
SPM_CACHE_STATUS: ${{ steps.spm_sanitize.outputs.status || 'not_applicable' }}
SPM_CACHE_EXACT_HIT: ${{ steps.spm_sanitize.outputs.status == 'exact_hit' }}
SPM_CACHE_MATCHED_KEY: ${{ steps.spm_restore.outputs.matched_key }}
SPM_CACHE_RESTORE_OUTCOME: ${{ steps.restore_spm.outcome || 'not_applicable' }}
SPM_CACHE_RESTORE_SECONDS: ${{ steps.spm_restore.outputs.seconds || '0' }}
SPM_CACHE_SAVE_STATUS: ${{ steps.spm_save.outputs.status || 'not_applicable' }}
SPM_CACHE_SAVE_SECONDS: ${{ steps.spm_save.outputs.seconds || '0' }}
DERIVED_DATA_CACHE_PRIMARY_KEY: ${{ steps.cache_meta.outputs.derived_data_key }}
DERIVED_DATA_CACHE_STATUS: ${{ steps.derived_data_restore.outputs.status || 'not_applicable' }}
DERIVED_DATA_CACHE_EXACT_HIT: ${{ steps.derived_data_restore.outputs.exact_hit || 'false' }}
DERIVED_DATA_CACHE_MATCHED_KEY: ${{ steps.derived_data_restore.outputs.matched_key }}
DERIVED_DATA_CACHE_RESTORE_OUTCOME: ${{ steps.restore_derived_data.outcome || 'not_applicable' }}
DERIVED_DATA_CACHE_RESTORE_SECONDS: ${{ steps.derived_data_restore.outputs.seconds || '0' }}
DERIVED_DATA_CACHE_SAVE_STATUS: ${{ steps.derived_data_save.outputs.status || 'not_applicable' }}
DERIVED_DATA_CACHE_SAVE_SECONDS: ${{ steps.derived_data_save.outputs.seconds || '0' }}
CACHE_COLD_RETRY: ${{ steps.build_macos.outputs.cache_retry || 'false' }}
run: |
set -euo pipefail
mkdir -p artifact
now=$(date +%s)
t0=${{ steps.t0.outputs.epoch }}
t1=${{ steps.t1.outputs.epoch || 0 }}
cat > artifact/timings.json <<JSON
{
"tag": "${{ inputs.tag }}",
"platform": "${{ inputs.platform }}",
"runner": "${{ inputs.runner }}",
"ref": "${{ inputs.ref }}",
"deps_seconds": $(( t1 > t0 ? t1 - t0 : 0 )),
"build_seconds": $(( t1 > 0 ? now - t1 : 0 )),
"post_checkout_total_seconds": $(( now - t0 ))
export TIMING_NOW=$now
python3 - <<'PY' > artifact/timings.json
import json
import os
import sys
def integer(name: str) -> int:
try:
return int(os.environ.get(name, "0"))
except ValueError:
return 0
def boolean(name: str) -> bool:
return os.environ.get(name, "false").lower() == "true"
t0 = integer("TIMING_T0")
t1 = integer("TIMING_T1")
t2 = integer("TIMING_T2")
now = integer("TIMING_NOW")
timing_clock_valid = t0 > 0
spm_status = os.environ["SPM_CACHE_STATUS"]
derived_status = os.environ["DERIVED_DATA_CACHE_STATUS"]
payload = {
"tag": os.environ["TIMING_TAG"],
"platform": os.environ["TIMING_PLATFORM"],
"runner": os.environ["TIMING_RUNNER"],
"ref": os.environ["TIMING_REF"],
"deps_seconds": t1 - t0 if timing_clock_valid and t1 > t0 else 0,
"build_seconds": t2 - t1 if timing_clock_valid and t1 > t0 and t2 > t1 else 0,
"post_checkout_total_seconds": now - t0 if timing_clock_valid and now > t0 else 0,
"ghosttykit_prebuilt": boolean("GHOSTTYKIT_PREBUILT"),
"ghosttykit_download_seconds": integer("GHOSTTYKIT_DOWNLOAD_SECONDS"),
"zig_install_seconds": integer("ZIG_INSTALL_SECONDS"),
"ghosttykit_fallback_seconds": integer("GHOSTTYKIT_FALLBACK_SECONDS"),
"cache_xcode_key": os.environ.get("CACHE_XCODE_KEY", ""),
"cache_branch_key": os.environ.get("CACHE_BRANCH_KEY", ""),
"cache_workspace_key": os.environ.get("CACHE_WORKSPACE_KEY", ""),
"cache_runner_key": os.environ.get("CACHE_RUNNER_KEY", ""),
"spm_cache_primary_key": os.environ.get("SPM_CACHE_PRIMARY_KEY", ""),
"spm_cache_status": spm_status,
"spm_cache_hit": spm_status in {"exact_hit", "fallback_hit"},
"spm_cache_exact_hit": boolean("SPM_CACHE_EXACT_HIT"),
"spm_cache_matched_key": os.environ.get("SPM_CACHE_MATCHED_KEY", ""),
"spm_cache_restore_outcome": os.environ["SPM_CACHE_RESTORE_OUTCOME"],
"spm_cache_restore_seconds": integer("SPM_CACHE_RESTORE_SECONDS"),
"spm_cache_save_status": os.environ["SPM_CACHE_SAVE_STATUS"],
"spm_cache_save_seconds": integer("SPM_CACHE_SAVE_SECONDS"),
"derived_data_cache_primary_key": os.environ.get("DERIVED_DATA_CACHE_PRIMARY_KEY", ""),
"derived_data_cache_status": derived_status,
"derived_data_cache_hit": derived_status in {"exact_hit", "fallback_hit"},
"derived_data_cache_exact_hit": boolean("DERIVED_DATA_CACHE_EXACT_HIT"),
"derived_data_cache_matched_key": os.environ.get("DERIVED_DATA_CACHE_MATCHED_KEY", ""),
"derived_data_cache_restore_outcome": os.environ["DERIVED_DATA_CACHE_RESTORE_OUTCOME"],
"derived_data_cache_restore_seconds": integer("DERIVED_DATA_CACHE_RESTORE_SECONDS"),
"derived_data_cache_save_status": os.environ["DERIVED_DATA_CACHE_SAVE_STATUS"],
"derived_data_cache_save_seconds": integer("DERIVED_DATA_CACHE_SAVE_SECONDS"),
"cache_cold_retry": boolean("CACHE_COLD_RETRY"),
}
JSON
json.dump(payload, fp=sys.stdout, indent=2, sort_keys=True)
print()
PY
t0=${TIMING_T0:-0}
t1=${TIMING_T1:-0}
t2=${TIMING_T2:-0}
if (( t0 > 0 && t1 > t0 )); then
deps_seconds=$(( t1 - t0 ))
else
deps_seconds=0
fi
if (( t0 > 0 && t1 > t0 && t2 > t1 )); then
build_seconds=$(( t2 - t1 ))
else
build_seconds=0
fi
if (( t0 > 0 && now > t0 )); then
total_seconds=$(( now - t0 ))
else
total_seconds=0
fi
{
echo "### reload-build timings"
echo ""
echo "- runner: \`${{ inputs.runner }}\`"
echo "- platform: \`${{ inputs.platform }}\`"
echo "- deps: $(( t1 > t0 ? t1 - t0 : 0 ))s"
echo "- build: $(( t1 > 0 ? now - t1 : 0 ))s"
echo "- post-checkout total: $(( now - t0 ))s"
echo "- runner: \`$TIMING_RUNNER\`"
echo "- platform: \`$TIMING_PLATFORM\`"
echo "- deps: ${deps_seconds}s"
echo "- build: ${build_seconds}s"
echo "- SPM cache: ${SPM_CACHE_STATUS} (restore ${SPM_CACHE_RESTORE_SECONDS}s, save ${SPM_CACHE_SAVE_SECONDS}s / ${SPM_CACHE_SAVE_STATUS})"
echo "- DerivedData cache: ${DERIVED_DATA_CACHE_STATUS} (restore ${DERIVED_DATA_CACHE_RESTORE_SECONDS}s, save ${DERIVED_DATA_CACHE_SAVE_SECONDS}s / ${DERIVED_DATA_CACHE_SAVE_STATUS})"
echo "- cache-triggered cold retry: ${CACHE_COLD_RETRY}"
echo "- GhosttyKit prebuilt: ${GHOSTTYKIT_PREBUILT} (download ${GHOSTTYKIT_DOWNLOAD_SECONDS}s, Zig fallback ${ZIG_INSTALL_SECONDS}s)"
echo "- post-checkout total: ${total_seconds}s"
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload artifact
if: ${{ always() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: reload-${{ inputs.tag }}-${{ inputs.platform }}
+3
View File
@@ -379,6 +379,9 @@ jobs:
-derivedDataPath "$IOS_DERIVED_DATA"
)
if [ -n "${TEST_FILTER:-}" ]; then
if [[ "$TEST_FILTER" == cmuxUITests/* ]]; then
XCODEBUILD_ARGS+=(-testPlan cmux-ui)
fi
XCODEBUILD_ARGS+=(-only-testing:"$TEST_FILTER")
else
# Full UI tests currently exceed the pull-request simulator budget
@@ -0,0 +1,62 @@
name: Testbox broker guard
# The main CI suite is dispatch-only right now, so the trust-boundary guard for
# the Blacksmith Testbox lane gets its own always-on workflow. It must run on
# every pull request, with no path filter: a path filter is exactly the thing a
# change that moves the guard could slip past.
on:
pull_request:
push:
branches:
- main
permissions: {}
concurrency:
group: testbox-broker-guard-${{ github.ref }}
cancel-in-progress: true
jobs:
guard:
name: Testbox broker trust boundary
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 10
permissions:
contents: read
steps:
- name: Checkout
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"
- name: Install guard dependencies
run: python3 -m pip install --disable-pip-version-check --no-input PyYAML==6.0.3
- name: Validate Blacksmith Testbox broker trust boundary
run: python3 tests/test_ci_testbox_broker_guard.py
- name: Validate receipt-bound cleanup ownership check
run: ./tests/test_testbox_cleanup_receipt_ref.sh
- name: Lint the Testbox lane helpers
run: shellcheck scripts/blacksmith-bounded-command.sh scripts/blacksmith-cmux-tui-testbox-stage.sh scripts/blacksmith-testbox-cleanup.sh scripts/blacksmith-testbox-keepalive.sh
- name: Lint the Testbox warmup workflow
env:
ACTIONLINT_VERSION: "1.7.7"
ACTIONLINT_SHA256: "023070a287cd8cccd71515fedc843f1985bf96c436b7effaecce67290e7e0757"
run: |
set -euo pipefail
archive="$RUNNER_TEMP/actionlint.tar.gz"
curl -fsSL -o "$archive" \
"https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz"
echo "${ACTIONLINT_SHA256} ${archive}" | sha256sum --check --strict
tar -xzf "$archive" -C "$RUNNER_TEMP" actionlint
"$RUNNER_TEMP/actionlint" \
.github/workflows/cmux-tui-testbox-warmup.yml \
.github/workflows/testbox-broker-guard.yml
+5
View File
@@ -18,6 +18,7 @@ ios/Config/AppStoreConnect.local.plist
# Swift Package Manager
.swiftpm/
.ci-source-packages/
.ci-reload-derived-data/
# GhosttyKit binary (built from ghostty submodule via scripts/setup.sh)
GhosttyKit.xcframework
@@ -73,3 +74,7 @@ artifacts/
# tmux verbose debug logs (tmux -v) that land in the cwd
tmux-*.log
# Remote-only Blacksmith cmux-tui benchmark output
/testbox-benchmark/
/.cmux-scratch/
+8
View File
@@ -17,6 +17,10 @@ A tag gives the app its own name, bundle ID, socket, and derived data path, so i
Other variants: `reloadp.sh` (Release), `reloads.sh` (Release as isolated "cmux STAGING"), `reload2.sh --tag <tag>` (both).
## Shared Mac fleet capacity
Every healthy slot in the canonical Mac fleet is general-purpose. Builds, iOS archives, tests, profiling, simulator and UI verification, and any other resource-intensive workload may use any available slot. Do not wait for an AWS-only builder or infer capacity from a workload label. Use the shared lease state and slot-isolated paths supplied by the fleet tooling.
Compile-only check, no launch:
```bash
@@ -127,3 +131,7 @@ Detailed contributor rules live in `skills/`. Use the task-specific skill before
- `cmux-shared-behavior`: shared action paths for multi-entrypoint behavior and optimistic updates.
- `cmux-ghostty`: Ghostty submodule and GhosttyKit workflow.
- `cmux-release`: release, version bump, changelog, pretag guard, release assets.
- Blacksmith Testbox (remote Linux builds for cmux-tui): warm your own box before any cmux-tui Rust or Zig
build, and never compile cmux-tui on the Mac. The skill lives in cmuxterm-hq at
`skills/infra/blacksmith-testbox/SKILL.md`; the workflows, `scripts/blacksmith-*.sh`, and the
`tests/test_testbox_*` guards stay here. Quickest path: `./scripts/blacksmith-testbox-demo.sh`.
+18 -13
View File
@@ -77,26 +77,31 @@ extension CMUXCLI {
/// the pane exits before the real command runs; that is why Claude Code
/// 2.1.183 teammates never opened a split pane (issue #6447).
///
/// Every command is run through `/bin/sh -c '<command>'`, so Ghostty execs a
/// shell rather than a builtin/expression/assignment-prefix. The whole command
/// is single-quoted, so it round-trips verbatim regardless of operators or
/// quoting there is no attempt to classify which commands "need" a shell,
/// which was unreliable (tmux shell-commands can hide operators with no
/// surrounding whitespace). Commands that are already a shell invocation (e.g.
/// OMO's `/bin/sh -c ""`) are simply run through one more shell, which execs
/// straight into them.
/// Every command is run through `/bin/sh -lc '<command>'`, so Ghostty execs a
/// login shell rather than a builtin/expression/assignment-prefix. The `-l`
/// is important: Ghostty's `exec -l` only changes argv[0], and does not make
/// macOS `/bin/sh` read `/etc/profile` when it is given a non-interactive `-c`
/// command. The login shell therefore runs `path_helper` and restores the
/// user's full login PATH before the command starts (issue #10189). The whole
/// command is single-quoted, so it round-trips verbatim regardless of
/// operators or quoting there is no attempt to classify which commands
/// "need" a shell, which was unreliable (tmux shell-commands can hide
/// operators with no surrounding whitespace). Commands that are already a
/// shell invocation (e.g. OMO's `/bin/sh -c ""`) are simply run through one
/// more shell, which execs straight into them.
///
/// A POSIX shell (`/bin/sh`) is used deliberately rather than the user's
/// `$SHELL`: the commands being wrapped are POSIX `sh` syntax (Claude Code's
/// `cd && env `, and the no-command fallback `exec ${SHELL:-/bin/sh} -l`),
/// and `csh`/`tcsh` login shells cannot parse `${VAR:-default}` parameter
/// expansion or `NAME=value` command prefixes. `/bin/sh` is always present and
/// runs the bodies correctly for every user. `-l` is not passed (`/bin/sh`
/// does not take it); on macOS Ghostty already supplies a login-style argv0.
/// and `csh`/`tcsh` cannot parse `${VAR:-default}` parameter expansion or
/// `NAME=value` command prefixes. `/bin/sh` is always present and runs the
/// bodies correctly for every user; its login mode is a shell-independent way to
/// invoke macOS `path_helper` without asking the user's shell to parse a
/// POSIX command body.
func tmuxShellInvokedStartCommand(_ command: String) -> String {
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return command }
return "/bin/sh -c \(tmuxShellQuote(trimmed))"
return "/bin/sh -lc \(tmuxShellQuote(trimmed))"
}
/// Like `tmuxShellInvokedStartCommand`, but first exports `prependEnv` inside
@@ -12,9 +12,16 @@ public struct CmxLegacyPrivateNetworkPairingCode: Sendable {
/// Returns a tokenless Tailscale-only v1 pairing URL, or `nil` when the
/// ticket has no Tailscale route to disclose.
public func encode(_ ticket: CmxAttachTicket) throws -> URL? {
public func encode(
_ ticket: CmxAttachTicket,
pairingURLScheme: CmxPairingURLScheme? =
CmxPairingURLSchemeResolver().resolved
) throws -> URL? {
let tailscaleRoutes = ticket.routes.filter { $0.kind == .tailscale }
guard !tailscaleRoutes.isEmpty else { return nil }
guard !tailscaleRoutes.isEmpty,
let scheme = pairingURLScheme?.rawValue else {
return nil
}
let legacyTicket = try CmxAttachTicket(
version: ticket.version,
@@ -35,7 +42,7 @@ public struct CmxLegacyPrivateNetworkPairingCode: Sendable {
encoder.dateEncodingStrategy = .iso8601
let payload = base64URLEncode(try encoder.encode(legacyTicket))
return URL(
string: "\(CmxPairingURLScheme.current)://attach?v=\(legacyTicket.version)&payload=\(payload)"
string: "\(scheme)://attach?v=\(legacyTicket.version)&payload=\(payload)"
)
}
@@ -77,8 +77,13 @@ public struct CmxPairingQRCode: Sendable {
/// route is dropped, never written into a scannable code.
public func encode(
_ ticket: CmxAttachTicket,
routeDisclosureMode: CmxPairingRouteDisclosureMode
routeDisclosureMode: CmxPairingRouteDisclosureMode,
pairingURLScheme: CmxPairingURLScheme? =
CmxPairingURLSchemeResolver().resolved
) -> String? {
guard let scheme = pairingURLScheme?.rawValue else {
return nil
}
let items: [String]
switch routeDisclosureMode {
case .irohIdentityOnly:
@@ -119,7 +124,7 @@ public struct CmxPairingQRCode: Sendable {
// 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
// code to a dev build that also claimed the scheme.
return "\(CmxPairingURLScheme.current)://attach?" + items.joined(separator: "&")
return "\(scheme)://attach?" + items.joined(separator: "&")
}
/// Whether `ticket` is expressible in the selected minimal grammar.
@@ -224,7 +229,7 @@ public struct CmxPairingQRCode: Sendable {
/// the minimal grammar).
public func isPairingCodeURLString(_ rawValue: String) -> Bool {
guard let url = URL(string: rawValue),
CmxPairingURLScheme.isPairingScheme(url.scheme),
CmxPairingURLScheme(rawValue: url.scheme) != nil,
url.host == "attach",
let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
return false
@@ -1,75 +1,95 @@
import Foundation
/// The channel-specific URL scheme carried by cmux pairing/attach deep links.
/// One validated URL scheme carried by a cmux pairing or attach deep link.
///
/// All builds used to register and emit one scheme (`cmux-ios`), so scanning a
/// beta/prod pairing QR with the iOS Camera app could open a *dev* build that
/// happened to be installed (the OS picks an arbitrary app when several claim
/// a scheme). The scheme is therefore channel-specific, mirroring how
/// `MobileBuildType` splits channels:
///
/// - **Development (DEBUG)** builds local Xcode and `reload.sh` tagged
/// builds on both Mac and iPhone register and emit ``development``.
/// - **Release** builds (TestFlight beta and App Store prod) register and
/// emit ``release``. Beta and prod share a scheme because they are the same
/// compile configuration and a phone realistically has only one of them.
///
/// Emitters (the Mac building a pairing QR or attach URL) use ``current`` so a
/// dev Mac pairs a dev phone and a release Mac pairs a release phone via the
/// system camera. Parsers (the in-app scanner, manual paste, the root scene's
/// deep-link gate) accept *any* pairing scheme via ``isPairingScheme(_:)`` /
/// ``hasPairingScheme(_:)``, so cross-channel pairing still works when the
/// user scans from inside the app.
///
/// The iOS app's registered scheme comes from `CMUX_IOS_URL_SCHEME` in
/// `ios/Config/Shared.xcconfig` (dev) and `ios/Config/Release.xcconfig`
/// (release); keep those values in sync with these constants.
///
/// lint:allow namespace-type the build channel's URL scheme is a pure
/// compile-time constant set with no per-instance state to inject; these
/// scheme strings and the stateless pairing-scheme predicates are a genuine
/// namespace, like the sanctioned FFI/seam holders.
/// Every installed iOS bundle registers exactly one scheme derived from its
/// complete bundle identifier. Parsers also accept the two historical shared
/// schemes so an old QR remains scannable inside an already-open app, but new
/// apps never register those shared schemes with iOS.
public struct CmxPairingURLScheme {
private init() {}
/// The validated, lowercase URL scheme.
public let rawValue: String
/// The scheme Release (TestFlight beta + App Store) builds register and emit.
/// Creates the exact scheme registered by one installed iOS bundle.
public init?(iOSBundleIdentifier: String?) {
guard let namespace = MobileIOSAppNamespace(
bundleIdentifier: iOSBundleIdentifier
) else {
return nil
}
let scheme = namespace.pairingURLScheme.lowercased()
guard Self.releaseSchemes.contains(scheme)
|| scheme == Self.untaggedDevelopmentScheme
|| scheme.hasPrefix(Self.developmentPrefix) else {
return nil
}
rawValue = scheme
}
/// Parses a classifiable bundle-specific or historical shared pairing
/// scheme. Unknown release-like namespaces fail closed so account preflight
/// cannot be bypassed by a syntactically valid but unclassified scheme.
public init?(rawValue: String?) {
guard let rawValue else { return nil }
let normalized = rawValue.lowercased()
if Self.all.contains(normalized) {
self.rawValue = normalized
return
}
let prefix = "cmux-ios-"
guard normalized.hasPrefix(prefix),
MobileIOSAppNamespace(
bundleIdentifier: String(normalized.dropFirst(prefix.count))
) != nil,
Self.releaseSchemes.contains(normalized)
|| normalized == Self.untaggedDevelopmentScheme
|| normalized.hasPrefix(Self.developmentPrefix) else {
return nil
}
self.rawValue = normalized
}
/// Parses the scheme from a complete pairing URL.
public init?(urlString: String) {
guard urlString.contains("://"),
let components = URLComponents(string: urlString),
let scheme = CmxPairingURLScheme(rawValue: components.scheme) else {
return nil
}
self = scheme
}
/// Whether this scheme identifies a tagged iOS development build.
public var isDevelopment: Bool {
rawValue == Self.development
|| rawValue == Self.untaggedDevelopmentScheme
|| rawValue.hasPrefix(Self.developmentPrefix)
}
/// Whether this scheme identifies an App Store or TestFlight build.
public var isRelease: Bool {
Self.releaseSchemes.contains(rawValue)
}
/// Historical shared Release scheme. Parse-only in new iOS builds.
public static let release = "cmux-ios"
/// The scheme development (DEBUG/tagged) builds register and emit.
/// Historical shared development scheme. Parse-only in new iOS builds.
public static let development = "cmux-ios-dev"
/// Every scheme any cmux build may emit; parsers accept all of them.
/// Historical schemes retained for source compatibility and old QR tests.
public static let all: [String] = [release, development]
/// The scheme this build emits in pairing QRs and attach URLs.
public static var current: String {
scheme(isDevelopmentBuild: isDevelopmentBuild)
}
private static let untaggedDevelopmentScheme = "cmux-ios-dev.cmux.ios"
private static let developmentPrefix = "cmux-ios-dev.cmux.ios."
/// Pure channel-to-scheme mapping, injected with the compile flag so the
/// derivation is testable from a single build configuration.
public static func scheme(isDevelopmentBuild: Bool) -> String {
isDevelopmentBuild ? development : release
}
/// Whether `scheme` is a pairing scheme from any cmux channel.
public static func isPairingScheme(_ scheme: String?) -> Bool {
guard let scheme else { return false }
return all.contains { $0.caseInsensitiveCompare(scheme) == .orderedSame }
}
/// Whether `rawValue` starts with any channel's pairing scheme (the
/// scanner/paste-side prefix check, before URL parsing).
public static func hasPairingScheme(_ rawValue: String) -> Bool {
let lowercased = rawValue.lowercased()
return all.contains { lowercased.hasPrefix($0 + "://") }
}
private static var isDevelopmentBuild: Bool {
#if DEBUG
true
#else
false
#endif
}
private static let releaseSchemes: Set<String> = [
release,
"cmux-ios-com.cmux.app",
"cmux-ios-dev.cmux.app.beta",
"cmux-ios-dev.cmux.app.internal",
"cmux-ios-dev.cmux.app.demo",
]
}
extension CmxPairingURLScheme: Equatable, Sendable {}
@@ -0,0 +1,71 @@
import Foundation
/// Resolves the pairing target for the current process without global state.
public struct CmxPairingURLSchemeResolver: Sendable {
private let currentIOSBundleIdentifier: String?
private let targetIOSBundleIdentifier: String?
private let macInstanceTag: String?
private let isDevelopmentBuild: Bool
/// Captures the current app identity and any explicit Mac pairing target.
///
/// A Mac may set `CMUX_IOS_PAIRING_BUNDLE_IDENTIFIER` to any authoritative
/// release-lane bundle id. Tagged Mac builds otherwise target their exact
/// same-tag iOS bundle.
public init(
bundle: Bundle = .main,
environment: [String: String] = ProcessInfo.processInfo.environment
) {
currentIOSBundleIdentifier = bundle.bundleIdentifier
targetIOSBundleIdentifier =
environment["CMUX_IOS_PAIRING_BUNDLE_IDENTIFIER"]
macInstanceTag = environment["CMUX_TAG"]
#if DEBUG
isDevelopmentBuild = true
#else
isDevelopmentBuild = false
#endif
}
init(
currentIOSBundleIdentifier: String?,
targetIOSBundleIdentifier: String?,
macInstanceTag: String?,
isDevelopmentBuild: Bool
) {
self.currentIOSBundleIdentifier = currentIOSBundleIdentifier
self.targetIOSBundleIdentifier = targetIOSBundleIdentifier
self.macInstanceTag = macInstanceTag
self.isDevelopmentBuild = isDevelopmentBuild
}
/// The exact scheme this process should emit, or `nil` on invalid identity.
public var resolved: CmxPairingURLScheme? {
#if os(iOS)
return CmxPairingURLScheme(
iOSBundleIdentifier: currentIOSBundleIdentifier
)
#else
if let targetIOSBundleIdentifier {
return CmxPairingURLScheme(
iOSBundleIdentifier: targetIOSBundleIdentifier
)
}
if macInstanceTag == nil || macInstanceTag?.isEmpty == true {
return CmxPairingURLScheme(
iOSBundleIdentifier: isDevelopmentBuild
? "dev.cmux.ios"
: "com.cmux.app"
)
}
guard let namespace = MobileIOSAppNamespace(
pairedMacInstanceTag: macInstanceTag
) else {
return nil
}
return CmxPairingURLScheme(
iOSBundleIdentifier: namespace.bundleIdentifier
)
#endif
}
}
@@ -213,6 +213,14 @@ public struct DiagnosticEventPresentation: Sendable {
}
}
/// Human-readable name of a configured connection method.
public func displayName(_ method: DiagnosticConnectionMethod) -> String {
switch method {
case .automatic: localized("diagnostics.connectionMethod.automatic", defaultValue: "Auto-Connect (Iroh)")
case .tailscale: localized("diagnostics.connectionMethod.tailscale", defaultValue: "Tailscale Only")
}
}
/// Human-readable name of a selected network path.
public func displayName(_ kind: DiagnosticPathKind) -> String {
switch kind {
@@ -681,6 +689,10 @@ public struct DiagnosticEventPresentation: Sendable {
return Field(key: "style", value: toastStyleName(raw))
case .toastDismissed:
return Field(key: "reason", value: toastDismissReasonName(raw))
case .connectionMethodPreferenceChanged, .connectionMethodConfigured:
return Field(key: "method", value: connectionMethodName(raw))
case .foregroundTransportSelected:
return Field(key: "transport", value: transportName(raw))
default:
if Self.appEventKindsWithValuePayload.contains(kind) {
return Field(key: "value", value: String(raw))
@@ -743,6 +755,11 @@ public struct DiagnosticEventPresentation: Sendable {
?? unknownPayloadName(raw)
}
private func connectionMethodName(_ raw: Int) -> String {
DiagnosticConnectionMethod(rawValue: raw).map(displayName)
?? unknownPayloadName(raw)
}
private func unknownPayloadName(_ raw: Int) -> String {
localized(
"diagnostics.unknown.payload",
@@ -762,7 +779,6 @@ public struct DiagnosticEventPresentation: Sendable {
.displayWorkspacePreviewLinesChanged,
.terminalScrollbackRowsChanged,
.telemetrySharingChanged,
.connectionMethodPreferenceChanged,
.notificationPreferenceChanged,
.terminalDraftStateChanged,
]
@@ -1388,6 +1404,7 @@ public struct DiagnosticEventPresentation: Sendable {
case "active_sessions": localized("diagnostics.field.activeSessions", defaultValue: "Active sessions")
case "count": localized("diagnostics.field.count", defaultValue: "Count")
case "value": localized("diagnostics.field.value", defaultValue: "Value")
case "method": localized("diagnostics.field.method", defaultValue: "Method")
case "action": localized("diagnostics.field.action", defaultValue: "Action")
case "tab": localized("diagnostics.field.tab", defaultValue: "Tab")
case "scope": localized("diagnostics.field.scope", defaultValue: "Scope")
@@ -767,6 +767,7 @@ public enum DiagnosticAppEventKind: Int, Sendable, Codable, CaseIterable {
case displayWorkspacePreviewLinesChanged = 528
case terminalScrollbackRowsChanged = 529
case telemetrySharingChanged = 530
/// `c`: ``DiagnosticConnectionMethod`` the user switched to.
case connectionMethodPreferenceChanged = 531
/// Detail: ``DiagnosticAppEventDetail/toolbarConfigurationAction(_:)``.
case customToolbarChanged = 532
@@ -836,6 +837,22 @@ public enum DiagnosticAppEventKind: Int, Sendable, Codable, CaseIterable {
// MARK: Appended persistence events
case pairedMacStoreWriteStarted = 660
// MARK: Appended connection reporting events
/// The configured connection method, recorded at composition and on every
/// foreground so any shared report window states it even after the ring
/// rolls past app launch. `c`: ``DiagnosticConnectionMethod``.
case connectionMethodConfigured = 661
/// The transport that actually carries the foreground connection, recorded
/// on connect and on every active-route change. `c`: ``DiagnosticTransportKind``.
case foregroundTransportSelected = 662
}
/// The user's configured connection method, mirrored from the settings picker
/// without account, address, or grant details.
public enum DiagnosticConnectionMethod: Int, Sendable, Codable, CaseIterable {
case automatic = 0
case tailscale = 1
}
/// High-level lifecycle state for one phone-controlled Simulator stream.
@@ -0,0 +1,93 @@
import Foundation
/// Immutable isolation boundary for one installed cmux iOS application.
///
/// The complete bundle identifier is the namespace. Distribution labels and
/// short development tags are deliberately not accepted here because either
/// can alias another installed app.
public struct MobileIOSAppNamespace: Equatable, Hashable, Sendable {
/// Exact bundle identifier that owns this namespace.
public let bundleIdentifier: String
/// Creates a namespace from one complete, validated iOS bundle identifier.
public init?(bundleIdentifier: String?) {
guard let bundleIdentifier else { return nil }
let trimmed = bundleIdentifier.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty,
trimmed == bundleIdentifier,
trimmed == trimmed.lowercased(),
trimmed.count <= 255,
trimmed.contains("."),
trimmed.range(
of: #"^[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$"#,
options: .regularExpression
) != nil
else {
return nil
}
self.bundleIdentifier = trimmed
}
/// Resolves the exact iOS bundle paired with one Mac app instance.
///
/// Tagged Mac builds pair with the same tagged iOS development bundle.
/// The stable Mac instance pairs with the public App Store bundle. Invalid
/// tags fail closed instead of aliasing another installed iOS app.
public init?(pairedMacInstanceTag instanceTag: String?) {
let tag = instanceTag?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if let instanceTag, instanceTag != tag {
return nil
}
let bundleIdentifier = if tag.isEmpty || tag == "default" {
"com.cmux.app"
} else {
"dev.cmux.ios.\(tag)"
}
self.init(bundleIdentifier: bundleIdentifier)
}
/// The exact Keychain access group this app must claim after signing.
public func keychainAccessGroup(teamIdentifier: String) -> String {
"\(teamIdentifier).\(bundleIdentifier)"
}
/// A Keychain service that cannot collide with another installed bundle.
public func keychainService(base: String) -> String {
"\(base).\(bundleIdentifier)"
}
/// The only pairing URL scheme this bundle registers with iOS.
public var pairingURLScheme: String {
"cmux-ios-\(bundleIdentifier)"
}
/// Opaque server partition for data restored to this exact app bundle.
public var serverScope: String {
let encoded = Data(bundleIdentifier.utf8)
.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
return "ios:v3:\(encoded)"
}
/// The only legacy backup collection that can be attributed to this bundle.
///
/// The App Store app owns the former unscoped release collection. Tagged
/// development bundles own their same-tag v2 collection. Beta, Internal,
/// and Demo intentionally adopt nothing because their old unscoped records
/// cannot be attributed without risking cross-build restore.
public var legacyBackupScope: MobileIOSLegacyBackupScope? {
if bundleIdentifier == "com.cmux.app" {
return .unscoped
}
let prefix = "dev.cmux.ios."
guard bundleIdentifier.hasPrefix(prefix),
let buildScope = MobileIOSBuildScope(
String(bundleIdentifier.dropFirst(prefix.count))
) else {
return nil
}
return .scoped(buildScope.serializedScope)
}
}
@@ -0,0 +1,16 @@
/// One unambiguous pre-v3 backup collection eligible for one-time adoption.
public enum MobileIOSLegacyBackupScope: Equatable, Sendable {
/// The former App Store collection that did not carry a scope header.
case unscoped
/// A former development collection identified by its exact v2 scope.
case scoped(String)
/// The legacy request header value, or `nil` for the unscoped collection.
public var headerValue: String? {
switch self {
case .unscoped: nil
case .scoped(let value): value
}
}
}
@@ -116,14 +116,15 @@ public struct MobileSyncPairingPayload: Equatable, Sendable, Codable {
encoder.dateEncodingStrategy = .iso8601
let data = try encoder.encode(self)
let payload = Self.base64URLEncode(data)
guard let url = URL(string: "\(CmxPairingURLScheme.current)://pair?v=\(version)&payload=\(payload)") else {
guard let scheme = CmxPairingURLSchemeResolver().resolved?.rawValue,
let url = URL(string: "\(scheme)://pair?v=\(version)&payload=\(payload)") else {
throw MobileSyncPairingPayloadError.invalidURL
}
return url
}
public static func decodeURL(_ url: URL, now: Date = Date()) throws -> MobileSyncPairingPayload {
guard CmxPairingURLScheme.isPairingScheme(url.scheme),
guard CmxPairingURLScheme(rawValue: url.scheme) != nil,
url.host == "pair",
let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let encodedPayload = components.queryItems?.first(where: { $0.name == "payload" })?.value,
@@ -103,6 +103,40 @@
}
}
},
"diagnostics.connectionMethod.automatic": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Auto-Connect (Iroh)"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "自動接続 (Iroh)"
}
}
}
},
"diagnostics.connectionMethod.tailscale": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Tailscale Only"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "Tailscaleのみ"
}
}
}
},
"diagnostics.count.bytes": {
"extractionState": "manual",
"localizations": {
@@ -5845,6 +5879,23 @@
}
}
},
"diagnostics.field.method": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Method"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "接続方法"
}
}
}
},
"diagnostics.field.publicPaths": {
"extractionState": "manual",
"localizations": {
@@ -4,6 +4,9 @@ import Testing
private let compactIrohQRCoder = CmxAttachTicketCompactCoder()
private let compactIrohQREndpointID = String(repeating: "c", count: 64)
private let compactIrohQRTarget = CmxPairingURLScheme(
iOSBundleIdentifier: "dev.cmux.app.beta"
)!
private func compactIrohQRExpiry() -> Date {
Date(timeIntervalSince1970: 4_000_000_000)
@@ -88,11 +91,12 @@ private func compactIrohQRHostPortRoute() throws -> CmxAttachRoute {
#expect(hints.isEmpty)
let pairingURL = try #require(CmxPairingQRCode().encode(
ticket,
routeDisclosureMode: .irohIdentityOnly
routeDisclosureMode: .irohIdentityOnly,
pairingURLScheme: compactIrohQRTarget
))
#expect(
pairingURL
== "\(CmxPairingURLScheme.current)://attach?v=3&i=\(compactIrohQREndpointID)"
== "\(compactIrohQRTarget.rawValue)://attach?v=3&i=\(compactIrohQREndpointID)"
)
#expect(!pairingURL.contains("payload="))
#expect(!pairingURL.contains("mac-1"))
@@ -130,7 +134,7 @@ private func compactIrohQRHostPortRoute() throws -> CmxAttachRoute {
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
let beforeURL =
"\(CmxPairingURLScheme.current)://attach?v=1&payload=\(compactBase64)"
"\(compactIrohQRTarget.rawValue)://attach?v=1&payload=\(compactBase64)"
let beforeImage = try #require(CmxPairingQRBitmap().makeImage(payload: beforeURL))
let afterImage = try #require(CmxPairingQRBitmap().makeImage(payload: pairingURL))
let quietZone = CmxPairingQRBitmap.quietZoneModules * 2
@@ -56,10 +56,12 @@ import Testing
try tailscaleRoute(index: 0, host: "100.64.0.5"),
])
let url = try #require(encodeLegacy(ticket))
// The scheme is channel-specific: a release Mac emits cmux-ios, a dev
// Mac emits cmux-ios-dev, so the system camera routes each channel's QR
// to its build. The rest of the URL is identical across channels.
#expect(url == "\(CmxPairingURLScheme.current)://attach?v=2&r=100.64.0.5:58465")
// The scheme is bundle-specific, so the system camera routes the QR to
// the matching installed iOS build. The rest of the URL is unchanged.
let scheme = try #require(
CmxPairingURLSchemeResolver().resolved?.rawValue
)
#expect(url == "\(scheme)://attach?v=2&r=100.64.0.5:58465")
let decoded = try CmxPairingQRCode().decode(try components(url))
#expect(decoded.routes == ticket.routes)
@@ -148,7 +150,10 @@ import Testing
let ticket = try pairingTicket(routes: [loopback, tailscale])
let url = try #require(encodeLegacy(ticket))
#expect(url == "\(CmxPairingURLScheme.current)://attach?v=2&r=100.64.0.5:58465")
let scheme = try #require(
CmxPairingURLSchemeResolver().resolved?.rawValue
)
#expect(url == "\(scheme)://attach?v=2&r=100.64.0.5:58465")
let decoded = try CmxPairingQRCode().decode(try components(url))
#expect(decoded.routes == [tailscale])
}
@@ -2,52 +2,153 @@ import Foundation
import Testing
@testable import CMUXMobileCore
/// The pairing/attach URL scheme is channel-specific so the system Camera app
/// can never hand a beta/prod QR to a dev build that also claimed the scheme:
/// dev (Debug/tagged) builds register + emit `cmux-ios-dev`, Release (beta +
/// prod) registers + emits `cmux-ios`. Parsers accept every channel's scheme so
/// cross-channel pairing still works from inside the app.
/// Every installed iOS bundle owns one pairing URL scheme. Parsers accept only
/// schemes whose release or development lane can be classified for account
/// preflight, while installed builds still register their exact bundle scheme.
@Suite struct CmxPairingURLSchemeTests {
@Test func developmentBuildsEmitDevScheme() {
#expect(CmxPairingURLScheme.scheme(isDevelopmentBuild: true) == "cmux-ios-dev")
@Test func everyInstalledBundleEmitsItsOwnScheme() {
#expect(
CmxPairingURLScheme(
iOSBundleIdentifier: "dev.cmux.app.internal"
)?.rawValue == "cmux-ios-dev.cmux.app.internal"
)
#expect(
CmxPairingURLScheme(
iOSBundleIdentifier: "dev.cmux.app.demo"
)?.rawValue == "cmux-ios-dev.cmux.app.demo"
)
#expect(
CmxPairingURLScheme(
iOSBundleIdentifier: "dev.cmux.ios.feature-a"
)?.rawValue == "cmux-ios-dev.cmux.ios.feature-a"
)
}
@Test func releaseBuildsEmitReleaseScheme() {
#expect(CmxPairingURLScheme.scheme(isDevelopmentBuild: false) == "cmux-ios")
}
@Test func currentMatchesThisBuildsCompileChannel() {
// `current` derives from the DEBUG compile flag, so a Debug test run
// emits the dev scheme and a Release test run emits the release scheme.
#if DEBUG
#expect(CmxPairingURLScheme.current == "cmux-ios-dev")
#else
#expect(CmxPairingURLScheme.current == "cmux-ios")
@Test func invalidIdentityDoesNotFallBackToAnotherApp() {
#expect(CmxPairingURLScheme(iOSBundleIdentifier: "") == nil)
#expect(CmxPairingURLScheme(iOSBundleIdentifier: "invalid bundle") == nil)
#expect(
CmxPairingURLScheme(
iOSBundleIdentifier: "dev.cmux.app.unrecognized"
) == nil
)
#if !os(iOS)
#expect(
CmxPairingURLSchemeResolver(
currentIOSBundleIdentifier: nil,
targetIOSBundleIdentifier: nil,
macInstanceTag: "invalid tag",
isDevelopmentBuild: true
).resolved == nil
)
#endif
}
@Test func parserAcceptsEverySchemeRegardlessOfChannel() {
// Both channels' schemes parse, case-insensitively, so a phone on
// either channel can pair from a QR minted by either channel's Mac.
#expect(CmxPairingURLScheme.isPairingScheme("cmux-ios"))
#expect(CmxPairingURLScheme.isPairingScheme("cmux-ios-dev"))
#expect(CmxPairingURLScheme.isPairingScheme("CMUX-IOS-DEV"))
#if !os(iOS)
@Test func untaggedDebugMacTargetsDefaultDebugIOSBundle() {
#if DEBUG
#expect(
CmxPairingURLSchemeResolver(
currentIOSBundleIdentifier: nil,
targetIOSBundleIdentifier: nil,
macInstanceTag: nil,
isDevelopmentBuild: true
).resolved?.rawValue == "cmux-ios-dev.cmux.ios"
)
#endif
}
@Test func untaggedMacBuildChannelsResolveDistinctExactBundles() {
#expect(
CmxPairingURLSchemeResolver(
currentIOSBundleIdentifier: nil,
targetIOSBundleIdentifier: nil,
macInstanceTag: nil,
isDevelopmentBuild: true
).resolved?.rawValue == "cmux-ios-dev.cmux.ios"
)
#expect(
CmxPairingURLSchemeResolver(
currentIOSBundleIdentifier: nil,
targetIOSBundleIdentifier: nil,
macInstanceTag: nil,
isDevelopmentBuild: false
).resolved?.rawValue == "cmux-ios-com.cmux.app"
)
}
@Test func macCanExplicitlyTargetEveryReleaseLane() {
for bundleIdentifier in [
"com.cmux.app",
"dev.cmux.app.beta",
"dev.cmux.app.internal",
"dev.cmux.app.demo",
] {
#expect(
CmxPairingURLSchemeResolver(
currentIOSBundleIdentifier: nil,
targetIOSBundleIdentifier: bundleIdentifier,
macInstanceTag: nil,
isDevelopmentBuild: false
).resolved?.rawValue
== "cmux-ios-\(bundleIdentifier)"
)
}
}
#endif
@Test func parserAcceptsNamespacedSchemes() {
#expect(CmxPairingURLScheme(rawValue: "cmux-ios-dev.cmux.app.internal") != nil)
#expect(CmxPairingURLScheme(rawValue: "cmux-ios-dev.cmux.app.demo") != nil)
#expect(CmxPairingURLScheme(rawValue: "CMUX-IOS-DEV.CMUX.IOS.FEATURE-A") != nil)
// Old QR codes remain scannable inside an already-open app. New builds
// do not register these shared schemes with iOS.
#expect(CmxPairingURLScheme(rawValue: "cmux-ios") != nil)
#expect(CmxPairingURLScheme(rawValue: "cmux-ios-dev") != nil)
}
@Test func parserRejectsForeignSchemes() {
#expect(!CmxPairingURLScheme.isPairingScheme(nil))
#expect(!CmxPairingURLScheme.isPairingScheme(""))
#expect(!CmxPairingURLScheme.isPairingScheme("https"))
// A different cmux scheme that is not a pairing scheme must not match.
#expect(!CmxPairingURLScheme.isPairingScheme("cmux-ios-staging"))
#expect(CmxPairingURLScheme(rawValue: nil) == nil)
#expect(CmxPairingURLScheme(rawValue: "") == nil)
#expect(CmxPairingURLScheme(rawValue: "https") == nil)
#expect(CmxPairingURLScheme(rawValue: "cmux-ios-*") == nil)
}
@Test func prefixCheckAcceptsBothChannelsAndRejectsOthers() {
#expect(CmxPairingURLScheme.hasPairingScheme("cmux-ios://attach?v=2&r=100.64.0.5:58465"))
#expect(CmxPairingURLScheme.hasPairingScheme("cmux-ios-dev://attach?v=2&r=100.64.0.5:58465"))
#expect(CmxPairingURLScheme.hasPairingScheme("CMUX-IOS://attach?v=2"))
#expect(!CmxPairingURLScheme.hasPairingScheme("https://example.com"))
// A bare scheme name without "://" is not a deep link.
#expect(!CmxPairingURLScheme.hasPairingScheme("cmux-ios"))
@Test func channelClassificationRecognizesOnlyAuthoritativeLanes() throws {
for bundleIdentifier in [
"com.cmux.app",
"dev.cmux.app.beta",
"dev.cmux.app.internal",
"dev.cmux.app.demo",
] {
let scheme = try #require(
CmxPairingURLScheme(
iOSBundleIdentifier: bundleIdentifier
)
)
#expect(scheme.isRelease)
#expect(!scheme.isDevelopment)
}
let development = try #require(
CmxPairingURLScheme(
iOSBundleIdentifier: "dev.cmux.ios.feature-a"
)
)
#expect(development.isDevelopment)
#expect(!development.isRelease)
#expect(CmxPairingURLScheme(rawValue: "cmux-ios-dev.cmux.app.unrecognized") == nil)
}
@Test func prefixCheckAcceptsNamespacedSchemesAndRejectsOthers() {
#expect(CmxPairingURLScheme(urlString:
"cmux-ios-dev.cmux.app.internal://attach?v=2&r=100.64.0.5:58465"
) != nil)
#expect(CmxPairingURLScheme(urlString:
"CMUX-IOS-DEV.CMUX.IOS.FEATURE-A://attach?v=2"
) != nil)
#expect(CmxPairingURLScheme(urlString: "cmux-ios://attach?v=2") != nil)
#expect(CmxPairingURLScheme(urlString: "cmux-ios-dev://attach?v=2") != nil)
#expect(CmxPairingURLScheme(urlString: "https://example.com") == nil)
#expect(CmxPairingURLScheme(urlString: "cmux-ios-dev.cmux.app.internal") == nil)
}
}
@@ -595,6 +595,48 @@ import Testing
}
}
/// A shared report must state the configured connection method and the
/// transport actually carrying the foreground connection in words, so a
/// support thread never needs a Settings screenshot to interpret dials.
@Test func describesConnectionMethodAndForegroundTransport() {
let configured = englishPresentation.describe(DiagnosticEvent(
code: .appFeatureAction,
tNanos: 1,
a: DiagnosticAppEventKind.connectionMethodConfigured.rawValue,
c: DiagnosticConnectionMethod.tailscale.rawValue
))
#expect(configured.fields == [
.init(key: "operation", value: "connectionMethodConfigured"),
.init(key: "method", value: "Tailscale Only"),
])
#expect(englishPresentation.summary(configured)
.contains("Method: Tailscale Only"))
let changed = englishPresentation.describe(DiagnosticEvent(
code: .appFeatureAction,
tNanos: 1,
a: DiagnosticAppEventKind.connectionMethodPreferenceChanged.rawValue,
c: DiagnosticConnectionMethod.automatic.rawValue
))
#expect(changed.fields == [
.init(key: "operation", value: "connectionMethodPreferenceChanged"),
.init(key: "method", value: "Auto-Connect (Iroh)"),
])
let transport = englishPresentation.describe(DiagnosticEvent(
code: .appFeatureAction,
tNanos: 1,
a: DiagnosticAppEventKind.foregroundTransportSelected.rawValue,
c: DiagnosticTransportKind.tailscale.rawValue
))
#expect(transport.fields == [
.init(key: "operation", value: "foregroundTransportSelected"),
.init(key: "transport", value: "Tailscale"),
])
#expect(englishPresentation.summary(transport)
.contains("Transport: Tailscale"))
}
@Test func extractsFailureAndTransportKinds() {
let event = DiagnosticEvent(
code: .endpointFailed,
@@ -0,0 +1,105 @@
import Foundation
import Testing
@testable import CMUXMobileCore
@Suite struct MobileIOSAppNamespaceTests {
@Test(
arguments: [
"com.cmux.app",
"dev.cmux.app.beta",
"dev.cmux.app.internal",
"dev.cmux.app.demo",
"dev.cmux.ios.feature-a",
"dev.cmux.ios.feature-b",
]
)
func fullBundleIdentifierOwnsEveryNamespace(bundleIdentifier: String) throws {
let namespace = try #require(
MobileIOSAppNamespace(bundleIdentifier: bundleIdentifier)
)
#expect(namespace.bundleIdentifier == bundleIdentifier)
#expect(
namespace.keychainService(base: "com.cmuxterm.iroh.identity")
== "com.cmuxterm.iroh.identity.\(bundleIdentifier)"
)
#expect(
namespace.keychainAccessGroup(teamIdentifier: "7WLXT3NR37")
== "7WLXT3NR37.\(bundleIdentifier)"
)
#expect(
namespace.pairingURLScheme
== "cmux-ios-\(bundleIdentifier)"
)
}
@Test func appTypesAndDevTagsNeverSharePersistentOrPairingScopes() throws {
let bundleIdentifiers = [
"com.cmux.app",
"dev.cmux.app.beta",
"dev.cmux.app.internal",
"dev.cmux.app.demo",
"dev.cmux.ios.feature-a",
"dev.cmux.ios.feature-b",
]
let namespaces = try bundleIdentifiers.map {
try #require(MobileIOSAppNamespace(bundleIdentifier: $0))
}
#expect(Set(namespaces.map(\.serverScope)).count == namespaces.count)
#expect(Set(namespaces.map(\.pairingURLScheme)).count == namespaces.count)
#expect(
Set(
namespaces.map {
$0.keychainService(base: "com.cmuxterm.iroh.identity")
}
).count == namespaces.count
)
}
@Test func rejectsMissingOrUnsafeBundleIdentifiers() {
#expect(MobileIOSAppNamespace(bundleIdentifier: nil) == nil)
#expect(MobileIOSAppNamespace(bundleIdentifier: "") == nil)
#expect(MobileIOSAppNamespace(bundleIdentifier: "Dev.cmux.ios.feature-a") == nil)
#expect(MobileIOSAppNamespace(bundleIdentifier: "dev.cmux.ios.*") == nil)
#expect(MobileIOSAppNamespace(bundleIdentifier: "dev cmux ios") == nil)
}
@Test func macInstanceTagResolvesOneExactIOSBundle() {
#expect(
MobileIOSAppNamespace(pairedMacInstanceTag: "feature-a")?.bundleIdentifier
== "dev.cmux.ios.feature-a"
)
#expect(
MobileIOSAppNamespace(pairedMacInstanceTag: "default")?.bundleIdentifier
== "com.cmux.app"
)
#expect(MobileIOSAppNamespace(pairedMacInstanceTag: "invalid tag") == nil)
#expect(MobileIOSAppNamespace(pairedMacInstanceTag: " feature-a ") == nil)
}
@Test func legacyBackupAdoptionIsLimitedToUnambiguousOwners() throws {
let appStore = try #require(
MobileIOSAppNamespace(bundleIdentifier: "com.cmux.app")
)
let tagged = try #require(
MobileIOSAppNamespace(bundleIdentifier: "dev.cmux.ios.feature-a")
)
#expect(appStore.legacyBackupScope == .unscoped)
#expect(
tagged.legacyBackupScope
== .scoped("ios:v2:ZmVhdHVyZS1h")
)
for bundleIdentifier in [
"dev.cmux.app.beta",
"dev.cmux.app.internal",
"dev.cmux.app.demo",
] {
#expect(
MobileIOSAppNamespace(
bundleIdentifier: bundleIdentifier
)?.legacyBackupScope == nil
)
}
}
}
@@ -23,12 +23,14 @@ public struct StackAuthClient: AuthClient {
/// - config: The resolved auth configuration (project id + publishable key).
/// - tokenStore: Where Stack persists tokens. Pass `.memory` for the
/// simulator DEBUG flow and `.keychain` for real devices/release.
/// - oauthBrowserSessionPrivacy: Whether OAuth may reuse Safari cookies.
/// - baseURL: Stack API origin. Defaults to Stack's production API.
/// - noAutomaticPrefetch: Disables Stack project prefetch when the host
/// owns startup sequencing.
public init(
config: AuthConfig,
tokenStore: TokenStoreInit,
oauthBrowserSessionPrivacy: OAuthBrowserSessionPrivacy = .shared,
baseURL: String = "https://api.stack-auth.com",
noAutomaticPrefetch: Bool = false
) {
@@ -38,7 +40,8 @@ public struct StackAuthClient: AuthClient {
publishableClientKey: config.stack.publishableClientKey,
baseUrl: baseURL,
tokenStore: tokenStore,
noAutomaticPrefetch: noAutomaticPrefetch
noAutomaticPrefetch: noAutomaticPrefetch,
oauthBrowserSessionPrivacy: oauthBrowserSessionPrivacy
)
)
}
@@ -14,4 +14,6 @@ enum AuthPhase: String, Sendable, Hashable {
case listTeams = "list_teams"
case postSignIn = "post_sign_in"
case accountDeletion = "account_deletion"
case pushRegistrationSession = "push_registration_session"
case pushUnregistrationSession = "push_unregistration_session"
}
@@ -0,0 +1,228 @@
import Foundation
import SQLite3
struct PendingUnregister: Codable, Hashable, Sendable {
let tokenHex: String
let accountID: String
}
/// Indexed durable storage for privacy-sensitive push cleanup obligations.
///
/// UserDefaults retains its domain in memory and is a poor fit for a queue that
/// can outlive several accounts. SQLite keeps the working set bounded: retries
/// read at most their requested batch, token reassignment uses an indexed
/// delete, and the uniqueness constraint compacts duplicate obligations.
final class PendingUnregisterStore {
private var database: OpaquePointer?
init(databaseURL: URL) throws {
try FileManager.default.createDirectory(
at: databaseURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
var opened: OpaquePointer?
let flags = SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX
guard sqlite3_open_v2(databaseURL.path, &opened, flags, nil) == SQLITE_OK,
let opened else {
if let opened { sqlite3_close_v2(opened) }
throw PendingUnregisterStoreError.openFailed
}
database = opened
do {
try execute("PRAGMA journal_mode=WAL;")
try execute("PRAGMA synchronous=FULL;")
try execute("PRAGMA auto_vacuum=INCREMENTAL;")
try execute(
"""
CREATE TABLE IF NOT EXISTS pending_unregister (
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
token_hex TEXT NOT NULL,
account_id TEXT NOT NULL,
UNIQUE(token_hex, account_id)
);
"""
)
try execute(
"""
CREATE INDEX IF NOT EXISTS pending_unregister_account_sequence
ON pending_unregister(account_id, sequence);
"""
)
try execute(
"""
CREATE INDEX IF NOT EXISTS pending_unregister_token
ON pending_unregister(token_hex);
"""
)
} catch {
sqlite3_close_v2(opened)
database = nil
throw error
}
}
deinit {
if let database {
sqlite3_close_v2(database)
}
}
@discardableResult
func insert(_ entry: PendingUnregister) -> Bool {
guard let statement = prepare(
"""
INSERT OR IGNORE INTO pending_unregister(token_hex, account_id)
VALUES (?, ?);
"""
) else { return false }
defer { sqlite3_finalize(statement) }
guard bind(entry.tokenHex, to: statement, at: 1),
bind(entry.accountID, to: statement, at: 2),
sqlite3_step(statement) == SQLITE_DONE else { return false }
return true
}
/// Inserts a legacy queue in one durable transaction. This keeps launch
/// migration linear and pays at most one FULL-synchronous commit.
@discardableResult
func insertAll(_ entries: [PendingUnregister]) -> Bool {
guard !entries.isEmpty else { return true }
guard sqlite3_exec(
database,
"BEGIN IMMEDIATE;",
nil,
nil,
nil
) == SQLITE_OK else { return false }
var committed = false
defer {
if !committed {
_ = sqlite3_exec(database, "ROLLBACK;", nil, nil, nil)
}
}
guard let statement = prepare(
"""
INSERT OR IGNORE INTO pending_unregister(token_hex, account_id)
VALUES (?, ?);
"""
) else { return false }
defer { sqlite3_finalize(statement) }
for entry in entries {
sqlite3_reset(statement)
sqlite3_clear_bindings(statement)
guard bind(entry.tokenHex, to: statement, at: 1),
bind(entry.accountID, to: statement, at: 2),
sqlite3_step(statement) == SQLITE_DONE else { return false }
}
guard sqlite3_exec(database, "COMMIT;", nil, nil, nil) == SQLITE_OK else {
return false
}
committed = true
return true
}
func batch(accountID: String, limit: Int) -> [PendingUnregister] {
guard limit > 0, let statement = prepare(
"""
SELECT token_hex, account_id
FROM pending_unregister
WHERE account_id = ?
ORDER BY sequence
LIMIT ?;
"""
) else { return [] }
defer { sqlite3_finalize(statement) }
guard bind(accountID, to: statement, at: 1),
sqlite3_bind_int64(statement, 2, Int64(limit)) == SQLITE_OK else {
return []
}
var result: [PendingUnregister] = []
result.reserveCapacity(limit)
while sqlite3_step(statement) == SQLITE_ROW {
guard let token = sqlite3_column_text(statement, 0),
let account = sqlite3_column_text(statement, 1) else {
continue
}
result.append(PendingUnregister(
tokenHex: String(cString: token),
accountID: String(cString: account)
))
}
return result
}
@discardableResult
func remove(tokenHex: String, accountID: String) -> Bool {
guard let statement = prepare(
"""
DELETE FROM pending_unregister
WHERE token_hex = ? AND account_id = ?;
"""
) else { return false }
defer { sqlite3_finalize(statement) }
guard bind(tokenHex, to: statement, at: 1),
bind(accountID, to: statement, at: 2),
sqlite3_step(statement) == SQLITE_DONE else { return false }
compactFreedPages()
return true
}
@discardableResult
func removeAll(tokenHex: String) -> Bool {
guard let statement = prepare(
"DELETE FROM pending_unregister WHERE token_hex = ?;"
) else { return false }
defer { sqlite3_finalize(statement) }
guard bind(tokenHex, to: statement, at: 1),
sqlite3_step(statement) == SQLITE_DONE else { return false }
compactFreedPages()
return true
}
var hasEntries: Bool {
guard let statement = prepare(
"SELECT 1 FROM pending_unregister LIMIT 1;"
) else { return false }
defer { sqlite3_finalize(statement) }
return sqlite3_step(statement) == SQLITE_ROW
}
private func compactFreedPages() {
_ = sqlite3_exec(database, "PRAGMA incremental_vacuum(4);", nil, nil, nil)
}
private func execute(_ sql: String) throws {
guard sqlite3_exec(database, sql, nil, nil, nil) == SQLITE_OK else {
throw PendingUnregisterStoreError.schemaFailed
}
}
private func prepare(_ sql: String) -> OpaquePointer? {
var statement: OpaquePointer?
guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK else {
return nil
}
return statement
}
private func bind(
_ value: String,
to statement: OpaquePointer,
at index: Int32
) -> Bool {
value.withCString { pointer in
sqlite3_bind_text(
statement,
index,
pointer,
-1,
unsafeBitCast(-1, to: sqlite3_destructor_type.self)
) == SQLITE_OK
}
}
}
private enum PendingUnregisterStoreError: Error {
case openFailed
case schemaFailed
}
@@ -21,6 +21,17 @@ public protocol PushRegistering: Sendable {
/// removing it server-side on disable.
func setEnabled(_ enabled: Bool) async
/// Commits a coordinator-owned preference in generation order. Opt-out
/// cleanup runs in an app-owned worker and this call awaits its bounded
/// attempt without transferring cancellation ownership. Enabling is
/// persisted here but must wait for ``reconcileEnabledIntent(generation:)``
/// after iOS notification authorization succeeds.
func applyEnabledIntent(_ enabled: Bool, generation: UInt64) async
/// Starts backend registration for the current enabled intent after the
/// coordinator has confirmed that iOS permits notification delivery.
func reconcileEnabledIntent(generation: UInt64) async
/// Cache and (when opted in) upload a freshly registered APNs device token.
func register(deviceToken: Data) async
@@ -21,12 +21,32 @@ public actor PushRegistrationService: PushRegistering {
private let bundleID: String
private let apnsEnvironment: String
private let defaults: UserDefaults
private let pendingUnregisterStoreURL: URL
private var pendingUnregisterStore: PendingUnregisterStore?
private let session: URLSession
private let retryDelays: [Duration]
private let retryJitter: @Sendable (ClosedRange<Double>) -> Double
private let retrySleep: @Sendable (Duration) async throws -> Void
private let sessionSnapshotTimeout: Duration
private let sessionSnapshotClock: any Clock<Duration>
private let sessionSnapshotTimeoutRegistry = AuthPhaseTimeoutRegistry()
private let authLog = AuthDebugLog()
private var retryTask: Task<Void, Never>?
private var unregisterDrainTask: Task<Void, Never>?
/// App-lifetime, direction-owned workers let a privacy-sensitive opt-out
/// proceed while an older registration request is still in flight. One
/// stored task per direction bounds concurrency during rapid toggling.
private var enableIntentReconciliationTask: Task<Void, Never>?
private var disableIntentReconciliationTask: Task<Void, Never>?
private var enableIntentReconciliationRequested = false
private var disableIntentReconciliationRequested = false
private var coordinatorIntentGeneration: UInt64 = 0
private var coordinatorIntentEnabled: Bool?
private var coordinatorIntentReconciledGeneration: UInt64?
private var pendingUnregisterRecoveryTask: Task<Void, Never>?
private var pendingUnregisterRecoveryGeneration: UUID?
private var unregisterDrainPreferenceGeneration: UUID?
// Actor reentrancy lets a second lifecycle callback enter while the first
// POST is suspended in URLSession. Keep one in-flight upload per token so
// foreground refresh, auth revalidation, and APNs callbacks cannot create
@@ -35,7 +55,6 @@ public actor PushRegistrationService: PushRegistering {
private var uploadTask: Task<Void, Never>?
private var uploadTaskTokenHex: String?
private var uploadTaskGeneration: UUID?
private var uploadTaskAccountID: String?
private var operationGeneration = UUID()
private var snapshotValue: PushRegistrationSnapshot
private var snapshotContinuations:
@@ -50,6 +69,26 @@ public actor PushRegistrationService: PushRegistering {
"cmux.notifications.pendingUnregisters.v2"
private static let pendingUnregisterAttemptBudget = 4
private static func defaultPendingUnregisterStoreURL(
suiteName: String?,
bundleID: String
) -> URL {
let namespace = (suiteName ?? bundleID).map { character in
character.isLetter || character.isNumber || character == "-"
? character
: "_"
}
let root = FileManager.default.urls(
for: .applicationSupportDirectory,
in: .userDomainMask
).first ?? FileManager.default.temporaryDirectory
return root
.appendingPathComponent("cmux", isDirectory: true)
.appendingPathComponent(
"push-cleanup-\(String(namespace)).sqlite3"
)
}
/// Creates a push registration service.
///
/// - Parameters:
@@ -69,6 +108,7 @@ public actor PushRegistrationService: PushRegistering {
bundleID: String,
apnsEnvironment: String,
suiteName: String? = nil,
pendingUnregisterStoreURL: URL? = nil,
session: sending URLSession = .shared,
retryDelays: [Duration] = [
.seconds(1),
@@ -81,7 +121,9 @@ public actor PushRegistrationService: PushRegistering {
},
retrySleep: @escaping @Sendable (Duration) async throws -> Void = {
try await ContinuousClock().sleep(for: $0)
}
},
sessionSnapshotTimeout: Duration = .seconds(15),
sessionSnapshotClock: any Clock<Duration> = ContinuousClock()
) {
self.tokenProvider = tokenProvider
self.apiBaseURL = apiBaseURL
@@ -92,11 +134,30 @@ public actor PushRegistrationService: PushRegistering {
} else {
self.defaults = .standard
}
Self.migrateLegacyPendingUnregisters(in: self.defaults)
let storeURL = pendingUnregisterStoreURL
?? Self.defaultPendingUnregisterStoreURL(
suiteName: suiteName,
bundleID: bundleID
)
self.pendingUnregisterStoreURL = storeURL
do {
self.pendingUnregisterStore = try PendingUnregisterStore(
databaseURL: storeURL
)
} catch {
self.pendingUnregisterStore = nil
pushLog.error("Unable to open durable push-token cleanup store")
}
Self.migrateLegacyPendingUnregisters(
in: self.defaults,
overflowStore: self.pendingUnregisterStore
)
self.session = session
self.retryDelays = retryDelays
self.retryJitter = retryJitter
self.retrySleep = retrySleep
self.sessionSnapshotTimeout = sessionSnapshotTimeout
self.sessionSnapshotClock = sessionSnapshotClock
let enabled = self.defaults.bool(forKey: Self.enabledKey)
let hasToken = self.defaults.string(forKey: Self.cachedTokenKey)?.isEmpty == false
self.snapshotValue = PushRegistrationSnapshot(
@@ -113,6 +174,16 @@ public actor PushRegistrationService: PushRegistering {
public func snapshots() -> AsyncStream<PushRegistrationSnapshot> {
let id = UUID()
let hasKnownRegistration = cachedTokenHex != nil
&& defaults.string(
forKey: Self.registeredAccountIDKey
)?.isEmpty == false
if !isEnabled,
hasPendingUnregisters || hasKnownRegistration {
coordinatorIntentEnabled = false
disableIntentReconciliationRequested = true
scheduleDisableIntentReconciliation()
}
return AsyncStream { continuation in
snapshotContinuations[id] = continuation
continuation.yield(snapshotValue)
@@ -123,21 +194,142 @@ public actor PushRegistrationService: PushRegistering {
}
public func setEnabled(_ enabled: Bool) async {
let wasEnabled = isEnabled
// The UI commits the shared preference before crossing into this actor.
// Snapshot state therefore carries the prior service intent needed to
// decide whether an opt-out still owes backend cleanup.
let owesBackendCleanup = snapshotValue.isEnabled
|| defaults.string(forKey: Self.registeredAccountIDKey) != nil
cancelRetry()
operationGeneration = UUID()
let generation = operationGeneration
defaults.set(enabled, forKey: Self.enabledKey)
if enabled {
await syncTokenIfPossible()
} else {
publish(.disabled)
if wasEnabled {
await unregisterFromServer()
if owesBackendCleanup {
await unregisterFromServer(
preferenceGeneration: generation
)
} else {
await retryPendingUnregisterIfPossible()
await retryPendingUnregisterIfPossible(
preferenceGeneration: generation
)
}
}
}
/// Commits the coordinator's latest preference immediately. Disable starts
/// app-owned backend cleanup and awaits its bounded attempt; enable waits
/// for the coordinator's separate post-authorization reconciliation call.
public func applyEnabledIntent(
_ enabled: Bool,
generation: UInt64
) async {
guard generation >= coordinatorIntentGeneration else { return }
if generation == coordinatorIntentGeneration,
coordinatorIntentEnabled == enabled {
return
}
coordinatorIntentGeneration = generation
coordinatorIntentEnabled = enabled
coordinatorIntentReconciledGeneration = nil
operationGeneration = UUID()
cancelRetry()
defaults.set(enabled, forKey: Self.enabledKey)
if enabled {
let hasToken = cachedTokenHex != nil
publish(PushRegistrationSnapshot(
isEnabled: true,
hasDeviceToken: hasToken,
backendState: hasToken
? .registrationRequired
: .awaitingDeviceToken
))
} else {
if let tokenHex = cachedTokenHex,
let accountID = defaults.string(
forKey: Self.registeredAccountIDKey
),
!accountID.isEmpty {
// Persist the cleanup before the worker can suspend on auth.
persistPendingUnregister(
tokenHex: tokenHex,
accountID: accountID
)
}
publish(.disabled)
}
if !enabled {
disableIntentReconciliationRequested = true
scheduleDisableIntentReconciliation()
// The worker is app-owned, so cancellation of a stale Settings
// task cannot cancel privacy cleanup. Awaiting it preserves the
// public `disable()` completion guarantee for callers that clear
// authentication immediately afterwards.
await disableIntentReconciliationTask?.value
}
}
/// Reconciles an enabled intent only after iOS authorization has succeeded.
/// Stale generations cannot upload a cached APNs token.
public func reconcileEnabledIntent(generation: UInt64) async {
guard generation == coordinatorIntentGeneration,
coordinatorIntentEnabled == true,
isEnabled else { return }
coordinatorIntentReconciledGeneration = generation
enableIntentReconciliationRequested = true
scheduleEnableIntentReconciliation()
}
private func scheduleEnableIntentReconciliation() {
guard enableIntentReconciliationTask == nil else { return }
enableIntentReconciliationTask = Task { [weak self] in
await self?.drainEnableIntentReconciliation()
}
}
private func drainEnableIntentReconciliation() async {
while enableIntentReconciliationRequested {
enableIntentReconciliationRequested = false
guard coordinatorIntentEnabled == true else { continue }
await syncTokenIfPossible()
}
enableIntentReconciliationTask = nil
if enableIntentReconciliationRequested {
scheduleEnableIntentReconciliation()
}
}
private func scheduleDisableIntentReconciliation() {
guard disableIntentReconciliationTask == nil else { return }
disableIntentReconciliationTask = Task { [weak self] in
await self?.drainDisableIntentReconciliation()
}
}
private func drainDisableIntentReconciliation() async {
while disableIntentReconciliationRequested {
disableIntentReconciliationRequested = false
guard coordinatorIntentEnabled == false else { continue }
let generation = coordinatorIntentGeneration
let preferenceGeneration = operationGeneration
await unregisterFromServer(
preferenceGeneration: preferenceGeneration
)
await retryPendingUnregisterIfPossible(
preferenceGeneration: preferenceGeneration
)
guard generation == coordinatorIntentGeneration,
coordinatorIntentEnabled == false else { continue }
publish(.disabled)
}
disableIntentReconciliationTask = nil
if disableIntentReconciliationRequested {
scheduleDisableIntentReconciliation()
}
}
public func register(deviceToken: Data) async {
let hex = deviceToken.map { String(format: "%02x", $0) }.joined()
let previousToken = cachedTokenHex
@@ -157,8 +349,16 @@ public actor PushRegistrationService: PushRegistering {
defaults.removeObject(forKey: Self.registeredAccountIDKey)
}
defaults.set(hex, forKey: Self.cachedTokenKey)
guard isEnabled else {
publish(.disabled)
guard canUploadForCurrentIntent else {
publish(
isEnabled
? PushRegistrationSnapshot(
isEnabled: true,
hasDeviceToken: true,
backendState: .registrationRequired
)
: .disabled
)
return
}
// A repeated callback for the same cached token should cancel only a
@@ -178,6 +378,16 @@ public actor PushRegistrationService: PushRegistering {
publish(.disabled)
return
}
guard canUploadForCurrentIntent else {
publish(PushRegistrationSnapshot(
isEnabled: true,
hasDeviceToken: cachedTokenHex != nil,
backendState: cachedTokenHex == nil
? .awaitingDeviceToken
: .registrationRequired
))
return
}
guard let hex = cachedTokenHex else {
publish(PushRegistrationSnapshot(
isEnabled: true,
@@ -202,12 +412,45 @@ public actor PushRegistrationService: PushRegistering {
}
public func unregisterFromServer() async {
// Treat direct cleanup retries as the current opt-out operation too,
// so a newer enable can supersede an in-flight DELETE and trigger the
// same final re-upload repair as coordinator-owned cleanup.
cancelRetry()
await unregisterFromServer(
preferenceGeneration: operationGeneration,
requiresDisabledPreference: false
)
}
private func unregisterFromServer(
preferenceGeneration: UUID?,
requiresDisabledPreference: Bool = true
) async {
if preferenceGeneration == nil {
cancelRetry()
}
guard let hex = cachedTokenHex else { return }
let session = try? await tokenProvider.authenticatedSessionSnapshot()
let ownerID = defaults.string(
let registeredOwnerID = defaults.string(
forKey: Self.registeredAccountIDKey
) ?? session?.accountID
)
if let registeredOwnerID, !registeredOwnerID.isEmpty {
// Record the privacy cleanup before any authentication await. A
// stalled session restore must not lose an already-known owner.
persistPendingUnregister(
tokenHex: hex,
accountID: registeredOwnerID
)
}
let session = await boundedSessionSnapshot(
phase: .pushUnregistrationSession,
recoveryGeneration: preferenceGeneration
)
if let preferenceGeneration,
preferenceGeneration != operationGeneration
|| (requiresDisabledPreference && isEnabled) {
return
}
let ownerID = registeredOwnerID ?? session?.accountID
guard let ownerID, !ownerID.isEmpty else { return }
// Persist before requiring live auth. This is the privacy guarantee for
// an offline or signed-out opt-out.
@@ -218,6 +461,19 @@ public actor PushRegistrationService: PushRegistering {
if await sendDelete(tokenHex: hex, sessionSnapshot: session) {
clearPendingUnregister(tokenHex: hex, accountID: ownerID)
clearRegisteredOwner(accountID: ownerID, tokenHex: hex)
let preferenceWasSuperseded = preferenceGeneration.map {
$0 != operationGeneration
|| (requiresDisabledPreference && isEnabled)
} ?? false
if preferenceWasSuperseded,
enableIntentIsReconciled,
let currentToken = cachedTokenHex,
currentToken == hex {
// A newer enable may have posted while this older DELETE was
// already in flight. Re-upsert after the DELETE acknowledgement
// so the latest preference is also the final backend state.
await upload(tokenHex: hex)
}
}
}
@@ -298,38 +554,51 @@ public actor PushRegistrationService: PushRegistering {
tokenHex: String,
replacingGeneration: UUID? = nil
) async {
let requestedAccountID = (try? await tokenProvider
.authenticatedSessionSnapshot())?.accountID
if let uploadTask,
uploadTaskTokenHex == tokenHex,
uploadTaskGeneration == operationGeneration,
uploadTaskGeneration != replacingGeneration,
uploadTaskAccountID == requestedAccountID {
await uploadTask.value
while canUploadForCurrentIntent, cachedTokenHex == tokenHex {
if let inFlightTask = uploadTask,
uploadTaskGeneration != replacingGeneration {
let inFlightGeneration = uploadTaskGeneration
await inFlightTask.value
if uploadTaskGeneration == inFlightGeneration {
uploadTask = nil
uploadTaskTokenHex = nil
uploadTaskGeneration = nil
}
guard canUploadForCurrentIntent,
cachedTokenHex == tokenHex else { return }
if snapshotValue.backendState == .registered {
return
}
// The mutation already represented the current operation. A
// newer generation loops and starts only after it completes.
if inFlightGeneration == operationGeneration {
return
}
continue
}
operationGeneration = UUID()
let generation = operationGeneration
let retryDelays = self.retryDelays
let task = Task { [weak self, retryDelays] in
guard let self else { return }
await self.attemptUpload(
tokenHex: tokenHex,
generation: generation,
remainingDelays: retryDelays
)
}
uploadTask = task
uploadTaskTokenHex = tokenHex
uploadTaskGeneration = generation
await task.value
if uploadTaskGeneration == generation {
uploadTask = nil
uploadTaskTokenHex = nil
uploadTaskGeneration = nil
}
return
}
operationGeneration = UUID()
let generation = operationGeneration
let retryDelays = self.retryDelays
let task = Task { [weak self, retryDelays] in
guard let self else { return }
await self.attemptUpload(
tokenHex: tokenHex,
generation: generation,
remainingDelays: retryDelays
)
}
uploadTask = task
uploadTaskTokenHex = tokenHex
uploadTaskGeneration = generation
uploadTaskAccountID = requestedAccountID
await task.value
if uploadTaskGeneration == generation {
uploadTask = nil
uploadTaskTokenHex = nil
uploadTaskGeneration = nil
uploadTaskAccountID = nil
}
}
private func attemptUpload(
@@ -337,7 +606,8 @@ public actor PushRegistrationService: PushRegistering {
generation: UUID,
remainingDelays: [Duration]
) async {
guard isEnabled, generation == operationGeneration,
guard canUploadForCurrentIntent,
generation == operationGeneration,
cachedTokenHex == tokenHex else { return }
publish(PushRegistrationSnapshot(
isEnabled: true,
@@ -352,13 +622,23 @@ public actor PushRegistrationService: PushRegistering {
"bundleId": bundleID,
"environment": apnsEnvironment,
"platform": "ios",
]
],
authPhase: .pushRegistrationSession
)
let result: RegistrationResult
let requestSession: AuthenticatedSessionSnapshot?
switch request {
case let .success(context):
requestSession = context.session
// A POST can commit before its response reaches the app. Record
// the owner first so a crash followed by an opt-out relaunch still
// has enough identity to delete that ambiguous registration.
if let requestSession = context.session {
persistPendingUnregister(
tokenHex: tokenHex,
accountID: requestSession.accountID
)
}
result = await performRegistration(context.request)
case let .failure(failure):
requestSession = nil
@@ -395,6 +675,9 @@ public actor PushRegistrationService: PushRegistering {
}
switch result {
case let .success(pushServiceConfigured):
let previousOwnerID = defaults.string(
forKey: Self.registeredAccountIDKey
)
if let requestSession {
defaults.set(
requestSession.accountID,
@@ -405,11 +688,8 @@ public actor PushRegistrationService: PushRegistering {
// current account also removes any old-account association, so a
// pending tombstone for this token is fulfilled without applying
// old credentials.
for pending in pendingUnregisters where pending.tokenHex == tokenHex {
clearPendingUnregister(
tokenHex: pending.tokenHex,
accountID: pending.accountID
)
if previousOwnerID != nil || hasPendingUnregisters {
clearPendingUnregisterToken(tokenHex: tokenHex)
}
if pushServiceConfigured {
publish(PushRegistrationSnapshot(
@@ -492,8 +772,9 @@ public actor PushRegistrationService: PushRegistering {
staleSession: AuthenticatedSessionSnapshot,
staleGeneration: UUID
) async {
let currentSession = try? await tokenProvider
.authenticatedSessionSnapshot()
let currentSession = await boundedSessionSnapshot(
phase: .pushRegistrationSession
)
if isEnabled,
cachedTokenHex == tokenHex,
currentSession?.accountID == staleSession.accountID {
@@ -522,8 +803,9 @@ public actor PushRegistrationService: PushRegistering {
}
guard isEnabled, let currentToken = cachedTokenHex,
let currentSession = try? await tokenProvider
.authenticatedSessionSnapshot(),
let currentSession = await boundedSessionSnapshot(
phase: .pushRegistrationSession
),
await tokenProvider.isAuthenticatedSessionCurrent(currentSession)
else { return }
await upload(tokenHex: currentToken, replacingGeneration: staleGeneration)
@@ -538,10 +820,14 @@ public actor PushRegistrationService: PushRegistering {
guard case let .success(context) = await makeRequest(
method: "DELETE",
path: "/api/device-tokens",
body: ["deviceToken": tokenHex],
body: [
"deviceToken": tokenHex,
"bundleId": bundleID,
],
capturedAccessToken: capturedAccessToken,
capturedRefreshToken: capturedRefreshToken,
sessionSnapshot: sessionSnapshot
sessionSnapshot: sessionSnapshot,
authPhase: .pushUnregistrationSession
) else { return false }
guard await performDelete(context.request) else { return false }
if let session = context.session {
@@ -556,7 +842,8 @@ public actor PushRegistrationService: PushRegistering {
body: [String: String],
capturedAccessToken: String? = nil,
capturedRefreshToken: String? = nil,
sessionSnapshot: AuthenticatedSessionSnapshot? = nil
sessionSnapshot: AuthenticatedSessionSnapshot? = nil,
authPhase: AuthPhase
) async -> Result<PushRequest, PushRegistrationFailure> {
let accessToken: String
let refreshToken: String
@@ -572,15 +859,14 @@ public actor PushRegistrationService: PushRegistering {
refreshToken = capturedRefreshToken
authenticatedSession = nil
} else {
do {
let session = try await tokenProvider
.authenticatedSessionSnapshot()
accessToken = session.accessToken
refreshToken = session.refreshToken
authenticatedSession = session
} catch {
guard let session = await boundedSessionSnapshot(
phase: authPhase
) else {
return .failure(.authenticationRequired)
}
accessToken = session.accessToken
refreshToken = session.refreshToken
authenticatedSession = session
}
guard let url = URL(string: apiBaseURL + path) else {
return .failure(.invalidConfiguration)
@@ -589,6 +875,7 @@ public actor PushRegistrationService: PushRegistering {
request.httpMethod = method
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
request.setValue(refreshToken, forHTTPHeaderField: "X-Stack-Refresh-Token")
request.setValue(bundleID, forHTTPHeaderField: "X-Cmux-App-Namespace")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
request.timeoutInterval = 15
@@ -661,12 +948,31 @@ public actor PushRegistrationService: PushRegistering {
}
}
private func retryPendingUnregisterIfPossible() async {
guard let session = try? await tokenProvider
.authenticatedSessionSnapshot() else { return }
private func retryPendingUnregisterIfPossible(
preferenceGeneration: UUID? = nil
) async {
guard let session = await boundedSessionSnapshot(
phase: .pushUnregistrationSession,
recoveryGeneration: preferenceGeneration
) else { return }
if let preferenceGeneration,
preferenceGeneration != operationGeneration || isEnabled {
return
}
let currentAccountID = session.accountID
let matching = pendingUnregisters.filter {
$0.accountID == currentAccountID
var seen = Set<PendingUnregister>()
let matching = (
pendingUnregisterOverflowBatch(
accountID: currentAccountID,
// Keep one lookahead entry so a bounded batch can tell
// whether another continuation is required.
limit: Self.pendingUnregisterAttemptBudget + 1
) + pendingUnregisterFallbackBatch(
accountID: currentAccountID,
limit: Self.pendingUnregisterAttemptBudget + 1
)
).filter {
seen.insert($0).inserted
}
let batch = Array(
matching.prefix(Self.pendingUnregisterAttemptBudget)
@@ -702,25 +1008,92 @@ public actor PushRegistrationService: PushRegistering {
tokenHex: pending.tokenHex
)
}
let preferenceWasSuperseded = preferenceGeneration.map {
$0 != operationGeneration || isEnabled
} ?? false
if preferenceWasSuperseded,
enableIntentIsReconciled,
let currentToken = cachedTokenHex,
results.contains(where: {
$0.0.tokenHex == currentToken && $0.1
}) {
// A newer enable raced cleanup that was already sent. Restore the
// current token only after every acknowledged DELETE has finished.
await upload(tokenHex: currentToken)
return
}
guard !preferenceWasSuperseded else { return }
if matching.count > batch.count,
results.contains(where: { $0.1 }) {
schedulePendingUnregisterContinuation()
schedulePendingUnregisterContinuation(
preferenceGeneration: preferenceGeneration
)
}
}
private func boundedSessionSnapshot(
phase: AuthPhase,
recoveryGeneration: UUID? = nil
) async -> AuthenticatedSessionSnapshot? {
let tokenProvider = tokenProvider
do {
return try await withAuthPhaseTimeout(
phase,
duration: sessionSnapshotTimeout,
clock: sessionSnapshotClock,
log: authLog,
registry: sessionSnapshotTimeoutRegistry,
blocksRetriesWhileTimedOutOperationActive: true
) {
// This provider API only reads a coherent stored token pair or
// awaits bounded launch bootstrap. Cancelling it cannot leave
// an ambiguous server mutation behind.
try await tokenProvider.authenticatedSessionSnapshot()
}
} catch let error as AuthError where error == .timedOut {
schedulePendingUnregisterRecovery(
preferenceGeneration: recoveryGeneration
)
return nil
} catch {
return nil
}
}
private var enableIntentIsReconciled: Bool {
guard isEnabled else { return false }
if coordinatorIntentEnabled == true {
return coordinatorIntentReconciledGeneration
== coordinatorIntentGeneration
}
return coordinatorIntentEnabled == nil
}
private var canUploadForCurrentIntent: Bool {
enableIntentIsReconciled
}
private func persistPendingUnregister(tokenHex: String, accountID: String) {
let entry = PendingUnregister(tokenHex: tokenHex, accountID: accountID)
var queue = pendingUnregisters
if !queue.contains(entry) {
queue.append(entry)
if durablePendingUnregisterStore()?.insert(entry) == true {
// SQLite is durable before the legacy fallback is removed.
storePendingUnregisters(
pendingUnregisters.filter { $0 != entry }
)
return
}
// Never evict a privacy cleanup obligation merely to enforce a local
// storage cap. The set is deduplicated by (account, token), and drains
// in bounded network batches so size cannot stall current readiness.
var queue = pendingUnregisters
queue.removeAll { $0 == entry }
queue.append(entry)
storePendingUnregisters(queue)
}
private func schedulePendingUnregisterContinuation() {
private func schedulePendingUnregisterContinuation(
preferenceGeneration: UUID? = nil
) {
if let preferenceGeneration {
unregisterDrainPreferenceGeneration = preferenceGeneration
}
guard unregisterDrainTask == nil else { return }
unregisterDrainTask = Task { [weak self] in
await Task.yield()
@@ -729,19 +1102,57 @@ public actor PushRegistrationService: PushRegistering {
}
}
private func schedulePendingUnregisterRecovery(
preferenceGeneration: UUID?
) {
if let preferenceGeneration {
pendingUnregisterRecoveryGeneration = preferenceGeneration
}
guard pendingUnregisterRecoveryTask == nil else { return }
let clock = sessionSnapshotClock
pendingUnregisterRecoveryTask = Task { [weak self, clock] in
do {
// AuthPhaseTimeoutRegistry holds a timed-out phase for 30s.
// Wait past that lease before asking the worker to retry.
try await clock.sleep(for: .seconds(31))
} catch {
return
}
guard !Task.isCancelled, let self else { return }
await self.finishPendingUnregisterRecovery()
}
}
private func finishPendingUnregisterRecovery() {
pendingUnregisterRecoveryTask = nil
let generation = pendingUnregisterRecoveryGeneration
pendingUnregisterRecoveryGeneration = nil
guard hasPendingUnregisters else { return }
schedulePendingUnregisterContinuation(
preferenceGeneration: generation
)
}
private func runPendingUnregisterContinuation() async {
unregisterDrainTask = nil
await retryPendingUnregisterIfPossible()
let generation = unregisterDrainPreferenceGeneration
unregisterDrainPreferenceGeneration = nil
await retryPendingUnregisterIfPossible(
preferenceGeneration: generation
)
}
private func clearPendingUnregister(
tokenHex: String,
accountID: String
) {
let filtered = pendingUnregisters.filter { entry in
_ = durablePendingUnregisterStore()?.remove(
tokenHex: tokenHex,
accountID: accountID
)
storePendingUnregisters(pendingUnregisters.filter { entry in
entry.tokenHex != tokenHex || entry.accountID != accountID
}
storePendingUnregisters(filtered)
})
}
private var pendingUnregisters: [PendingUnregister] {
@@ -760,44 +1171,114 @@ public actor PushRegistrationService: PushRegistering {
}
private static func migrateLegacyPendingUnregisters(
in defaults: UserDefaults
in defaults: UserDefaults,
overflowStore: PendingUnregisterStore?
) {
guard let tokenHex = defaults.string(
forKey: pendingUnregisterTokenKey
), let accountID = defaults.string(
forKey: pendingUnregisterAccountIDKey
), !tokenHex.isEmpty, !accountID.isEmpty else { return }
var entries = (defaults.data(forKey: pendingUnregisterQueueKey)
.flatMap { try? JSONDecoder().decode(
[PendingUnregister].self,
from: $0
) }) ?? []
let legacy = PendingUnregister(
tokenHex: tokenHex,
accountID: accountID
)
if !entries.contains(legacy) { entries.append(legacy) }
if let data = try? JSONEncoder().encode(entries) {
defaults.set(data, forKey: pendingUnregisterQueueKey)
if let tokenHex = defaults.string(
forKey: pendingUnregisterTokenKey
), let accountID = defaults.string(
forKey: pendingUnregisterAccountIDKey
), !tokenHex.isEmpty, !accountID.isEmpty {
let legacy = PendingUnregister(
tokenHex: tokenHex,
accountID: accountID
)
entries.removeAll { $0 == legacy }
entries.append(legacy)
}
var seen = Set<PendingUnregister>()
var newestFirst: [PendingUnregister] = []
for entry in entries.reversed() where seen.insert(entry).inserted {
newestFirst.append(entry)
}
let normalized = Array(newestFirst.reversed())
guard normalized.isEmpty
|| overflowStore?.insertAll(normalized) == true else {
// Keep every legacy key intact when durable migration fails.
return
}
defaults.removeObject(forKey: pendingUnregisterQueueKey)
defaults.removeObject(forKey: pendingUnregisterTokenKey)
defaults.removeObject(forKey: pendingUnregisterAccountIDKey)
}
private func storePendingUnregisters(_ entries: [PendingUnregister]) {
if entries.isEmpty {
var seen = Set<PendingUnregister>()
var newestFirst: [PendingUnregister] = []
for entry in entries.reversed() where seen.insert(entry).inserted {
newestFirst.append(entry)
}
let normalized = Array(newestFirst.reversed())
if normalized.isEmpty {
defaults.removeObject(forKey: Self.pendingUnregisterQueueKey)
defaults.removeObject(forKey: Self.pendingUnregisterTokenKey)
defaults.removeObject(forKey: Self.pendingUnregisterAccountIDKey)
return
}
if let data = try? JSONEncoder().encode(entries) {
if let data = try? JSONEncoder().encode(normalized) {
defaults.set(data, forKey: Self.pendingUnregisterQueueKey)
}
defaults.removeObject(forKey: Self.pendingUnregisterTokenKey)
defaults.removeObject(forKey: Self.pendingUnregisterAccountIDKey)
}
private var hasPendingUnregisters: Bool {
durablePendingUnregisterStore()?.hasEntries == true
|| !pendingUnregisters.isEmpty
}
private func pendingUnregisterOverflowBatch(
accountID: String,
limit: Int
) -> [PendingUnregister] {
durablePendingUnregisterStore()?.batch(
accountID: accountID,
limit: limit
) ?? []
}
private func pendingUnregisterFallbackBatch(
accountID: String,
limit: Int
) -> [PendingUnregister] {
Array(pendingUnregisters.lazy.filter {
$0.accountID == accountID
}.prefix(limit))
}
private func clearPendingUnregisterToken(tokenHex: String) {
_ = durablePendingUnregisterStore()?.removeAll(tokenHex: tokenHex)
storePendingUnregisters(
pendingUnregisters.filter { $0.tokenHex != tokenHex }
)
}
private func durablePendingUnregisterStore() -> PendingUnregisterStore? {
if let pendingUnregisterStore {
return pendingUnregisterStore
}
do {
let store = try PendingUnregisterStore(
databaseURL: pendingUnregisterStoreURL
)
pendingUnregisterStore = store
Self.migrateLegacyPendingUnregisters(
in: defaults,
overflowStore: store
)
pushLog.info("Recovered durable push-token cleanup store")
return store
} catch {
pushLog.error("Unable to recover durable push-token cleanup store")
return nil
}
}
private func clearRegisteredOwner(
accountID: String,
tokenHex: String
@@ -956,8 +1437,3 @@ private struct RegistrationErrorResponse: Decodable {
let retryAfterSeconds: Int?
let limit: Int?
}
private struct PendingUnregister: Codable, Hashable {
let tokenHex: String
let accountID: String
}
@@ -19,15 +19,28 @@ public actor KeychainStackTokenStore: StackAuthTokenStoreProtocol {
private static let accessTokenAccount = "cmux-auth-access-token"
private static let refreshTokenAccount = "cmux-auth-refresh-token"
private let service: String
private let accessGroup: String?
private let legacyProjectID: String?
private let log = AuthDebugLog()
private var cachedAccessToken: String?
private var cachedRefreshToken: String?
/// Creates a keychain store writing under `service`.
/// - Parameter service: The keychain service name; see ``serviceName(bundleIdentifier:)``.
public init(service: String) {
/// Creates a Keychain store writing under one exact signed access group.
///
/// - Parameters:
/// - service: The bundle-scoped Keychain service.
/// - accessGroup: The app's exact signed Keychain access group.
/// - legacyProjectID: The Stack project whose older account-only items
/// may be adopted from this same access group.
public init(
service: String,
accessGroup: String? = nil,
legacyProjectID: String? = nil
) {
self.service = service
self.accessGroup = accessGroup
self.legacyProjectID = legacyProjectID
}
/// The keychain service name auth tokens are stored under, namespaced by
@@ -43,12 +56,18 @@ public actor KeychainStackTokenStore: StackAuthTokenStoreProtocol {
public func getStoredAccessToken() async -> String? {
if let cachedAccessToken { return cachedAccessToken }
return keychainRead(account: Self.accessTokenAccount)
return readOrAdoptLegacyToken(
account: Self.accessTokenAccount,
legacyAccount: legacyProjectID.map { "stack-auth-access-\($0)" }
)
}
public func getStoredRefreshToken() async -> String? {
if let cachedRefreshToken { return cachedRefreshToken }
return keychainRead(account: Self.refreshTokenAccount)
return readOrAdoptLegacyToken(
account: Self.refreshTokenAccount,
legacyAccount: legacyProjectID.map { "stack-auth-refresh-\($0)" }
)
}
public func setTokens(accessToken: String?, refreshToken: String?) async {
@@ -83,13 +102,20 @@ public actor KeychainStackTokenStore: StackAuthTokenStoreProtocol {
cachedRefreshToken = nil
keychainDelete(account: Self.accessTokenAccount)
keychainDelete(account: Self.refreshTokenAccount)
deleteLegacyTokens()
}
@discardableResult
public func clearTokensIfCurrent(accessToken: String?, refreshToken: String?) async -> Bool {
let snapshot = AuthTokenSnapshot(
accessToken: keychainRead(account: Self.accessTokenAccount),
refreshToken: keychainRead(account: Self.refreshTokenAccount)
accessToken: readOrAdoptLegacyToken(
account: Self.accessTokenAccount,
legacyAccount: legacyProjectID.map { "stack-auth-access-\($0)" }
),
refreshToken: readOrAdoptLegacyToken(
account: Self.refreshTokenAccount,
legacyAccount: legacyProjectID.map { "stack-auth-refresh-\($0)" }
)
)
guard snapshot.matches(expectedAccessToken: accessToken, expectedRefreshToken: refreshToken) else {
log.log("keychain.clearTokensIfCurrent: skipped stale clear")
@@ -110,7 +136,10 @@ public actor KeychainStackTokenStore: StackAuthTokenStoreProtocol {
newRefreshToken: String?,
newAccessToken: String?
) async {
let current = keychainRead(account: Self.refreshTokenAccount)
let current = readOrAdoptLegacyToken(
account: Self.refreshTokenAccount,
legacyAccount: legacyProjectID.map { "stack-auth-refresh-\($0)" }
)
let matches = current == compareRefreshToken
log.log("keychain.compareAndSet: matches=\(matches) hasNewRefresh=\(newRefreshToken?.isEmpty == false) hasNewAccess=\(newAccessToken?.isEmpty == false)")
guard matches else { return }
@@ -121,13 +150,58 @@ public actor KeychainStackTokenStore: StackAuthTokenStoreProtocol {
}
#if canImport(Security)
private func readOrAdoptLegacyToken(
account: String,
legacyAccount: String?
) -> String? {
if let current = keychainRead(account: account) {
return current
}
// Legacy account-only items are ambiguous without the exact signed
// access group. Never let a caller using the current-token-only API
// adopt another installed cmux bundle's Stack session.
guard accessGroup != nil,
let legacyAccount,
let legacy = keychainReadLegacy(account: legacyAccount),
keychainWrite(legacy, account: account) else {
return nil
}
keychainDeleteLegacy(account: legacyAccount)
return legacy
}
private func deleteLegacyTokens() {
guard accessGroup != nil, let legacyProjectID else { return }
keychainDeleteLegacy(account: "stack-auth-access-\(legacyProjectID)")
keychainDeleteLegacy(account: "stack-auth-refresh-\(legacyProjectID)")
}
private func baseQuery(account: String) -> [String: Any] {
[
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecUseDataProtectionKeychain as String: true,
]
if let accessGroup {
query[kSecAttrAccessGroup as String] = accessGroup
}
return query
}
private func legacyBaseQuery(account: String) -> [String: Any] {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
// The legacy Stack SDK omitted this attribute when adding items,
// which Keychain persists as the empty service. An omitted query
// attribute is a wildcard and could match another credential.
kSecAttrService as String: "",
kSecAttrAccount as String: account,
]
if let accessGroup {
query[kSecAttrAccessGroup as String] = accessGroup
}
return query
}
private func keychainRead(account: String) -> String? {
@@ -170,7 +244,28 @@ public actor KeychainStackTokenStore: StackAuthTokenStoreProtocol {
private func keychainDelete(account: String) {
_ = SecItemDelete(baseQuery(account: account) as CFDictionary)
}
private func keychainReadLegacy(account: String) -> String? {
var query = legacyBaseQuery(account: account)
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else {
if status != errSecItemNotFound {
log.log("keychain legacy READ status=\(status) account=\(account)")
}
return nil
}
return String(data: data, encoding: .utf8)
}
private func keychainDeleteLegacy(account: String) {
_ = SecItemDelete(legacyBaseQuery(account: account) as CFDictionary)
}
#else
private func readOrAdoptLegacyToken(account: String, legacyAccount: String?) -> String? { nil }
private func deleteLegacyTokens() {}
private func keychainRead(account: String) -> String? { nil }
private func keychainWrite(_ value: String, account: String) -> Bool { false }
private func keychainDelete(account: String) {}
@@ -0,0 +1,56 @@
import Foundation
@testable import CmuxAuthRuntime
actor CancellationIgnoringPushTokenProvider: TokenProviding {
private let snapshotValue = AuthenticatedSessionSnapshot(
generation: 1,
accountID: "push-user-1",
accessToken: "access",
refreshToken: "refresh"
)
private let started: TestPhaseSignal
private let blocker: TestContinuationBlocker
private let cancellationObserved = TestPhaseSignal()
private let completed = TestPhaseSignal()
private(set) var snapshotRequestCount = 0
init(started: TestPhaseSignal, blocker: TestContinuationBlocker) {
self.started = started
self.blocker = blocker
}
func authenticatedSessionSnapshot() async throws
-> AuthenticatedSessionSnapshot {
snapshotRequestCount += 1
await started.markStarted()
let cancellationObserved = cancellationObserved
return await withTaskCancellationHandler {
await blocker.wait()
await completed.markStarted()
return snapshotValue
} onCancel: {
Task { await cancellationObserved.markStarted() }
}
}
func waitUntilCancellationObserved() async {
await cancellationObserved.waitUntilStarted()
}
func waitUntilCompleted() async {
await completed.waitUntilStarted()
}
func isAuthenticatedSessionCurrent(
_ snapshot: AuthenticatedSessionSnapshot
) async -> Bool {
snapshot == snapshotValue
}
func accessToken() async throws -> String { snapshotValue.accessToken }
func storedAccessToken() async -> String? { snapshotValue.accessToken }
func refreshToken() async -> String? { snapshotValue.refreshToken }
func forceRefreshAccessToken() async throws -> String {
snapshotValue.accessToken
}
}
@@ -0,0 +1,47 @@
import Foundation
#if canImport(Security)
import Security
#endif
import Testing
@testable import CmuxAuthRuntime
@Suite(.serialized)
struct KeychainStackTokenStoreTests {
#if canImport(Security)
@Test func clearingLegacyTokensPreservesSameAccountInAnotherService() async throws {
let projectID = UUID().uuidString
let account = "stack-auth-access-\(projectID)"
let unrelatedService = "cmux-test-unrelated-\(UUID().uuidString)"
let unrelatedToken = Data("unrelated-token".utf8)
let unrelatedQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: unrelatedService,
kSecAttrAccount as String: account,
]
_ = SecItemDelete(unrelatedQuery as CFDictionary)
defer { _ = SecItemDelete(unrelatedQuery as CFDictionary) }
var insertion = unrelatedQuery
insertion[kSecValueData as String] = unrelatedToken
insertion[kSecAttrAccessible as String] =
kSecAttrAccessibleAfterFirstUnlock
try #require(SecItemAdd(insertion as CFDictionary, nil) == errSecSuccess)
let store = KeychainStackTokenStore(
service: "cmux-test-current-\(UUID().uuidString)",
legacyProjectID: projectID
)
await store.clearTokens()
var lookup = unrelatedQuery
lookup[kSecReturnData as String] = true
lookup[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
#expect(
SecItemCopyMatching(lookup as CFDictionary, &result)
== errSecSuccess
)
#expect(result as? Data == unrelatedToken)
}
#endif
}
@@ -159,6 +159,20 @@ actor RetryDelayRecorder {
// append to the same singleton between this test's reset and its assertion,
// failing nondeterministically. `.serialized` removes that interleaving.
@Suite(.serialized) struct PushRegistrationServiceTests {
private func testPendingUnregisterStoreURL(for suite: String) -> URL {
FileManager.default.temporaryDirectory
.appendingPathComponent("push-cleanup-\(suite).sqlite3")
}
private func pendingUnregisters(
suite: String,
accountID: String
) -> [PendingUnregister] {
(try? PendingUnregisterStore(
databaseURL: testPendingUnregisterStoreURL(for: suite)
).batch(accountID: accountID, limit: 100)) ?? []
}
private func makeService(
tokenProvider: any TokenProviding = FakeTokenProvider()
) -> (PushRegistrationService, UserDefaults) {
@@ -172,6 +186,9 @@ actor RetryDelayRecorder {
bundleID: "dev.cmux.ios",
apnsEnvironment: "sandbox",
suiteName: suite,
pendingUnregisterStoreURL: testPendingUnregisterStoreURL(
for: suite
),
session: URLSession(configuration: configuration)
)
return (service, defaults)
@@ -185,7 +202,10 @@ actor RetryDelayRecorder {
seedDefaults: (UserDefaults) -> Void = { _ in },
retrySleep: @escaping @Sendable (Duration) async throws -> Void = {
try await ContinuousClock().sleep(for: $0)
}
},
sessionSnapshotTimeout: Duration = .seconds(15),
sessionSnapshotClock: any Clock<Duration> = ContinuousClock(),
pendingUnregisterStoreURL: URL? = nil
) -> (PushRegistrationService, UserDefaults) {
let defaults = UserDefaults(suiteName: suite)!
seedDefaults(defaults)
@@ -209,10 +229,14 @@ actor RetryDelayRecorder {
bundleID: "dev.cmux.ios.push1",
apnsEnvironment: "sandbox",
suiteName: suite,
pendingUnregisterStoreURL: pendingUnregisterStoreURL
?? testPendingUnregisterStoreURL(for: suite),
session: URLSession(configuration: configuration),
retryDelays: retryDelays,
retryJitter: { _ in 1 },
retrySleep: retrySleep
retrySleep: retrySleep,
sessionSnapshotTimeout: sessionSnapshotTimeout,
sessionSnapshotClock: sessionSnapshotClock
)
return (service, defaults)
}
@@ -288,6 +312,10 @@ actor RetryDelayRecorder {
}
#expect(request?.httpMethod == "DELETE")
#expect(request?.value(forHTTPHeaderField: "X-Stack-Refresh-Token") == "captured-refresh")
#expect(
request?.value(forHTTPHeaderField: "X-Cmux-App-Namespace")
== "dev.cmux.ios"
)
}
@Test func signOutUnregisterNeverFallsBackToLiveProvider() async {
@@ -328,14 +356,10 @@ actor RetryDelayRecorder {
refreshToken: "captured-refresh"
)
let queueData = defaults.data(
forKey: "cmux.notifications.pendingUnregisters.v2"
)
let queue = queueData.flatMap {
try? JSONSerialization.jsonObject(with: $0)
as? [[String: String]]
}
#expect(queue == [["tokenHex": "ab", "accountID": "old-user"]])
#expect(pendingUnregisters(
suite: suite,
accountID: "old-user"
) == [PendingUnregister(tokenHex: "ab", accountID: "old-user")])
#expect(
defaults.string(forKey: "cmux.notifications.pendingUnregisterToken")
== nil
@@ -392,11 +416,14 @@ actor RetryDelayRecorder {
forKey: "cmux.notifications.registeredAccountID"
) == "account-a"
)
let queueText = defaults.data(
forKey: "cmux.notifications.pendingUnregisters.v2"
).flatMap { String(data: $0, encoding: .utf8) }
#expect(queueText?.contains("account-a") == true)
#expect(queueText?.contains("account-b") == false)
#expect(pendingUnregisters(
suite: suite,
accountID: "account-a"
) == [PendingUnregister(tokenHex: "aa", accountID: "account-a")])
#expect(pendingUnregisters(
suite: suite,
accountID: "account-b"
).isEmpty)
}
@Test func legacySignOutCannotProveRegisteredOwnerMatchesCredentials() async {
@@ -418,10 +445,10 @@ actor RetryDelayRecorder {
)
#expect(await PushRegistrationURLProtocol.script.requests.isEmpty)
let queueText = defaults.data(
forKey: "cmux.notifications.pendingUnregisters.v2"
).flatMap { String(data: $0, encoding: .utf8) }
#expect(queueText?.contains("account-a") == true)
#expect(pendingUnregisters(
suite: suite,
accountID: "account-a"
) == [PendingUnregister(tokenHex: "aa", accountID: "account-a")])
}
@Test func enabledWithoutAPNsTokenReportsAwaitingTokenInsteadOfReady() async {
@@ -737,13 +764,10 @@ actor RetryDelayRecorder {
await service.setEnabled(false)
let persisted = try? JSONDecoder().decode(
[[String: String]].self,
from: defaults.data(
forKey: "cmux.notifications.pendingUnregisters.v2"
) ?? Data()
)
#expect(persisted == [["tokenHex": "ab", "accountID": "account-a"]])
#expect(pendingUnregisters(
suite: suite,
accountID: "account-a"
) == [PendingUnregister(tokenHex: "ab", accountID: "account-a")])
#expect(await PushRegistrationURLProtocol.script.requests.isEmpty)
let (returned, _) = makeScriptedService(
@@ -767,6 +791,28 @@ actor RetryDelayRecorder {
)
}
@Test func relaunchRecoversOptOutCommittedBeforeServiceHandoff() async {
await PushRegistrationURLProtocol.script.reset([.response(200)])
let (service, _) = makeScriptedService(
seedDefaults: { defaults in
defaults.set(false, forKey: "cmux.notifications.pushEnabled")
defaults.set("ab", forKey: "cmux.notifications.deviceTokenHex")
defaults.set(
"push-user-1",
forKey: "cmux.notifications.registeredAccountID"
)
}
)
_ = await service.snapshots()
await PushRegistrationURLProtocol.script.waitForRequestCount(1)
#expect(
await PushRegistrationURLProtocol.script.requests.map(\.httpMethod)
== ["DELETE"]
)
}
@Test func accountBOwnedOptOutNeverDeletesAccountATokenWithBCredentials() async {
await PushRegistrationURLProtocol.script.reset([.response(200)])
let suite = "push-optout-owner-mismatch-\(UUID().uuidString)"
@@ -785,11 +831,14 @@ actor RetryDelayRecorder {
await service.setEnabled(false)
#expect(await PushRegistrationURLProtocol.script.requests.isEmpty)
let queueText = defaults.data(
forKey: "cmux.notifications.pendingUnregisters.v2"
).flatMap { String(data: $0, encoding: .utf8) }
#expect(queueText?.contains("account-a") == true)
#expect(queueText?.contains("account-b") == false)
#expect(pendingUnregisters(
suite: suite,
accountID: "account-a"
) == [PendingUnregister(tokenHex: "aa", accountID: "account-a")])
#expect(pendingUnregisters(
suite: suite,
accountID: "account-b"
).isEmpty)
}
@Test func malformedDeleteAcknowledgementKeepsDurableTombstone() async {
@@ -807,10 +856,10 @@ actor RetryDelayRecorder {
await service.setEnabled(false)
#expect(
defaults.data(forKey: "cmux.notifications.pendingUnregisters.v2")
!= nil
)
#expect(pendingUnregisters(
suite: suite,
accountID: "account-a"
) == [PendingUnregister(tokenHex: "ab", accountID: "account-a")])
#expect(
defaults.string(forKey: "cmux.notifications.registeredAccountID")
== "account-a"
@@ -873,6 +922,177 @@ actor RetryDelayRecorder {
)
}
@Test func enablingDuringInFlightDisableRepostsAfterLateDelete() async {
let started = TestPhaseSignal()
let blocker = TestContinuationBlocker()
await PushRegistrationURLProtocol.script.reset([
.response(200),
.gatedResponse(200, started: started, blocker: blocker),
.response(200),
.response(200),
])
let (service, _) = makeScriptedService()
await service.register(deviceToken: Data([0xAA]))
await service.setEnabled(true)
let disabling = Task {
await service.setEnabled(false)
}
await started.waitUntilStarted()
await service.setEnabled(true)
await blocker.release()
await disabling.value
#expect(
await PushRegistrationURLProtocol.script.requests.map(\.httpMethod)
== ["POST", "DELETE", "POST", "POST"]
)
#expect(await service.snapshot.backendState == .registered)
}
@Test func coordinatorIntentWorkerDrainsLatestOptOutAfterLatePost() async {
let started = TestPhaseSignal()
let blocker = TestContinuationBlocker()
await PushRegistrationURLProtocol.script.reset([
.gatedResponse(200, started: started, blocker: blocker),
.response(200),
.response(200),
])
let suite = "push-ambiguous-post-\(UUID().uuidString)"
let (service, _) = makeScriptedService(suite: suite)
await service.register(deviceToken: Data([0xAA]))
await service.applyEnabledIntent(true, generation: 1)
await service.reconcileEnabledIntent(generation: 1)
await started.waitUntilStarted()
#expect(pendingUnregisters(
suite: suite,
accountID: "push-user-1"
) == [PendingUnregister(
tokenHex: "aa",
accountID: "push-user-1"
)])
await service.applyEnabledIntent(false, generation: 2)
// Opt-out cleanup must start while the superseded POST is still
// parked. Waiting for that POST could leave the backend token active
// indefinitely even though the UI already reports notifications off.
#expect(
await PushRegistrationURLProtocol.script.waitForRequestCount(2)
)
#expect(
await PushRegistrationURLProtocol.script.requests.map(\.httpMethod)
== ["POST", "DELETE"]
)
#expect(await service.snapshot == .disabled)
await blocker.release()
await PushRegistrationURLProtocol.script.waitForRequestCount(3)
#expect(
await PushRegistrationURLProtocol.script.requests.map(\.httpMethod)
== ["POST", "DELETE", "DELETE"]
)
#expect(await service.snapshot == .disabled)
}
@Test func coordinatorIntentAuthenticationHasBoundedSingleAttempt() async {
let started = TestPhaseSignal()
let blocker = TestContinuationBlocker()
let provider = CancellationIgnoringPushTokenProvider(
started: started,
blocker: blocker
)
let clock = ManualTestClock()
let timeout = Duration.seconds(2)
let (service, _) = makeScriptedService(
tokenProvider: provider,
accountID: nil,
sessionSnapshotTimeout: timeout,
sessionSnapshotClock: clock
)
await service.register(deviceToken: Data([0xAA]))
await service.applyEnabledIntent(true, generation: 1)
await service.reconcileEnabledIntent(generation: 1)
await started.waitUntilStarted()
await clock.waitUntilSleepers()
clock.advance(by: timeout)
#expect(
await wait(
for: .failed(.authenticationRequired),
from: service
)
)
// The timed-out provider deliberately ignores cancellation. A newer
// enable intent fails against the active phase instead of accumulating
// another unowned task behind it.
await service.applyEnabledIntent(true, generation: 2)
await service.reconcileEnabledIntent(generation: 2)
#expect(
await wait(
for: .failed(.authenticationRequired),
from: service
)
)
await provider.waitUntilCancellationObserved()
#expect(await provider.snapshotRequestCount == 1)
await blocker.release()
await provider.waitUntilCompleted()
}
@Test func coordinatorOptOutAuthenticationHasBoundedSingleAttempt() async {
let started = TestPhaseSignal()
let blocker = TestContinuationBlocker()
let provider = CancellationIgnoringPushTokenProvider(
started: started,
blocker: blocker
)
let clock = ManualTestClock()
let timeout = Duration.seconds(2)
let (service, _) = makeScriptedService(
tokenProvider: provider,
accountID: nil,
seedDefaults: { defaults in
defaults.set(
true,
forKey: "cmux.notifications.pushEnabled"
)
defaults.set(
"aa",
forKey: "cmux.notifications.deviceTokenHex"
)
defaults.set(
"push-user-1",
forKey: "cmux.notifications.registeredAccountID"
)
},
sessionSnapshotTimeout: timeout,
sessionSnapshotClock: clock
)
let disabling = Task {
await service.applyEnabledIntent(false, generation: 1)
}
await started.waitUntilStarted()
await clock.waitUntilSleepers()
clock.advance(by: timeout)
await provider.waitUntilCancellationObserved()
await disabling.value
// A direct cleanup retry must fail against the still-active timed-out
// phase instead of starting a second authentication operation.
await service.unregisterFromServer()
#expect(await provider.snapshotRequestCount == 1)
#expect(await service.snapshot == .disabled)
await blocker.release()
await provider.waitUntilCompleted()
}
@Test func signOutDuringInFlightRegistrationDeletesAfterLatePost() async {
let started = TestPhaseSignal()
let blocker = TestContinuationBlocker()
@@ -956,21 +1176,23 @@ actor RetryDelayRecorder {
accessToken: "b-access",
refreshToken: "b-refresh"
)
await service.syncTokenIfPossible()
let currentSync = Task {
await service.syncTokenIfPossible()
}
await blocker.release()
await oldUpload.value
await currentSync.value
let requests = await PushRegistrationURLProtocol.script.requests
#expect(
requests.map(\.httpMethod)
== ["POST", "POST", "DELETE", "POST"]
== ["POST", "DELETE", "POST"]
)
#expect(
requests.map {
$0.value(forHTTPHeaderField: "Authorization")
} == [
"Bearer a-access",
"Bearer b-access",
"Bearer a-access",
"Bearer b-access",
]
@@ -1151,10 +1373,10 @@ actor RetryDelayRecorder {
let firstRequests = await PushRegistrationURLProtocol.script.requests
#expect(firstRequests.map(\.httpMethod) == ["POST", "DELETE"])
#expect(await service.snapshot.backendState == .registered)
let pendingText = defaults.data(
forKey: "cmux.notifications.pendingUnregisters.v2"
).flatMap { String(data: $0, encoding: .utf8) }
#expect(pendingText?.contains("aa") == true)
#expect(pendingUnregisters(
suite: suite,
accountID: "account-a"
) == [PendingUnregister(tokenHex: "aa", accountID: "account-a")])
await PushRegistrationURLProtocol.script.reset([
.response(200),
@@ -1305,9 +1527,115 @@ actor RetryDelayRecorder {
#expect(deletedTokens == ["aa", "bb", "aa", "bb"])
}
@Test func successfulReassignmentClearsOldTombstoneWithoutLosingNewOwner() async {
@Test func pendingCleanupMigrationMovesEveryEntryToIndexedStore() async throws {
let storeURL = FileManager.default.temporaryDirectory
.appendingPathComponent("push-overflow-\(UUID().uuidString).sqlite3")
defer { try? FileManager.default.removeItem(at: storeURL) }
let existing = (0..<200).map { index in
[
"tokenHex": String(format: "%064x", index),
"accountID": "historical-account-\(index)",
]
}
let (service, defaults) = makeScriptedService(
accountID: nil,
seedDefaults: { defaults in
defaults.set(
try? JSONSerialization.data(withJSONObject: existing),
forKey: "cmux.notifications.pendingUnregisters.v2"
)
defaults.set(
true,
forKey: "cmux.notifications.pushEnabled"
)
defaults.set(
String(repeating: "f", count: 64),
forKey: "cmux.notifications.deviceTokenHex"
)
defaults.set(
"current-account",
forKey: "cmux.notifications.registeredAccountID"
)
},
pendingUnregisterStoreURL: storeURL
)
await service.applyEnabledIntent(false, generation: 1)
#expect(defaults.data(
forKey: "cmux.notifications.pendingUnregisters.v2"
) == nil)
let store = try PendingUnregisterStore(databaseURL: storeURL)
let oldest = store.batch(
accountID: "historical-account-0",
limit: 2
)
let newest = store.batch(accountID: "current-account", limit: 2)
#expect(oldest.map(\.accountID) == ["historical-account-0"])
#expect(newest.map(\.accountID) == ["current-account"])
}
@Test func durableCleanupStoreReopensAfterLaunchFailure() async throws {
await PushRegistrationURLProtocol.script.reset([.response(200)])
let fileManager = FileManager.default
let root = fileManager.temporaryDirectory.appendingPathComponent(
"push-store-reopen-\(UUID().uuidString)",
isDirectory: true
)
let parkedRoot = root.appendingPathExtension("parked")
let storeURL = root.appendingPathComponent("cleanup.sqlite3")
defer {
try? fileManager.removeItem(at: root)
try? fileManager.removeItem(at: parkedRoot)
}
do {
let store = try PendingUnregisterStore(databaseURL: storeURL)
#expect(store.insert(PendingUnregister(
tokenHex: "aa",
accountID: "account-a"
)))
}
try fileManager.moveItem(at: root, to: parkedRoot)
#expect(fileManager.createFile(atPath: root.path, contents: Data()))
let (service, _) = makeScriptedService(
accountID: "account-a",
pendingUnregisterStoreURL: storeURL
)
try fileManager.removeItem(at: root)
try fileManager.moveItem(at: parkedRoot, to: root)
_ = await service.snapshots()
#expect(
await PushRegistrationURLProtocol.script.waitForRequestCount(1)
)
#expect(
await PushRegistrationURLProtocol.script.requests.map(\.httpMethod)
== ["DELETE"]
)
let reopenedStore = try PendingUnregisterStore(databaseURL: storeURL)
var cleanupFinished = false
for _ in 0..<1_000 {
if reopenedStore.batch(accountID: "account-a", limit: 2).isEmpty {
cleanupFinished = true
break
}
await Task.yield()
}
#expect(cleanupFinished)
}
@Test func successfulReassignmentClearsOldTombstoneWithoutLosingNewOwner() async throws {
await PushRegistrationURLProtocol.script.reset([.response(200)])
let suite = "push-owner-reassignment-\(UUID().uuidString)"
let storeURL = FileManager.default.temporaryDirectory
.appendingPathComponent("push-owner-\(UUID().uuidString).sqlite3")
defer { try? FileManager.default.removeItem(at: storeURL) }
let overflowStore = try PendingUnregisterStore(databaseURL: storeURL)
#expect(overflowStore.insert(PendingUnregister(
tokenHex: "ab",
accountID: "old-user"
)))
let (service, defaults) = makeScriptedService(
tokenProvider: FakeTokenProvider(
access: "new-access",
@@ -1329,7 +1657,8 @@ actor RetryDelayRecorder {
"old-user",
forKey: "cmux.notifications.pendingUnregisterAccountID"
)
}
},
pendingUnregisterStoreURL: storeURL
)
await service.setEnabled(true)
@@ -1346,6 +1675,7 @@ actor RetryDelayRecorder {
defaults.string(forKey: "cmux.notifications.pendingUnregisterAccountID")
== nil
)
#expect(!overflowStore.hasEntries)
}
@Test func legacySingleTombstoneMigratesOnceAndIsRemovedAfterSuccess() async {
@@ -36,6 +36,16 @@ public struct CmxIrohBackpressuredClientBroker:
}
}
/// Reports whether the wrapped client retains request authorization.
public func hasBindingAuthorization() async -> Bool {
await broker.hasBindingAuthorization()
}
/// Returns the binding ID represented by the wrapped client's proof.
public func bindingAuthorizationID() async -> String? {
await broker.bindingAuthorizationID()
}
public func discover() async throws -> CmxIrohDiscoveryResponse {
try await gate.perform(accountID: accountID, operation: .discovery) {
try await broker.discover()
@@ -81,6 +91,20 @@ public struct CmxIrohBackpressuredClientBroker:
try await broker.revoke(bindingID: bindingID)
}
}
/// Revokes an older same-device binding through the wrapped stale route.
public func revokeStale(bindingID: String) async throws {
try await gate.perform(accountID: accountID, operation: .revocation) {
try await broker.revokeStale(bindingID: bindingID)
}
}
/// Revokes one same-build Mac through the wrapped account-management path.
public func forgetMac(bindingID: String) async throws {
try await gate.perform(accountID: accountID, operation: .revocation) {
try await broker.forgetMac(bindingID: bindingID)
}
}
}
/// Operation-gated host broker used by an account-owned Mac runtime.
@@ -160,6 +184,13 @@ public struct CmxIrohBackpressuredHostBroker:
try await broker.revoke(bindingID: bindingID)
}
}
/// Revokes an older same-device binding through the wrapped stale route.
public func revokeStale(bindingID: String) async throws {
try await gate.perform(accountID: accountID, operation: .revocation) {
try await broker.revokeStale(bindingID: bindingID)
}
}
}
/// Operation-gated relay-policy broker sharing a runtime's account gate.
@@ -0,0 +1,45 @@
public import CMUXMobileCore
/// Proof material that lets a fresh broker client act as one registered binding.
public struct CmxIrohBindingRequestAuthorization: Sendable {
/// The exact broker binding whose endpoint key signs each request.
public let bindingID: String
/// The exact app namespace recorded on the authorized binding.
public let clientNamespace: String
let signer: CmxIrohRegistrationSigner
/// Reconstructs request authorization from retained binding and identity state.
///
/// - Parameters:
/// - bindingID: The exact registered broker binding identifier.
/// - clientNamespace: The exact namespace recorded during registration.
/// - identity: The endpoint identity material that owns the binding.
/// - endpointID: The endpoint identifier recorded on the binding.
/// - Throws: ``CmxIrohRegistrationError/endpointIdentityMismatch`` when the
/// supplied identity does not derive the recorded endpoint.
public init(
bindingID: String,
clientNamespace: String,
identity: CmxIrohIdentityMaterial,
endpointID: CmxIrohPeerIdentity
) throws {
self.bindingID = bindingID
self.clientNamespace = clientNamespace
signer = try CmxIrohRegistrationSigner(
identity: identity,
endpointID: endpointID.endpointID
)
}
init(
bindingID: String,
clientNamespace: String,
signer: CmxIrohRegistrationSigner
) {
self.bindingID = bindingID
self.clientNamespace = clientNamespace
self.signer = signer
}
}
@@ -6,4 +6,8 @@ public protocol CmxIrohBindingRevoking: Sendable {
///
/// - Parameter bindingID: The broker-owned lowercase binding UUID.
func revoke(bindingID: String) async throws
/// Revokes an older same-device binding through the account-scoped stale
/// cleanup route, rather than pretending the caller owns that ID.
func revokeStale(bindingID: String) async throws
}
@@ -7,6 +7,7 @@ public struct CmxIrohBrokerBindingMetadata: Codable, Equatable, Sendable {
case bindingID
case deviceID
case appInstanceID
case clientNamespace
case tag
case platform
case endpointID
@@ -23,6 +24,9 @@ public struct CmxIrohBrokerBindingMetadata: Codable, Equatable, Sendable {
/// The installation's broker-facing app-instance UUID.
public let appInstanceID: String
/// The exact app namespace that owns the binding.
public let clientNamespace: String
/// The build tag registered with the broker.
public let tag: String
@@ -44,6 +48,7 @@ public struct CmxIrohBrokerBindingMetadata: Codable, Equatable, Sendable {
/// - bindingID: The broker-owned lowercase binding UUID.
/// - deviceID: The account device's lowercase UUID.
/// - appInstanceID: The installation's lowercase app-instance UUID.
/// - clientNamespace: The exact bundle-derived app namespace.
/// - tag: The safe build tag sent during registration.
/// - platform: The endpoint's platform role.
/// - endpointID: The registered Iroh endpoint identity.
@@ -54,6 +59,7 @@ public struct CmxIrohBrokerBindingMetadata: Codable, Equatable, Sendable {
bindingID: String,
deviceID: String,
appInstanceID: String,
clientNamespace: String = "legacy",
tag: String,
platform: CmxIrohPlatform,
endpointID: CmxIrohPeerIdentity,
@@ -63,13 +69,15 @@ public struct CmxIrohBrokerBindingMetadata: Codable, Equatable, Sendable {
guard Self.isCanonicalUUID(bindingID),
Self.isCanonicalUUID(deviceID),
Self.isCanonicalUUID(appInstanceID),
Self.isSafeTag(tag),
cmxIrohIsSafeToken(clientNamespace, maximumUTF8ByteCount: 255),
cmxIrohIsSafeToken(tag),
(1 ... Int(Int32.max)).contains(identityGeneration) else {
throw CmxIrohBrokerCredentialRepositoryError.invalidBinding
}
self.bindingID = bindingID
self.deviceID = deviceID
self.appInstanceID = appInstanceID
self.clientNamespace = clientNamespace
self.tag = tag
self.platform = platform
self.endpointID = endpointID
@@ -84,6 +92,7 @@ public struct CmxIrohBrokerBindingMetadata: Codable, Equatable, Sendable {
bindingID = binding.bindingID
deviceID = binding.deviceID
appInstanceID = binding.appInstanceID
clientNamespace = binding.clientNamespace
tag = binding.tag
platform = binding.platform
endpointID = binding.endpointID
@@ -101,6 +110,10 @@ public struct CmxIrohBrokerBindingMetadata: Codable, Equatable, Sendable {
bindingID: container.decode(String.self, forKey: .bindingID),
deviceID: container.decode(String.self, forKey: .deviceID),
appInstanceID: container.decode(String.self, forKey: .appInstanceID),
clientNamespace: container.decodeIfPresent(
String.self,
forKey: .clientNamespace
) ?? "legacy",
tag: container.decode(String.self, forKey: .tag),
platform: container.decode(CmxIrohPlatform.self, forKey: .platform),
endpointID: container.decode(CmxIrohPeerIdentity.self, forKey: .endpointID),
@@ -116,13 +129,4 @@ public struct CmxIrohBrokerBindingMetadata: Codable, Equatable, Sendable {
UUID(uuidString: value)?.uuidString.lowercased() == value
}
private static func isSafeTag(_ value: String) -> Bool {
guard (1 ... 64).contains(value.utf8.count) else { return false }
return value.utf8.allSatisfy { byte in
(48 ... 57).contains(byte)
|| (65 ... 90).contains(byte)
|| (97 ... 122).contains(byte)
|| [45, 46, 58, 95].contains(byte)
}
}
}
@@ -7,6 +7,7 @@ public struct CmxIrohBrokerBinding: Codable, Equatable, Sendable {
case bindingID = "binding_id"
case deviceID = "device_id"
case appInstanceID = "app_instance_id"
case clientNamespace = "client_namespace"
case tag
case platform
case displayName = "display_name"
@@ -22,6 +23,9 @@ public struct CmxIrohBrokerBinding: Codable, Equatable, Sendable {
public let bindingID: String
public let deviceID: String
public let appInstanceID: String
/// The exact bundle-derived app namespace that owns this binding.
public let clientNamespace: String
public let tag: String
public let platform: CmxIrohPlatform
public let displayName: String?
@@ -38,6 +42,10 @@ public struct CmxIrohBrokerBinding: Codable, Equatable, Sendable {
let bindingID = try container.decode(String.self, forKey: .bindingID)
let deviceID = try container.decode(String.self, forKey: .deviceID)
let appInstanceID = try container.decode(String.self, forKey: .appInstanceID)
let clientNamespace = try container.decodeIfPresent(
String.self,
forKey: .clientNamespace
) ?? "legacy"
let tag = try container.decode(String.self, forKey: .tag)
let endpointID = try container.decode(String.self, forKey: .endpointID)
let identityGeneration = try container.decode(Int.self, forKey: .identityGeneration)
@@ -52,11 +60,12 @@ public struct CmxIrohBrokerBinding: Codable, Equatable, Sendable {
guard Self.isCanonicalUUID(bindingID),
Self.isCanonicalUUID(deviceID),
Self.isCanonicalUUID(appInstanceID),
Self.isSafeToken(tag),
cmxIrohIsSafeToken(clientNamespace, maximumUTF8ByteCount: 255),
cmxIrohIsSafeToken(tag),
(1 ... Int(Int32.max)).contains(identityGeneration),
capabilities.count <= 32,
Set(capabilities).count == capabilities.count,
capabilities.allSatisfy(Self.isSafeToken),
capabilities.allSatisfy({ cmxIrohIsSafeToken($0) }),
displayName.map(Self.isSafeDisplayName) ?? true,
pathHints.count <= CmxAttachEndpoint.maximumIrohPathHintCount,
pathHints.filter({ $0.kind == .relayURL }).count <= 2,
@@ -72,6 +81,7 @@ public struct CmxIrohBrokerBinding: Codable, Equatable, Sendable {
self.bindingID = bindingID
self.deviceID = deviceID
self.appInstanceID = appInstanceID
self.clientNamespace = clientNamespace
self.tag = tag
platform = try container.decode(CmxIrohPlatform.self, forKey: .platform)
self.displayName = displayName
@@ -89,6 +99,7 @@ public struct CmxIrohBrokerBinding: Codable, Equatable, Sendable {
try container.encode(bindingID, forKey: .bindingID)
try container.encode(deviceID, forKey: .deviceID)
try container.encode(appInstanceID, forKey: .appInstanceID)
try container.encode(clientNamespace, forKey: .clientNamespace)
try container.encode(tag, forKey: .tag)
try container.encode(platform, forKey: .platform)
try container.encodeIfPresent(displayName, forKey: .displayName)
@@ -105,16 +116,6 @@ public struct CmxIrohBrokerBinding: Codable, Equatable, Sendable {
UUID(uuidString: value)?.uuidString.lowercased() == value
}
private static func isSafeToken(_ value: String) -> Bool {
guard (1 ... 64).contains(value.utf8.count) else { return false }
return value.utf8.allSatisfy { byte in
(48 ... 57).contains(byte)
|| (65 ... 90).contains(byte)
|| (97 ... 122).contains(byte)
|| [45, 46, 58, 95].contains(byte)
}
}
private static func isSafeDisplayName(_ value: String) -> Bool {
!value.isEmpty
&& value.utf16.count <= 128
@@ -4,6 +4,8 @@ public struct CmxIrohChallengeRequest: Encodable, Equatable, Sendable {
public let deviceId: String
/// Stable app-instance UUID.
public let appInstanceId: String
/// Exact app namespace that owns the prospective binding.
public let clientNamespace: String
/// Safe build or app-instance tag.
public let tag: String
/// Exact Iroh EndpointID that will sign the challenge.
@@ -16,6 +18,7 @@ public struct CmxIrohChallengeRequest: Encodable, Equatable, Sendable {
init(payload: CmxIrohRegistrationPayload, payloadSHA256: String) {
deviceId = payload.deviceID
appInstanceId = payload.appInstanceID
clientNamespace = payload.clientNamespace
tag = payload.tag
endpointId = payload.endpointID
identityGeneration = payload.identityGeneration
@@ -10,10 +10,27 @@ public protocol CmxIrohClientBrokerServing: CmxIrohRegistryServing,
prepared: CmxIrohPreparedRegistration,
signer: CmxIrohRegistrationSigner
) async throws -> CmxIrohRegistrationResponse
/// Reports whether signed post-registration broker requests can be made.
/// A rate-limited registration cannot establish this proof on a cold start.
func hasBindingAuthorization() async -> Bool
/// Returns the binding ID represented by the retained request proof.
func bindingAuthorizationID() async -> String?
/// Revokes one same-build Mac through the explicit account-management path.
func forgetMac(bindingID: String) async throws
}
public extension CmxIrohClientBrokerServing {
/// Accepts the operation when a conformer does not impose a local broker floor.
func preflight(operation _: CmxIrohBrokerOperation) async throws {}
/// Reports no retained request proof for conformers that do not persist one.
func hasBindingAuthorization() async -> Bool { false }
/// Reports no retained binding ID for conformers that do not persist proof.
func bindingAuthorizationID() async -> String? { nil }
}
extension CmxIrohTrustBrokerClient: CmxIrohClientBrokerServing {}
@@ -456,6 +456,7 @@ public actor CmxIrohClientOfflinePolicyCache {
left.bindingID == right.bindingID
&& left.deviceID == right.deviceID
&& left.appInstanceID == right.appInstanceID
&& left.clientNamespace == right.clientNamespace
&& left.tag == right.tag
&& left.platform == right.platform
&& left.endpointID == right.endpointID
@@ -469,7 +470,7 @@ public actor CmxIrohClientOfflinePolicyCache {
for expectation: CmxIrohClientOfflinePolicyExpectation
) -> String {
let transcript = Data(
"cmux/iroh/offline-client-policy-scope/v1\0\(expectation.accountID)\0\(expectation.localBindingExpectation.appInstanceID)".utf8
"cmux/iroh/offline-client-policy-scope/v2\0\(expectation.accountID)\0\(expectation.localBindingExpectation.clientNamespace)\0\(expectation.localBindingExpectation.appInstanceID)".utf8
)
return SHA256.hash(data: transcript)
.map { String(format: "%02x", $0) }
@@ -3,6 +3,7 @@ public import Foundation
extension CmxIrohClientRuntime {
func performSignOut(
pendingRevocation: CmxIrohPendingRevocation?,
bindingAuthorization: CmxIrohBindingRequestAuthorization?,
revision: UInt64
) async -> CmxIrohClientSignOutPreparation {
async let wasPersisted = Self.persist(pendingRevocation, to: pendingRevocations)
@@ -10,7 +11,8 @@ extension CmxIrohClientRuntime {
let (persisted, _) = await (wasPersisted, networkTeardown)
let preparation = CmxIrohClientSignOutPreparation(
pendingRevocation: pendingRevocation,
wasPersisted: persisted
wasPersisted: persisted,
bindingAuthorization: bindingAuthorization
)
guard lifecyclePhase == .signingOut,
@@ -15,6 +15,7 @@ extension CmxIrohClientRuntime {
let expectation = try CmxIrohLocalBindingExpectation(
deviceID: configuration.deviceID,
appInstanceID: configuration.appInstanceID,
clientNamespace: configuration.clientNamespace,
tag: configuration.tag,
platform: .ios,
endpointID: expectedEndpointID,
@@ -101,6 +102,7 @@ extension CmxIrohClientRuntime {
)
let prepared = try signer.prepare(payload: payload)
let registration: CmxIrohRegistrationResponse?
var registrationFailure: (any Error)?
do {
registration = try await broker.register(prepared: prepared, signer: signer)
} catch {
@@ -108,6 +110,7 @@ extension CmxIrohClientRuntime {
// Registration backpressure blocks mutation, while a fresh
// authenticated discovery can still confirm an existing tuple.
registration = nil
registrationFailure = error
} else {
guard !prefetchedDiscoveryRejectedCachedBinding,
Self.recoversWithCachedPolicy(error),
@@ -130,12 +133,38 @@ extension CmxIrohClientRuntime {
if let registration, !expectation.matches(registration.binding) {
throw CmxIrohClientRuntimeError.invalidLocalBinding
}
if registration == nil,
!(await broker.hasBindingAuthorization()) {
// No registration response means this broker instance did not get
// a chance to install fresh proof. Do not drain revocations or
// issue namespaced discovery requests without persisted proof.
throw registrationFailure
?? CmxIrohTrustBrokerClientError.invalidAuthentication
}
if registration != nil {
lastRegistrationRefreshState = refreshState
}
let revokedPendingBinding: Bool
let activeBindingID: String?
if let registration {
activeBindingID = registration.binding.bindingID
} else {
activeBindingID = await broker.bindingAuthorizationID()
}
guard let activeBindingID else {
throw CmxIrohTrustBrokerClientError.invalidAuthentication
}
revokedPendingBinding = try await pendingRevocations.reconcilePending(
accountID: configuration.accountID,
beforeRegisteringTag: configuration.tag,
activeBindingID: activeBindingID,
using: broker
)
try requireCurrent(revision)
let discovery: CmxIrohDiscoveryResponse
do {
if let embedded = registration?.discovery,
if !revokedPendingBinding,
let embedded = registration?.discovery,
registration?.embeddedDiscoveryComplete == true {
guard let snapshotRevision = embedded.revision,
let registrationRevision = registration?.revision,
@@ -264,6 +293,7 @@ extension CmxIrohClientRuntime {
return try CmxIrohRegistrationPayload(
deviceID: configuration.deviceID,
appInstanceID: configuration.appInstanceID,
clientNamespace: configuration.clientNamespace,
tag: configuration.tag,
platform: .ios,
displayName: configuration.displayName,
@@ -287,12 +317,6 @@ extension CmxIrohClientRuntime {
}
func preparePolicyResolution(revision: UInt64) async throws {
try await pendingRevocations.revokePending(
accountID: configuration.accountID,
beforeRegisteringTag: configuration.tag,
using: broker
)
try requireCurrent(revision)
try await broker.preflight(operation: .discovery)
try requireCurrent(revision)
}
@@ -90,6 +90,7 @@ extension CmxIrohClientRuntime {
let expectation = try CmxIrohLocalBindingExpectation(
deviceID: binding.deviceID,
appInstanceID: binding.appInstanceID,
clientNamespace: binding.clientNamespace,
tag: binding.tag,
platform: binding.platform,
endpointID: binding.endpointID,
@@ -326,6 +326,7 @@ public actor CmxIrohClientRuntime {
let expectation = try CmxIrohLocalBindingExpectation(
deviceID: configuration.deviceID,
appInstanceID: configuration.appInstanceID,
clientNamespace: configuration.clientNamespace,
tag: configuration.tag,
platform: .ios,
endpointID: liveEndpointIdentity,
@@ -733,6 +734,14 @@ public actor CmxIrohClientRuntime {
bindingID: binding.bindingID
)
}
let bindingAuthorization = localBinding.flatMap { binding in
try? CmxIrohBindingRequestAuthorization(
bindingID: binding.bindingID,
clientNamespace: binding.clientNamespace,
identity: configuration.identity,
endpointID: binding.endpointID
)
}
lifecyclePhase = .signingOut
lifecycleRevision &+= 1
let revision = lifecycleRevision
@@ -745,6 +754,7 @@ public actor CmxIrohClientRuntime {
let operation = Task {
await self.performSignOut(
pendingRevocation: pendingRevocation,
bindingAuthorization: bindingAuthorization,
revision: revision
)
}
@@ -11,6 +11,9 @@ public struct CmxIrohClientRuntimeConfiguration: Equatable, Sendable {
/// The account-and-build-scoped app-instance UUID.
public let appInstanceID: String
/// Exact installed-app namespace sent to every broker request.
public let clientNamespace: String
/// The release channel or tagged-build scope registered with the broker.
public let tag: String
@@ -61,6 +64,7 @@ public struct CmxIrohClientRuntimeConfiguration: Equatable, Sendable {
accountID: String,
deviceID: String,
appInstanceID: String,
clientNamespace: String,
tag: String,
displayName: String?,
identity: CmxIrohIdentityMaterial,
@@ -73,6 +77,7 @@ public struct CmxIrohClientRuntimeConfiguration: Equatable, Sendable {
self.accountID = accountID
self.deviceID = cmxCanonicalDeviceID(deviceID)
self.appInstanceID = appInstanceID.lowercased()
self.clientNamespace = clientNamespace
self.tag = tag
self.displayName = displayName
self.identity = identity
@@ -6,6 +6,9 @@ public struct CmxIrohSignOutPreparation: Equatable, Sendable {
/// Whether the first device-only persistence attempt succeeded.
public let wasPersisted: Bool
/// In-memory proof retained across local identity deletion for immediate revoke.
public let bindingAuthorization: CmxIrohBindingRequestAuthorization?
/// The broker binding to revoke, or `nil` before registration.
public var bindingID: String? { pendingRevocation?.bindingID }
@@ -14,12 +17,28 @@ public struct CmxIrohSignOutPreparation: Equatable, Sendable {
/// - Parameters:
/// - pendingRevocation: The validated prior binding, or `nil` before registration.
/// - wasPersisted: Whether it was durably queued before local teardown.
/// - bindingAuthorization: Ephemeral proof for a fresh captured-token client.
public init(
pendingRevocation: CmxIrohPendingRevocation?,
wasPersisted: Bool
wasPersisted: Bool,
bindingAuthorization: CmxIrohBindingRequestAuthorization? = nil
) {
self.pendingRevocation = pendingRevocation
self.wasPersisted = pendingRevocation == nil || wasPersisted
self.bindingAuthorization = bindingAuthorization
}
/// Compares durable state and the authorized binding without exposing key bytes.
public static func == (
lhs: CmxIrohSignOutPreparation,
rhs: CmxIrohSignOutPreparation
) -> Bool {
lhs.pendingRevocation == rhs.pendingRevocation
&& lhs.wasPersisted == rhs.wasPersisted
&& lhs.bindingAuthorization?.bindingID
== rhs.bindingAuthorization?.bindingID
&& lhs.bindingAuthorization?.clientNamespace
== rhs.bindingAuthorization?.clientNamespace
}
/// Revokes the captured binding with a broker authenticated from captured tokens.
@@ -6,25 +6,26 @@ public import Foundation
/// access group, so the data-protection Keychain returns
/// `errSecMissingEntitlement`. Production compositions must keep using
/// ``CmxIrohKeychainIdentityStore``.
public final class CmxIrohDevelopmentFileIdentityStore:
CmxIrohSecureIdentityStoring,
@unchecked Sendable
public actor CmxIrohDevelopmentFileIdentityStore:
CmxIrohSecureIdentityStoring
{
private let directory: URL
nonisolated private let directory: URL
/// Creates a store inside a tag-specific application-support directory.
public init(directory: URL) {
self.directory = directory
}
public func read(account: String) throws -> Data? {
/// Loads one development identity record.
public func read(account: String) async throws -> Data? {
try CmxIrohDevelopmentFileStorage.read(
account: account,
directory: directory
)
}
public func write(_ data: Data, account: String) throws {
/// Replaces one development identity record.
public func write(_ data: Data, account: String) async throws {
try CmxIrohDevelopmentFileStorage.write(
data,
account: account,
@@ -33,11 +34,9 @@ public final class CmxIrohDevelopmentFileIdentityStore:
}
/// Whether ANY identity record file exists, without reading or creating
/// one. Development-build counterpart of
/// ``CmxIrohKeychainIdentityStore/containsAnyRecord()``; file storage lives
/// in the app container (which CAN travel in a backup), an accepted
/// dev-only weakening of the continuity signal.
public func containsAnyRecord() -> Bool {
/// one. File storage lives in the app container (which CAN travel in a
/// backup), an accepted dev-only weakening of the continuity signal.
public nonisolated func containsAnyRecord() -> Bool {
let entries = (try? FileManager.default.contentsOfDirectory(
at: directory,
includingPropertiesForKeys: nil
@@ -45,14 +44,16 @@ public final class CmxIrohDevelopmentFileIdentityStore:
return !entries.isEmpty
}
public func delete(account: String) throws {
/// Removes one development identity record.
public func delete(account: String) async throws {
try CmxIrohDevelopmentFileStorage.delete(
account: account,
directory: directory
)
}
public func deleteAll() throws {
/// Removes every development identity record in this store.
public func deleteAll() async throws {
try CmxIrohDevelopmentFileStorage.deleteAll(in: directory)
}
}
@@ -197,6 +197,7 @@ public actor CmxIrohHostPolicyCache {
let binding = policy.binding
guard binding.deviceID == expectation.deviceID,
binding.appInstanceID == expectation.appInstanceID,
binding.clientNamespace == expectation.clientNamespace,
binding.tag == expectation.tag,
binding.platform == .mac,
binding.endpointID == expectation.endpointID,
@@ -235,7 +236,7 @@ public actor CmxIrohHostPolicyCache {
for expectation: CmxIrohHostPolicyExpectation
) -> String {
let transcript = Data(
"cmux/iroh/offline-host-policy-scope/v1\0\(expectation.accountID)\0\(expectation.appInstanceID)".utf8
"cmux/iroh/offline-host-policy-scope/v2\0\(expectation.accountID)\0\(expectation.clientNamespace)\0\(expectation.appInstanceID)".utf8
)
return SHA256.hash(data: transcript)
.map { String(format: "%02x", $0) }
@@ -12,6 +12,9 @@ public struct CmxIrohHostPolicyExpectation: Equatable, Sendable {
/// The current app-instance UUID, which changes when the account or build tag changes.
public let appInstanceID: String
/// The exact Mac app namespace that owns this endpoint.
public let clientNamespace: String
/// The build tag registered with the trust broker.
public let tag: String
@@ -36,6 +39,7 @@ public struct CmxIrohHostPolicyExpectation: Equatable, Sendable {
/// - accountID: The current authenticated account identifier.
/// - deviceID: The account device's lowercase UUID.
/// - appInstanceID: The installation's lowercase app-instance UUID.
/// - clientNamespace: The exact bundle-derived app namespace.
/// - tag: The safe build tag used for broker registration.
/// - endpointID: The current local Iroh EndpointID.
/// - identityGeneration: The positive local identity generation.
@@ -46,6 +50,7 @@ public struct CmxIrohHostPolicyExpectation: Equatable, Sendable {
accountID: String,
deviceID: String,
appInstanceID: String,
clientNamespace: String = "legacy",
tag: String,
endpointID: CmxIrohPeerIdentity,
identityGeneration: Int,
@@ -56,16 +61,18 @@ public struct CmxIrohHostPolicyExpectation: Equatable, Sendable {
accountID.utf8.count <= 1_024,
Self.isCanonicalUUID(deviceID),
Self.isCanonicalUUID(appInstanceID),
Self.isSafeToken(tag),
cmxIrohIsSafeToken(clientNamespace, maximumUTF8ByteCount: 255),
cmxIrohIsSafeToken(tag),
(1 ... Int(Int32.max)).contains(identityGeneration),
capabilities.count <= 32,
Set(capabilities).count == capabilities.count,
capabilities.allSatisfy(Self.isSafeToken) else {
capabilities.allSatisfy({ cmxIrohIsSafeToken($0) }) else {
throw CmxIrohHostPolicyCacheError.invalidExpectation
}
self.accountID = accountID
self.deviceID = deviceID
self.appInstanceID = appInstanceID
self.clientNamespace = clientNamespace
self.tag = tag
self.endpointID = endpointID
self.identityGeneration = identityGeneration
@@ -77,13 +84,4 @@ public struct CmxIrohHostPolicyExpectation: Equatable, Sendable {
UUID(uuidString: value)?.uuidString.lowercased() == value
}
private static func isSafeToken(_ value: String) -> Bool {
guard (1 ... 64).contains(value.utf8.count) else { return false }
return value.utf8.allSatisfy { byte in
(48 ... 57).contains(byte)
|| (65 ... 90).contains(byte)
|| (97 ... 122).contains(byte)
|| [45, 46, 58, 95].contains(byte)
}
}
}
@@ -7,13 +7,11 @@ extension CmxIrohHostRuntime {
expectedEndpointID: CmxIrohPeerIdentity,
revision: UInt64
) async throws -> ResolvedPolicy {
try await revokePendingBeforeRegistration()
try requireCurrent(revision)
var failureCount = 0
while true {
try requireCurrent(revision)
do {
return try await resolvePolicyAfterPendingRevocations(
return try await resolvePolicyAfterAuthenticatedRegistration(
engine: engine,
expectedEndpointID: expectedEndpointID,
revision: revision,
@@ -21,6 +19,8 @@ extension CmxIrohHostRuntime {
)
} catch is CancellationError {
throw CancellationError()
} catch let failure as CmxIrohPostRegistrationRevocationFailure {
throw failure.underlying
} catch {
try requireCurrent(revision)
guard CmxIrohTrustBrokerClientError
@@ -48,25 +48,30 @@ extension CmxIrohHostRuntime {
revision: UInt64,
allowCachedFallback: Bool
) async throws -> ResolvedPolicy {
try await revokePendingBeforeRegistration()
try requireCurrent(revision)
return try await resolvePolicyAfterPendingRevocations(
engine: engine,
expectedEndpointID: expectedEndpointID,
revision: revision,
allowCachedFallback: allowCachedFallback
)
do {
return try await resolvePolicyAfterAuthenticatedRegistration(
engine: engine,
expectedEndpointID: expectedEndpointID,
revision: revision,
allowCachedFallback: allowCachedFallback
)
} catch let failure as CmxIrohPostRegistrationRevocationFailure {
throw failure.underlying
}
}
private func revokePendingBeforeRegistration() async throws {
try await pendingRevocations.revokePending(
private func reconcilePendingAfterRegistration(
activeBindingID: String
) async throws -> Bool {
try await pendingRevocations.reconcilePending(
accountID: configuration.accountID,
beforeRegisteringTag: configuration.tag,
activeBindingID: activeBindingID,
using: broker
)
}
private func resolvePolicyAfterPendingRevocations(
private func resolvePolicyAfterAuthenticatedRegistration(
engine: CmxConnectivityEngine,
expectedEndpointID: CmxIrohPeerIdentity,
revision: UInt64,
@@ -117,9 +122,19 @@ extension CmxIrohHostRuntime {
}
try requireCurrent(revision)
try validateLocalBinding(registration.binding, endpointID: expectedEndpointID)
let revokedPendingBinding: Bool
do {
revokedPendingBinding = try await reconcilePendingAfterRegistration(
activeBindingID: registration.binding.bindingID
)
} catch {
throw CmxIrohPostRegistrationRevocationFailure(underlying: error)
}
try requireCurrent(revision)
let discovery: CmxIrohDiscoveryResponse
do {
if let embedded = registration.discovery,
if !revokedPendingBinding,
let embedded = registration.discovery,
registration.embeddedDiscoveryComplete {
guard let snapshotRevision = embedded.revision,
let registrationRevision = registration.revision,
@@ -228,6 +243,7 @@ extension CmxIrohHostRuntime {
return try CmxIrohRegistrationPayload(
deviceID: configuration.deviceID,
appInstanceID: configuration.appInstanceID,
clientNamespace: configuration.clientNamespace,
tag: configuration.tag,
platform: .mac,
displayName: configuration.displayName,
@@ -304,6 +320,7 @@ extension CmxIrohHostRuntime {
) throws {
guard binding.deviceID == configuration.deviceID,
binding.appInstanceID == configuration.appInstanceID,
binding.clientNamespace == configuration.clientNamespace,
binding.tag == configuration.tag,
binding.platform == .mac,
binding.endpointID == endpointID,
@@ -322,6 +339,7 @@ extension CmxIrohHostRuntime {
let binding = policy.binding
guard binding.deviceID == configuration.deviceID,
binding.appInstanceID == configuration.appInstanceID,
binding.clientNamespace == configuration.clientNamespace,
binding.tag == configuration.tag,
binding.platform == .mac,
binding.endpointID == endpointID,
@@ -232,6 +232,14 @@ extension CmxIrohHostRuntime {
bindingID: binding.bindingID
)
}
let bindingAuthorization = localBinding.flatMap { binding in
try? CmxIrohBindingRequestAuthorization(
bindingID: binding.bindingID,
clientNamespace: binding.clientNamespace,
identity: configuration.identity,
endpointID: binding.endpointID
)
}
lifecyclePhase = .signingOut
lifecycleRevision &+= 1
let revision = lifecycleRevision
@@ -244,6 +252,7 @@ extension CmxIrohHostRuntime {
let operation = Task {
await self.performSignOut(
pendingRevocation: pendingRevocation,
bindingAuthorization: bindingAuthorization,
requiresNetworkDeactivation: requiresNetworkDeactivation,
revision: revision
)
@@ -3,6 +3,7 @@ public import Foundation
extension CmxIrohHostRuntime {
func performSignOut(
pendingRevocation: CmxIrohPendingRevocation?,
bindingAuthorization: CmxIrohBindingRequestAuthorization?,
requiresNetworkDeactivation: Bool,
revision: UInt64
) async -> CmxIrohHostSignOutPreparation {
@@ -17,7 +18,8 @@ extension CmxIrohHostRuntime {
let (persisted, _) = await (wasPersisted, networkTeardown)
let preparation = CmxIrohHostSignOutPreparation(
pendingRevocation: pendingRevocation,
wasPersisted: persisted
wasPersisted: persisted,
bindingAuthorization: bindingAuthorization
)
guard lifecyclePhase == .signingOut,
@@ -7,6 +7,8 @@ public struct CmxIrohHostRuntimeConfiguration: Equatable, Sendable {
public let deviceID: String
public let appInstanceID: String
/// Exact Mac build namespace sent to every broker request.
public let clientNamespace: String
public let tag: String
public let displayName: String?
public let identity: CmxIrohIdentityMaterial
@@ -29,6 +31,7 @@ public struct CmxIrohHostRuntimeConfiguration: Equatable, Sendable {
/// - accountID: The exact account that owns this host binding.
/// - deviceID: The account device's lowercase UUID.
/// - appInstanceID: The current app-instance UUID.
/// - clientNamespace: The exact installed Mac bundle namespace.
/// - tag: The broker registration build tag.
/// - displayName: The optional user-visible Mac name.
/// - identity: The stable Iroh secret and generation.
@@ -43,6 +46,7 @@ public struct CmxIrohHostRuntimeConfiguration: Equatable, Sendable {
accountID: String,
deviceID: String,
appInstanceID: String,
clientNamespace: CmxIrohMacBundleNamespace,
tag: String,
displayName: String?,
identity: CmxIrohIdentityMaterial,
@@ -57,6 +61,7 @@ public struct CmxIrohHostRuntimeConfiguration: Equatable, Sendable {
self.accountID = accountID
self.deviceID = cmxCanonicalDeviceID(deviceID)
self.appInstanceID = appInstanceID.lowercased()
self.clientNamespace = clientNamespace.rawValue
self.tag = tag
self.displayName = displayName
self.identity = identity
@@ -6,11 +6,15 @@ public actor CmxIrohIdentityRepository {
private static let installMarkerKey = "cmux.iroh.identity.install-marker.v1"
private static let activeScopeKey = "cmux.iroh.identity.active-scope.v1"
private static let recordVersion: UInt8 = 1
private static let maximumQueuedOperations = 64
private let secureStore: any CmxIrohSecureIdentityStoring
private let installState: any CmxIrohInstallStateStoring
private let randomBytes: @Sendable () throws -> Data
private let marker: @Sendable () -> String
private var operationIsActive = false
private var operationWaiters: [UUID: CheckedContinuation<Void, any Error>] = [:]
private var operationWaiterOrder: [UUID] = []
/// Creates an identity repository with injectable persistence and entropy.
public init(
@@ -32,34 +36,84 @@ public actor CmxIrohIdentityRepository {
/// A missing install marker removes Keychain material that survived an app
/// uninstall. Changing account scope removes the prior account key before
/// creating a new EndpointID.
public func identity(accountID: String, appInstanceID: String) throws -> CmxIrohIdentityMaterial {
let scope = try prepareScope(accountID: accountID, appInstanceID: appInstanceID)
if let encoded = try secureStore.read(account: scope) {
public func identity(accountID: String, appInstanceID: String) async throws -> CmxIrohIdentityMaterial {
try await beginOperation()
defer { endOperation() }
try Task.checkCancellation()
let scope = try await prepareScope(accountID: accountID, appInstanceID: appInstanceID)
if let encoded = try await secureStore.read(account: scope) {
return try Self.decode(encoded)
}
return try create(scope: scope, generation: 1)
return try await create(scope: scope, generation: 1)
}
/// Replaces the active account key and increments its identity generation.
public func rotate(accountID: String, appInstanceID: String) throws -> CmxIrohIdentityMaterial {
let scope = try prepareScope(accountID: accountID, appInstanceID: appInstanceID)
let current = try secureStore.read(account: scope).map(Self.decode)
public func rotate(accountID: String, appInstanceID: String) async throws -> CmxIrohIdentityMaterial {
try await beginOperation()
defer { endOperation() }
try Task.checkCancellation()
let scope = try await prepareScope(accountID: accountID, appInstanceID: appInstanceID)
let current = try await secureStore.read(account: scope).map(Self.decode)
let generation = try current.map { material in
guard material.generation < Int(Int32.max) else {
throw CmxIrohIdentityRepositoryError.invalidGeneration
}
return material.generation + 1
} ?? 1
return try create(scope: scope, generation: generation)
return try await create(scope: scope, generation: generation)
}
/// Removes all endpoint identity when signing out or locally revoking it.
public func deactivate() throws {
try secureStore.deleteAll()
public func deactivate() async throws {
try await beginOperation()
defer { endOperation() }
try Task.checkCancellation()
try await secureStore.deleteAll()
installState.set(nil, forKey: Self.activeScopeKey)
}
private func prepareScope(accountID: String, appInstanceID: String) throws -> String {
private func beginOperation() async throws {
guard operationIsActive else {
operationIsActive = true
return
}
guard operationWaiterOrder.count < Self.maximumQueuedOperations else {
throw CmxIrohIdentityRepositoryError.operationLimitExceeded
}
let id = UUID()
try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation {
(continuation: CheckedContinuation<Void, any Error>) in
if Task.isCancelled {
continuation.resume(throwing: CancellationError())
return
}
operationWaiters[id] = continuation
operationWaiterOrder.append(id)
}
} onCancel: {
Task { await self.cancelOperationWaiter(id) }
}
}
private func endOperation() {
guard let id = operationWaiterOrder.first else {
operationIsActive = false
return
}
operationWaiterOrder.removeFirst()
operationWaiters.removeValue(forKey: id)?.resume()
}
private func cancelOperationWaiter(_ id: UUID) {
guard let continuation = operationWaiters.removeValue(forKey: id) else {
return
}
operationWaiterOrder.removeAll { $0 == id }
continuation.resume(throwing: CancellationError())
}
private func prepareScope(accountID: String, appInstanceID: String) async throws -> String {
guard !accountID.isEmpty,
accountID.utf8.count <= 1_024,
!appInstanceID.isEmpty,
@@ -68,7 +122,7 @@ public actor CmxIrohIdentityRepository {
}
var clearedSecureStore = false
if installState.string(forKey: Self.installMarkerKey) == nil {
try secureStore.deleteAll()
try await secureStore.deleteAll()
clearedSecureStore = true
installState.set(nil, forKey: Self.activeScopeKey)
installState.set(marker(), forKey: Self.installMarkerKey)
@@ -76,17 +130,17 @@ public actor CmxIrohIdentityRepository {
let scope = Self.scope(accountID: accountID, appInstanceID: appInstanceID)
if installState.string(forKey: Self.activeScopeKey) != scope {
if !clearedSecureStore {
try secureStore.deleteAll()
try await secureStore.deleteAll()
}
installState.set(scope, forKey: Self.activeScopeKey)
}
return scope
}
private func create(scope: String, generation: Int) throws -> CmxIrohIdentityMaterial {
private func create(scope: String, generation: Int) async throws -> CmxIrohIdentityMaterial {
let secretKey = try CmxIrohSecretKey(bytes: randomBytes())
let material = try CmxIrohIdentityMaterial(secretKey: secretKey, generation: generation)
try secureStore.write(Self.encode(material), account: scope)
try await secureStore.write(Self.encode(material), account: scope)
return material
}
@@ -9,6 +9,9 @@ public enum CmxIrohIdentityRepositoryError: Error, Equatable, Sendable {
/// The identity generation is zero, exhausted, or database-incompatible.
case invalidGeneration
/// Too many identity operations are waiting behind a stalled persistence call.
case operationLimitExceeded
/// Secure random generation failed with the platform status code.
case randomGenerationFailed(Int32)
}
@@ -4,12 +4,24 @@ import Security
/// Device-only Keychain storage for Iroh relay capabilities.
public actor CmxIrohKeychainCredentialStore: CmxIrohSecureCredentialStoring {
private let service: String
private let accessGroup: String?
private let legacyService: String?
/// Creates a Keychain store isolated by service name.
///
/// - Parameter service: The generic-password service identifier.
public init(service: String = "com.cmuxterm.iroh.relay-credentials.v1") {
/// - Parameters:
/// - service: The bundle-scoped generic-password service identifier.
/// - accessGroup: The app's exact signed Keychain access group.
/// - legacyService: An older service whose item may be adopted only from
/// the same exact access group.
public init(
service: String = "com.cmuxterm.iroh.relay-credentials.v1",
accessGroup: String? = nil,
legacyService: String? = nil
) {
self.service = service
self.accessGroup = accessGroup
self.legacyService = legacyService == service ? nil : legacyService
}
/// Loads one opaque-scope capability from Keychain.
@@ -18,7 +30,24 @@ public actor CmxIrohKeychainCredentialStore: CmxIrohSecureCredentialStoring {
/// - Returns: The stored capability, or `nil` when none exists.
/// - Throws: ``CmxIrohKeychainCredentialStoreError`` when Keychain fails.
public func read(account: String) throws -> Data? {
var query = baseQuery(account: account)
if let current = try read(service: service, account: account) {
return current
}
guard let legacyService,
let legacy = try read(service: legacyService, account: account) else {
return nil
}
try write(
legacy,
account: account,
accessibility: .afterFirstUnlockThisDeviceOnly
)
try delete(query: baseQuery(service: legacyService, account: account))
return legacy
}
private func read(service: String, account: String) throws -> Data? {
var query = baseQuery(service: service, account: account)
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
@@ -44,7 +73,7 @@ public actor CmxIrohKeychainCredentialStore: CmxIrohSecureCredentialStoring {
account: String,
accessibility: CmxIrohSecureCredentialAccessibility
) throws {
let query = baseQuery(account: account)
let query = baseQuery(service: service, account: account)
let attributes: [String: Any] = [
kSecValueData as String: data,
kSecAttrAccessible as String: secAccessibility(accessibility),
@@ -83,17 +112,26 @@ public actor CmxIrohKeychainCredentialStore: CmxIrohSecureCredentialStoring {
/// - Parameter account: The repository-derived scope.
/// - Throws: ``CmxIrohKeychainCredentialStoreError`` when Keychain fails.
public func delete(account: String) throws {
try delete(query: baseQuery(account: account))
try delete(query: baseQuery(service: service, account: account))
if let legacyService {
try delete(query: baseQuery(service: legacyService, account: account))
}
}
/// Removes every relay capability owned by this Keychain service.
///
/// - Throws: ``CmxIrohKeychainCredentialStoreError`` when Keychain fails.
public func deleteAll() throws {
try delete(query: baseQuery())
try delete(query: baseQuery(service: service))
if let legacyService {
try delete(query: baseQuery(service: legacyService))
}
}
private func baseQuery(account: String? = nil) -> [String: Any] {
private func baseQuery(
service: String,
account: String? = nil
) -> [String: Any] {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
@@ -103,6 +141,9 @@ public actor CmxIrohKeychainCredentialStore: CmxIrohSecureCredentialStoring {
if let account {
query[kSecAttrAccount as String] = account
}
if let accessGroup {
query[kSecAttrAccessGroup as String] = accessGroup
}
return query
}
@@ -2,18 +2,44 @@ public import Foundation
import Security
/// Device-only Keychain storage for Iroh EndpointID secret material.
public final class CmxIrohKeychainIdentityStore: CmxIrohSecureIdentityStoring, @unchecked Sendable {
public actor CmxIrohKeychainIdentityStore: CmxIrohSecureIdentityStoring {
private let service: String
private let accessGroup: String?
private let legacyService: String?
/// Creates a Keychain store isolated by service name.
///
/// - Parameter service: The generic-password service identifier.
public init(service: String = "com.cmuxterm.iroh.endpoint-identity.v1") {
/// - Parameters:
/// - service: The bundle-scoped generic-password service identifier.
/// - accessGroup: The app's exact signed Keychain access group.
/// - legacyService: An older service whose item may be adopted only from
/// the same exact access group.
public init(
service: String = "com.cmuxterm.iroh.endpoint-identity.v1",
accessGroup: String? = nil,
legacyService: String? = nil
) {
self.service = service
self.accessGroup = accessGroup
self.legacyService = legacyService == service ? nil : legacyService
}
public func read(account: String) throws -> Data? {
var query = baseQuery(account: account)
/// Loads one identity, adopting its same-access-group legacy record when needed.
public func read(account: String) async throws -> Data? {
if let current = try read(service: service, account: account) {
return current
}
guard let legacyService,
let legacy = try read(service: legacyService, account: account) else {
return nil
}
try writeStored(legacy, account: account)
try delete(query: baseQuery(service: legacyService, account: account))
return legacy
}
private func read(service: String, account: String) throws -> Data? {
var query = baseQuery(service: service, account: account)
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
@@ -27,8 +53,13 @@ public final class CmxIrohKeychainIdentityStore: CmxIrohSecureIdentityStoring, @
return data
}
public func write(_ data: Data, account: String) throws {
let query = baseQuery(account: account)
/// Replaces one identity in the bundle-scoped Keychain service.
public func write(_ data: Data, account: String) async throws {
try writeStored(data, account: account)
}
private func writeStored(_ data: Data, account: String) throws {
let query = baseQuery(service: service, account: account)
let updateStatus = SecItemUpdate(
query as CFDictionary,
[kSecValueData as String: data] as CFDictionary
@@ -48,28 +79,20 @@ public final class CmxIrohKeychainIdentityStore: CmxIrohSecureIdentityStoring, @
}
}
public func delete(account: String) throws {
try delete(query: baseQuery(account: account))
/// Removes one identity from the current and eligible legacy services.
public func delete(account: String) async throws {
try delete(query: baseQuery(service: service, account: account))
if let legacyService {
try delete(query: baseQuery(service: legacyService, account: account))
}
}
/// Whether ANY identity record exists under this store's service, without
/// reading or creating one.
///
/// Items here are `AfterFirstUnlockThisDeviceOnly`: they never travel in a
/// device backup, so a present record is proof the app previously ran (and
/// activated iroh) on THIS physical device the non-migrating continuity
/// signal the device-registry mirror adoption gates on. Any error
/// (including a locked Keychain) reports `false`: absence of proof, never
/// proof of absence, so callers stay fail-safe.
public func containsAnyRecord() -> Bool {
var query = baseQuery()
query[kSecMatchLimit as String] = kSecMatchLimitOne
let status = SecItemCopyMatching(query as CFDictionary, nil)
return status == errSecSuccess
}
public func deleteAll() throws {
try delete(query: baseQuery())
/// Removes every identity from the current and eligible legacy services.
public func deleteAll() async throws {
try delete(query: baseQuery(service: service))
if let legacyService {
try delete(query: baseQuery(service: legacyService))
}
}
/// Generates one Ed25519 secret using Security.framework.
@@ -85,7 +108,10 @@ public final class CmxIrohKeychainIdentityStore: CmxIrohSecureIdentityStoring, @
return data
}
private func baseQuery(account: String? = nil) -> [String: Any] {
private func baseQuery(
service: String,
account: String? = nil
) -> [String: Any] {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
@@ -95,6 +121,9 @@ public final class CmxIrohKeychainIdentityStore: CmxIrohSecureIdentityStoring, @
if let account {
query[kSecAttrAccount as String] = account
}
if let accessGroup {
query[kSecAttrAccessGroup as String] = accessGroup
}
return query
}
@@ -5,6 +5,9 @@ import Foundation
public struct CmxIrohLocalBindingExpectation: Equatable, Sendable {
public let deviceID: String
public let appInstanceID: String
/// The exact bundle-derived app namespace expected in discovery.
public let clientNamespace: String
public let tag: String
public let platform: CmxIrohPlatform
public let endpointID: CmxIrohPeerIdentity
@@ -15,6 +18,7 @@ public struct CmxIrohLocalBindingExpectation: Equatable, Sendable {
public init(
deviceID: String,
appInstanceID: String,
clientNamespace: String = "legacy",
tag: String,
platform: CmxIrohPlatform,
endpointID: CmxIrohPeerIdentity,
@@ -24,15 +28,17 @@ public struct CmxIrohLocalBindingExpectation: Equatable, Sendable {
) throws {
guard Self.isCanonicalUUID(deviceID),
Self.isCanonicalUUID(appInstanceID),
Self.isSafeToken(tag),
cmxIrohIsSafeToken(clientNamespace, maximumUTF8ByteCount: 255),
cmxIrohIsSafeToken(tag),
(1 ... Int(Int32.max)).contains(identityGeneration),
capabilities.count <= 32,
Set(capabilities).count == capabilities.count,
capabilities.allSatisfy(Self.isSafeToken) else {
capabilities.allSatisfy({ cmxIrohIsSafeToken($0) }) else {
throw CmxIrohLocalBindingExpectationError.invalidExpectation
}
self.deviceID = deviceID
self.appInstanceID = appInstanceID
self.clientNamespace = clientNamespace
self.tag = tag
self.platform = platform
self.endpointID = endpointID
@@ -45,6 +51,7 @@ public struct CmxIrohLocalBindingExpectation: Equatable, Sendable {
public func matches(_ binding: CmxIrohBrokerBinding) -> Bool {
binding.deviceID == deviceID
&& binding.appInstanceID == appInstanceID
&& binding.clientNamespace == clientNamespace
&& binding.tag == tag
&& binding.platform == platform
&& binding.endpointID == endpointID
@@ -58,13 +65,4 @@ public struct CmxIrohLocalBindingExpectation: Equatable, Sendable {
UUID(uuidString: value)?.uuidString.lowercased() == value
}
private static func isSafeToken(_ value: String) -> Bool {
guard (1 ... 64).contains(value.utf8.count) else { return false }
return value.utf8.allSatisfy { byte in
(48 ... 57).contains(byte)
|| (65 ... 90).contains(byte)
|| (97 ... 122).contains(byte)
|| [45, 46, 58, 95].contains(byte)
}
}
}
@@ -0,0 +1,29 @@
import Foundation
/// Broker namespace owned by one exact installed macOS app bundle.
public struct CmxIrohMacBundleNamespace: Equatable, Hashable, Sendable {
/// Canonical `mac:<bundle-id>` value sent to the trust broker.
public let rawValue: String
/// Creates a namespace from one complete macOS bundle identifier.
public init?(bundleIdentifier: String?) {
guard let bundleIdentifier else { return nil }
let trimmed = bundleIdentifier.trimmingCharacters(
in: .whitespacesAndNewlines
)
guard trimmed == bundleIdentifier,
trimmed.contains("."),
trimmed.utf8.count <= 251,
trimmed.range(
of: #"^[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$"#,
options: .regularExpression
) != nil else {
return nil
}
let value = "mac:\(trimmed.lowercased())"
guard cmxIrohIsSafeToken(value, maximumUTF8ByteCount: 255) else {
return nil
}
rawValue = value
}
}
@@ -0,0 +1,4 @@
struct CmxIrohMacForgetRequest: Encodable {
let bindingId: String
let intent = "forget_mac"
}
@@ -68,4 +68,5 @@ public struct CmxIrohPendingRevocation: Codable, Equatable, Sendable {
private static func isCanonicalUUID(_ value: String) -> Bool {
UUID(uuidString: value)?.uuidString.lowercased() == value
}
}
@@ -70,12 +70,14 @@ public actor CmxIrohPendingRevocationOutbox {
/// - accountID: The currently authenticated account.
/// - tag: The build tag about to register.
/// - broker: An authenticated idempotent binding revoker.
/// - Returns: Whether at least one pending binding was revoked.
/// - Throws: The first broker, validation, decoding, or persistence error.
@discardableResult
public func revokePending(
accountID: String,
beforeRegisteringTag tag: String,
using broker: any CmxIrohBindingRevoking
) async throws {
) async throws -> Bool {
guard CmxIrohPendingRevocation.isSafeAccountID(accountID),
CmxIrohPendingRevocation.isSafeTag(tag) else {
throw CmxIrohPendingRevocationError.invalidRecord
@@ -88,6 +90,41 @@ public actor CmxIrohPendingRevocationOutbox {
try await removeConfirmed(revocation)
}
return !ordered.isEmpty
}
/// Reconciles pending bindings after registration has installed one active
/// binding. A broker may reuse the same binding identifier when a sign-out
/// was queued and the app signs back in before the queue drained. That
/// identifier is already active again, so removing its stale queue entry
/// must not send a revoke request for it.
///
/// - Returns: Whether at least one different pending binding was revoked.
public func reconcilePending(
accountID: String,
beforeRegisteringTag tag: String,
activeBindingID: String,
using broker: any CmxIrohBindingRevoking
) async throws -> Bool {
guard UUID(uuidString: activeBindingID)?.uuidString.lowercased() == activeBindingID,
CmxIrohPendingRevocation.isSafeAccountID(accountID),
CmxIrohPendingRevocation.isSafeTag(tag) else {
throw CmxIrohPendingRevocationError.invalidRecord
}
let snapshot = try await pending(accountID: accountID)
let ordered = snapshot.filter { $0.tag == tag }
+ snapshot.filter { $0.tag != tag }
var revoked = false
for revocation in ordered {
if revocation.bindingID == activeBindingID {
try await removeConfirmed(revocation)
continue
}
try await broker.revokeStale(bindingID: revocation.bindingID)
try await removeConfirmed(revocation)
revoked = true
}
return revoked
}
private func removeConfirmed(
@@ -0,0 +1,3 @@
struct CmxIrohPostRegistrationRevocationFailure: Error {
let underlying: any Error
}
@@ -10,6 +10,7 @@ public struct CmxIrohRegistrationPayload: Encodable, Equatable, Sendable {
case routeContractVersion = "route_contract_version"
case deviceID = "deviceId"
case appInstanceID = "appInstanceId"
case clientNamespace
case tag
case platform
case displayName
@@ -27,6 +28,8 @@ public struct CmxIrohRegistrationPayload: Encodable, Equatable, Sendable {
public let deviceID: String
/// Stable app-instance UUID for this installation and tag.
public let appInstanceID: String
/// Exact app namespace that owns this binding.
public let clientNamespace: String
/// Safe build or app-instance tag.
public let tag: String
/// Device role used by grant policy.
@@ -53,6 +56,7 @@ public struct CmxIrohRegistrationPayload: Encodable, Equatable, Sendable {
public init(
deviceID: String,
appInstanceID: String,
clientNamespace: String = "legacy",
tag: String,
platform: CmxIrohPlatform,
displayName: String? = nil,
@@ -66,6 +70,7 @@ public struct CmxIrohRegistrationPayload: Encodable, Equatable, Sendable {
) throws {
guard Self.isBrokerUUID(deviceID),
Self.isBrokerUUID(appInstanceID),
Self.isSafeToken(clientNamespace, maximum: 255),
Self.isSafeToken(tag, maximum: 64),
(try? CmxIrohPeerIdentity(endpointID: endpointID)) != nil,
(1...Int(Int32.max)).contains(identityGeneration),
@@ -89,6 +94,7 @@ public struct CmxIrohRegistrationPayload: Encodable, Equatable, Sendable {
routeContractVersion = Self.currentRouteContractVersion
self.deviceID = cmxCanonicalDeviceID(deviceID)
self.appInstanceID = appInstanceID.lowercased()
self.clientNamespace = clientNamespace
self.tag = tag
self.platform = platform
self.displayName = displayName
@@ -72,6 +72,29 @@ public struct CmxIrohRegistrationSigner: Sendable {
)
}
/// Signs one authenticated broker request with the registered endpoint key.
func signBrokerRequest(
bindingID: String,
method: String,
path: String,
timestamp: Int64,
body: Data
) throws -> String {
guard Self.isBrokerUUID(bindingID),
!method.isEmpty,
method.utf8.allSatisfy({ (65...90).contains($0) }),
!path.isEmpty,
path.utf8.allSatisfy({ $0 >= 0x21 && $0 <= 0x7e }),
timestamp > 0 else {
throw CmxIrohRegistrationError.invalidChallenge
}
let bodySHA256 = Self.hex(Data(SHA256.hash(data: body)))
let transcript = Data(
"cmux/iroh/binding-request/v1\n\(bindingID.lowercased())\n\(method)\n\(path)\n\(timestamp)\n\(bodySHA256)".utf8
)
return Self.base64URL(signingKey.sign(message: transcript).toBytes())
}
private static func base64URL(_ data: Data) -> String {
data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
@@ -0,0 +1,16 @@
import Foundation
func cmxIrohIsSafeToken(
_ value: String,
maximumUTF8ByteCount: Int = 64
) -> Bool {
guard (1 ... maximumUTF8ByteCount).contains(value.utf8.count) else {
return false
}
return value.utf8.allSatisfy { byte in
(48 ... 57).contains(byte)
|| (65 ... 90).contains(byte)
|| (97 ... 122).contains(byte)
|| [45, 46, 58, 95].contains(byte)
}
}
@@ -3,14 +3,14 @@ public import Foundation
/// Minimal secure-storage boundary used by the Iroh identity repository.
public protocol CmxIrohSecureIdentityStoring: Sendable {
/// Loads the record for an opaque account scope.
func read(account: String) throws -> Data?
func read(account: String) async throws -> Data?
/// Replaces the record for an opaque account scope.
func write(_ data: Data, account: String) throws
func write(_ data: Data, account: String) async throws
/// Removes one opaque account scope.
func delete(account: String) throws
func delete(account: String) async throws
/// Removes every Iroh identity owned by this app installation.
func deleteAll() throws
func deleteAll() async throws
}
@@ -0,0 +1,4 @@
struct CmxIrohStaleBindingRevocationRequest: Encodable {
let bindingId: String
let intent = "revoke_stale"
}
@@ -1,6 +1,23 @@
public import CMUXMobileCore
public import Foundation
private func cmxIsSafeClientNamespace(_ value: String) -> Bool {
(1 ... 255).contains(value.utf8.count)
&& value.utf8.allSatisfy {
(48 ... 57).contains($0)
|| (65 ... 90).contains($0)
|| (97 ... 122).contains($0)
|| [45, 46, 58, 95].contains($0)
}
}
private func cmxIsSafeBrokerHeaderValue(_ value: String) -> Bool {
(1 ... 16 * 1_024).contains(value.utf8.count)
&& !value.unicodeScalars.contains(
where: { $0.value < 0x20 || $0.value == 0x7f }
)
}
/// One access + refresh credential pair captured from a single session snapshot.
///
/// Assembling a request from one snapshot prevents pairing a stale access token
@@ -269,12 +286,16 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
private let transport: any CmxIrohHTTPTransport
private let requestTimeout: TimeInterval
private let backpressureGate: CmxIrohBrokerBackpressureGate?
private let clientNamespace: String
private var bindingAuthorization: CmxIrohBindingRequestAuthorization?
private let discoveryScope: CmxConnectivityDiscoveryScope?
/// Creates a client that rejects cleartext non-loopback API origins.
public init(
baseURL: URL,
tokenSource: CmxIrohBrokerTokenSource,
clientNamespace: String,
bindingAuthorization: CmxIrohBindingRequestAuthorization? = nil,
discoveryScope: CmxConnectivityDiscoveryScope? = nil,
requestTimeout: TimeInterval = 10,
backpressureMode: CmxIrohBrokerBackpressureMode = .automatic
@@ -282,6 +303,8 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
try self.init(
baseURL: baseURL,
tokenSource: tokenSource,
clientNamespace: clientNamespace,
bindingAuthorization: bindingAuthorization,
discoveryScope: discoveryScope,
transport: CmxIrohURLSessionTransport(),
requestTimeout: requestTimeout,
@@ -293,18 +316,26 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
init(
baseURL: URL,
tokenSource: CmxIrohBrokerTokenSource,
clientNamespace: String,
bindingAuthorization: CmxIrohBindingRequestAuthorization? = nil,
discoveryScope: CmxConnectivityDiscoveryScope? = nil,
transport: any CmxIrohHTTPTransport,
requestTimeout: TimeInterval = 10,
backpressureMode: CmxIrohBrokerBackpressureMode = .automatic
) throws {
guard Self.isAllowedBaseURL(baseURL), requestTimeout > 0 else {
guard Self.isAllowedBaseURL(baseURL),
cmxIsSafeClientNamespace(clientNamespace),
bindingAuthorization?.clientNamespace == nil
|| bindingAuthorization?.clientNamespace == clientNamespace,
requestTimeout > 0 else {
throw CmxIrohTrustBrokerClientError.invalidBaseURL
}
self.baseURL = baseURL
self.tokenSource = tokenSource
self.transport = transport
self.requestTimeout = requestTimeout
self.clientNamespace = clientNamespace
self.bindingAuthorization = bindingAuthorization
self.discoveryScope = discoveryScope
switch backpressureMode {
case .automatic:
@@ -322,6 +353,16 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
)
}
/// Reports whether this client retains a signed binding request proof.
public func hasBindingAuthorization() async -> Bool {
bindingAuthorization != nil
}
/// Returns the binding ID represented by the retained request proof.
public func bindingAuthorizationID() async -> String? {
bindingAuthorization?.bindingID
}
public func issueChallenge(
_ request: CmxIrohChallengeRequest
) async throws -> CmxIrohChallengeResponse {
@@ -346,7 +387,9 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
prepared: CmxIrohPreparedRegistration,
signer: CmxIrohRegistrationSigner
) async throws -> CmxIrohRegistrationResponse {
try await withBackpressure(operation: .registration) {
let response: CmxIrohRegistrationResponse = try await withBackpressure(
operation: .registration
) {
let challenge: CmxIrohChallengeResponse = try await self.sendUngated(
path: "api/devices/iroh/challenge",
method: "POST",
@@ -355,8 +398,15 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
let request = try signer.sign(prepared: prepared, challenge: challenge)
return try await self.registerUngated(request)
}
bindingAuthorization = CmxIrohBindingRequestAuthorization(
bindingID: response.binding.bindingID,
clientNamespace: clientNamespace,
signer: signer
)
return response
}
/// Discovers account bindings visible to this client's exact build namespace.
public func discover() async throws -> CmxIrohDiscoveryResponse {
try await withBackpressure(operation: .discovery) {
if self.discoveryScope != nil {
@@ -488,6 +538,7 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
)
}
/// Revokes the caller's own binding.
public func revoke(bindingID: String) async throws {
let response: RevokeResponse = try await send(
path: "api/devices/iroh",
@@ -500,6 +551,32 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
}
}
/// Revokes an older binding owned by this app namespace and physical device.
public func revokeStale(bindingID: String) async throws {
let response: RevokeResponse = try await send(
path: "api/devices/iroh",
method: "DELETE",
body: CmxIrohStaleBindingRevocationRequest(bindingId: bindingID),
operation: .revocation
)
guard response.revoked, response.lanRendezvousRotated else {
throw CmxIrohTrustBrokerClientError.invalidResponse
}
}
/// Revokes one same-build Mac through the explicit account-management path.
public func forgetMac(bindingID: String) async throws {
let response: RevokeResponse = try await send(
path: "api/devices/iroh",
method: "DELETE",
body: CmxIrohMacForgetRequest(bindingId: bindingID),
operation: .revocation
)
guard response.revoked, response.lanRendezvousRotated else {
throw CmxIrohTrustBrokerClientError.invalidResponse
}
}
private func registerUngated(
_ request: CmxIrohRegisterRequest
) async throws -> CmxIrohRegistrationResponse {
@@ -792,7 +869,8 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
) async throws -> Response {
let accessToken = credentials.accessToken
let refreshToken = credentials.refreshToken
guard Self.isSafeHeaderValue(accessToken), Self.isSafeHeaderValue(refreshToken) else {
guard cmxIsSafeBrokerHeaderValue(accessToken),
cmxIsSafeBrokerHeaderValue(refreshToken) else {
throw CmxIrohTrustBrokerClientError.invalidAuthentication
}
let pathURL = baseURL.appendingPathComponent(path)
@@ -811,6 +889,31 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
request.timeoutInterval = requestTimeout
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
request.setValue(refreshToken, forHTTPHeaderField: "X-Stack-Refresh-Token")
request.setValue(clientNamespace, forHTTPHeaderField: "X-Cmux-App-Namespace")
if let bindingAuthorization,
path != "api/devices/iroh/challenge",
path != "api/devices/iroh/register" {
let timestamp = Int64(Date().timeIntervalSince1970)
let signature = try bindingAuthorization.signer.signBrokerRequest(
bindingID: bindingAuthorization.bindingID,
method: method,
path: path,
timestamp: timestamp,
body: body ?? Data()
)
request.setValue(
bindingAuthorization.bindingID,
forHTTPHeaderField: "X-Cmux-Iroh-Binding-ID"
)
request.setValue(
String(timestamp),
forHTTPHeaderField: "X-Cmux-Iroh-Request-Time"
)
request.setValue(
signature,
forHTTPHeaderField: "X-Cmux-Iroh-Request-Signature"
)
}
request.setValue("application/json", forHTTPHeaderField: "Accept")
if let body {
request.httpBody = body
@@ -869,11 +972,6 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
return scheme == "http" && ["127.0.0.1", "::1", "localhost"].contains(host)
}
private static func isSafeHeaderValue(_ value: String) -> Bool {
(1 ... 16 * 1_024).contains(value.utf8.count)
&& !value.unicodeScalars.contains(where: { $0.value < 0x20 || $0.value == 0x7f })
}
private static func retryAfterSeconds(_ value: String?) -> Int? {
guard let value,
!value.isEmpty,
@@ -36,6 +36,7 @@ struct ClientRuntimeTestFixture {
accountID: "account-a",
deviceID: binding.deviceID,
appInstanceID: binding.appInstanceID,
clientNamespace: binding.clientNamespace,
tag: binding.tag,
displayName: binding.displayName,
identity: identity,
@@ -135,6 +135,10 @@ private actor BackpressuredHostBrokerProbe:
throw BackpressuredHostBrokerProbeError.unexpectedCall
}
func revokeStale(bindingID _: String) async throws {
throw BackpressuredHostBrokerProbeError.unexpectedCall
}
func issueRelayBootstrap(
endpointID _: CmxIrohPeerIdentity
) async throws -> CmxIrohRelayBootstrapResponse {
@@ -75,6 +75,7 @@ struct CmxIrohBrokerCredentialPairTests {
let client = try CmxIrohTrustBrokerClient(
baseURL: #require(URL(string: "https://cmux.example")),
tokenSource: tokenSource,
clientNamespace: "legacy",
transport: transport
)
@@ -21,6 +21,7 @@ extension CmxIrohClientRuntimeTests {
accountID: "account-a",
deviceID: fixture.initiator.deviceID,
appInstanceID: discovery.bindings[0].appInstanceID,
clientNamespace: discovery.bindings[0].clientNamespace,
tag: fixture.initiator.tag,
displayName: nil,
identity: identity,
@@ -92,6 +93,7 @@ extension CmxIrohClientRuntimeTests {
accountID: "account-a",
deviceID: fixture.initiator.deviceID,
appInstanceID: discovery.bindings[0].appInstanceID,
clientNamespace: discovery.bindings[0].clientNamespace,
tag: fixture.initiator.tag,
displayName: nil,
identity: identity,
@@ -164,6 +166,7 @@ extension CmxIrohClientRuntimeTests {
accountID: "account-a",
deviceID: fixture.initiator.deviceID,
appInstanceID: discovery.bindings[0].appInstanceID,
clientNamespace: discovery.bindings[0].clientNamespace,
tag: fixture.initiator.tag,
displayName: nil,
identity: identity,
@@ -24,6 +24,7 @@ struct CmxIrohClientRuntimeEmptyFleetTests {
accountID: fixture.configuration.accountID,
deviceID: fixture.configuration.deviceID,
appInstanceID: fixture.configuration.appInstanceID,
clientNamespace: fixture.configuration.clientNamespace,
tag: fixture.configuration.tag,
displayName: fixture.configuration.displayName,
identity: fixture.identity,
@@ -26,6 +26,7 @@ extension CmxIrohClientRuntimeTests {
accountID: fixture.configuration.accountID,
deviceID: fixture.configuration.deviceID,
appInstanceID: fixture.configuration.appInstanceID,
clientNamespace: fixture.configuration.clientNamespace,
tag: fixture.configuration.tag,
displayName: fixture.configuration.displayName,
identity: fixture.configuration.identity,
@@ -3,6 +3,12 @@ import Foundation
import Testing
@testable import CmuxIrohTransport
private extension CmxIrohClientRuntime {
func installLocalBindingForSignOutTest(_ binding: CmxIrohBrokerBinding) {
localBinding = binding
}
}
@Suite
struct CmxIrohClientRuntimeTests {
@Test
@@ -19,6 +25,7 @@ struct CmxIrohClientRuntimeTests {
accountID: fixture.configuration.accountID,
deviceID: fixture.configuration.deviceID,
appInstanceID: fixture.configuration.appInstanceID,
clientNamespace: fixture.configuration.clientNamespace,
tag: fixture.configuration.tag,
displayName: fixture.configuration.displayName,
identity: fixture.configuration.identity,
@@ -149,6 +156,7 @@ struct CmxIrohClientRuntimeTests {
accountID: "account-a",
deviceID: fixture.initiator.deviceID,
appInstanceID: localBinding.appInstanceID,
clientNamespace: localBinding.clientNamespace,
tag: fixture.initiator.tag,
displayName: nil,
identity: identity,
@@ -216,6 +224,49 @@ struct CmxIrohClientRuntimeTests {
await runtime.stop()
}
@Test
func pendingRevocationInvalidatesEmbeddedRegistrationDiscovery() async throws {
let fixture = try ClientRuntimeTestFixture()
let staleDiscovery = try ClientRuntimeTestFixture.discovery(
binding: fixture.binding,
revision: 1
)
let authoritativeDiscovery = try ClientRuntimeTestFixture.discovery(
binding: fixture.binding,
revision: 2
)
let pendingRevocations = fixture.pendingRevocations()
let pending = try CmxIrohPendingRevocation(
accountID: fixture.configuration.accountID,
tag: "older-build",
bindingID: "123e4567-e89b-42d3-a456-426614174099"
)
try await pendingRevocations.enqueue(pending)
let broker = TestRevisionedClientBroker(
binding: fixture.binding,
discoveries: [authoritativeDiscovery],
relay: fixture.relayResponse(),
embeddedRegistrationDiscovery: staleDiscovery,
embeddedRegistrationDiscoveryIsComplete: true,
registrationRevision: 1
)
let runtime = try CmxIrohClientRuntime(
factory: TestIrohEndpointFactory(endpoints: [
TestIrohEndpoint(identity: fixture.endpointID),
]),
broker: broker,
configuration: fixture.configuration,
pendingRevocations: pendingRevocations,
now: { fixture.now }
)
try await runtime.start()
#expect(await broker.syncCount == 1)
#expect(await runtime.connectivityEngine.snapshot().routeRevision == 2)
await runtime.stop()
}
@Test
func startupFetchesPaginatedDiscoveryWhenRegistrationAndSyncSnapshotsAreUnproven() async throws {
let fixture = try ClientRuntimeTestFixture()
@@ -269,6 +320,7 @@ struct CmxIrohClientRuntimeTests {
accountID: fixture.configuration.accountID,
deviceID: fixture.configuration.deviceID,
appInstanceID: fixture.configuration.appInstanceID,
clientNamespace: fixture.configuration.clientNamespace,
tag: fixture.configuration.tag,
displayName: fixture.configuration.displayName,
identity: fixture.configuration.identity,
@@ -594,6 +646,129 @@ struct CmxIrohClientRuntimeTests {
await runtime.stop()
}
@Test
func rateLimitedRegistrationDrainsPendingRevocationsBeforeDiscovery() async throws {
let fixture = try ClientRuntimeTestFixture()
let pendingRevocations = fixture.pendingRevocations()
let pending = try CmxIrohPendingRevocation(
accountID: fixture.configuration.accountID,
tag: "older-build",
bindingID: "123e4567-e89b-42d3-a456-426614174099"
)
try await pendingRevocations.enqueue(pending)
let broker = TestIrohClientBroker(
binding: fixture.binding,
discovery: fixture.discovery,
relay: fixture.relayResponse(),
registrationError: CmxIrohTrustBrokerClientError.rateLimited(
code: "device_registration_hour_quota",
retryAfterSeconds: 600
)
)
let runtime = try CmxIrohClientRuntime(
factory: TestIrohEndpointFactory(endpoints: [
TestIrohEndpoint(identity: fixture.endpointID),
]),
broker: broker,
configuration: fixture.configuration,
pendingRevocations: pendingRevocations,
now: { fixture.now }
)
try await runtime.start()
#expect(await broker.observedRegistrations().count == 1)
#expect(await broker.observedRevokedBindingIDs() == [pending.bindingID])
#expect(await broker.observedDiscoveryCount() == 1)
#expect(
try await pendingRevocations.pending(
accountID: fixture.configuration.accountID
).isEmpty
)
await runtime.stop()
}
@Test
func rateLimitedRegistrationWithoutBindingProofDoesNotDrainOrDiscover() async throws {
let fixture = try ClientRuntimeTestFixture()
let pendingRevocations = fixture.pendingRevocations()
let pending = try CmxIrohPendingRevocation(
accountID: fixture.configuration.accountID,
tag: "older-build",
bindingID: "123e4567-e89b-42d3-a456-426614174099"
)
try await pendingRevocations.enqueue(pending)
let broker = TestIrohClientBroker(
binding: fixture.binding,
discovery: fixture.discovery,
relay: fixture.relayResponse(),
bindingAuthorizationAvailable: false,
registrationError: CmxIrohTrustBrokerClientError.rateLimited(
code: "device_registration_hour_quota",
retryAfterSeconds: 600
)
)
let runtime = try CmxIrohClientRuntime(
factory: TestIrohEndpointFactory(endpoints: [
TestIrohEndpoint(identity: fixture.endpointID),
]),
broker: broker,
configuration: fixture.configuration,
pendingRevocations: pendingRevocations,
now: { fixture.now }
)
await #expect(throws: CmxIrohTrustBrokerClientError.rateLimited(
code: "device_registration_hour_quota",
retryAfterSeconds: 600
)) {
try await runtime.start()
}
#expect(await broker.observedDiscoveryCount() == 0)
#expect(try await pendingRevocations.pending(
accountID: fixture.configuration.accountID
) == [pending])
}
@Test
func rateLimitedRegistrationDoesNotRevokeRetainedAuthorization() async throws {
let fixture = try ClientRuntimeTestFixture()
let pendingRevocations = fixture.pendingRevocations()
let pending = try CmxIrohPendingRevocation(
accountID: fixture.configuration.accountID,
tag: fixture.configuration.tag,
bindingID: fixture.binding.bindingID
)
try await pendingRevocations.enqueue(pending)
let broker = TestIrohClientBroker(
binding: fixture.binding,
discovery: fixture.discovery,
relay: fixture.relayResponse(),
registrationError: CmxIrohTrustBrokerClientError.rateLimited(
code: "device_registration_hour_quota",
retryAfterSeconds: 600
)
)
let runtime = try CmxIrohClientRuntime(
factory: TestIrohEndpointFactory(endpoints: [
TestIrohEndpoint(identity: fixture.endpointID),
]),
broker: broker,
configuration: fixture.configuration,
pendingRevocations: pendingRevocations,
now: { fixture.now }
)
try await runtime.start()
#expect(await broker.observedRevokedBindingIDs().isEmpty)
#expect(await broker.observedDiscoveryCount() == 1)
#expect(try await pendingRevocations.pending(
accountID: fixture.configuration.accountID
).isEmpty)
await runtime.stop()
}
@Test
func rateLimitedRegistrationRejectsMissingOrSubstitutedDiscoveryBinding() async throws {
let fixture = try ClientRuntimeTestFixture()
@@ -1005,6 +1180,10 @@ struct CmxIrohClientRuntimeTests {
#expect(preparation.bindingID == fixture.binding.bindingID)
#expect(preparation.wasPersisted)
#expect(
preparation.bindingAuthorization?.bindingID
== fixture.binding.bindingID
)
#expect(await recorder.observedLocalWipes() == [true])
#expect(await offlineStore.deleteAllCount() == 1)
#expect(await runtime.snapshot().state == .inactive)
@@ -1024,6 +1203,38 @@ struct CmxIrohClientRuntimeTests {
#expect(await runtime.snapshot().state == .inactive)
}
@Test
func signOutAuthorizationUsesPersistedLegacyBindingNamespace() async throws {
let fixture = try ClientRuntimeTestFixture()
let configuration = CmxIrohClientRuntimeConfiguration(
accountID: fixture.configuration.accountID,
deviceID: fixture.configuration.deviceID,
appInstanceID: fixture.configuration.appInstanceID,
clientNamespace: "dev.cmux.app.beta",
tag: fixture.configuration.tag,
displayName: fixture.configuration.displayName,
identity: fixture.configuration.identity,
capabilities: fixture.configuration.capabilities,
managedRelayURLs: fixture.configuration.managedRelayURLs
)
let runtime = try CmxIrohClientRuntime(
factory: TestIrohEndpointFactory(endpoints: []),
broker: TestIrohClientBroker(
binding: fixture.binding,
discovery: fixture.discovery,
relay: fixture.relayResponse()
),
configuration: configuration,
pendingRevocations: fixture.pendingRevocations(),
now: { fixture.now }
)
await runtime.installLocalBindingForSignOutTest(fixture.binding)
let preparation = await runtime.deactivateForSignOut()
#expect(preparation.bindingAuthorization?.clientNamespace == "legacy")
}
@Test
func suspendedSignOutPersistenceBlocksRestartUntilLocalTeardownCompletes() async throws {
let fixture = try ClientRuntimeTestFixture()
@@ -1112,7 +1323,7 @@ struct CmxIrohClientRuntimeTests {
}
@Test
func pendingRevocationFailureBlocksRegistrationAndOfflineFallback() async throws {
func pendingRevocationFailureStopsAfterAuthenticatedRegistration() async throws {
let fixture = try ClientRuntimeTestFixture()
let store = TestSecureCredentialStore()
let pendingRevocations = CmxIrohPendingRevocationOutbox(secureStore: store)
@@ -1145,7 +1356,8 @@ struct CmxIrohClientRuntimeTests {
try await runtime.start()
}
#expect(await broker.observedRegistrations().isEmpty)
#expect(await broker.observedRegistrations().count == 1)
#expect(await broker.observedDiscoveryCount() == 0)
#expect(await broker.observedRevokedBindingIDs() == [pending.bindingID])
#expect(
try await pendingRevocations.pending(
@@ -1269,6 +1481,10 @@ private actor TestRevisionedClientBroker:
func revoke(bindingID _: String) {}
func revokeStale(bindingID _: String) {}
func forgetMac(bindingID _: String) {}
func waitUntilSyncCount(_ minimum: Int) async {
while syncCount < minimum {
await Task.yield()
@@ -140,7 +140,8 @@ struct CmxIrohCustomRelayLiveTests {
refreshToken: refreshToken
)
}
)
),
clientNamespace: "legacy"
)
let runTag = "relay-live-\(UUID().uuidString.lowercased())"
let firstSecretKey = try randomSecretKey()
@@ -153,6 +153,7 @@ struct CmxIrohCustomRelayRuntimeTests {
accountID: fixture.configuration.accountID,
deviceID: fixture.configuration.deviceID,
appInstanceID: fixture.configuration.appInstanceID,
clientNamespace: fixture.configuration.clientNamespace,
tag: fixture.configuration.tag,
displayName: fixture.configuration.displayName,
identity: fixture.identity,
@@ -230,6 +231,7 @@ struct CmxIrohCustomRelayRuntimeTests {
accountID: fixture.configuration.accountID,
deviceID: fixture.configuration.deviceID,
appInstanceID: fixture.configuration.appInstanceID,
clientNamespace: fixture.configuration.clientNamespace,
tag: fixture.configuration.tag,
displayName: fixture.configuration.displayName,
identity: fixture.identity,
@@ -4,16 +4,16 @@ import Testing
@Suite(.serialized)
struct CmxIrohDevelopmentFileStorageTests {
@Test func identityRoundTripsWithPrivateFilesystemPermissions() throws {
@Test func identityRoundTripsWithPrivateFilesystemPermissions() async throws {
let fixture = try Fixture()
defer { fixture.remove() }
let store = CmxIrohDevelopmentFileIdentityStore(
directory: fixture.directory
)
try store.write(Data([1, 2, 3]), account: "identity-scope")
try await store.write(Data([1, 2, 3]), account: "identity-scope")
#expect(try store.read(account: "identity-scope") == Data([1, 2, 3]))
#expect(try await store.read(account: "identity-scope") == Data([1, 2, 3]))
#expect(try fixture.permissions(at: fixture.directory) == 0o700)
#expect(try fixture.permissions(
at: fixture.directory.appendingPathComponent(
@@ -52,15 +52,15 @@ struct CmxIrohDevelopmentFileStorageTests {
#expect(FileManager.default.fileExists(atPath: unrelated.path))
}
@Test func traversalScopeIsRejected() throws {
@Test func traversalScopeIsRejected() async throws {
let fixture = try Fixture()
defer { fixture.remove() }
let store = CmxIrohDevelopmentFileIdentityStore(
directory: fixture.directory
)
#expect(throws: CmxIrohDevelopmentFileStoreError.invalidAccount) {
try store.write(Data([1]), account: "../outside")
await #expect(throws: CmxIrohDevelopmentFileStoreError.invalidAccount) {
try await store.write(Data([1]), account: "../outside")
}
}
@@ -5,6 +5,14 @@ import Testing
@testable import CmuxIrohTransport
private extension CmxIrohHostRuntime {
func installLocalBindingForSignOutTest(
_ binding: CmxIrohBrokerBindingMetadata
) {
localBinding = binding
}
}
extension CmxIrohHostRuntimeTests {
@Test
func emptyPublicHintsRenewRegistrationBeforePrivatePortFreshnessExpires() async throws {
@@ -353,10 +361,45 @@ extension CmxIrohHostRuntimeTests {
await store.resumeSuspendedWrite()
let preparation = await signOut.value
#expect(preparation.wasPersisted)
#expect(
preparation.bindingAuthorization?.bindingID
== fixture.binding.bindingID
)
#expect(await ordering.values() == ["true:true"])
#expect(await runtime.snapshot().state == .inactive)
}
@Test
func signOutAuthorizationUsesPersistedLegacyBindingNamespace() async throws {
let fixture = try HostRuntimeFixture()
let legacyBinding = try CmxIrohBrokerBindingMetadata(
bindingID: fixture.binding.bindingID,
deviceID: fixture.binding.deviceID,
appInstanceID: fixture.binding.appInstanceID,
clientNamespace: "legacy",
tag: fixture.binding.tag,
platform: fixture.binding.platform,
endpointID: fixture.binding.endpointID,
identityGeneration: fixture.binding.identityGeneration,
pathHints: fixture.binding.pathHints
)
let runtime = CmxIrohHostRuntime(
factory: TestIrohEndpointFactory(endpoints: []),
broker: TestIrohHostBroker(
registrationBinding: fixture.binding,
discovery: fixture.discovery
),
configuration: fixture.configuration,
pendingRevocations: fixture.pendingRevocations(),
handleTransport: { session, _ in await session.close() }
)
await runtime.installLocalBindingForSignOutTest(legacyBinding)
let preparation = await runtime.deactivateForSignOut()
#expect(preparation.bindingAuthorization?.clientNamespace == "legacy")
}
@Test
func failedSignOutPersistenceClosesHostAndQuarantinesLocalState() async throws {
let fixture = try HostRuntimeFixture()
@@ -87,6 +87,54 @@ extension CmxIrohHostRuntimeTests {
await runtime.stop()
}
@Test
func pendingRevocationInvalidatesEmbeddedRegistrationDiscovery() async throws {
let fixture = try HostRuntimeFixture()
let staleDiscovery = try HostRuntimeFixture.discovery(
binding: fixture.binding,
relays: HostRuntimeFixture.relayURLs,
lanGeneration: 1,
revision: 1
)
let authoritativeDiscovery = try HostRuntimeFixture.discovery(
binding: fixture.binding,
relays: HostRuntimeFixture.relayURLs,
lanGeneration: 2,
revision: 2
)
let pendingRevocations = fixture.pendingRevocations()
let pending = try CmxIrohPendingRevocation(
accountID: fixture.configuration.accountID,
tag: "older-build",
bindingID: "123e4567-e89b-42d3-a456-426614174099"
)
try await pendingRevocations.enqueue(pending)
let broker = TestIrohHostBroker(
registrationBinding: fixture.binding,
discovery: authoritativeDiscovery,
embeddedRegistrationDiscovery: staleDiscovery,
embeddedRegistrationDiscoveryIsComplete: true,
registrationRevision: 1
)
let runtime = CmxIrohHostRuntime(
factory: TestIrohEndpointFactory(endpoints: [
TestIrohEndpoint(identity: fixture.endpointID),
]),
broker: broker,
configuration: fixture.configuration,
pendingRevocations: pendingRevocations,
handleTransport: { session, _ in await session.close() }
)
try await runtime.start()
#expect(await broker.observedRevokedBindingIDs() == [pending.bindingID])
#expect(await broker.observedDiscoveryCount() == 1)
#expect(await runtime.connectivityEngine?.snapshot().routeRevision == 2)
#expect(await runtime.lanAdvertisementContext()?.rendezvous.generation == 2)
await runtime.stop()
}
@Test
func embeddedDiscoveryMustExactlyMatchTheRegistrationRevision() async throws {
let fixture = try HostRuntimeFixture()
@@ -231,4 +231,6 @@ private actor TestRevisionedHostBroker:
}
func revoke(bindingID _: String) {}
func revokeStale(bindingID _: String) {}
}
@@ -1,6 +1,7 @@
import CMUXMobileCore
import CryptoKit
import Foundation
import Testing
@testable import CmuxIrohTransport
struct HostRuntimeFixture {
@@ -9,6 +10,7 @@ struct HostRuntimeFixture {
let binding: CmxIrohBrokerBinding
let discovery: CmxIrohDiscoveryResponse
let managedRelays: Set<String>
let clientNamespace: CmxIrohMacBundleNamespace
let configuration: CmxIrohHostRuntimeConfiguration
init(
@@ -27,6 +29,11 @@ struct HostRuntimeFixture {
.joined()
)
managedRelays = Set(Self.relayURLs)
clientNamespace = try #require(
CmxIrohMacBundleNamespace(
bundleIdentifier: "com.cmuxterm.tests"
)
)
binding = try Self.binding(
endpointID: endpointID.endpointID,
lastSeenAt: now,
@@ -41,6 +48,7 @@ struct HostRuntimeFixture {
accountID: "account-a",
deviceID: binding.deviceID,
appInstanceID: binding.appInstanceID,
clientNamespace: clientNamespace,
tag: binding.tag,
displayName: binding.displayName,
identity: identity,
@@ -60,6 +68,7 @@ struct HostRuntimeFixture {
accountID: configuration.accountID,
deviceID: binding.deviceID,
appInstanceID: binding.appInstanceID,
clientNamespace: clientNamespace,
tag: binding.tag,
displayName: binding.displayName,
identity: identity,
@@ -178,6 +187,7 @@ struct HostRuntimeFixture {
"binding_id": bindingID,
"device_id": deviceID,
"app_instance_id": "123e4567-e89b-42d3-a456-426614174012",
"client_namespace": "mac:com.cmuxterm.tests",
"tag": "cmux-ios-v0",
"platform": "mac",
"display_name": "Test Mac",
@@ -297,7 +297,7 @@ struct CmxIrohHostRuntimeTests {
}
@Test
func pendingRevocationFailureBlocksHostRegistrationAndCachedFallback() async throws {
func pendingRevocationFailureStopsAfterAuthenticatedRegistration() async throws {
let fixture = try HostRuntimeFixture()
let pendingRevocations = CmxIrohPendingRevocationOutbox(
secureStore: TestSecureCredentialStore()
@@ -327,15 +327,50 @@ struct CmxIrohHostRuntimeTests {
try await runtime.start()
}
#expect(await broker.observedRegistrationCount() == 0)
#expect(await broker.observedRegistrationCount() == 1)
#expect(await broker.observedRevokedBindingIDs() == [pending.bindingID])
#expect(
try await pendingRevocations.pending(
accountID: fixture.configuration.accountID
) == [pending]
) == [pending]
)
}
@Test
func registrationReconcilesPendingBindingWithoutRevokingFreshBinding() async throws {
let fixture = try HostRuntimeFixture()
let pendingRevocations = CmxIrohPendingRevocationOutbox(
secureStore: TestSecureCredentialStore()
)
let pending = try CmxIrohPendingRevocation(
accountID: fixture.configuration.accountID,
tag: fixture.configuration.tag,
bindingID: fixture.binding.bindingID
)
try await pendingRevocations.enqueue(pending)
let broker = TestIrohHostBroker(
registrationBinding: fixture.binding,
discovery: fixture.discovery
)
let runtime = CmxIrohHostRuntime(
factory: TestIrohEndpointFactory(
endpoints: [TestIrohEndpoint(identity: fixture.endpointID)]
),
broker: broker,
configuration: fixture.configuration,
pendingRevocations: pendingRevocations,
handleTransport: { session, _ in await session.close() }
)
try await runtime.start()
#expect(await broker.observedRevokedBindingIDs().isEmpty)
#expect(try await pendingRevocations.pending(
accountID: fixture.configuration.accountID
).isEmpty)
await runtime.stop()
}
}
actor TestIrohHostBroker: CmxIrohHostBrokerServing {
@@ -489,6 +524,10 @@ actor TestIrohHostBroker: CmxIrohHostBrokerServing {
if let revokeError { throw revokeError }
}
func revokeStale(bindingID: String) throws {
try revoke(bindingID: bindingID)
}
func observedRegistrationCount() -> Int { registrationCount }
func observedPreflightOperations() -> [CmxIrohBrokerOperation] {
preflightOperations
@@ -14,7 +14,7 @@ struct CmxIrohIdentityRepositoryTests {
#expect(first == second)
#expect(first.generation == 1)
#expect(harness.secure.deleteAllCount == 1)
#expect(await harness.secure.deleteAllCount() == 1)
}
@Test("account switches rotate and do not resurrect prior keys")
@@ -28,7 +28,7 @@ struct CmxIrohIdentityRepositoryTests {
#expect(accountA.secretKey != accountB.secretKey)
#expect(accountA.secretKey != accountAAgain.secretKey)
#expect(harness.secure.deleteAllCount == 3)
#expect(await harness.secure.deleteAllCount() == 3)
}
@Test("missing install marker rejects a key that survived uninstall")
@@ -42,7 +42,7 @@ struct CmxIrohIdentityRepositoryTests {
#expect(original.secretKey != afterReinstall.secretKey)
#expect(afterReinstall.generation == 1)
#expect(harness.secure.deleteAllCount == 2)
#expect(await harness.secure.deleteAllCount() == 2)
}
@Test("explicit rotation increments generation without changing scope")
@@ -84,6 +84,64 @@ struct CmxIrohIdentityRepositoryTests {
try await repository.identity(accountID: "user", appInstanceID: "")
}
}
@Test("concurrent identity loads share one persisted identity")
func concurrentIdentityLoadsAreSerialized() async throws {
let suiteName = "CmxIrohIdentityRepositoryTests.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suiteName))
defer { defaults.removePersistentDomain(forName: suiteName) }
let store = TestControllableSecureIdentityStore()
let entropy = TestIdentityEntropy()
let repository = CmxIrohIdentityRepository(
secureStore: store,
installState: CmxIrohUserDefaultsInstallStateStore(defaults: defaults),
randomBytes: { entropy.nextBytes() },
marker: { entropy.nextMarker() }
)
await store.suspendNextWrite()
let first = Task {
try await repository.identity(accountID: "user", appInstanceID: "app")
}
await store.waitUntilWriteIsSuspended()
let second = Task {
try await repository.identity(accountID: "user", appInstanceID: "app")
}
try await ContinuousClock().sleep(for: .milliseconds(50))
await store.resumeSuspendedWrite()
let firstIdentity = try await first.value
let secondIdentity = try await second.value
#expect(firstIdentity == secondIdentity)
#expect(await store.recordCount() == 1)
}
@Test("deactivation waits for an in-flight identity write")
func deactivationFencesInFlightIdentityWrite() async throws {
let suiteName = "CmxIrohIdentityRepositoryTests.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suiteName))
defer { defaults.removePersistentDomain(forName: suiteName) }
let store = TestControllableSecureIdentityStore()
let repository = CmxIrohIdentityRepository(
secureStore: store,
installState: CmxIrohUserDefaultsInstallStateStore(defaults: defaults),
randomBytes: { Data(repeating: 7, count: 32) },
marker: { "install-marker" }
)
await store.suspendNextWrite()
let identity = Task {
try await repository.identity(accountID: "user", appInstanceID: "app")
}
await store.waitUntilWriteIsSuspended()
let deactivate = Task { try await repository.deactivate() }
try await ContinuousClock().sleep(for: .milliseconds(50))
await store.resumeSuspendedWrite()
_ = try await identity.value
try await deactivate.value
#expect(await store.recordCount() == 0)
}
}
private final class IdentityHarness: @unchecked Sendable {
@@ -101,32 +159,25 @@ private final class IdentityHarness: @unchecked Sendable {
}
}
private final class TestSecureIdentityStore: CmxIrohSecureIdentityStoring, @unchecked Sendable {
private let lock = NSLock()
private actor TestSecureIdentityStore: CmxIrohSecureIdentityStoring {
private var records: [String: Data] = [:]
private var storedDeleteAllCount = 0
var deleteAllCount: Int {
lock.withLock { storedDeleteAllCount }
}
func deleteAllCount() -> Int { storedDeleteAllCount }
func read(account: String) -> Data? {
lock.withLock { records[account] }
}
func read(account: String) -> Data? { records[account] }
func write(_ data: Data, account: String) {
lock.withLock { records[account] = data }
records[account] = data
}
func delete(account: String) {
_ = lock.withLock { records.removeValue(forKey: account) }
records.removeValue(forKey: account)
}
func deleteAll() {
lock.withLock {
records.removeAll()
storedDeleteAllCount += 1
}
records.removeAll()
storedDeleteAllCount += 1
}
}
@@ -0,0 +1,32 @@
import Testing
@testable import CmuxIrohTransport
@Suite
struct CmxIrohMacBundleNamespaceTests {
@Test func exactBundlesRemainDistinctEvenWhenTheirTagsMatch() throws {
let stable = try #require(
CmxIrohMacBundleNamespace(
bundleIdentifier: "com.cmuxterm.app"
)
)
let staging = try #require(
CmxIrohMacBundleNamespace(
bundleIdentifier: "com.cmuxterm.app.staging"
)
)
#expect(stable.rawValue == "mac:com.cmuxterm.app")
#expect(staging.rawValue == "mac:com.cmuxterm.app.staging")
#expect(stable != staging)
}
@Test func invalidOrMissingBundleIdentityFailsClosed() {
#expect(CmxIrohMacBundleNamespace(bundleIdentifier: nil) == nil)
#expect(CmxIrohMacBundleNamespace(bundleIdentifier: "") == nil)
#expect(
CmxIrohMacBundleNamespace(
bundleIdentifier: "com.cmuxterm.app:other"
) == nil
)
}
}
@@ -94,6 +94,26 @@ struct CmxIrohPendingRevocationOutboxTests {
)
}
@Test("reconciliation removes a re-registered binding without revoking it")
func reconciliationDoesNotRevokeActiveBinding() async throws {
let store = TestSecureCredentialStore()
let outbox = CmxIrohPendingRevocationOutbox(secureStore: store)
let pending = try revocation()
try await outbox.enqueue(pending)
let broker = PendingRevocationBroker()
let revoked = try await outbox.reconcilePending(
accountID: accountID,
beforeRegisteringTag: tag,
activeBindingID: pending.bindingID,
using: broker
)
#expect(!revoked)
#expect(await broker.revokedBindingIDs().isEmpty)
#expect(try await outbox.pending(accountID: accountID).isEmpty)
}
private func revocation() throws -> CmxIrohPendingRevocation {
try CmxIrohPendingRevocation(
accountID: accountID,
@@ -116,5 +136,9 @@ private actor PendingRevocationBroker: CmxIrohBindingRevoking {
if let error { throw error }
}
func revokeStale(bindingID: String) throws {
try revoke(bindingID: bindingID)
}
func revokedBindingIDs() -> [String] { bindingIDs }
}
@@ -29,6 +29,7 @@ struct CmxIrohRegistrationSignerTests {
let payload = try CmxIrohRegistrationPayload(
deviceID: "123e4567-e89b-12d3-a456-426614174000",
appInstanceID: "123e4567-e89b-12d3-a456-426614174001",
clientNamespace: "dev.cmux.app.internal",
tag: "stable",
platform: .ios,
displayName: "Phone",
@@ -71,6 +72,11 @@ struct CmxIrohRegistrationSignerTests {
)
#expect(payloadObject["endpointId"] as? String == endpointID)
#expect(payloadObject["endpointID"] == nil)
#expect(payloadObject["clientNamespace"] as? String == "dev.cmux.app.internal")
#expect(
prepared.challengeRequest.clientNamespace
== "dev.cmux.app.internal"
)
let pathHints = try #require(payloadObject["pathHints"] as? [[String: Any]])
let encodedHint = try #require(pathHints.first)
#expect(encodedHint["observed_at"] is String)
@@ -126,6 +126,7 @@ struct CmxIrohRelayPolicyBrokerTests {
CmxIrohBrokerCredentials(accessToken: "access", refreshToken: "refresh")
}
),
clientNamespace: "legacy",
transport: transport
)
}
@@ -10,8 +10,8 @@ struct CmxIrohRuntimeConfigurationDeviceIDTests {
let uppercaseUUID = "AAAAAAAA-BBBB-4CCC-8DDD-EEEEEEEEEEEE"
let lowercaseUUID = uppercaseUUID.lowercased()
let uuidHost = hostConfiguration(deviceID: uppercaseUUID, fixture: fixture)
let opaqueHost = hostConfiguration(deviceID: "Legacy-Mac-ID", fixture: fixture)
let uuidHost = try hostConfiguration(deviceID: uppercaseUUID, fixture: fixture)
let opaqueHost = try hostConfiguration(deviceID: "Legacy-Mac-ID", fixture: fixture)
let uuidClient = clientConfiguration(deviceID: uppercaseUUID, fixture: fixture)
let opaqueClient = clientConfiguration(deviceID: "Legacy-iOS-ID", fixture: fixture)
@@ -24,11 +24,17 @@ struct CmxIrohRuntimeConfigurationDeviceIDTests {
private func hostConfiguration(
deviceID: String,
fixture: HostRuntimeFixture
) -> CmxIrohHostRuntimeConfiguration {
CmxIrohHostRuntimeConfiguration(
) throws -> CmxIrohHostRuntimeConfiguration {
let clientNamespace = try #require(
CmxIrohMacBundleNamespace(
bundleIdentifier: "com.cmuxterm.tests"
)
)
return CmxIrohHostRuntimeConfiguration(
accountID: "account-a",
deviceID: deviceID,
appInstanceID: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
clientNamespace: clientNamespace,
tag: "test",
displayName: nil,
identity: fixture.identity,
@@ -46,6 +52,7 @@ struct CmxIrohRuntimeConfigurationDeviceIDTests {
accountID: "account-a",
deviceID: deviceID,
appInstanceID: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
clientNamespace: fixture.clientNamespace.rawValue,
tag: "test",
displayName: nil,
identity: fixture.identity,
@@ -92,6 +92,7 @@ struct CmxIrohTrustBrokerClientAuthRecoveryTests {
refreshToken: "stale-refresh"
)
}),
clientNamespace: "legacy",
transport: transport
)
@@ -169,6 +170,7 @@ struct CmxIrohTrustBrokerClientAuthRecoveryTests {
snapshot: { await snapshots.snapshot() },
forceRefresh: { await snapshots.forceRefresh() }
),
clientNamespace: "legacy",
transport: transport
)
@@ -200,6 +202,7 @@ struct CmxIrohTrustBrokerClientAuthRecoveryTests {
snapshot: { await snapshots.snapshot() },
forceRefresh: { await snapshots.forceRefresh() }
),
clientNamespace: "legacy",
transport: transport
)
@@ -248,6 +251,7 @@ struct CmxIrohTrustBrokerClientAuthRecoveryTests {
await recorder.recover(rejected)
}
),
clientNamespace: "legacy",
transport: transport
)
}
@@ -130,6 +130,7 @@ extension CmxIrohTrustBrokerClientTests {
tokenSource: CmxIrohBrokerTokenSource(
credentialPair: { nil }
),
clientNamespace: "legacy",
transport: transport
)
await #expect(throws: CmxIrohTrustBrokerClientError.missingAuthentication) {
@@ -149,6 +150,7 @@ extension CmxIrohTrustBrokerClientTests {
tokenSource: CmxIrohBrokerTokenSource(
credentialPair: { throw CancellationError() }
),
clientNamespace: "legacy",
transport: transport
)
await #expect(throws: CancellationError.self) {
@@ -172,6 +174,7 @@ extension CmxIrohTrustBrokerClientTests {
tokenSource: CmxIrohBrokerTokenSource(
credentialPair: { throw TransientTokenReadError() }
),
clientNamespace: "legacy",
transport: transport
)
await #expect(throws: CmxIrohTrustBrokerClientError.connectivity) {
@@ -186,6 +189,7 @@ extension CmxIrohTrustBrokerClientTests {
_ = try CmxIrohTrustBrokerClient(
baseURL: #require(URL(string: "http://cmux.example")),
tokenSource: Self.networkTokenSource,
clientNamespace: "legacy",
transport: RecordingBrokerTransport(responses: [])
)
}
@@ -232,6 +236,7 @@ extension CmxIrohTrustBrokerClientTests {
let client = try CmxIrohTrustBrokerClient(
baseURL: try #require(URL(string: "https://cmux.example")),
tokenSource: Self.networkTokenSource,
clientNamespace: "legacy",
transport: CmxIrohURLSessionTransport(configuration: configuration),
requestTimeout: 0.1
)
@@ -248,6 +253,7 @@ extension CmxIrohTrustBrokerClientTests {
try CmxIrohTrustBrokerClient(
baseURL: #require(URL(string: "https://cmux.example")),
tokenSource: Self.networkTokenSource,
clientNamespace: "legacy",
transport: transport
)
}
@@ -72,6 +72,86 @@ struct CmxIrohTrustBrokerClientTests {
])
}
@Test
func postRegistrationRequestsCarryExactBindingProof() async throws {
let transport = RecordingBrokerTransport(responses: [
.json(
status: 201,
body: #"{"challenge_id":"123e4567-e89b-42d3-a456-426614174000","nonce":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","expires_at":"2026-07-10T01:00:00.000Z"}"#
),
.json(status: 201, body: Self.registrationResponse),
.json(status: 200, body: Self.discoveryResponse),
])
let client = try makeClient(transport: transport)
let signer = try registrationSigner()
let prepared = try signer.prepare(payload: registrationPayload())
_ = try await client.register(prepared: prepared, signer: signer)
_ = try await client.discover()
let requests = await transport.requests()
let discovery = try #require(requests.last)
#expect(
discovery.value(forHTTPHeaderField: "X-Cmux-Iroh-Binding-ID")
== "123e4567-e89b-42d3-a456-426614174010"
)
#expect(
Int64(
discovery.value(
forHTTPHeaderField: "X-Cmux-Iroh-Request-Time"
) ?? ""
) != nil
)
#expect(
discovery.value(
forHTTPHeaderField: "X-Cmux-Iroh-Request-Signature"
)?.count == 86
)
#expect(
requests.dropLast().allSatisfy {
$0.value(
forHTTPHeaderField: "X-Cmux-Iroh-Request-Signature"
) == nil
}
)
}
@Test
func freshManagementClientUsesRetainedBindingProof() async throws {
let transport = RecordingBrokerTransport(responses: [
.json(status: 200, body: Self.discoveryResponse),
])
let authorization = try CmxIrohBindingRequestAuthorization(
bindingID: Self.bindingID,
clientNamespace: "dev.cmux.app.internal",
identity: identityMaterial(),
endpointID: CmxIrohPeerIdentity(endpointID: Self.endpointID)
)
let client = try CmxIrohTrustBrokerClient(
baseURL: #require(URL(string: "https://cmux.example")),
tokenSource: Self.tokenSource,
clientNamespace: "dev.cmux.app.internal",
bindingAuthorization: authorization,
transport: transport
)
_ = try await client.discover()
let request = try #require(await transport.requests().first)
#expect(
request.value(forHTTPHeaderField: "X-Cmux-App-Namespace")
== "dev.cmux.app.internal"
)
#expect(
request.value(forHTTPHeaderField: "X-Cmux-Iroh-Binding-ID")
== Self.bindingID
)
#expect(
request.value(forHTTPHeaderField: "X-Cmux-Iroh-Request-Signature")?
.count == 86
)
}
@Test
func registrationDecodesEmbeddedAuthoritativeDiscovery() async throws {
var responseObject = try #require(
@@ -468,6 +548,51 @@ struct CmxIrohTrustBrokerClientTests {
JSONSerialization.jsonObject(with: body) as? [String: Any]
)
#expect(object["bindingId"] as? String == bindingID)
#expect(object["intent"] == nil)
}
@Test
func forgetMacUsesExplicitAccountManagementIntent() async throws {
let transport = RecordingBrokerTransport(responses: [
.json(
status: 200,
body: #"{"revoked":true,"lan_rendezvous_rotated":true}"#
),
])
let client = try makeClient(transport: transport)
try await client.forgetMac(bindingID: Self.bindingID)
let captured = try #require(await transport.requests().first)
#expect(captured.url?.path == "/api/devices/iroh")
#expect(captured.httpMethod == "DELETE")
let body = try #require(captured.httpBody)
let object = try #require(
JSONSerialization.jsonObject(with: body) as? [String: Any]
)
#expect(object["bindingId"] as? String == Self.bindingID)
#expect(object["intent"] as? String == "forget_mac")
}
@Test
func revokeStaleUsesExplicitStaleCleanupIntent() async throws {
let transport = RecordingBrokerTransport(responses: [
.json(
status: 200,
body: #"{"revoked":true,"lan_rendezvous_rotated":true}"#
),
])
let client = try makeClient(transport: transport)
try await client.revokeStale(bindingID: Self.bindingID)
let captured = try #require(await transport.requests().first)
let body = try #require(captured.httpBody)
let object = try #require(
JSONSerialization.jsonObject(with: body) as? [String: Any]
)
#expect(object["bindingId"] as? String == Self.bindingID)
#expect(object["intent"] as? String == "revoke_stale")
}
@Test
@@ -1032,6 +1157,7 @@ struct CmxIrohTrustBrokerClientTests {
try CmxIrohTrustBrokerClient(
baseURL: #require(URL(string: "https://cmux.example")),
tokenSource: Self.tokenSource,
clientNamespace: "dev.cmux.app.internal",
discoveryScope: discoveryScope,
transport: transport
)
@@ -1060,12 +1186,18 @@ struct CmxIrohTrustBrokerClientTests {
}
private func registrationSigner() throws -> CmxIrohRegistrationSigner {
try CmxIrohRegistrationSigner(
identity: identityMaterial(),
endpointID: Self.endpointID
)
}
private func identityMaterial() throws -> CmxIrohIdentityMaterial {
let secret = try CmxIrohSecretKey(bytes: Data((0 ..< 32).map(UInt8.init)))
let material = try CmxIrohIdentityMaterial(
return try CmxIrohIdentityMaterial(
secretKey: secret,
generation: 1
)
return try CmxIrohRegistrationSigner(identity: material, endpointID: Self.endpointID)
}
private func registrationPayload() throws -> CmxIrohRegistrationPayload {
@@ -0,0 +1,55 @@
import Foundation
@testable import CmuxIrohTransport
actor TestControllableSecureIdentityStore: CmxIrohSecureIdentityStoring {
private var records: [String: Data] = [:]
private var shouldSuspendNextWrite = false
private var suspendedWrite: CheckedContinuation<Void, Never>?
private var writeSuspensionWaiters: [CheckedContinuation<Void, Never>] = []
func read(account: String) -> Data? {
records[account]
}
func write(_ data: Data, account: String) async {
if shouldSuspendNextWrite {
shouldSuspendNextWrite = false
await withCheckedContinuation { continuation in
suspendedWrite = continuation
let waiters = writeSuspensionWaiters
writeSuspensionWaiters.removeAll(keepingCapacity: false)
for waiter in waiters { waiter.resume() }
}
}
records[account] = data
}
func delete(account: String) {
records.removeValue(forKey: account)
}
func deleteAll() {
records.removeAll(keepingCapacity: false)
}
func suspendNextWrite() {
shouldSuspendNextWrite = true
}
func waitUntilWriteIsSuspended() async {
guard suspendedWrite == nil else { return }
await withCheckedContinuation { continuation in
writeSuspensionWaiters.append(continuation)
}
}
func resumeSuspendedWrite() {
let continuation = suspendedWrite
suspendedWrite = nil
continuation?.resume()
}
func recordCount() -> Int {
records.count
}
}
@@ -7,6 +7,7 @@ actor TestIrohClientBroker: CmxIrohClientBrokerServing {
private let discoveryResponse: CmxIrohDiscoveryResponse
private let relayResponse: CmxIrohRelayTokenResponse
private let pairGrantResponse: CmxIrohPairGrantResponse?
private let bindingAuthorizationAvailable: Bool
private let revokeError: (any Error)?
private let registrationHook: (@Sendable (_ count: Int) async -> Void)?
private let discoveryHook: (@Sendable (_ count: Int) async -> Void)?
@@ -29,6 +30,7 @@ actor TestIrohClientBroker: CmxIrohClientBrokerServing {
discovery: CmxIrohDiscoveryResponse,
relay: CmxIrohRelayTokenResponse,
pairGrant: CmxIrohPairGrantResponse? = nil,
bindingAuthorizationAvailable: Bool = true,
issueRelayAtRegistration: Bool = true,
registrationError: (any Error)? = nil,
discoveryErrorsByCount: [Int: any Error] = [:],
@@ -43,6 +45,7 @@ actor TestIrohClientBroker: CmxIrohClientBrokerServing {
discoveryResponse = discovery
relayResponse = relay
pairGrantResponse = pairGrant
self.bindingAuthorizationAvailable = bindingAuthorizationAvailable
self.revokeError = revokeError
self.registrationError = registrationError
self.discoveryErrorsByCount = discoveryErrorsByCount
@@ -50,6 +53,14 @@ actor TestIrohClientBroker: CmxIrohClientBrokerServing {
self.discoveryHook = discoveryHook
}
func hasBindingAuthorization() async -> Bool {
bindingAuthorizationAvailable
}
func bindingAuthorizationID() async -> String? {
bindingAuthorizationAvailable ? registration.binding.bindingID : nil
}
func register(
prepared: CmxIrohPreparedRegistration,
signer _: CmxIrohRegistrationSigner
@@ -109,6 +120,14 @@ actor TestIrohClientBroker: CmxIrohClientBrokerServing {
if let revokeError { throw revokeError }
}
func revokeStale(bindingID: String) throws {
try revoke(bindingID: bindingID)
}
func forgetMac(bindingID: String) throws {
try revoke(bindingID: bindingID)
}
func observedRegistrations() -> [CmxIrohPreparedRegistration] {
preparedRegistrations
}
@@ -258,6 +258,14 @@ public struct ChatArtifactFailurePresentation: Equatable, Sendable {
systemImage: "doc.badge.ellipsis",
allowsRetry: false
)
case .unknown(let code):
// The Mac replied, so this copy must not blame connectivity.
self = Self(
title: Self.localized("chat.artifact.failure.unknown.title", defaultValue: "Unrecognized error"),
message: Self.unknownMessage(code: code),
systemImage: "questionmark.circle",
allowsRetry: false
)
}
}
@@ -310,4 +318,18 @@ public struct ChatArtifactFailurePresentation: Equatable, Sendable {
limitText
)
}
private static func unknownMessage(code: String?) -> String {
guard let code else {
return localized(
"chat.artifact.failure.unknown.message",
defaultValue: "The Mac reported an error this app doesn't recognize. Update cmux on both devices."
)
}
let format = localized(
"chat.artifact.failure.unknown.message_with_code",
defaultValue: "The Mac reported an error this app doesn't recognize (%@). Update cmux on both devices."
)
return String.localizedStringWithFormat(format, code)
}
}
@@ -165,6 +165,8 @@ public struct ChatArtifactInlineViewer: View {
.chat
case .terminal:
.terminal
case .panel:
.panel
case .workspaceChanges:
.workspaceChanges
case .unsupported:
@@ -330,6 +330,27 @@
"ja": { "stringUnit": { "state": "translated", "value": "転送が中断されました" } }
}
},
"chat.artifact.failure.unknown.message": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "The Mac reported an error this app doesn't recognize. Update cmux on both devices." } },
"ja": { "stringUnit": { "state": "translated", "value": "Macがこのアプリで認識できないエラーを返しました。両方のデバイスでcmuxをアップデートしてください。" } }
}
},
"chat.artifact.failure.unknown.message_with_code": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "The Mac reported an error this app doesn't recognize (%@). Update cmux on both devices." } },
"ja": { "stringUnit": { "state": "translated", "value": "Macがこのアプリで認識できないエラーを返しました(%@)。両方のデバイスでcmuxをアップデートしてください。" } }
}
},
"chat.artifact.failure.unknown.title": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Unrecognized error" } },
"ja": { "stringUnit": { "state": "translated", "value": "認識できないエラー" } }
}
},
"chat.artifact.failure.unsupported.message": {
"extractionState": "manual",
"localizations": {
@@ -43,6 +43,7 @@ struct ChatArtifactFailurePresentationTests {
(.localStorageUnavailable, "Local storage unavailable", true),
(.loadFailed, "Couldn't load file", true),
(.tooLarge(limitBytes: 1_024), "File too large to preview", false),
(.unknown(code: "artifact_rev_gone"), "Unrecognized error", false),
]
#expect(unreachable.title == "Mac unreachable")
@@ -62,6 +63,18 @@ struct ChatArtifactFailurePresentationTests {
}
}
@Test
func unknownCopySurfacesTheCodeWithoutBlamingConnectivity() {
let coded = ChatArtifactFailurePresentation(error: .unknown(code: "artifact_rev_gone"), scope: .chat)
let uncoded = ChatArtifactFailurePresentation(error: .unknown(code: nil), scope: .chat)
// The Mac replied, so the copy must not send the user to check connectivity.
#expect(coded.message.contains("artifact_rev_gone"))
#expect(!coded.message.contains("Check the connection"))
#expect(!uncoded.message.contains("Check the connection"))
#expect(!uncoded.message.isEmpty)
}
@Test
func forbiddenCopyMatchesAuthorizationScope() {
let chat = ChatArtifactFailurePresentation(error: .forbidden, scope: .chat)

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