Compare commits

...
Author SHA1 Message Date
lawrencecchen a1b859b66e fix: close autoreview bridge and scrubber gaps 2026-08-06 13:41:52 -07:00
lawrencecchen 3efe85e981 test: cover autoreview bridge and scrubber gaps 2026-08-06 13:40:51 -07:00
lawrencecchen 120581aa99 Merge remote-tracking branch 'origin/fix-client-config-env-fixture' into task-fix-unresolved-sentry-issues 2026-08-06 13:04:51 -07:00
lawrencecchen d5a9a783b4 test: assert SSH process death and exact attach verb 2026-08-06 13:04:26 -07:00
lawrencecchen b3094e7c94 ci: run WebAuthn credential reply guard 2026-08-06 13:02:27 -07:00
lawrencecchen c904c43290 Merge remote-tracking branch 'origin/fix-client-config-env-fixture' into task-fix-unresolved-sentry-issues 2026-08-06 12:58:28 -07:00
lawrencecchen 18a778ec4e test: inject SSH executable into attach startup 2026-08-06 12:57:43 -07:00
lawrencecchen c89180a1d7 fix: avoid WebAuthn attachment getter crash 2026-08-06 12:54:36 -07:00
lawrencecchen be606bb3c9 test: cover WebAuthn attachment getter crash 2026-08-06 12:54:25 -07:00
lawrencecchen 7c65d7de33 Merge remote-tracking branch 'origin/fix-client-config-env-fixture' into task-fix-unresolved-sentry-issues 2026-08-06 12:44:14 -07:00
lawrencecchen bb10140964 fix: harden SSH authentication reaper ownership 2026-08-06 12:38:03 -07:00
lawrencecchen 7e83bd4481 test: cover SSH cleanup review gaps 2026-08-06 12:31:28 -07:00
lawrencecchen 59bc534ac8 Merge remote-tracking branch 'origin/fix-client-config-env-fixture' into task-fix-unresolved-sentry-issues 2026-08-06 12:21:17 -07:00
lawrencecchen daf36f9cdb Merge remote-tracking branch 'origin/main' into task-fix-unresolved-sentry-issues 2026-08-06 12:19:06 -07:00
lawrencecchen 29ba0a6658 Merge remote-tracking branch 'origin/fix-ssh-process-group-owner' into fix-client-config-env-fixture
# Conflicts:
#	Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift
2026-08-06 12:15:59 -07:00
lawrencecchen 5a37ab3222 Merge remote-tracking branch 'origin/main' into fix-client-config-env-fixture 2026-08-06 12:14:56 -07:00
lawrencecchen 163f8bf37a test: make SSH deadline check deterministic 2026-08-06 10:49:56 -07:00
lawrencecchen 9cb66f1e7d fix: retain durable safe SSH recovery 2026-08-06 10:17:21 -07:00
lawrencecchen b303e1f996 test: cover durable safe SSH recovery 2026-08-06 10:15:41 -07:00
lawrencecchen 86647cb6be fix: preserve SSH reaper caller group 2026-08-06 10:00:49 -07:00
lawrencecchen aaa166e5ed test: detach SSH cleanup recovery launcher 2026-08-06 09:59:50 -07:00
lawrencecchen b507e90943 fix: finalize owned SSH groups after completion 2026-08-06 09:48:25 -07:00
lawrencecchen ec8ea5e58b test: cover completed SSH cleanup ownership 2026-08-06 09:45:15 -07:00
lawrencecchen 76c470fbdf fix: retain SSH cleanup ownership until reaped 2026-08-06 09:34:49 -07:00
lawrencecchen b7cb0bc26f test: cover retryable SSH cleanup ownership 2026-08-06 09:15:34 -07:00
lawrencecchen 7b79c160fe fix: reap frozen SSH cleanup descendants 2026-08-06 09:00:12 -07:00
lawrencecchen 5cc27f8fa9 test: require deadline cleanup to reap descendants 2026-08-06 08:56:12 -07:00
lawrencecchen 120a88fa8a fix: deduplicate frozen SSH process records 2026-08-06 08:47:47 -07:00
lawrencecchen f7375014b3 test: cover duplicate SSH cleanup snapshots 2026-08-06 08:47:26 -07:00
lawrencecchen 51acb6843d fix: bound SSH cleanup and remove final state 2026-08-06 08:41:01 -07:00
lawrencecchen 7dc1040867 test: cover SSH cleanup deadline and final state removal 2026-08-06 08:39:14 -07:00
Lawrence Chen 95ef75b4fa Add client-local terminal multiview architecture (#9387)
* Implement client-local terminal multiview architecture

* Harden terminal multiview lifecycle

* Complete terminal multiview lifecycle contracts

* Fix projected terminal response and close

* Document terminal project in CLI help

* Document terminal projection resource path

* Align lifecycle docs with terminal projections

* Test enhanced prefix split routing regression

* Implement client-local terminal projections

* test(tui): cover legacy workspace selection recovery

* fix(tui): seed legacy compatibility workspace

* test(tui): reproduce slow-client output failures

* fix(tui): keep slow client projections current

* test(tui): move backpressure fixtures into workers

* test(tui): reproduce modifier prefix cancellation

* fix(tui): keep modifiers outside semantic input

* test(tui): reproduce Ctrl-D exit topology leak

* fix(tui): atomically detach exited terminal views

* test(tui): reproduce input and resize lifecycle races

* test(tui): reproduce semantic destination races

* fix(tui): make terminal projections client-local and receipt ordered

* fix(tui): preserve queued creation through terminal exit

* test(tui): reproduce zero-size split crash

* fix(tui): make degenerate split layout total

* style(tui): format degenerate split layout

* test(tui): reproduce empty startup input route

* fix(tui): establish the startup input route

* fix(tui): keep smoke terminals process-owned

* test(tui): reproduce burst input loss

* test(tui): reproduce missing size lease release loop

* fix(tui): make view size release idempotent

* test(tui): reproduce stale attachment resize failure

* fix(tui): supersede stale attachment resizes

* fix(tui): retain host input under mutation bursts

* test(tui): reproduce multiview projection leaks

* fix(tui): isolate backend view projections

* test(tui): reproduce retired attach lease leak

* fix(tui): release retired view attachments

* fix(tui): restore projection viewport state

* test(tui): reproduce boxed request schema omission

* fix(tui): publish multiview control contracts

* test(tui): isolate CLI fixtures from user config

* test(tui): reproduce sidebar host leak on shutdown

* fix(tui): bind auxiliary PTYs to daemon lifetime

* test(tui): make attach smoke lifecycle-aware

* test(tui): reproduce stale surface attach race

* fix(tui): retire stale surface attach races

* test(tui): reproduce mirror retirement attach race

* fix(tui): classify superseded surface attaches

* test(tui): keep attach smoke ephemeral

* test(tui): reproduce hidden terminal host launch failure

* fix(tui): report terminal host launch failures

* test(tui): allow raced fanout completion

* test(tui): require protocol bump for multiview

* fix(tui): version the multiview resource contract

* fix(tui): keep one prelaunch resource protocol

* test(tui): reproduce concurrent PTY descriptor leak

* fix(tui): make host disconnect teardown race-free

* test(tui): reject internal session recovery errors

* fix(tui): keep recovery errors product-facing

* test(sdk): reproduce legacy terminal snapshot rejection

* fix(sdk): decode legacy terminal snapshots

* fix(sdk): derive Rust catalog hash in test

* test(sdk): lock terminal tab identity validation

* test(tui): reproduce public projection review failures

* fix(tui): make public projections self-consistent

* fix(tui): satisfy projection lints

* fix(tui): restore cross-platform validation

* test(web): isolate hosted Subrouter environment

* fix(web): inject hosted Subrouter client factory

* fix(tui): isolate Zig SDK toolchain

* fix(tui): make recovery validation deterministic

* fix(tui): preserve terminal exit stream ownership

* test(tui): cover terminal projection recovery gaps

* test(tui): require close cleanup outside creation fence

* fix(tui): harden terminal multiview lifecycle

* test(tui): make SDK cancellation check deterministic

* test(tui): make teardown checks deterministic

* test(sdk): synchronize idle stream deadline

* test(sidebar): synchronize idle stream snapshot

* test(sdk): synchronize stream close ownership

* Classify fanout completions by deadline

* fix(tui): guard late cell pixel completion

* test(tui): synchronize clear permit assertion

* test(tui): synchronize shutdown drain assertion

* test(tui): gate ordered write drain checks

* test(tui): synchronize navigation supersession

* test(tui): bound browser event fixtures

* Make selection input fixture output-free

* Make animated selection fixture output-free

* test(tui): scale loaded browser verification

* ci(tui): serialize instrumented runtime tests

* test(remote): require instrumented latency budget

* ci(remote): budget latency under instrumentation

* test(tui): synchronize deferred host ack

* ci(tui): isolate host backpressure ordering

* ci(tui): scope valgrind origin tracking

* ci(tui): scope TLS valgrind undefined checks

* test(tui): require ordered workspace publication fence

* fix(tui): publish workspace deltas in commit order

* test(tui): reject expired fanout admission

* fix(tui): stop fanout admission at shared deadline

* style(tui): format concurrency regression

* fix(tui): fence fanout deadline admission

* test(tui): keep publication probe formatter-stable

* style(tui): stabilize deadline job formatting

* refactor(tui): name resource close plan inputs

* ci(tui): isolate valgrind runtime instrumentation

* test(tui): expose hidden host frame queue limit

* fix(tui): make host backpressure byte-authoritative

* test(tui): stop leaking SDK stream metadata

* docs(tui): describe unbounded host wakeup accurately

* ci(tui): shard valgrind leak checks

* chore: keep terminal multiview change scoped

* test(tui): make scheduler fixtures cooperative

* ci(tui): isolate application valgrind tests

* test(web): cover hosted coderouter production gate

* test(zig): assert the remaining stream deadline

* docs(tui): define all-view exit detachment

* test(tui): scale remote fixtures under instrumentation

* test(go): cover omitted terminal tab identities

* refactor(go): localize terminal alias presence check

* docs(go): clarify terminal projection validation

* ci(tui): keep normal test deadlines strict

* test(tui): isolate shutdown cancellation timing

* test(tui): reject legacy zero-view snapshots

* fix(tui): restore resource protocol v2

* test(tui): await committed terminal before teardown

* test(tui): isolate render scan instrumentation

* test(tui): make queued attach deadlines deterministic

* test(tui): accept completed browser resize
2026-08-06 08:37:11 -07:00
lawrencecchen 270febbca8 fix: recover interrupted SSH authentication cleanup 2026-08-06 08:21:08 -07:00
lawrencecchen 7be6d89a49 test: cover SSH cleanup snapshot failure 2026-08-06 08:17:29 -07:00
lawrencecchen 307fb10bce fix: contain escaped SSH authentication groups 2026-08-06 08:00:33 -07:00
lawrencecchen 59e8e4ce88 test: cover escaped SSH authentication groups 2026-08-06 07:59:38 -07:00
Lawrence Chen fb6d4c1ae5 docs(cli): sync restore help contract (#9714) 2026-08-06 07:45:14 -07:00
lawrencecchen 437675b8ed fix: close SSH authentication publication race 2026-08-06 07:44:00 -07:00
lawrencecchen 32e74bf5aa fix: secure SSH authentication cleanup state 2026-08-06 07:38:45 -07:00
lawrencecchen d26473ae9b fix: reap SSH authentication group anchors 2026-08-06 07:23:31 -07:00
lawrencecchen 4d61d68e73 fix: own SSH authentication process groups 2026-08-06 07:09:04 -07:00
lawrencecchen 8abbb1e75a fix: bound SSH identity scans 2026-08-06 06:41:45 -07:00
lawrencecchen 82e5ff5585 fix: revalidate SSH cleanup signals 2026-08-06 06:33:09 -07:00
lawrencecchen ca5761af97 fix: isolate graceful SSH cleanup groups 2026-08-06 06:19:41 -07:00
lawrencecchen 1b8a737494 test: reject shared-group TERM escapes 2026-08-06 06:16:26 -07:00
lawrencecchen c5da32cd45 fix: own SSH authentication cleanup snapshots 2026-08-06 06:07:34 -07:00
lawrencecchen e13c6f0cd6 test: run SSH deadline cleanup under zsh 2026-08-06 05:50:40 -07:00
lawrencecchen 681c4fd27f test: report surviving SSH cleanup descendants 2026-08-06 05:50:39 -07:00
Lawrence Chen f5cd28364f Connect Hexclave and Stripe billing analytics to PostHog (#9702)
* Connect Stack and Stripe identities to PostHog

* Harden billing analytics delivery and sign-out

* Backfill paid plan identity on sign-in

* Clarify analytics retention disclosures

* Prevent cross-account analytics attribution

* Fail closed on unresolved analytics identity

* Gate analytics until auth identity resolves

* Preserve paid identity joins after auth gating

* Use Stack's typed cookie token store

* Observe Hexclave auth state for analytics

* Bound and coalesce analytics identity refresh

* Keep billing analytics events order independent

* Support analytics identity timeouts across browsers

* Separate auth changes from passive analytics refresh

* Fail passive identity refreshes closed

* Buffer analytics during identity revalidation

* Sanitize buffered analytics across identity changes

* Store identity marker with PostHog state

* Drop buffered events across account changes
2026-08-06 05:33:44 -07:00
lawrencecchen 8e8d008ece Merge SSH cleanup review fixes 2026-08-06 05:26:12 -07:00
lawrencecchen a970e8a4d5 fix: make SSH deadline cleanup cross-shell safe 2026-08-06 05:25:54 -07:00
lawrencecchen 7de195d859 test: run SSH deadline cleanup under zsh 2026-08-06 05:22:47 -07:00
lawrencecchen e6f2439848 Merge SSH cleanup fix into CI stabilization 2026-08-06 05:19:20 -07:00
lawrencecchen e7e3ba5f7a fix: terminate SSH descendants after grace deadline 2026-08-06 05:14:20 -07:00
lawrencecchen 129fd3cba8 test: report surviving SSH cleanup descendants 2026-08-06 05:03:38 -07:00
lawrencecchen 7b87d1ec9b Merge remote-tracking branch 'origin/main' into fix-client-config-env-fixture 2026-08-06 04:37:34 -07:00
Lawrence Chen a0126d11d1 Test Stripe entitlement lifecycle states (#9707) 2026-08-06 04:34:42 -07:00
lawrencecchen 244ed6005b test: reject replaced remote restore socket paths 2026-08-06 04:09:21 -07:00
lawrencecchen 05a6052263 test: require ready sockets in remote restore fixtures 2026-08-06 04:07:11 -07:00
lawrencecchen 2699b088e1 fix: publish only verified remote restore sockets 2026-08-06 04:02:43 -07:00
lawrencecchen 50d87dceb0 test: reproduce unready remote restore socket publication 2026-08-06 04:00:08 -07:00
lawrencecchen 45f7733718 test: cover selected frame global realm e2e 2026-08-06 03:42:44 -07:00
lawrencecchen b602196e5a fix: evaluate browser scripts in selected frame realm 2026-08-06 03:37:54 -07:00
lawrencecchen eef8e248cf test: reproduce selected frame realm leak 2026-08-06 03:35:32 -07:00
lawrencecchen 37e5739840 Merge remote-tracking branch 'origin/main' into fix-client-config-env-fixture 2026-08-06 03:23:18 -07:00
lawrencecchen 4357c5ad32 fix: correlate socket proof and refresh icon cache 2026-08-06 03:21:02 -07:00
lawrencecchen 4d0ffad6c9 test: reproduce cache and socket proof races 2026-08-06 03:19:01 -07:00
Lawrence Chen 4c3325dad3 Merge pull request #9678 from manaflow-ai/codex/site-macos-nightly-cta
Add discoverable cmux Browser download page
2026-08-06 03:15:18 -07:00
lawrencecchen ce6f5ea7d3 fix: preserve punctuated credential redaction 2026-08-06 03:08:38 -07:00
lawrencecchen d37f7646f6 test: reproduce punctuated credential suffix leaks 2026-08-06 03:07:30 -07:00
lawrencecchen 5905a4c64d fix: support disabled icon render caching 2026-08-06 02:59:13 -07:00
cmux-lawrence 404f0108f1 fix(web): track Browser landing downloads 2026-08-06 02:59:02 -07:00
lawrencecchen db5823704c test: reproduce zero-limit icon cache crash 2026-08-06 02:58:38 -07:00
cmux-lawrence 43677144d1 test(web): cover Browser landing download analytics 2026-08-06 02:57:30 -07:00
lawrencecchen 1fb99b17c2 fix: bound browser evaluation routing and payloads 2026-08-06 02:51:01 -07:00
lawrencecchen bbd349f190 test: reproduce browser evaluation boundary gaps 2026-08-06 02:49:47 -07:00
lawrencecchen cf20119b12 fix: preserve punctuated session alias scrubbing 2026-08-06 02:40:48 -07:00
lawrencecchen 261e28f5af test: reproduce punctuated sid scrub bypass 2026-08-06 02:39:55 -07:00
Lawrence Chen 9bf23bcf01 Polish pricing CTAs and annual default (#9701)
* test pricing page annual default and compact CTAs

* Default pricing to annual and compact paid CTAs
2026-08-06 02:37:08 -07:00
lawrencecchen fca1cd395e Fix client config production test fixture 2026-08-06 02:36:45 -07:00
Lawrence Chen 8527de7a4f Fix npm bootstrap latest-tag verification (#9700)
* test: cover npm-required bootstrap latest tag

* fix: honor npm bootstrap latest invariant

* fix: redact npm tag validation failures

* fix: constrain npm bootstrap latest exception

* fix: parse npm prerelease tag safely
2026-08-06 02:28:38 -07:00
lawrencecchen 2dda952e09 chore: satisfy sentry review policies 2026-08-06 02:28:33 -07:00
lawrencecchen c416ac04fb fix: normalize nested undefined browser results 2026-08-06 02:19:56 -07:00
lawrencecchen aa25d2cb8d test: reproduce nested undefined browser results 2026-08-06 02:19:05 -07:00
Lawrence Chen 4569dbbaf0 Gate hosted coderouter on Pro (#9699) 2026-08-06 02:04:48 -07:00
lawrencecchen f2d4f94953 fix: retry history after transcript rebinding 2026-08-06 02:01:23 -07:00
lawrencecchen c689efe5d9 fix: coalesce transcript history resolution 2026-08-06 01:34:19 -07:00
lawrencecchen 7fb5d05983 test: make remote fork expectations deterministic 2026-08-06 01:34:18 -07:00
lawrencecchen d247d96909 fix: bound explorer reconciliation and prewarm work 2026-08-06 01:05:30 -07:00
lawrencecchen 6c2d9acd47 test: reproduce reconciliation and prewarm ownership gaps 2026-08-06 00:51:46 -07:00
lawrencecchen b1ddf06b87 fix: harden browser callbacks and markdown assets 2026-08-06 00:51:45 -07:00
lawrencecchen 17b5249fb5 test: reproduce WebKit actor and markdown asset crashes 2026-08-06 00:51:45 -07:00
lawrencecchen 841dbbdf41 test: use Swift Testing for process depth regression 2026-08-06 00:20:58 -07:00
lawrencecchen 78a5a38997 fix: avoid VT export during panel close 2026-08-06 00:12:13 -07:00
lawrencecchen 820a4ae6b8 test: reproduce close-history VT export crash 2026-08-06 00:11:19 -07:00
lawrencecchen 90c0c56ec6 fix: satisfy application undo warning budget 2026-08-06 00:07:43 -07:00
lawrencecchen 0c599d2b62 Merge remote-tracking branch 'origin/main' into task-fix-unresolved-sentry-issues 2026-08-06 00:05:16 -07:00
lawrencecchen c07998b07a fix: bound diagnostics and defer portal layout 2026-08-06 00:04:54 -07:00
lawrencecchen a66658f8a3 test: reproduce deep process and portal layout crashes 2026-08-06 00:04:18 -07:00
Lawrence Chen ce79b78632 Support private Pi route authentication (#9695) 2026-08-05 23:55:39 -07:00
lawrencecchen 4eff05e803 Merge remote-tracking branch 'origin/main' into task-fix-unresolved-sentry-issues 2026-08-05 23:25:33 -07:00
lawrencecchen 3c30cc21e6 fix: route application undo before AppKit menu 2026-08-05 23:23:43 -07:00
lawrencecchen d9a80ece79 test: reproduce application undo menu crash 2026-08-05 22:42:20 -07:00
Lawrence Chen 79a3f646a9 Stop caching coderouter quota usage (#9694) 2026-08-05 22:39:44 -07:00
lawrencecchen 907a300ec8 fix: retire Quick Look previews without close 2026-08-05 22:36:28 -07:00
lawrencecchen c4e450b7bc test: reproduce Quick Look retirement assertion 2026-08-05 22:36:02 -07:00
lawrencecchen a9d8eea8e7 fix: stop forwarding optional drag-end selector 2026-08-05 22:34:13 -07:00
lawrencecchen 1e770deca7 test: reproduce sidebar drag-end selector crash 2026-08-05 22:33:37 -07:00
lawrencecchen 5b5503965f fix: cache terminal key-state accessibility text 2026-08-05 22:31:30 -07:00
lawrencecchen a4b6c4e3b5 test: reproduce key-state localization stall 2026-08-05 22:30:46 -07:00
lawrencecchen 7b47270fe6 fix: stabilize split divider observer teardown 2026-08-05 22:26:48 -07:00
lawrencecchen 55e875cfe3 test: reproduce split divider observer crash 2026-08-05 22:26:24 -07:00
Lawrence Chen 2ac8401a37 Complete coderouter private beta routing reliability (#9692) 2026-08-05 22:22:24 -07:00
lawrencecchen 7c3ce0d163 fix: keep macOS 27 preload hosts ordered out 2026-08-05 22:10:36 -07:00
lawrencecchen 6d618a5c38 test: reproduce macOS 27 preload host crash 2026-08-05 22:09:59 -07:00
lawrencecchen 013cd639c6 fix: stabilize sidebar row action ownership 2026-08-05 22:02:15 -07:00
lawrencecchen 18ee5fa007 test: reproduce sidebar action release crash 2026-08-05 22:01:31 -07:00
lawrencecchen 7ef7704644 fix: remove sidebar scheduler block bridge 2026-08-05 21:42:59 -07:00
lawrencecchen 4f57afcdbc test: reproduce sidebar scheduler block crash 2026-08-05 21:42:11 -07:00
lawrencecchen 0bf034df3f fix: cache right sidebar shortcut lookups 2026-08-05 21:39:24 -07:00
lawrencecchen 4ee8744ca4 test: reproduce sidebar shortcut lookup stall 2026-08-05 21:39:02 -07:00
lawrencecchen 24bcc35632 fix: retry delayed Codex hook targets 2026-08-05 21:31:39 -07:00
lawrencecchen cf1993f115 test: reproduce delayed Codex hook target 2026-08-05 21:14:13 -07:00
Abdulaziz AlbaharandClaude Fable 5 e01238959d iOS: Make Tailscale connection method strict (#9497)
* test(ios): require truthful Tailscale-only selection

* fix(ios): make Tailscale selection authoritative

* test(ios): verify strict transport choice copy

* fix(ios): stop Iroh discovery in strict mode

* test(ios): align strict transport fixtures

* test(ios): await failed route teardown

* test(ios): assert transport selection behavior

* fix(ios): bind onboarding preview transport choice

* test(ios): pin exclusive method selection and physical teardown

Review follow-ups from PR 9497: the onboarding UI test now asserts the
untapped method is deselected after each tap, the strict-switch test
polls the boxed live Iroh transport for physical close instead of only
the store's logical route, and the connectionMethodStore declaration
documents why nil is unreachable in the shipping app.

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

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-08-05 21:06:07 -07:00
Lawrence Chen 4665c6ea8b Allow coderouter legacy cleanup after new accounts (#9689)
* Allow cleanup after new encrypted accounts are added

* Make legacy credential migration monotonic
2026-08-05 20:59:49 -07:00
Lawrence Chen caf19d82fd Move coderouter credentials to KMS-encrypted RDS (#9686) 2026-08-05 20:51:08 -07:00
lawrencecchen 36a805038e fix: typecheck transcript binding tuples 2026-08-05 20:43:13 -07:00
Lawrence Chenandcmux-lawrence 7edde50e21 Limit transient Actions artifact retention (#9687)
Co-authored-by: cmux-lawrence <[email protected]>
2026-08-05 20:40:36 -07:00
lawrencecchen 8ecb57fe71 refactor: align sentry fixes with review policy 2026-08-05 20:35:30 -07:00
lawrencecchen dc952f67c0 fix: skip redundant omnibar placeholder updates 2026-08-05 20:06:11 -07:00
lawrencecchen 063667ab59 test: require cached omnibar placeholder updates 2026-08-05 20:05:53 -07:00
lawrencecchen 875d6458c6 fix: reuse notification menu time style 2026-08-05 19:32:56 -07:00
lawrencecchen 9c8554b6c5 test: require cached notification menu time style 2026-08-05 19:31:59 -07:00
lawrencecchen 4334c23354 fix: use immutable port delivery batch 2026-08-05 19:26:04 -07:00
lawrencecchen 5c19b00e77 fix: move transcript existence checks off main 2026-08-05 19:25:00 -07:00
lawrencecchen 29f270ccf6 test: require off-main transcript existence checks 2026-08-05 19:22:12 -07:00
lawrencecchen 50805b85bd test: expect canonical ssh path for remote forks 2026-08-05 19:10:06 -07:00
lawrencecchen 7a1722d5fa fix: remove latest sentry main-thread stalls 2026-08-05 19:06:17 -07:00
lawrencecchen b4cb94b046 test: cover latest sentry main-actor hotspots 2026-08-05 19:05:10 -07:00
lawrencecchen 80c2d2b1ff fix: move heartbeat identity capture off main 2026-08-05 18:58:39 -07:00
lawrencecchen 907a1ab51d test: require detached heartbeat identity capture 2026-08-05 18:57:03 -07:00
lawrencecchen f4993652c0 fix: preserve remote directory provenance for forks 2026-08-05 18:41:22 -07:00
lawrencecchen 6a3d2a6ea0 test: refresh sentry fix regression coverage 2026-08-05 18:40:28 -07:00
lawrencecchen 4ef9103cca Merge remote-tracking branch 'origin/main' into task-fix-unresolved-sentry-issues 2026-08-05 18:15:35 -07:00
lawrencecchen d6c481a17d fix: use indexed workspace pane ownership 2026-08-05 17:52:45 -07:00
lawrencecchen a6aae2d4c5 test: require indexed workspace pane lookup 2026-08-05 17:52:24 -07:00
lawrencecchen 0a32d002a2 fix: satisfy Swift isolation warnings 2026-08-05 17:46:39 -07:00
Lawrence Chen 6bb1d7b5f7 Share coderouter usage cache across Vercel instances (#9679) 2026-08-05 17:37:53 -07:00
lawrencecchen c1fe70066c test: unwrap optional menu bar title 2026-08-05 17:30:50 -07:00
Lawrence Chen ad214598a7 Cache coderouter usage and collapse token auth query (#9676) 2026-08-05 17:25:08 -07:00
cmux-lawrence 592f98bdaf web: remove untranslated Browser detail 2026-08-05 17:23:59 -07:00
cmux-lawrence 49aca0ddc7 web: localize Browser availability copy 2026-08-05 17:23:02 -07:00
cmux-lawrence 01ac0386c4 web: label Browser channel explicitly 2026-08-05 17:22:05 -07:00
cmux-lawrence 288315003c web: add cmux Browser download landing 2026-08-05 17:17:14 -07:00
lawrencecchen 9a57ff8b95 fix: batch session restore geometry updates 2026-08-05 17:15:26 -07:00
lawrencecchen 1f0ca8b1bb test: cover batched session restore geometry 2026-08-05 17:14:17 -07:00
Lawrence Chen 7fbd9d2ff7 Parallelize coderouter usage and add dedicated landing (#9675) 2026-08-05 17:13:21 -07:00
lawrencecchen a25209bceb fix: eliminate remaining sidebar and settings stalls 2026-08-05 17:04:44 -07:00
lawrencecchen a9d56cc659 test: cover sidebar config reload invalidation 2026-08-05 16:54:34 -07:00
lawrencecchen db7c3760bf refactor: inject system keyboard loader 2026-08-05 16:49:48 -07:00
lawrencecchen 56edcfc781 fix: move latest release stalls off main 2026-08-05 16:40:17 -07:00
lawrencecchen 7516f64464 test: cover latest release main-thread stalls 2026-08-05 16:37:59 -07:00
lawrencecchen 8ace9433da fix: preserve authoritative text box drafts 2026-08-05 16:28:10 -07:00
lawrencecchen e271e48be2 test: cover attachment-side text insertion 2026-08-05 16:15:39 -07:00
lawrencecchen a8e0e42050 fix: address exact-head review findings 2026-08-05 16:03:14 -07:00
Abdulaziz Albahar 4b7796a960 Fix end-to-end push notification reliability (#9319)
* test push notification delivery failures

* test remaining push reliability failures

* test tighten push reliability contracts

* test prevent duplicate alerts across push retries

* test persist partial push delivery retries

* test serialize persisted push retries

* test preserve unregisters across account switches

* test migrate unregister tombstones to queue

* test retry transient registration statuses

* test close push delivery retry gaps

* test bind push retries to logical payloads

* fix reliable push delivery backend

* test push registration ownership and rotation

* test fix phone admission boundary and race

* test cover push registration lifecycle races

* test reuse push expiration across retries

* test compile push lifecycle assertions

* test push readiness and responsive toggles

* test reuse expiration after stale push lease

* test isolate async URL protocol callbacks

* test align APNs retries with provider guidance

* test require APNs correlation UUIDs

* test detach scripted push transport

* fix APNs retry and request identity policy

* test isolate APNs retry classes and expiration validation

* test preserve logical push expiration across retries

* test make push registration transport deterministic

* test freeze push recipients and crash recovery state

* fix isolate durable push delivery workflow

* test enforce APNs provider boundaries

* test reuse APNs provider connections

* fix bound and reuse APNs delivery sessions

* test preserve push correlation on internal failure

* test cover push readiness recovery and live admission

* fix retain push correlation on internal failures

* fix make push registration account safe

* test reject originless push redirects

* fix reject non-http push redirects

* fix expose live push readiness

* fix fail closed on TestFlight push entitlements

* test reject false push readiness

* fix fail closed on push readiness

* test specify durable phone push queue

* test cover push queue recovery boundaries

* test expose phone forwarding controls

* fix expose phone forwarding controls

* test wire phone push queue coverage

* test preserve provider push retry delay

* test reject stale notification policy completion

* test publish push auth identity transitions

* test expose phone push queue health

* test expose truthful push test stage

* fix make phone push delivery durable

* test report degraded push retry storage

* test retry transient push authentication

* fix retry transient push authentication

* fix expose end-to-end push readiness

* fix serialize push settings mutations

* fix emulate signing entitlement booleans

* fix compile phone push RPC handlers

* fix route all push popover entrypoints

* test stabilize push database stress cases

* fix ios push readiness module dependency

* fix audit missing push credentials

* test bound device token stress cases

* test expose unconfigured push provider

* fix report unconfigured push provider

* fix import push status in ios coordinator

* test: cover push review regressions

* fix: harden phone push delivery end to end

* fix return restored agent argument

* fix: address push reliability review findings

Server: delivery no longer aborts with the client request (partial APNs
outcomes survive disconnects), provider backoffs are clamped to the event
TTL or finalized as expired, expired records stop blocking reclaims,
account deletion waits for active device delivery leases, and lease
release uses a partial index instead of a table scan.

Mac: queue restore adopts the observed identity so the identity stream's
first yield no longer clears the persisted queue, the queue store
scavenges abandoned tmp snapshots, and in-flight policy discard uses the
delivery-identity index.

iOS: push-readiness preview env key routed through UITestConfig, device
limit count localized via localizedStringWithFormat.

Tests: TTL clamp unit + route coverage, aborted-signal regression,
unknown-admission fail-closed, negative Retry-After clamp, retainOnly
generation scope, account-deletion lease block.

* fix notifications phone push settings bindings

* test clamp provider push retry to event ttl

* test allow retry-after rounding drift

* fix: route phone_push.status.get auth failures to reauth disconnect

A revoked, expired, or account-mismatched token during the authenticated
status probe previously left the shell connected and only cleared push
readiness. Definitive authorization errors now route through
disconnectForAuthorizationFailureIfNeeded like every other authenticated
RPC; transient failures still just clear the cached Mac status.

* fix: lockfile guard false positive, IPA gate messages, test isolation

The Package.resolved policy flagged this PR for adding a path dependency
(CmuxMobileRPC into CmuxMobileShellUI) that was already reachable through
CmuxMobileShell: Xcode's originHash covers resolved remote inputs, so no
honest resolution changes any lockfile byte (verified against Xcode 26.6,
which rewrites the swift-CLI hash back). The guard now requires lockfile
diffs only for dependency edits that can move the pinned set: changed
remote requirements, or path edits pulling previously unreachable remote
pins in or out. Verified both negative cases still fail.

upload-testflight entitlement-gate failures now name the durable IPA
instead of the deleted temp-extracted .app path.

Two push-coordinator lifecycle tests now use isolated UserDefaults suites
instead of leaking through .standard.

* test: cover deferred push edge cases

* fix: close deferred push reliability gaps

* test: make injected push clock assertion causal

* fix iOS push status text returns

* fix: make push timeout sendable

* fix: baseline inherited iOS convention debt

* fix: route iOS convention baseline lint

* fix: make live push clock sendable

* test: cover remaining push delivery races

* test: harden push verification boundaries

* fix: close final push delivery races

* test: cover final push edge cases

* fix: preserve truthful push readiness results

* test: synchronize push queue lock coverage

* fix: call the Darwin file lock function

* fix: preserve push preview initializer order

* test: fix workspace drop fixture initializer

* test: synchronize push settings mutations

* test: tap push switch controls reliably

* test: require push opt-in at workspace list

* test: correct push lifecycle assertions

* test: make push settings regression observable

* test: keep push settings assertions alive headlessly

* fix: enable reliable mobile push notifications
2026-08-05 15:41:33 -07:00
lawrencecchen f78ebd7c6a test: cover interrupted drains and draft edits 2026-08-05 15:41:05 -07:00
Abdulaziz AlbaharandClaude Fable 5 6d0d313622 Read hosted tenant delete token lazily from process.env (#9669)
0eecd5afea (#9607) switched SUBROUTER_STACK_TENANT_DELETE_TOKEN to the
validated env object, but t3-env freezes values at first import, so
tenant-control configuration became unobservable after boot and the
unconfigured paths broke: the exchange route returns 200 instead of 503
and account deletion fires hosted tenant deletes for accounts that never
enabled Subrouter. web tests have been red on main since (CI paused).
env.ts still validates presence on Vercel non-preview deployments.

Co-authored-by: Claude Fable 5 <[email protected]>
2026-08-05 15:40:58 -07:00
lawrencecchen 39031f7726 fix: close exact-head review gaps 2026-08-05 15:13:09 -07:00
lawrencecchen fa9e8c64b2 test: cover bounded drains and draft ordering 2026-08-05 14:52:49 -07:00
lawrencecchen d583bad57d fix: index pending watcher requests by probe 2026-08-05 14:10:49 -07:00
lawrencecchen 7a8cb0bec0 test: cover indexed pending watcher teardown 2026-08-05 14:10:48 -07:00
lawrencecchen f283152c19 fix: scope detached keyboard snapshot reads 2026-08-05 02:31:32 -07:00
Lawrence Chen 2d9ba4b090 Proxy Codex model discovery through CodeRouter (#9641)
* test: require CodeRouter model discovery proxy

* Proxy Codex model discovery through CodeRouter
2026-08-05 02:22:46 -07:00
Lawrence Chen fe198fb88d Restore hosted Subrouter CLI configuration (#9638)
* test: preserve hosted Subrouter CLI config

* fix: restore hosted Subrouter CLI config
2026-08-05 02:22:02 -07:00
Lawrence Chen 81ae632de6 Fix SDK registry bootstrap verification (#9640)
* test: cover omitted PyPI publisher claims

* fix: repair SDK bootstrap verification
2026-08-05 02:14:09 -07:00
lawrencecchen b93ca71090 fix: bridge keyboard snapshot providers 2026-08-05 02:08:55 -07:00
lawrencecchen 2e3784abc2 fix: bridge synchronous keyboard callbacks 2026-08-05 02:01:15 -07:00
lawrencecchen cfcd0d3910 fix: isolate keyboard snapshot access 2026-08-05 01:55:18 -07:00
lawrencecchen d1daa291d2 refactor: satisfy sentry fix review gates 2026-08-05 01:46:01 -07:00
Lawrence Chen 40ff1c1667 Restore Vercel OIDC credentials for RDS access (#9637)
* test: require Vercel OIDC for RDS pools

* Restore Vercel OIDC credentials for runtime RDS access
2026-08-05 01:28:33 -07:00
Lawrence Chen 0447b3cb45 Fix CodeRouter responses middleware routing (#9636)
* test: preserve CodeRouter data-plane route through middleware

* Bypass localization for CodeRouter responses
2026-08-05 01:08:19 -07:00
Lawrence Chen 6e9ca01fc4 Move CodeRouter data plane to Vercel (#9633)
* test: require Vercel-native CodeRouter data plane

* Move CodeRouter data plane to Vercel

* Allow authenticated operators to run RDS migrations

* Serialize refreshes and fail over cooked accounts
2026-08-05 00:56:28 -07:00
Austin Wang c9e66e8dc9 Merge pull request #9631 from manaflow-ai/fix-9624-restore-caller-identification
Fix restore caller surface resolution
2026-08-05 00:55:35 -07:00
lawrencecchen b0867a9639 fix: bound browser eval graph normalization 2026-08-05 00:45:09 -07:00
lawrencecchen c7ade06ba4 test: reproduce browser alias expansion hang 2026-08-05 00:43:12 -07:00
lawrencecchen bb4f7ab993 Merge remote-tracking branch 'origin/main' into task-fix-unresolved-sentry-issues 2026-08-05 00:25:54 -07:00
austinpower1258 7ee8f64f9d fix(cli): restore caller surface resolution 2026-08-04 23:50:09 -07:00
Lawrence Chen d228709009 Merge pull request #9632 from manaflow-ai/codex/linux-auto-update-cta
web: prioritize auto-updating Linux installer
2026-08-04 23:21:15 -07:00
cmux-lawrence 0c80c2896c web: prioritize auto-updating Linux installer 2026-08-04 23:16:06 -07:00
cmux-lawrence ff147074bd test(web): require auto-updating Linux installer CTA 2026-08-04 23:06:30 -07:00
lawrencecchen 82343459e1 fix: reconcile selected browser portal state 2026-08-04 22:56:11 -07:00
Austin Wang ec0cd3308e Merge pull request #9324 from mykmelez/myk/fix-stale-port-badges
Retire stale sidebar port badges despite vanished TTYs, privileged owners, and zombies
2026-08-04 22:30:30 -07:00
lawrencecchen ce0c9de5ef test: reproduce browser portal registry drift 2026-08-04 22:08:24 -07:00
Austin Wang ca1e087edf Merge pull request #9621 from manaflow-ai/fix-9619-restore-preserves-workspace-title
Preserve workspace titles during session restore
2026-08-04 22:08:03 -07:00
lawrencecchen 4e2c2ca616 revert: drop disproven workspace visibility hypothesis 2026-08-04 22:07:19 -07:00
Lawrence Chen 0ff3fdc2f2 Merge pull request #9628 from manaflow-ai/codex/website-download-routes
Publish verified cmux Browser nightly downloads
2026-08-04 22:06:02 -07:00
cmux-lawrence 3060a4054e web: align mac downloads with universal artifacts 2026-08-04 22:00:48 -07:00
austinpower1258 929dee81ef fix: reconcile title after focused surface transfer 2026-08-04 21:59:03 -07:00
lawrencecchen 6eac884b73 fix: resolve active browser workspace visibility live 2026-08-04 21:52:01 -07:00
cmux-lawrence 17709529d0 web: add verified browser nightly downloads 2026-08-04 21:50:48 -07:00
lawrencecchen 0825dfd5d5 test: reproduce stale workspace portal visibility 2026-08-04 21:50:10 -07:00
lawrencecchen efd51a88ce build: pin GhosttyKit focus fix 2026-08-04 21:42:33 -07:00
austinpower1258 10eb95cbf6 test(cli): reproduce restore caller resolution regression 2026-08-04 21:38:30 -07:00
lawrencecchen 581a46c5e3 fix: keep Ghostty focus callbacks nonblocking 2026-08-04 21:27:57 -07:00
lawrencecchen fcf1352fc6 test: reproduce blocking Ghostty focus callback 2026-08-04 21:21:10 -07:00
lawrencecchen fae6258595 test: recognize sendable shortcut actions 2026-08-04 21:00:02 -07:00
lawrencecchen e2d314efd7 fix: thread live browser visibility through initializer 2026-08-04 20:58:10 -07:00
lawrencecchen bb453942d7 Merge remote-tracking branch 'origin/main' into task-fix-unresolved-sentry-issues 2026-08-04 20:46:32 -07:00
lawrencecchen 9460ae4d46 Revert "test: reproduce surviving SSH auth ancestors"
This reverts commit c41be59e8c.
2026-08-04 20:44:46 -07:00
lawrencecchen 03fbe4224f Revert "fix: terminate deep SSH auth process trees"
This reverts commit 53655f0d7a.
2026-08-04 20:44:46 -07:00
lawrencecchen 4755a52419 fix: resolve browser portal visibility from live selection 2026-08-04 20:44:17 -07:00
lawrencecchen 321e4ca176 test: reproduce stale browser portal visibility 2026-08-04 20:22:53 -07:00
austinpower1258 b5a6cb640f fix: harden restored title boundary lifecycle 2026-08-04 20:04:07 -07:00
lawrencecchen 53655f0d7a fix: terminate deep SSH auth process trees 2026-08-04 19:52:12 -07:00
lawrencecchen c41be59e8c test: reproduce surviving SSH auth ancestors 2026-08-04 19:37:16 -07:00
austinpower1258 b3c16f3478 fix: preserve restored workspace titles 2026-08-04 19:33:33 -07:00
lawrencecchen 0053b3c9d1 fix: update PostHog project token initializer 2026-08-04 19:29:32 -07:00
austinpower1258 24f6e13fc1 test: preserve titles across restore bootstrap 2026-08-04 19:17:25 -07:00
lawrencecchen 6f15ba610e fix: isolate workspace composition observations 2026-08-04 19:06:36 -07:00
Austin Wang 2f3d92281f Merge pull request #9566 from manaflow-ai/issue-9518-dock-focus-cmd-l-cmd-shift-t
Route all surface shortcuts through focused Dock
2026-08-04 19:02:07 -07:00
Lawrence Chen a2b3c10f11 Fix cmux TUI release packaging (#9608)
* Fix cmux TUI release packaging

* Allow TUI release branch dry runs

* Update TUI package smoke command

* Revert "Allow TUI release branch dry runs"

This reverts commit 0ccb643704.
2026-08-04 18:15:32 -07:00
Lawrence Chen 0eecd5afea Fix CodeRouter trusted tenant exchange (#9607)
* Make hosted tenant exchange self-contained

* Route CLI auth through versioned exchange endpoint

* Use a semantic tenant exchange endpoint

* Use CodeRouter control header for hosted exchange

* Read hosted credentials through validated runtime env
2026-08-04 18:12:23 -07:00
lawrencecchen 1266c51c51 tests: use inert surfaces for visibility probes 2026-08-04 17:59:34 -07:00
lawrencecchen e987a2d431 tests: isolate visibility probes from shell startup 2026-08-04 17:44:09 -07:00
lawrencecchen 2ffa60dee1 tests: await mounted runtime before invalidation checks 2026-08-04 17:29:35 -07:00
lawrencecchen 77a732d9eb fix: keep sidebar selection updates idempotent 2026-08-04 17:10:16 -07:00
lawrencecchen 1c9286de91 tests: await content startup recovery 2026-08-04 16:40:53 -07:00
lawrencecchen f0d4a1a8f4 Merge remote-tracking branch 'origin/main' into task-fix-unresolved-sentry-issues 2026-08-04 16:13:04 -07:00
lawrencecchen e03035b5d1 fix: cache large TextBox session drafts 2026-08-04 16:08:48 -07:00
lawrencecchen 25e0df6f46 test: reproduce repeated TextBox autosave rebuild 2026-08-04 16:00:40 -07:00
Lawrence Chen 4cf7cc39a6 Add copy-paste CLI authorization page (#9597)
* Add copy-paste CLI authorization page

* Exclude device authorization from locale routing
2026-08-04 15:50:50 -07:00
lawrencecchen 2e85b3317a fix: encode presence heartbeats off main actor 2026-08-04 15:47:34 -07:00
lawrencecchen 907914c01e test: reproduce heartbeat encoding on app actor 2026-08-04 15:44:45 -07:00
lawrencecchen 7a0b5c5259 fix: isolate hidden workspace theme refreshes 2026-08-04 15:39:58 -07:00
lawrencecchen fa412a74c9 test: reproduce hidden theme state invalidation 2026-08-04 15:39:23 -07:00
lawrencecchen 21418acdd0 fix: keep memory guardrail on cached titles 2026-08-04 15:38:33 -07:00
lawrencecchen 4adb4ef8b9 test: reproduce memory guardrail title polling 2026-08-04 15:37:52 -07:00
lawrencecchen 6c2cdd812b revert: drop simulator linkage workaround 2026-08-04 15:32:06 -07:00
lawrencecchen ac59c42838 fix: isolate pane drop overlay updates 2026-08-04 15:25:40 -07:00
lawrencecchen 4555e5f296 test: reproduce pane drop overlay invalidation 2026-08-04 15:24:58 -07:00
lawrencecchen b67ba12a38 fix: update PostHog queue synchronization 2026-08-04 15:23:49 -07:00
austinpower1258 314224f4ed fix: route surface shortcuts to focused Dock 2026-08-04 15:17:48 -07:00
austinpower1258 c40b650276 test: cover Dock shortcut routing gaps 2026-08-04 15:17:33 -07:00
lawrencecchen 86686c8d10 fix: keep simulator package products static 2026-08-04 15:02:20 -07:00
lawrencecchen c3a0adb6d0 Reuse menu bar notification items 2026-08-04 14:52:57 -07:00
lawrencecchen 2266e76f01 test: retain menu items across refresh regression 2026-08-04 14:38:19 -07:00
lawrencecchen 56784648ed fix: avoid duplicate simulator linkage in tests 2026-08-04 14:37:22 -07:00
lawrencecchen 9c52955add test: reproduce menu notification item churn 2026-08-04 14:21:16 -07:00
lawrencecchen e0bf80a1d6 fix: isolate static simulator CLI product 2026-08-04 14:03:45 -07:00
lawrencecchen dd178a8caf fix: statically link simulator CLI support 2026-08-04 13:33:37 -07:00
lawrencecchen 829bfc5ecb Merge remote-tracking branch 'origin/main' into task-fix-unresolved-sentry-issues 2026-08-04 12:53:59 -07:00
lawrencecchen 142497cfad Merge remote-tracking branch 'origin/main' into task-fix-unresolved-sentry-issues 2026-08-04 12:12:49 -07:00
Austin Wang e48438c4ef Merge pull request #9536 from manaflow-ai/issue-9462-google-sheets-browser-pane-cpu-hang
Use current Safari identity for Google Sheets
2026-08-04 12:03:37 -07:00
Austin Wang 91b195c1d8 Merge pull request #8612 from manaflow-ai/fix/atd-sidebar-link-click-8596
Fix AppKit sidebar description link clicks
2026-08-04 12:02:28 -07:00
lawrencecchen fe1a67c5f1 fix: avoid repeated TextBox delegate registration 2026-08-04 10:53:21 -07:00
lawrencecchen 2ac807a954 tests: require idempotent TextBox delegate setup 2026-08-04 10:52:23 -07:00
lawrencecchen ce8288c3ef Merge remote-tracking branch 'origin/main' into task-fix-unresolved-sentry-issues 2026-08-04 09:47:58 -07:00
lawrencecchen a3328daafb fix: attach browser preload to owned window view 2026-08-04 09:37:03 -07:00
lawrencecchen ebf5b80405 tests: require attached browser preload host 2026-08-04 09:35:35 -07:00
lawrencecchen d8dbdb32f5 fix: share socket connection delivery path 2026-08-04 08:13:29 -07:00
Austin Wang 2c26320ab8 Merge pull request #9580 from manaflow-ai/fix/pr-8614-merge-carrier
Land PR #8614 after resolving current main conflicts
2026-08-04 08:00:58 -07:00
lawrencecchen 527b885986 tests: suspend PR discovery responsiveness waits 2026-08-04 07:54:50 -07:00
austinpower1258 c685309bba Merge active orphan fixture repair into PR #8614 carrier 2026-08-04 07:48:20 -07:00
austinpower1258 f155b74809 cmuxTests: model active orphan shortcut route 2026-08-04 07:48:04 -07:00
austinpower1258 d927a96d63 Merge orphan route pruning repair into PR #8614 carrier 2026-08-04 07:35:26 -07:00
austinpower1258 b99b5c2b28 cmuxTests: await orphan shortcut route pruning 2026-08-04 07:20:04 -07:00
lawrencecchen 25ead042c2 tests: isolate PR discovery from mobile traffic 2026-08-04 07:03:05 -07:00
austinpower1258 00784263f0 Merge rect fixture compile repair into PR #8614 carrier 2026-08-04 06:42:23 -07:00
austinpower1258 470552b843 cmuxTests: keep FIFO diagnostic in one string literal 2026-08-04 06:42:16 -07:00
austinpower1258 b3b6c65c2f Merge rect publication fixture repair into PR #8614 carrier 2026-08-04 06:31:22 -07:00
austinpower1258 f5eae784e8 cmuxTests: route rect replies past setup commands 2026-08-04 06:31:12 -07:00
austinpower1258 3dfb475d52 Merge latest main into PR #8614 carrier 2026-08-04 06:02:31 -07:00
austinpower1258 2b5dc40667 Merge remote-tracking branch 'origin/main' into fix/thirteen-test-suite-repairs 2026-08-04 06:01:42 -07:00
Abdulaziz Albahar 28f88c819c Stagger Iroh relay credential refreshes (#9581)
* test: require staggered relay refresh slots

* fix: stagger relay credential refresh by endpoint role

* test: cover client relay refresh slot distribution

* test: assert stable bounded relay refresh slots
2026-08-04 07:57:13 -05:00
austinpower1258 11cab6a727 Merge Sendable clock default into PR #8614 carrier 2026-08-04 05:45:56 -07:00
austinpower1258 1d309236c8 Use a Sendable focus-history clock default 2026-08-04 05:45:46 -07:00
austinpower1258 05e79d3252 Merge current main and final review fixes into PR #8614 carrier 2026-08-04 05:30:27 -07:00
austinpower1258 2e1427c499 Merge remote-tracking branch 'origin/main' into fix/thirteen-test-suite-repairs 2026-08-04 05:30:09 -07:00
Austin Wangandejc3 539a1b2ad5 cmuxTests: repair three red suites, one of which was killing its own test host (#9572)
* cmuxTests: derive the theme reload target from a dash-free socket suffix

The CLI derives a theme reload target from the socket file name, collapsing every run of
non-alphanumerics in the slug to a dot. #6452 made this fixture's socket path unique with a raw
UUID to stop two runs colliding in /tmp, which put the UUID's dashes into the derived identifier
as dots, so the expected literal could no longer match and the test waited out its five seconds.
The stdout assertion kept passing because the derived id still has the expected value as a
prefix, which is why this read as a timeout rather than a string mismatch.

Keeps the unique suffix hex-only so the expected identifier stays a plain template instead of a
call into the CLI's own helper, which would agree by construction.

* cmuxTests: drop two palette assertions for a gate that no longer exists

#8173 replaced the fork-probe reuse gate: `!cachedResultHadFallback` became
`cachedResultIsFresh`, and the fallback case is now re-verified against SharedLiveAgentIndex at
the call site instead of being refused outright. The parameter stayed in both signatures, so
these two assertions still compiled while asserting the opposite of what the product does, and
WorkspaceForkConversationContextMenuTests asserts the new contract in both directions a few
files away.

Removes the two assertions whose only purpose was the removed term, and renames the clear-side
test to say what it still covers.

* cmuxTests: stop the remote-connection suite killing its own test host

Three separate problems, in order of blast radius.

Two assertions indexed `operations` right after asserting its count. A count assertion does not
stop execution, so on failure the next line trapped with Index out of range and took the shared
test host down, and every remaining test in the shard never ran. Measured twice in one run.

Four @MainActor tests waited on a DispatchSemaphore. configureRemoteConnection enqueues its
session transition as a main-actor Task, so blocking the main actor stopped the very work being
waited on from ever being scheduled. They now use expectations, which pump the run loop.

Fifteen fixtures passed an unresolved %C control template. The broker deliberately refuses to
own a path it cannot resolve, so no lease was ever taken and cleanup could not run; six inverted
expectations were passing vacuously as a result. They now use the resolved form ssh -G produces,
and a new test pins the unowned-template policy so the fixtures cannot quietly regress to it.

Two more read activeRemoteSessionControllerID straight after configureRemoteConnection and now
await the transition instead.

* cmuxTests: point the daemon-upload tests at the transport that replaced scp

Two tests waited on an scp invocation that no longer happens. #8434 moved the daemon upload off scp
and onto the ssh exec channel, streaming the binary into `cat >`, and did not touch these tests.
Their stubs only fulfilled inside an `executable == "/usr/bin/scp"` branch, so the expectation
could never fire, the wait spent its whole budget, and the unwrap on the next line reported nil.

Both now capture the upload from the ssh branch. The property each one is about is unchanged: the
daemon still has to land on an absolute path under the remote HOME, that path just travels inside
the remote command instead of an scp destination, so the assertion moved with it.

The scp branch is kept and fails loudly. If the upload ever returns to scp, that should be a
sentence in the failure output rather than a silent timeout, which is precisely how these two broke.

The reinstall test also now records how many capability hellos preceded the upload and requires at
least one. Retargeting alone would have let it pass on a first install, which is not the
missing-pty-capability path it is named for.

Renamed the first test off "ScpDestination" since it no longer describes what is asserted.

* cmuxTests: fix three CLI tests that could not pass, and stop one hiding why

Three separate causes, all in the fixtures rather than the product.

Two socket-selection tests replied to the CLI with a bareword. SocketClient only treats OK, OK …,
PONG, ERROR: … or JSON as a complete single-line reply, so a bareword sends it into the multiline
drain pass, where reconfiguring the receive timeout on a socket whose peer already hung up fails with
EINVAL — and the CLI reports "Invalid argument" instead of the reply it already had buffered. The
replies are now OK-framed. These were the only two barewords in the suite, which is why eleven
near-identical siblings pass.

Both now also assert which responder received the request. That is the property they exist for —
the tagged socket is chosen and the stable one is not — and unlike the stdout comparison it cannot
be made vacuous by a future change to the reply.

A fork-diagnostics fixture passed agent "project-agent", which is not in the CLI's catalog, so the
command exited before emitting any JSON. The test has never passed; it went in already red alongside
the pi-family gate it is meant to cover. It now uses grok, a catalog agent that is neither pi-family
nor one of the transcript-walking agents, so the basename gate is still what is under test.

The shared helper turned all of that into a JSON decoding failure, because it only expected a zero
exit before parsing. It now requires the exit status and a completed run, so the next fixture mistake
reports the CLI's own error text instead of a parse error.

* cmuxTests: pair the pi-basename fixture with an agent that can actually fork

The pi-family basename test asked for fork_command_available, fork_supported and
fork_startup_input_available, but its fixture stored the record under a grok
launcher pair. A captured launch command is only used when its launcher describes
the requested agent, so the grok/omo pair was dropped as untrusted, no fork argv
was built for any agent, and all four assertions failed on
agent_has_no_fork_command without ever reaching the rule under test.

Store the record under opencode instead, whose wrapper launcher is omo. The
capture is now trusted, the fork argv resolves through the omo launcher, and the
executable basename stays /tmp/pi so the disagreement between the structured
identity and the basename is still what the test measures. The omo launcher also
answers fork support before the opencode executable probe, so the result does not
depend on a /tmp/pi existing on the machine running the test.

* cmuxTests: assert the stderr-closed CLI does not crash, instead of a CLI that no longer exists

This test asserted exit 1 and a "Usage:" banner on stdout. Neither has been true since #f48922aa94:
an unknown command exits 2 with a single line and no usage dump, and that line goes to stderr — which
the test closes with 2>&-. So it could not pass, and the crash it was written for was not what it
checked.

The regression is still worth guarding. cc4a6109d8 replaced FileHandle.standardError.write, which
raises and aborts when stderr is closed, with a raw Darwin.write that returns -1 on EBADF. The oracle
is therefore that the CLI exited on its own terms rather than dying from a signal, so ProcessRunResult
now carries terminationReason and both runners set it. Without that, a signalled process is
indistinguishable from an ordinary non-zero exit, because its terminationStatus is just the signal
number.

The command now runs under exec, so the process being waited on is the CLI rather than the shell. A
shell reports a signalled child as a normal exit with status 128+signal, which would have hidden
exactly the crash being tested.

It also pins CMUX_SOCKET_PATH and the home directory. Socket resolution otherwise consults a
machine-global marker file, and a spawn with a pristine temp home was measured reaching a real running
app — which would make the exit code depend on what is running on the machine. With the socket pinned
the unknown-command path is a single branch, so the test asserts exit 2 exactly rather than settling
for non-zero.

* cmuxTests: isolate the CLI regression suite from the machine's own cmux

A CLI spawned from this suite with a pristine temp home and a scrubbed
environment still reached a real running app. CFFIXED_USER_HOME moves the socket
directory but not socket discovery: the CLI also reads the machine-wide
/tmp/cmux-last-socket-path marker, and for an untagged debug build it scans /tmp
for cmux-debug-*.sock and connects to what it finds. Resolution runs before the
command dispatches, so even `claude-teams --help` did this. Every spawn site that
is not itself testing resolution now pins CMUX_SOCKET_PATH to a per-run path, the
three stable-variant tests write the marker inside their own temp home, and
runShell takes an explicit environment instead of handing the child everything
the test host was launched with.

Two tests bound a responder on /tmp/cmux.sock, the release app's socket path, and
UnixSocketResponder unlinks before it binds, so a run could take the control
socket away from a release app in use. The early returns meant to prevent that
raced the app, disagreed about whether a dangling symlink counts as present, and
turned the tests into silent passes. The symlink fallback case moves to the
user-scoped stable path inside its temp home. The legacy case keeps the part that
needs the real path, that /tmp/cmux.sock is classified as a stable implicit
default, and no longer creates, binds, or removes it. Three more guards tested
paths inside a freshly created temp home and could never fire, so they are gone.

stderr was pointed at the stdout pipe while about thirty tests parse stdout as
JSON or compare it to an exact reply, so one diagnostic line from the runtime
broke a content check instead of naming itself. stderr now has its own pipe,
failure messages carry both streams, and the negative checks that meant "the CLI
never said this anywhere" read both rather than silently narrowing to stdout.
Readers for both pipes start before the wait, because reading after
waitUntilExit deadlocks once a child fills a pipe buffer and that looks like a
hang inside the CLI. A launch failure is reported on stdout as well as stderr,
since five sibling suites share this runner and print only stdout.

Runs that assert nothing about latency no longer carry a 5s cap and take a 60s
guard instead, which still fails a stuck CLI rather than passing slowly. The two
browser-download tests keep their 3s and 16s caps, where the deadline is the
assertion. The two theme tests with fixed bundle identifiers now scope them per
run, since the reload notification goes out machine-wide; for the nightly one
that means scoping the socket file name too, because the identifier is derived
from it.

* cmuxTests: assert the exit code this fixture actually produces

The stderr-closed test asserted exit 2, the unknown-command code. Measured, it exits 1: the pinned
socket has no listener, so the CLI fails at connect and the top-level handler returns before the
unknown-command arm runs. That ordering makes the fixture a better exercise of what the test guards,
not a worse one, because the connect error is written to the stderr the test has closed. The run
confirmed the guard itself holds — termination reason was a normal exit, not a signal.

* cmuxTests: report stderr in sessions helper failures

* cmuxTests: preserve restore assertions after stream split

* cmuxTests: close review gaps in process and upload fixtures

* cmuxTests: align remote fixtures with streamed input and scoped identity

* cmuxTests: yield main actor while awaiting daemon upload

* cmuxTests: repair CLI regression fixtures and child lifetimes

* cmuxTests: isolate daemon bootstrap fixtures from ControlMaster

* cmuxTests: keep theme notification state nonisolated

* cmuxTests: detach live argv fixture from test host

* cmuxTests: own Go discovery in daemon reinstall fixture

* cmuxTests: make subprocess and bootstrap fixtures deterministic

* cmuxTests: remove detached fixture wall clock

* cmuxTests: use async-safe scoped locking

* cmuxTests: make off-host process work concurrent

* cmuxTests: keep blocking process wait off cooperative executor

---------

Co-authored-by: ejc3 <[email protected]>
2026-08-04 05:29:17 -07:00
austinpower1258 bb881787bd Merge focus-dismissal review fix into PR #8614 carrier 2026-08-04 05:22:01 -07:00
austinpower1258 dc64d16d3a tests: stop after focus-dismissal timeout 2026-08-04 05:21:48 -07:00
austinpower1258 ef49d76840 Merge warning-free focus-history clock into PR #8614 carrier 2026-08-04 05:18:05 -07:00
austinpower1258 e495092446 Mark focus-history clock closure Sendable 2026-08-04 05:17:49 -07:00
austinpower1258 aa5e7dc9f6 Merge final shortcut test repairs into PR #8614 carrier 2026-08-04 05:09:21 -07:00
austinpower1258 ef4c5de385 Merge remote-tracking branch 'origin/main' into fix/thirteen-test-suite-repairs 2026-08-04 04:54:23 -07:00
austinpower1258 96e515bad7 tests: align shortcut suite with current reload policy 2026-08-04 04:54:19 -07:00
lawrencecchen 9fc49dc652 fix: keep socket ingress off cooperative executor 2026-08-04 04:48:25 -07:00
lawrencecchen 62be68452b tests: reproduce control socket consumer starvation 2026-08-04 04:43:31 -07:00
lawrencecchen 73ca2e2baa tests: await sidebar activity snapshot before PR discovery 2026-08-04 04:40:21 -07:00
austinpower1258 c90a038699 Merge remote-tracking branch 'origin/main' into fix/atd-sidebar-link-click-8596 2026-08-04 04:20:28 -07:00
austinpower1258 af88c26294 Merge review fixes into PR #8614 carrier 2026-08-04 04:20:18 -07:00
austinpower1258 5e06c67e87 tests: prove managed shortcut writes are refused 2026-08-04 04:19:48 -07:00
lawrencecchen 0c9a7eb7c1 Merge remote-tracking branch 'origin/main' into task-fix-unresolved-sentry-issues
# Conflicts:
#	Sources/FileExplorerStore.swift
#	Sources/GitStatusProvider.swift
#	cmuxTests/WorkspacePullRequestSidebarTests.swift
2026-08-04 04:16:38 -07:00
Austin Wang a0680fd439 Merge pull request #9569 from manaflow-ai/cli-headless-fixes
cmuxTests: integrate headless CLI mock fixes against current main
2026-08-04 04:11:26 -07:00
EJandejc3 2b6ea53707 cmuxTests: gate the keyDown-forward assertion on a live surface (#8504)
* cmuxTests: gate the keyDown-forward assertion on a live surface

testTypingRepairForwardsKeyDown asserted that the repaired keyDown reached
libghostty, but forwarding only happens once the runtime surface is live, and the
headless xctest host does not always spin one up. The assertion then fails for a
reason that has nothing to do with typing repair.

Gate the forward observation on a live surface, matching the constraint the
neighbouring tests in this file already respect.

This commit previously also carried a fix for remote-workspace restore handing
the terminal a local working directory. That fix is #8634, which restructures the
same logic behind a named predicate and gates both paths rather than one, so it
is dropped here to avoid two competing changes to the same function.

* cmuxTests: skip the keyDown-forward check visibly instead of silently dropping it

The forward assertion was wrapped in `if terminalPanel.surface.hasLiveSurface`, which means on a host
without a live libghostty surface the oracle simply disappears and the test still reports green. The
comment even said the headless host does not always provide one, so the coverage was not just
conditional, it was silently conditional, and nothing distinguished a run that checked the forwarding
from a run that did not.

XCTSkipUnless says it out loud. The repair-routing assertions above run first and are checked on every
host either way, so nothing that was verified before is verified less now.

---------

Co-authored-by: ejc3 <[email protected]>
2026-08-04 03:57:33 -07:00
austinpower1258 1943a80b7a cmuxTests: align headless CLI lifecycle expectations 2026-08-04 03:50:33 -07:00
Austin Wangandejc3 99f7e1baf6 Recover one pane instead of restarting the session when its seed retention overflows (#9575)
* remote-tmux: cover a pane that retains past its own ceiling

Red on purpose. Two tests drive the two branches that can reach a pane's retention
ceiling and assert what should happen: the pane recovers and the transport keeps
running. Today both fail with the connection in `.reconnecting` and no pane marked
for a deferred reseed.

They need one change to be reachable at all. The per-pane comparison read the
hard-coded static while the seed tests inject a small mirror limit, so no fixture
could reach this branch and it has never had coverage. The comparison now goes
through `min(static, mirror budget)`, which is the same value at the shipped
default because the mirror-wide default is exactly twice the per-pane static.

That bound is also the honest one: without it a single pane may retain more than
the whole mirror is allowed, which is why one retaining pane always crosses the
per-pane line first and the mirror-wide check only becomes reachable with three
panes retaining at once.

* remote-tmux: recover one pane instead of restarting the session on seed overflow

A pane whose surface has not reached its remote size yet cannot accept a seed, so the
mirror retains it. When that retention crossed the pane's ceiling the mirror called
`beginReconnecting()`, which is the "this control stream is unusable" path. The stream
was fine; a renderer had run out of room.

What it cost: the state change wipes every pane's retained seed and every deferred
reseed, then the reattach reseeds all of them with `clearScrollback: true`. That emits
ESC[3J, so each pane loses its locally saved lines and gets back at most what
`capture-pane` returns. One slow pane truncated the scrollback of every other pane in
the session.

The remedy was already in this file, three lines below each of the three call sites,
and already used for the neighbouring condition: drop that pane's retained bytes, mark
it, and re-seed it from an authoritative `capture-pane` once its grid is ready. The
recapture is what makes dropping the bytes safe, and it touches one pane.

The condition is reachable in ordinary use — a large `cat` in a pane whose tab has not
been opened yet will do it on a fast link. It is close to unreachable on the
high-latency path that motivated #8436, so the harsh branch fired mainly in conditions
that PR was not about.

Of the eighteen `beginReconnecting()` calls in Sources, this was the only one outside
the connection itself and the only one that could fire while the stream was healthy.

* remote-tmux: a seed budget ceiling no longer restarts a healthy stream

Two sites in the connection's own seed accounting called `beginReconnecting()` under an
explicit `connectionState == .connected` guard, so a producer running out of room restarted a
stream that was working. The reattach then reseeds every pane with `clearScrollback`, emitting
ESC[3J, so one slow pane truncated every sibling pane's scrollback — the same blast radius the
mirror-side change removes, one layer down. Two independent design reviews ranked this the
worst remaining problem, and it makes the claim in this branch's description true rather than
nearly true.

`recoverPaneSeedBudget` discards that pane's retained bytes and re-seeds it authoritatively.
Freeing the bytes first is what makes room for the re-seed to be admitted, and the re-seed is
deferred to the next main-actor turn because this runs inside the reservation that just failed
— a synchronous call re-enters it and recurses until the stack overflows, which a fuzz run
measured.

The budget test follows: it pinned `.reconnecting` and an empty seed table, and now pins the
blast radius instead. The pane that crossed the budget is released, a pane that did not keeps
its seed, and only the offender's bytes return to the budget.

* remote-tmux: recover an overflowing pane seed once under total-budget backpressure

reservePendingPaneSeedBytes already recovers the pane (recording
pane-seed-total-backpressure) when the aggregate budget is exhausted, and
the caller's combined guard then recovered it a second time under
pane-seed-backpressure. Each recovery enqueues a clear-scrollback reseed,
so one overflow scheduled two. Split the guard so the per-pane ceiling
keeps its marker and a reserve failure returns without recovering again.

* remote-tmux: cover bounded pane seed recovery retries

* remote-tmux: bound deferred pane seed recovery

---------

Co-authored-by: ejc3 <[email protected]>
2026-08-04 03:49:46 -07:00
9bdeca5d91 cmuxTests: settle async focus broadcasts before asserting their side effects (#8507)
* tests: settle focus broadcasts before asserting focus-history and notification dismissal

Five tests in the Workspace/TabManager suites called Workspace.focusPanel (or
addWorkspace) and immediately asserted the side effects that focus is supposed to
produce: a focus-history entry, and dismissal of the focused pane's unread
notification. Those side effects no longer happen synchronously.

Both of them run from the .ghosttyDidFocusSurface observer in TabManager, and that
notification is emitted through FocusSurfaceBroadcaster, which by contract never
delivers synchronously -- it coalesces onto a later main-queue turn so that emitting
mid-mutation cannot re-enter the focus/selection path. That indirection is what fixed
the unbounded focus cycle in issue #5100. Focus itself still lands synchronously,
which is why the surrounding focusedPanelId assertions kept passing and only the
side-effect assertions failed.

Drain the main queue before reading focus history or notification state, matching what
the passing tests in these same two files already do.

testFocusHistoryMenuSnapshotCarriesFocusedTimestamp needed one more correction: a
.back snapshot lists where focus would return to, so its first item is the focus record
stamped by TabManager()'s own initial workspace, not by the later addWorkspace call.
The lower bound of the causal interval now reads before TabManager() so it actually
brackets the record under assertion.

These suites are not in any CI -only-testing allowlist, so nothing caught the drift.

* tests: inject focus-history timestamps

* tests: settle active focus-history coverage

---------

Co-authored-by: ejc3 <[email protected]>
Co-authored-by: Austin Wang <[email protected]>
2026-08-04 03:46:57 -07:00
austinpower1258 752f0f9eef Merge PR #8614 after resolving current main conflicts
Preserve the original PR head as ancestry while carrying the reviewed conflict resolution against current main.
2026-08-04 03:45:42 -07:00
austinpower1258 34ad688380 Merge remote-tracking branch 'origin/main' into cli-headless-fixes 2026-08-04 03:42:25 -07:00
austinpower1258 bfe921f9ec Merge remote-tracking branch 'origin/main' into fix/thirteen-test-suite-repairs 2026-08-04 03:41:33 -07:00
EJandejc3 0cc8445541 Open a browser at the end of the tab strip, not one slot short (#8705)
`openBrowser(insertAtEnd:)` passed a final position to `reorderTab`, which is
addressed in bonsplit insertion gaps. The end of the strip is `count`, not
`count - 1`, so the old value asked for the gap in front of the last tab and
left the new browser one slot short of the end.

It looked correct whenever exactly one tab followed the insertion point, since
the position and the gap agree there, which is why the existing two-tab test
did not catch it.

Before: TabManagerSurfaceCreationTests, 11 tests, 1 failure
After:  TabManagerSurfaceCreationTests, 11 tests, 0 failures

Co-authored-by: ejc3 <[email protected]>
2026-08-04 03:38:33 -07:00
austinpower1258 72aa0f0f73 Merge remote-tracking branch 'origin/main' into fix/thirteen-test-suite-repairs
# Conflicts:
#	cmuxTests/BrowserConfigTests.swift
#	cmuxTests/BrowserPanelTests.swift
#	cmuxTests/OmnibarAndToolsTests.swift
2026-08-04 03:19:26 -07:00
Austin Wang 6d49edf927 Merge pull request #9576 from manaflow-ai/browser-headless-fixes
browser: integrate headless suite fixes against current main
2026-08-04 03:12:21 -07:00
austinpower1258 2d615e2b5b Merge remote-tracking branch 'origin/main' into fix/atd-sidebar-link-click-8596 2026-08-04 03:11:29 -07:00
austinpower1258 66798bf699 Avoid nested type for sidebar link layout cache 2026-08-04 03:07:16 -07:00
austinpower1258 7ac4a2520a Merge remote-tracking branch 'origin/main' into cli-headless-fixes 2026-08-04 03:06:30 -07:00
austinpower1258 4d3e14d90d Merge remote-tracking branch 'origin/main' into fix/thirteen-test-suite-repairs 2026-08-04 03:03:34 -07:00
austinpower1258 6639a2bd75 Merge remote-tracking branch 'origin/main' into cli-headless-fixes 2026-08-04 03:02:55 -07:00
Austin Wang c7b47c3e93 web: expose changelog versions to agent page variants (#9579) 2026-08-04 03:02:45 -07:00
austinpower1258 5dd6ea231f Revert "cmuxTests: avoid XCTest expectation host crashes"
This reverts commit b53005c56f.
2026-08-04 03:00:46 -07:00
austinpower1258 e7c4253c71 Retire ports when a terminal hibernates 2026-08-04 02:58:23 -07:00
8b8b1b0b87 Give the PR refresh run-loop test something real to observe (#8724)
* sidebar-git: give the PR refresh run-loop test something real to observe

testPullRequestRefreshRepositoryDiscoveryDoesNotBlockMainRunLoop counted calls to
a stubbed `git remote -v` subprocess as its proxy for "repository discovery ran".
The refresh stopped spawning that process in #2797, which replaced it with
in-process config parsing, so the counter sat at zero and the assertion failed.
The checks after it were worse than failing: with no discovery observed, they held
whether or not anything happened at all.

Repository discovery is the blocking filesystem work the refresh does before it
reaches the network, so that is what the test should watch. This adds
GitRepositoryDiscovering for the two calls PullRequestProbeService makes while
resolving candidate seeds, and lets a host inject it. GitMetadataService conforms
and stays the only implementation the app installs, so behavior is unchanged;
PullRequestPollService and the probe service accept the protocol instead of the
concrete type, which every existing call site already satisfies.

The test injects a discovery that counts and sleeps. It resolves no slugs, which
keeps the refresh off the GitHub transport and away from `gh auth token`.

The test now makes two claims rather than three. The invocation count is the one
that can fail for a product reason, and it is the one that was broken. The
run-loop tick gap stays as a coarse guard against a seconds-long stall.

The old "discovery did not run on the main thread" check is gone, along with the
observation box that fed it. `repositorySlugs` is a nonisolated async requirement,
so SE-0338 runs it off the caller's actor however the refresh schedules it: the
check passed no matter what the product did, including if discovery were rewritten
to be awaited inline. A test that cannot fail is not evidence, and keeping it
would have implied coverage the test does not have.

The run-loop tick gap stays as a coarse guard, and its comment now says why it is
loose: 45 seeds times 30ms of injected blocking is 1.35s against a 2.0s ceiling, so
this test's own work cannot trip it. It fires only if the product adds a
multi-second main-thread stall on top.

The counter is renamed to RepositoryDiscoveryInvocationCounter, since it counts
discovery calls rather than command-runner calls, and the new TabManager parameter
carries a note that it overrides discovery for the pull-request refresh only.

* chore: prepare PR 8724 origin transfer

* test: transfer deterministic PR refresh coverage

---------

Co-authored-by: ejc3 <[email protected]>
Co-authored-by: Austin Wang <[email protected]>
2026-08-04 02:53:53 -07:00
Abdulaziz Albahar f52578acb9 Preserve Iroh sessions across relay policy refresh (#9538)
* test: prove relay policy refresh mutates once

* fix: preserve sessions across relay policy refresh

* fix: clean up failed relay policy activation
2026-08-04 04:53:46 -05:00
austinpower1258 622654ae0b Merge remote-tracking branch 'origin/main' into fix/thirteen-test-suite-repairs 2026-08-04 02:52:30 -07:00
b959519136 cmuxTests: stop the shortcut routing suite from taking the test host down (#8635)
One test in this suite has been killing the xctest host, which is worse than a red suite:
the host dies with no verdict and every suite batched with it loses its results too.

The evidence names the test. scripts/ci/cmux-unit-test-timings.json was generated from a
green main run by scraping per-test completion lines, and it holds 247 entries for this
suite. testWelcomeWindowSidebarShortcutsUseSharedToggleCommands is the only declared test
absent from it. A test that neither passes nor fails nor skips is one the host died inside.

That test is also the only place in this 12,000-line file that calls performClose on a
window it constructed, and the only closed window here that leaves AppKit's close-time
release enabled; the other twenty disable it, and the product does the same for its own
windows. The test holds the window through ARC while the delegate's window context and the
focus-capture swizzle hold weak references to it, so the deferred close drops the last
retain a runloop turn later and the process aborts rather than failing a test.

Separately, the one test that constructs a second AppDelegate restored AppDelegate.shared
but not the surface registry's route retirer, which init had pointed at the temporary
delegate and which the registry holds weakly. That left the retirer nil for the remainder
of the host, so later tests ran against a registry that never sweeps retired routes.

A third latent host kill stays for its own change: a key-event helper calls fatalError
instead of failing, and converting it needs a throwing signature at fourteen call sites,
which does not belong in the same diff as the crash it would obscure.

Co-authored-by: ejc3 <[email protected]>
Co-authored-by: Austin Wang <[email protected]>
2026-08-04 02:47:40 -07:00
austinpower1258 fcc476c9ca Merge remote-tracking branch 'origin/main' into cli-headless-fixes 2026-08-04 02:47:04 -07:00
austinpower1258 a1726129e4 web: expose changelog versions to agent page variants 2026-08-04 02:46:26 -07:00
EJandejc3 e4bd9695d1 Show git status in the file explorer for repos reached through a symlink (#8577)
* file explorer: show git status for repos reached through a symlink

GitStatusProvider compared git's physical repo root (/private/var/...) against
the caller's explorer root spelled logically (/var, /tmp, or a symlinked project
dir) by raw string prefix, so every entry was dropped and the file explorer showed
no git status for any workspace behind a symlink. Resolve both roots to one spelling
for the containment check and emit keys under the caller's spelling so
FileExplorerStore lookups match. The ssh path keeps the caller's spelling on both
axes, so remote paths are never resolved against the local filesystem.

* file explorer: say when the root == "/" key branch is reached

---------

Co-authored-by: ejc3 <[email protected]>
2026-08-04 02:44:50 -07:00
64f7726b4f tests: stop the portal first-reveal fixture from killing the test host (#8689)
BrowserPortalFirstRevealScrollTests declares 16 tests. Run alone it completed 10
of them and restarted the app host three times, so the suite had no verdict and
anything sharing its host lost one too.

makeWindowFixture builds an NSWindow and three tests close it. AppKit releases a
window on close unless the owner opts out, and ARC still holds a strong reference,
so each of those closes over-releases and takes the process down. The count lines
up: exactly three tests call close(), and there were exactly three restarts. The
one test that builds its own window already sets the flag, so this was an omission
in the shared fixture rather than a deliberate difference.

The product does this everywhere it owns a window (BrowserPanel, the prewarmed
pool, the popup controller, ReleasingWindowController); only this fixture missed it.

Before: 3 restarts, 10 of 16 tests ran, ** TEST FAILED **
After:  0 restarts, 16 of 16 tests ran, ** TEST SUCCEEDED **

Both arms ran on the same worktree and the same warm derived-data path, one suite
per app host, with only this change between them.

Co-authored-by: ejc3 <[email protected]>
Co-authored-by: Austin Wang <[email protected]>
2026-08-04 02:43:37 -07:00
austinpower1258 3064ef4318 Revert "cmuxTests: retain markdown test windows across close"
This reverts commit 2ca500aa2f.
2026-08-04 02:42:06 -07:00
austinpower1258 97203addca Merge remote-tracking branch 'origin/main' into fix/thirteen-test-suite-repairs 2026-08-04 02:41:40 -07:00
EJandejc3 85fe23c44e CmuxAuthRuntime: wake the sign-in test waits on an event (#8644)
The HostBrowserSignInFlow harness waits spun on Task.yield() until their
condition held. Under CPU contention that is a bet on when the awaited task
gets scheduled, and the spinning loop competes with it for the same cores.
Running the package suites a few at a time was enough to lose the whole
target to

    HostBrowserSignInFlowTestSupport.swift:102: Fatal error: Timed out waiting
    for 1 host-browser session(s); got 0

since the timeout aborts the process and takes all 167 tests with it.

Raising the deadline does not fix that. With 48 busy loops on 16 cores, a
ten-second budget aborted the same way, only later. So each wait now suspends
until the fake it waits on resumes it: the session factory resumes session
waiters as it appends a session, the fake client resumes them as a currentUser
read parks on the closed user gate, and the gateable client resumes them once
an exchange has written its tokens or a clear has emptied the store. The
condition wait registers with the observation system instead, since the flow
and the coordinator are both @Observable. FlowFakeAuthClient's
storedAccessTokenDidPark() and ManualTestClock already worked this way.

The deadlines stay on as a net, so a genuine hang still reports by name rather
than suspending the run forever. They no longer bound a passing run.

Co-authored-by: ejc3 <[email protected]>
2026-08-04 02:41:00 -07:00
austinpower1258 516ac4b4cd Merge remote-tracking branch 'origin/main' into browser-headless-fixes 2026-08-04 02:39:39 -07:00
austinpower1258 2ca500aa2f cmuxTests: retain markdown test windows across close 2026-08-04 02:39:25 -07:00
austinpower1258 370a3ff944 cmuxTests: match mock socket thread QoS to waiters 2026-08-04 02:39:03 -07:00
austinpower1258 cb2506ab61 tests: fix omnibar overlay accumulator shadowing 2026-08-04 02:38:36 -07:00
austinpower1258 b554509c73 Add failing coverage for hibernated port retirement 2026-08-04 02:38:01 -07:00
27da2328b3 Default test-process windows to releasedWhenClosed = false (rebase of #7768) (#8832)
* Default test-process windows to releasedWhenClosed = false

AppKit defaults a code-created NSWindow to releasedWhenClosed == YES, so under ARC every close()
in test teardown sends an extra release. The window deallocates while still in the test's
autorelease pool, and the post-test pool drain then over-releases it: EXC_BAD_ACCESS in
objc_release, which kills the shared app host. xcodebuild relaunches the host and its summary
covers only the last launch, so verdicts pending in the dead host go missing rather than red.

A constructor in the test bundle swizzles NSWindow's two designated initializers so every window
created in the test process defaults to releasedWhenClosed == NO. A subclass's super.init reaches
the swizzled implementation, so NSPanel and every test-local subclass are covered without being
touched. Nothing in cmux sets releasedWhenClosed = YES deliberately, and production already sets
NO at its own call sites. The tradeoff is that AppKit-internal self-releasing windows leak in the
test process, which is harmless there.

Rebased onto current main from #7768; only the two source files are carried over, and the project
file entries are re-added against main's copy.

Co-authored-by: ejc3 <[email protected]>

* cmuxTests: also disable the window appearance animation in the guard

Greptile's review asked for this and trackTestWindow already does both: a window's appearance
animation is its own object and can outlive the window, committing CoreAnimation transactions off
the main thread for the rest of the run. Setting animationBehavior alongside releasedWhenClosed
means AppKit never creates the animation for a test-process window.

Nothing in the test targets asserts on animationBehavior, and the three production sites that
choose one deliberately assign after init returns, so an init-time default cannot override them.
Measured before pushing: the guard tests plus BrowserDeveloperToolsVisibilityPersistenceTests
produce the same verdicts with and without this change — same 11 pre-existing failures, nothing
added or removed, 0 restarts both ways.

* cmuxTests: cover guarded window animation defaults

* cmuxTests: type Swift Testing failure comment

---------

Co-authored-by: lawrencecchen <[email protected]>
Co-authored-by: ejc3 <[email protected]>
Co-authored-by: Austin Wang <[email protected]>
2026-08-04 02:35:48 -07:00
a77df5a35e cmuxTests: unbreak the build and re-sync the remote-tmux reorder/targeting suites (#8427)
* tests: drain all paneRects in programmaticMirrorReorder… (broken by #7315)

#7315 (exact feed-forward sizing / verified pane geometry) changed a mirror
window to publish only when its own paneRects reply lands, and those fetches are
enqueued incrementally — window @2's fetch appears after @1 resolves. The test
replied to a single snapshot of pending paneRects, so @2 never published, the
mirror built one tab instead of two, and the reorder + windowOrder assertions
failed (panelIds.count == 1, not 2).

The product is correct — the sibling mirror suites and the multiplex fuzzer build
multi-window mirrors green. This is a stale test setup: drain every paneRects
fetch (bounded loop) so both windows publish, then the two-tab reorder holds.

Red/green: on clean main the test fails with panelIds.count → 1 == 2; with the
drain it passes (1 test). Test-only change; no product code touched.

* tests: drain post-#7315 follow-up commands in RemoteTmuxWindowReorderTests

#7315 (verified pane geometry) and the pane-border-status work changed the
control-command stream the reorder/close state machine emits: a window-list
publish now also enqueues a per-window paneRects refetch, and closing a window
issues a border-status unsubscribe (a plain send(), kind .other). The suite
drives the connection with positional commandNumber:0 replies, so an undrained
follow-up sits at the FIFO head and swallows the reply meant for the reorder/
close list-windows recovery — the batch never recovers, the connection never
reconnects, and retained panes never release. All 33 assertions across 9 tests
failed on clean main for this one reason.

Fix is test-only: publish helpers drain every follow-up (paneRects + .other), a
drainLeadingOther helper clears them ahead of each correlated reply, and the
exact-pending assertions compare with those incidental follow-ups filtered out.
The product is correct — the multiplex fuzzer and the sibling mirror suites build
multi-window mirrors and reorder/close them green.

Red/green: clean main fails the suite with 33 issues; with this it passes 14/14.
No product code changed. Broke in #7315.

* tests: address review findings on the mirror/reorder test fixups

From the CodeRabbit/Greptile pass:

- drainLeadingOther replied to every paneRects with a hardcoded `%0`; a
  re-published @2/@3 needs its own pane id (the `windowId * 10` convention
  publishWindows stages), or its pending layout can't publish.
- reorderPending filtered incidentals globally, so a paneRects landing BETWEEN
  two list-windows (an ordering anomaly) would be elided and the equality
  assertion would still pass. Trim only TRAILING incidental follow-ups; an
  interleaved one now survives and fails the assertion.
- The mirror-targeting rects drain iterated a stale snapshot while each reply
  consumes the FIFO head, so an incidental preceding a fetch could mis-correlate
  pane data. Drain strictly from the head and stop at the first correlated command.

* tests: stop the reorder drains from swallowing correlated commands

Both drain helpers replied to whatever sat at the FIFO head, so a `listWindows` or
`windowReorder` arriving early was consumed with an empty reply and its later
positional result mis-correlated — the failure the drains exist to prevent. Each now
answers only the incidental follow-ups (`paneRects`, `.other`) and stops at the first
correlated command. `drainLeadingOther` also gains the bounded guard the other drains
already had.

---------

Co-authored-by: ejc3 <[email protected]>
Co-authored-by: Austin Wang <[email protected]>
2026-08-04 02:33:48 -07:00
EJandejc3 7081a23261 tests: update the remote-tmux resolver assertion to the shared builder's argv (#8550)
* tests: update the remote-tmux resolver assertion to the shared builder's argv

RemoteTmuxAuthTests/controlModeArgumentsUseRemoteTmuxResolverAfterDestinationGuard
fails on main. It asserts the remote command ends with

  'cmux-remote-tmux' '-CC' 'attach-session' '-t' 'work session'

but #8442 generalized the tmux-specific resolver into RemoteExecutableCommandBuilder,
which passes the executable name and not-found sentinel as arguments:

  'cmux-remote-executable' 'tmux' 'cmux-remote-tmux: tmux not found' '-CC' ...

The test's intent still holds — a destination that looks like an SSH flag is still
passed after `--` and the remote command still routes through the resolver — so only
the asserted literal was stale. Pin both halves instead: the command goes through the
resolver, and what it forwards is the tmux attach for this session.

* tests: pin the resolver's not-found sentinel in the control-mode argv

The resolver argv is 'cmux-remote-executable' <name> <sentinel> followed by the
forwarded arguments, so asserting the executable name and the tmux attach suffix
left the sentinel between them unpinned. Derive it from
RemoteTmuxHost.tmuxNotFoundSentinel so the assertion cannot drift from the
constant the resolver actually emits.

---------

Co-authored-by: ejc3 <[email protected]>
2026-08-04 02:31:54 -07:00
austinpower1258 4636dd9353 Canonicalize TTY identities for port attribution 2026-08-04 02:30:16 -07:00
EJandejc3 07dd5a1cd9 A fake WKNavigation was killing the test host, hiding a whole suite (#8633)
* cmuxTests: stop a fake WKNavigation from killing the test host

BrowserDiscardRestorePolicyCancelTests logs that it started and then produces no verdict at
all, which is what a dead host looks like rather than a failing assertion. That is why the
suite reads as consistent with the product when you go through it test by test: it does not
fail, it dies, and it takes every suite sharing the host down with it.

The cause is constructing WKNavigation directly. WebKit builds the embedded C++
API::Navigation itself, so a bare WKNavigation() carries unconstructed storage. Allocating one
is harmless; releasing it is not. Reproduced outside the test bundle, deterministically:
EXC_BREAKPOINT inside CFRetain from -[WKNavigation dealloc] with WebKit initialised, and
SIGSEGV through WebCoreObjCScheduleDeallocateOnMainRunLoop from the same dealloc without it.
The first death needs no window: the fake is stored as the pending restore navigation, the
next call clears that reference, and the release traps mid-assertion before anything prints.

That also explains why no output survives. The probe reproduced the missing-log signature
too: with stdout on a pipe, the crash discards the buffer, so even the line printed just
before it never reaches the log.

The bookkeeping under test only ever compares these by identity, so the fakes are minted
through a helper that keeps them retained for the run. No assertion changes. WKNavigation()
appears nowhere else in the repo.

Whether the eight tests then pass is a separate question this crash has been hiding.

* cmuxTests: the same suite closes a test-owned window AppKit also releases

Retaining the fake navigations got this suite far enough to run and pass several tests where
it previously produced nothing, which confirmed the first cause and exposed a second one in
the same file. One test builds an NSWindow, holds it through ARC, and closes it in a defer
without disabling AppKit's close-time release, so the last retain goes away underneath the
live references and the host aborts instead of a test failing. That is the same defect already
proven in the shortcut routing suite, and about forty other closing sites in cmuxTests
already guard against it.

---------

Co-authored-by: ejc3 <[email protected]>
2026-08-04 02:29:17 -07:00
EJandejc3 a76deb63e0 Close only the workspaces a tab manager actually owns (#8753)
closeWorkspace checks only that more than one tab is open, then runs its whole
teardown. It frees every panel's Ghostty surface, which SIGHUPs the child
processes, empties the workspace's panels and titles, clears owningTabManager,
and publishes a workspace-closed event. Membership in `tabs` is only enforced at
the very end, when the array element is removed; the recordHistory block does
look the index up earlier, but only to decide where in the history to record.

So handing a manager a workspace from another window kills that workspace's
terminals, strips its panels, and announces a close for a workspace that is still
open on screen.

#889 added this teardown and, directly above it, a `tabs.firstIndex(where:)`
guard, along with the test that covers this. A later "Reapply" merge kept the
teardown and dropped the guard, so the destructive half outlived its
precondition.

Two call sites already make this check themselves rather than relying on
closeWorkspace: AppDelegate re-checks `sourceManager.tabs.contains` before
closing a source workspace, and TerminalController records `existedBefore` and
skips candidates that fail it. Both predate #889, so they are not compensating
for the lost guard — they are evidence that callers have always needed this
precondition and have been paying for it individually.

One path does change. Workspace.swift resolves a manager as
`owningTabManager ?? tabManagerFor(tabId:) ?? AppDelegate.shared?.tabManager`,
and that last fallback is reached precisely when no manager owns the workspace.
Previously such a call tore the workspace down through an unrelated manager;
now it returns early, which is the intent of the guard.

testCloseWorkspaceIgnoresWorkspaceNotOwnedByManager covers this and has been
failing: it hands the manager a foreign workspace and checks that the workspace
keeps its panel, which is the terminal that would otherwise be killed.

Co-authored-by: ejc3 <[email protected]>
2026-08-04 02:26:45 -07:00
austinpower1258 729b26d568 Merge remote-tracking branch 'origin/main' into fix/thirteen-test-suite-repairs 2026-08-04 02:10:30 -07:00
austinpower1258 5d25a5dd35 tests: harden browser headless regressions 2026-08-04 02:01:38 -07:00
cmux reload-cloud 8ef38547a1 Merge branch 'main' of https://github.com/manaflow-ai/cmux into HEAD 2026-08-04 02:00:53 -07:00
cmux reload-cloud c7374d1715 fix: use current Safari identity for Sheets 2026-08-04 02:00:26 -07:00
austinpower1258 2da63bafc8 Merge remote-tracking branch 'origin/main' into fix/atd-sidebar-link-click-8596 2026-08-04 01:58:08 -07:00
cmux reload-cloud fe19c02230 test: require current Safari identity for Sheets 2026-08-04 01:57:22 -07:00
austinpower1258 54854e33a4 Add failing coverage for full-path TTY attribution 2026-08-04 01:57:19 -07:00
Lawrence Chen 3a8705467a Add per-version changelog pages (#9543)
* Add per-version changelog pages

* Localize changelog version pages

* Inject changelog storage

* Fix nested docs pager matching

* Fix Italian changelog labels

* Prerender changelog pages

* Handle unmatched docs pager paths
2026-08-04 01:56:59 -07:00
EJandejc3 d3eeacf0b3 CMUXProjectModel: find the worktree root instead of counting directories (#8641)
* CMUXProjectModel: find the worktree root instead of counting directories

Every project-loading test in XcodeProjectAdapterTests fails under `swift test`:

    Caught error: unreadable(file:///.../Packages/cmux.xcodeproj)

The suite locates cmux.xcodeproj by stepping up five parent directories from
its own #filePath. That was right when packages sat directly in Packages/, but
they now live under a group folder (Packages/macOS/CMUXProjectModel), so five
steps land on Packages/ and the adapter is handed a path that does not exist.
Seven of the fifteen tests in the package fail as a result.

Search upward for the directory that actually contains cmux.xcodeproj. Packages
are expected to move between the Shared, iOS and macOS group folders, so a fixed
depth breaks again on the next move while a search does not.

The search stops after eight directories rather than running to the filesystem
root. An unbounded walk out of a checkout that is nested inside another checkout
would find the outer checkout's cmux.xcodeproj and quietly test that project
instead. It also resolves symlinks on #filePath first, so a symlinked package
path still lands on a directory the walk can compare against.

When nothing is found the initializer throws and names what happened, instead of
handing the adapter a path it invented. Copying the package somewhere with no
cmux.xcodeproj above it now reports

    cmux.xcodeproj is not in /.../Tests/CMUXProjectModelTests or in any of the 7
    directories above it, so these tests have no project to load. Run them from a
    cmux checkout, or point CMUX_PROJECT_FIXTURE at a directory that contains
    cmux.xcodeproj.

and all nine tests in the suite fail. Two of them used to pass against the
missing path without loading anything: canLoad only inspects the path extension,
and the workspace test returns early when the file is absent.

* CMUXProjectModelTests: derive fixture siblings from the containing directory

CMUX_PROJECT_FIXTURE pointing at cmux.xcworkspace produced a projectURL nested inside
the workspace bundle, and a .xcodeproj override had the symmetric bug. When the override
names either bundle, its siblings now come from the directory that holds it.

---------

Co-authored-by: ejc3 <[email protected]>
2026-08-04 01:56:14 -07:00
austinpower1258 d806cf59d4 Merge remote-tracking branch 'origin/main' into fix/thirteen-test-suite-repairs 2026-08-04 01:54:17 -07:00
austinpower1258 b53005c56f cmuxTests: avoid XCTest expectation host crashes 2026-08-04 01:47:34 -07:00
EJandejc3 8c9ee247a0 file-explorer: drop cancelled loads before they list a stale path (#8595)
loadChildren only checked cancellation after provider.listDirectory, so a
root reload triggered during an SSH provider swap left the cancelled local
load free to still call listDirectory, now through the freshly swapped SSH
transport, listing the old local path. Bail at the top of loadChildren when
the task is already cancelled, before any listing.

Co-authored-by: ejc3 <[email protected]>
2026-08-04 01:46:33 -07:00
austinpower1258 9d4bf9a990 Merge remote-tracking branch 'origin/main' into cli-headless-fixes 2026-08-04 01:40:38 -07:00
EJandejc3 0a68bbee35 remote-tmux: convert CLI tab-reorder final position to a bonsplit insertion gap (#8600)
controlSurfaceReorder passed the CLI's requested final tab position straight
to reorderSurface, but that API takes a bonsplit insertion gap. Moving a tab
to a higher slot needs index + 1 so the gap lands after the tab currently in
that slot; otherwise a move to sourceIndex + 1 is a silent no-op that still
reports success. Mirror the final-to-insertion conversion the other reorder
call sites already do.

Co-authored-by: ejc3 <[email protected]>
2026-08-04 01:40:04 -07:00
EJandejc3 91df18db5c tests: stop three fixtures from killing the test host on window close (#8701)
Three suites created an NSWindow and later closed it without opting out of
AppKit's close-time release, so each close over-released a window ARC still
held and took the whole test host down with it.

A dead host is worse than a failing test: the suite reports no verdict, and
every suite sharing that host loses its verdict too.

  TerminalNotificationSocketActionTests  2 restarts, 0 of 7 tests ran   -> 0 restarts, 7 pass
  FilePreviewPanelTextSavingTests        2 restarts, 2 of 27 tests ran  -> 0 restarts, 27 run
  FilePreviewReviewFeedbackTests         1 restart, 11 of 17 tests ran  -> 0 restarts, 17 run

FilePreviewPanelTextSavingTests closes a window in fourteen tests, all through
one private windowHosting helper, so the guard goes there rather than at each
call site. The other twenty-two suites in that file build windows and only ever
orderOut them, which does not release, so they need nothing.

Two of these suites still have assertion failures behind the crash that nobody
could see while the host was dying: three in FilePreviewPanelTextSavingTests
(24 of 27 pass) and one in FilePreviewReviewFeedbackTests (16 of 17 pass).
Those are separate bugs and get their own change.

The product already sets isReleasedWhenClosed = false everywhere it owns a
window; only these fixtures were missing it.

Co-authored-by: ejc3 <[email protected]>
2026-08-04 01:38:15 -07:00
austinpower1258 cd643fbcfb Merge remote-tracking branch 'origin/main' into browser-headless-fixes 2026-08-04 01:36:39 -07:00
austinpower1258 c7dcd52bcf Order test window for sidebar click dispatch 2026-08-04 01:36:22 -07:00
austinpower1258 f6b382aaee Guarantee enough scans after late port kicks 2026-08-04 01:35:43 -07:00
EJandejc3 007fe3527a Pin four unread session-restore tests to the model the product implements (#8798)
* tests: pin the restore model these four unread tests were written against

Four session-restore tests in WorkspaceManualUnreadTests still describe the
pre-#2797-era restore model, where an unread notification present at snapshot time
was dropped and came back as a purely visual "restored unread indicator" with a
count of zero.

e4856922b0 changed that on purpose. Snapshots now carry the notifications
themselves, restore re-inserts them still unread, and the restored-unread
indicator is set only when a snapshot claims unread with no unread notification to
back it. Both gates are live: the workspace level checks
`snapshot.notifications?.contains { !$0.isRead }` before setting the indicator, and
the panel level does the same. Setting both would count one notification twice.

So these tests asserted an indicator that the product deliberately no longer sets,
and a count of zero for a notification the product deliberately keeps unread. They
now assert the restored notification directly and leave the indicator false, which
is the behavior the product implements. The independence the last two tests are
named for still holds: manual unread and a restored notification each contribute,
so the count is two until the manual half is cleared.

The assertions after markPanelRead and markRead are untouched, because marking read
clears the notification and the old expectations there were already correct. The
test names are unchanged; the CI shard timings key on them.

* cmuxTests: assert the combined unread count, not only its two flags

The independence test checked the manual indicator and the notification separately but
never the number they add up to, so a regression in either contribution could not move
a count this suite looks at. unreadCount(forTabId:) is the notification total plus one
for any workspace-level indicator, so this setup must read 2 before the panel is marked
read and 0 after.

---------

Co-authored-by: ejc3 <[email protected]>
2026-08-04 01:35:21 -07:00
austinpower1258 fe88fd0ae1 Merge remote-tracking branch 'origin/main' into cli-headless-fixes 2026-08-04 01:34:19 -07:00
EJandejc3 71fb52ce63 remote-tmux: parse a session list that arrives with CRLF line endings (#8704)
A remote that runs tmux under a pty sends CRLF, because ONLCR rewrites every
newline on the way out. The session-list parser split on "\n" and then tried
to strip a trailing "\r", and neither step works: Swift treats CRLF as a
single Character, so the split finds no separator and `line.last == "\r"`
never matches, since the last Character of `...crlf\r\n` is `"\r\n"`.

The whole listing therefore parsed as one session whose name swallowed the
rest of the output, so such a host showed a single bogus workspace instead of
its sessions.

Split on any newline instead, which is what the sibling parser for the same
transport's stdout already does in RemoteTmuxVersion.swift. The strip and the
empty-line guard both go away, because split(whereSeparator:) omits empty
subsequences.

Before: RemoteTmuxSessionListParserTests, 8 tests, ** TEST FAILED **
After:  RemoteTmuxSessionListParserTests, 8 tests, ** TEST SUCCEEDED **

Co-authored-by: ejc3 <[email protected]>
2026-08-04 01:33:27 -07:00
austinpower1258 c4c171bec9 cmuxTests: adapt current SSH host test to shared loop 2026-08-04 01:31:46 -07:00
lawrencecchen fa7bff6dd0 Merge remote-tracking branch 'origin/main' into task-fix-unresolved-sentry-issues 2026-08-04 01:20:54 -07:00
austinpower1258 9fcfb5f498 Add failing coverage for late-burst port retirement
A single kick near the end of an existing scan burst can receive fewer than the three authoritative misses required to retire a stale port. Pin that idle-workspace timing path before changing the scheduler.
2026-08-04 01:20:51 -07:00
lawrencecchen bf2e2514fd chore: update Bonsplit Sentry fixes 2026-08-04 01:19:36 -07:00
austinpower1258 a3e99e705c Fix merged sidebar action test fixture 2026-08-04 01:18:44 -07:00
austinpower1258 2f48ef1063 Merge remote-tracking branch 'origin/main' into cli-headless-fixes 2026-08-04 01:16:36 -07:00
austinpower1258 c315b8cf3c Exercise sidebar link clicks through the window 2026-08-04 01:11:55 -07:00
austinpower1258 8eff531da8 cmuxTests: adapt current CLI socket tests to shared loop 2026-08-04 01:10:37 -07:00
austinpower1258 625712e757 Merge remote-tracking branch 'origin/main' into fix/thirteen-test-suite-repairs 2026-08-04 01:10:02 -07:00
austinpower1258 c2dbb5aba3 Reconcile sidebar link review feedback 2026-08-04 01:09:32 -07:00
austinpower1258 64dc135ec3 Merge remote-tracking branch 'origin/main' into cli-headless-fixes 2026-08-04 01:04:44 -07:00
austinpower1258 a70bd20b78 Merge remote-tracking branch 'origin/main' into fix/thirteen-test-suite-repairs 2026-08-04 00:56:25 -07:00
austinpower1258 9f88b6d71a Keep TCP port evidence complete across lsof warnings
Pass -w to the PID-scoped TCP listener query so unrelated filesystem mount warnings cannot poison every scan. Actual command failures, timeouts, malformed output, and PID-scoped uncertainty remain incomplete.

This is the green half for the persistent-warning regression in 6b54e60f76.
2026-08-04 00:55:13 -07:00
cmux reload-cloud 1b2763d4ce fix: separate Sheets transport and WebKit identities 2026-08-04 00:54:59 -07:00
austinpower1258 6b54e60f76 Add failing coverage for persistent lsof warnings
Issue #9152 reports a Time Machine filesystem warning on every lsof invocation. Model that warning in the full port lifecycle harness and prove PID-scoped TCP evidence must remain authoritative, while tightening the existing process-identity and retry test seams.

This is the tests-only red half of the regression pair.
2026-08-04 00:53:32 -07:00
lawrencecchen 44bfbf3b22 fix: type WebKit callbacks to main actor 2026-08-04 00:48:21 -07:00
cmux reload-cloud a224b9a5d5 test: require supported Sheets transport identity 2026-08-04 00:46:31 -07:00
cmux reload-cloud 6d8ba314a9 Merge branch 'main' of https://github.com/manaflow-ai/cmux into HEAD 2026-08-04 00:45:19 -07:00
austinpower1258 ec67c509f4 Merge remote-tracking branch 'origin/main' into fix/atd-sidebar-link-click-8596
# Conflicts:
#	Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift
#	cmuxTests/SidebarAppKitRowCellTests.swift
2026-08-04 00:44:43 -07:00
lawrencecchen 6061ff34bb test: reproduce legacy WebKit actor crash 2026-08-03 23:58:34 -07:00
cmux reload-cloud db30cc93eb Merge remote-tracking branch 'origin/main' into HEAD 2026-08-03 23:47:11 -07:00
cmux reload-cloud cd90d0b9f6 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	cmuxTests/BrowserUserAgentPolicyWebKitTests.swift
2026-08-03 23:42:56 -07:00
cmux reload-cloud c3cc0d1522 revert: remove #9483 browser policy changes
Restore the original navigation-action API and remove the regression test that encoded the same-URL stale-identity fallback. Keep PR #9482 nil/empty normalization as the sole convergence rule.
2026-08-03 23:40:34 -07:00
lawrencecchen a1e25fadb8 fix: update hibernation batch limit reference 2026-08-03 23:02:38 -07:00
lawrencecchen 4b34e03a15 fix: serialize native surface lifecycle 2026-08-03 22:44:03 -07:00
cmux reload-cloud 6770f596be revert: remove stale browser identity fallback 2026-08-03 22:03:28 -07:00
cmux reload-cloud 1c906c1a2d test: reject stale Sheets identity fallback 2026-08-03 21:53:07 -07:00
cmux reload-cloud c5060a8f83 Merge branch 'main' of https://github.com/manaflow-ai/cmux into HEAD
# Conflicts:
#	Sources/Panels/WKWebView+BrowserUserAgentPolicy.swift
2026-08-03 21:42:23 -07:00
lawrencecchen 5ba36cfdd2 test: reproduce overlapping native teardown lanes 2026-08-03 20:35:47 -07:00
lawrencecchen f3fb230785 fix: keep memory probes out of libghostty 2026-08-03 17:41:05 -07:00
lawrencecchen c13462c86a test: reproduce periodic memory probe hang 2026-08-03 17:38:17 -07:00
lawrencecchen 458e42eade fix: index browser history imports 2026-08-03 17:35:39 -07:00
lawrencecchen 9451f1a267 test: reproduce large history import hang 2026-08-03 17:32:35 -07:00
austinpower1258 f170ffef77 fix: make browser identity replay idempotent 2026-08-03 16:42:31 -07:00
austinpower1258 c56ee7d57f test: reproduce Google Sheets identity replay 2026-08-03 16:42:31 -07:00
lawrencecchen ddd3d8d0bb fix: serialize terminal close teardown 2026-08-03 16:18:05 -07:00
lawrencecchen 585c88cc85 test: reproduce overlapping terminal frees 2026-08-03 16:14:54 -07:00
lawrencecchen a6c6e24d44 fix: isolate drag event lookup to main actor 2026-08-03 15:05:26 -07:00
lawrencecchen df0d82121a fix: bound Sentry breadcrumb scrubbing 2026-08-03 14:29:50 -07:00
lawrencecchen 4dd027d140 test: reproduce Sentry scrubber backtracking 2026-08-03 14:23:23 -07:00
lawrencecchen 96cdc3eeaf fix: filter window defaults invalidation 2026-08-03 14:06:54 -07:00
lawrencecchen 1268cb171e fix: isolate workspace customization persistence 2026-08-03 13:25:21 -07:00
lawrencecchen fc461d78de test: reproduce customization journal invalidation 2026-08-03 13:23:57 -07:00
lawrencecchen f7213e29a9 fix: isolate titlebar hit testing to main actor 2026-08-03 13:02:14 -07:00
lawrencecchen 286c428e50 fix: bind Bonsplit hit testing to main actor 2026-08-03 12:50:12 -07:00
lawrencecchen d88198dc6d fix: mint control refs on demand 2026-08-03 12:40:18 -07:00
lawrencecchen 55eb6c598d test: reproduce global control ref refresh stall 2026-08-03 12:23:07 -07:00
lawrencecchen b7ce8c6713 fix: satisfy concurrency warning budget 2026-08-03 12:00:05 -07:00
lawrencecchen ce08267fdd fix: scope tab manager observation to leaves 2026-08-03 11:48:50 -07:00
lawrencecchen a1f25212da fix: prewarm agent policies off main 2026-08-03 11:18:55 -07:00
lawrencecchen 75fda19562 test: reproduce sanitizer policy initialization hang 2026-08-03 11:18:03 -07:00
lawrencecchen 216127c0a4 fix: isolate workspace list invalidation 2026-08-03 11:08:53 -07:00
lawrencecchen 913ba122e6 test: reproduce workspace root invalidation 2026-08-03 11:08:53 -07:00
lawrencecchen 9bb79bd9b2 test: align sidebar height cache contract 2026-08-03 11:08:53 -07:00
lawrencecchen d23a34c8e4 test: import session width settings 2026-08-03 11:08:53 -07:00
lawrencecchen 73f4e51fc4 fix: keep workspace updates responsive 2026-08-03 11:08:53 -07:00
lawrencecchen a03120331b test: cover terminal presentation update boundary 2026-08-03 11:08:53 -07:00
lawrencecchen 8f5cafffa0 test: reproduce workspace insertion starvation 2026-08-03 11:08:53 -07:00
lawrencecchen 0949a6e8e5 fix: test socket telemetry through shared behavior 2026-08-03 09:07:52 -07:00
lawrencecchen 77a42b703f test: exercise socket telemetry behavior 2026-08-03 09:06:47 -07:00
lawrencecchen 0dfb89a230 fix: preserve authoritative socket telemetry 2026-08-03 09:03:08 -07:00
lawrencecchen 89b8f67345 test: preserve typed socket diagnostics 2026-08-03 09:02:45 -07:00
lawrencecchen 8635db93bc fix: classify sanitized socket noise by errno 2026-08-03 08:56:06 -07:00
lawrencecchen 23371ee653 test: reproduce sanitized socket noise regression 2026-08-03 08:54:25 -07:00
lawrencecchen 173e599cda fix: sanitize socket errors shown by CLI 2026-08-03 08:46:30 -07:00
lawrencecchen 034a078b81 test: require sanitized socket failures 2026-08-03 08:44:35 -07:00
lawrencecchen 794d87b254 fix: activate shortcut snapshots after store startup 2026-08-03 08:34:41 -07:00
lawrencecchen 13d0e895bf test: reproduce shortcut observer startup cycle 2026-08-03 08:33:49 -07:00
lawrencecchen edfab7888f Address final shortcut regression review 2026-08-03 08:13:35 -07:00
lawrencecchen b358def1f9 test: remove snapshot harness initialization cycle 2026-08-03 08:00:56 -07:00
lawrencecchen 6fc3169668 fix: preserve reentrant snapshot refreshes 2026-08-03 07:46:25 -07:00
lawrencecchen 1cd2bfb85e test: reproduce reentrant snapshot refresh loss 2026-08-03 07:46:06 -07:00
lawrencecchen d8c3c260de fix: make snapshot task result type explicit 2026-08-03 07:45:40 -07:00
lawrencecchen a9c12d0adf fix: snapshot notification shortcuts off main actor 2026-08-03 07:36:29 -07:00
lawrencecchen 585ead1911 test: reproduce blocked shortcut persistence startup 2026-08-03 07:33:53 -07:00
lawrencecchen edee341c7f Open system files asynchronously 2026-08-03 07:21:57 -07:00
lawrencecchen b4981bbecd Add async system opener regression 2026-08-03 07:21:39 -07:00
lawrencecchen dafb5618fa Merge terminal teardown hang fix
# Conflicts:
#	Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h
2026-08-03 07:19:00 -07:00
lawrencecchen 13aeb3f205 Harden localized socket error diagnostics 2026-08-03 07:11:31 -07:00
lawrencecchen 59f11ea44a Add strict socket error decoding regression 2026-08-03 07:10:15 -07:00
lawrencecchen 7888fe8d3e Merge remote-tracking branch 'origin/main' into task-fix-unresolved-sentry-issues 2026-08-03 07:02:24 -07:00
lawrencecchen d15f3f09da Pin sidebar scale tests to measured implementation 2026-08-03 06:58:48 -07:00
lawrencecchen e4061caa3a Move newly active Sentry stalls off main thread 2026-08-03 06:53:47 -07:00
lawrencecchen 7ee3435171 Add regressions for newly active Sentry stalls 2026-08-03 06:37:51 -07:00
lawrencecchen 8b61f2fc74 Fix final review production risks 2026-08-03 06:22:42 -07:00
lawrencecchen 99ed72aa66 Add regressions for final review risks 2026-08-03 06:12:22 -07:00
lawrencecchen 6ceb5280dd Address final Sentry fix review findings 2026-08-03 06:05:58 -07:00
lawrencecchen 437219b10b Scope sidebar fault capture to interaction stress 2026-08-03 05:57:14 -07:00
lawrencecchen 00486def73 Stabilize sidebar scale tests on macOS 26 2026-08-03 05:56:10 -07:00
lawrencecchen d4a2d94c85 Serialize keyboard layout integration tests 2026-08-03 05:52:36 -07:00
lawrencecchen 80fb150c15 Keep lifecycle resume index saves off main 2026-08-03 05:34:45 -07:00
lawrencecchen 89103caf9d Localize browser CLI errors 2026-08-03 05:27:33 -07:00
lawrencecchen 72dd86459d Test nonblocking lifecycle resume index saves 2026-08-03 05:24:59 -07:00
lawrencecchen 37827c0188 Fix async keyboard composition regression test 2026-08-03 05:19:57 -07:00
lawrencecchen 3b5876b1be Fix latest Sentry hangs and socket grouping 2026-08-03 04:45:09 -07:00
lawrencecchen 9cdaa3fc02 Add latest Sentry hang regressions 2026-08-03 04:11:09 -07:00
lawrencecchen 124464ef6b Merge remote-tracking branch 'origin/main' into task-fix-unresolved-sentry-issues 2026-08-03 04:02:39 -07:00
lawrencecchen 63f69f1ef8 Support large TextBox attachment batches 2026-08-03 03:16:06 -07:00
lawrencecchen 349f555d8c Add large attachment group regressions 2026-08-03 03:16:05 -07:00
lawrencecchen 7a2993b3d1 Prevent oversized file attachment hangs 2026-08-03 02:19:28 -07:00
lawrencecchen 3b03edf3bb Add oversized attachment regression 2026-08-03 01:54:06 -07:00
lawrencecchen 2b11289c5c Accept structured socket listener evidence 2026-08-03 01:13:46 -07:00
lawrencecchen 1948822f2e Add structured listener CI regression 2026-08-03 01:13:30 -07:00
lawrencecchen 1a8551c3e6 Harden remote status and browser retries 2026-08-03 00:32:31 -07:00
lawrencecchen 5ced600dd7 Add remote framing and browser wait regressions 2026-08-03 00:30:58 -07:00
lawrencecchen db3a3b277e Sanitize browser failures and preserve file selection 2026-08-03 00:09:55 -07:00
lawrencecchen 7239ca897d Add browser failure sanitization regression 2026-08-02 23:45:53 -07:00
lawrencecchen 422b0926e8 Complete retained socket verification safely 2026-08-02 23:45:19 -07:00
lawrencecchen 91cfe21b7d Add delayed socket verification regressions 2026-08-02 23:41:41 -07:00
lawrencecchen 93d099d68c Move file explorer Git status into CmuxGit 2026-08-02 23:10:55 -07:00
lawrencecchen 8e1fa661c2 Fix structured browser failure propagation 2026-08-02 21:55:40 -07:00
lawrencecchen 46487da6bc Fix review-found Sentry regressions 2026-08-02 21:47:37 -07:00
lawrencecchen bc7d9298fa Add regression tests for review findings 2026-08-02 21:42:03 -07:00
lawrencecchen 20790782a1 Fix Swift 6 File Explorer test predicate 2026-08-02 21:07:49 -07:00
lawrencecchen 7bac968c92 Fix Settings lifecycle test symbol collision 2026-08-02 20:48:09 -07:00
lawrencecchen 21019fe3f6 Keep Sentry regression tests deterministic 2026-08-02 20:34:23 -07:00
lawrencecchen bd7c041b6a Merge branch 'task-sentry-sidebar-copy-hang' into task-fix-unresolved-sentry-issues 2026-08-02 20:33:11 -07:00
lawrencecchen 06369ab108 Isolate sidebar and terminal appearance updates 2026-08-02 20:31:17 -07:00
lawrencecchen 2c695ba9e3 Merge branch 'feat-bonsplit-sentry-hotpaths' into task-fix-unresolved-sentry-issues 2026-08-02 20:25:22 -07:00
lawrencecchen 82a8a70235 Merge branch 'task-sentry-file-explorer-production' into task-fix-unresolved-sentry-issues 2026-08-02 20:25:21 -07:00
lawrencecchen d814ed235b Merge branch 'task-sentry-socket-recovery-production' into task-fix-unresolved-sentry-issues 2026-08-02 20:25:21 -07:00
lawrencecchen 6af604fa77 Merge remote-tracking branch 'origin/main' into task-fix-unresolved-sentry-issues 2026-08-02 20:25:21 -07:00
lawrencecchen 0629ad79ea Stabilize comprehensive browser fixture setup 2026-08-02 20:24:28 -07:00
lawrencecchen 94bb9236a4 Stress Settings window lifecycle churn 2026-08-02 20:19:41 -07:00
lawrencecchen 4d4b9451a1 Fix browser eval bridge cycle handling 2026-08-02 20:19:36 -07:00
lawrencecchen e85a5d16a6 fix: update Bonsplit tab hot paths 2026-08-02 20:13:45 -07:00
lawrencecchen 5d78c79836 fix: complete nonblocking ownership proof connect 2026-08-02 20:12:44 -07:00
lawrencecchen f479d102fc test: cover in-progress socket ownership proof 2026-08-02 20:11:40 -07:00
lawrencecchen 321967e226 fix: prove retained socket path ownership 2026-08-02 20:08:49 -07:00
lawrencecchen 0ac3ceffc7 Reuse loaded File Explorer child order 2026-08-02 20:04:42 -07:00
lawrencecchen ca172cc642 test: reject replaced identity-pending socket 2026-08-02 20:04:05 -07:00
lawrencecchen 551422bfa5 Add File Explorer ordering stress regressions 2026-08-02 20:02:37 -07:00
lawrencecchen f7467f353e fix: make socket startup recovery generation-safe 2026-08-02 20:00:45 -07:00
lawrencecchen 10939b10e9 Add sidebar and appearance hang regressions 2026-08-02 19:48:50 -07:00
lawrencecchen 38ba44adbc test: cover socket startup recovery races 2026-08-02 19:48:16 -07:00
Abdulaziz Albahar 604f216d7f fix: publish pending socket retries 2026-08-02 19:43:54 -07:00
Abdulaziz Albahar 679f207725 fix: recover transient listener startup failures 2026-08-02 19:43:54 -07:00
Abdulaziz Albahar 26945799be test: cover transient listener startup collision 2026-08-02 19:43:54 -07:00
lawrencecchen 21d9d83818 Merge File Explorer hang fixes from PR 8809 2026-08-02 19:42:29 -07:00
lawrencecchen 84c87251bb Add browser bridge and lifecycle regressions 2026-08-02 19:37:45 -07:00
lawrencecchen aec139a92a Clarify DOMRect bridge coverage 2026-08-02 19:21:42 -07:00
lawrencecchen 911ecf6bab Serialize cross-realm DOMRect values 2026-08-02 19:07:08 -07:00
lawrencecchen 6a1abc0cc6 Cover cross-realm DOMRect bridge values 2026-08-02 19:06:33 -07:00
lawrencecchen 61dda0b6de Add focused DOMRect bridge regression 2026-08-02 18:57:28 -07:00
lawrencecchen c45e37de6c Prevent WebKit and Settings Sentry crashes 2026-08-02 18:31:07 -07:00
lawrencecchen e92e1cc731 Add Sentry crash regression coverage 2026-08-02 18:28:43 -07:00
Myk MelezandClaude Fable 5 15ca94fd16 Pin mixed vanished/unreadable TTY diagnostics as incomplete
Review claimed a vanished TTY could launder another terminal's failed
diagnostic into a complete scan. It cannot: non-ENOENT diagnostics never
remove a terminal from the retry set, so completeness requires either a
clean re-query or explicit ENOENT for every terminal. Pin the exact
mixed case: ENOENT for one TTY plus Permission denied for another
re-queries only the unreadable terminal and stays incomplete when its
diagnostic persists.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PDYSKWqKSHrr6MAuMtou9C
2026-08-02 15:58:45 -07:00
austinpower1258 cb37e0dfa3 test: bound stuck close regression failure 2026-07-31 23:44:03 -07:00
austinpower1258 e443d3aa7b test: assert terminal free gate state directly 2026-07-31 23:42:37 -07:00
austinpower1258 6e66669792 test: isolate terminal free gate state 2026-07-31 23:28:55 -07:00
austinpower1258 d5c5c3e8ad fix: isolate stuck terminal close teardowns 2026-07-31 23:27:02 -07:00
austinpower1258 40578022e7 test: require surface-scoped native free gate 2026-07-31 23:23:24 -07:00
austinpower1258 7fcc434fe6 test: cover stuck close teardown isolation 2026-07-31 23:16:41 -07:00
austinpower1258 96c6c1f86a test: bound terminal teardown watchdog waits 2026-07-31 22:41:06 -07:00
austinpower1258 4ba55e78b7 Merge remote-tracking branch 'origin/main' into issue-9220-sidebar-click-hang 2026-07-31 22:30:21 -07:00
austinpower1258 77ab34b3a4 fix: move explicit terminal teardown off main actor 2026-07-31 22:25:38 -07:00
austinpower1258 0a42b05f6b test: reproduce main-actor terminal teardown hang 2026-07-31 21:56:12 -07:00
Myk MelezandClaude Fable 5 92fa684218 Document why English ENOENT matching is locale-safe on Darwin
Review asked to pin LC_ALL on the ps invocation because vanishedTTYNames
matches the English strerror(ENOENT) text. Darwin libc ships no localized
message catalogs, so ps emits this exact string under any locale (verified
empirically with LC_ALL=ja_JP.UTF-8); record that constraint instead of
widening the CommandRunning API for an unreachable failure mode.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PDYSKWqKSHrr6MAuMtou9C
2026-07-31 17:21:29 -07:00
Myk MelezandClaude Fable 5 a5dc5afa06 Retire stale ports despite vanished TTYs, privileged owners, and zombies
One fix per mechanism the failing tests pin:

- `runPS` drops the terminals `ps` reports as ENOENT and retries with
  the rest, bounded so a pty churning mid-scan cannot spin. Terminals
  that are all gone report authoritative emptiness — a freed pty can
  hold no process — so their stale badges clear too, while any other
  diagnostic still yields incomplete, which retains ports rather than
  dropping them on weak evidence. Vanished terminals are matched by
  device name, so the two-device diagnostic form (`/dev/ttyX and
  /dev/X`) and TTYs registered by full device path are recognized, and
  "every terminal is gone" outranks the retry budget so authoritative
  emptiness does not depend on which attempt the final pty closed
  during.
- Birth identities are read through `sysctl(KERN_PROC_PID)` instead of
  `proc_pidinfo`. It reports the same birth timestamp for any live
  process regardless of owner, and still reports nothing for an exited
  PID, so recycling detection keeps working and now covers privileged
  PIDs it previously had to guess about. `SZOMB` is rejected
  explicitly: sysctl also describes an exited-but-unreaped process, and
  session restore treats a matching identity as proof the agent is
  alive.
- `PIDPresence` routes through the same process-table read that
  supplies birth identities, so liveness and identity cannot drift
  apart, and an unreaped process reads as absent to every caller
  weighing whether it might still own something.
  `Workspace.agentPIDProcessIdentity(pid:)` reads through that single
  reader as well instead of keeping its own `proc_pidinfo` copy.

The tests added here pin behavior this fix introduces rather than the
original bug: zombie identities reading as absent, diagnostic forms
that must not trigger the retry, and the retry budget staying
incomplete when a TTY never becomes scannable.

Fixes #9152.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016pmrs1n9Z2UKquWcbXhmBe
2026-07-31 16:53:39 -07:00
Myk MelezandClaude Fable 5 6ddcc4c0e2 Add failing tests for ports that can never retire
Sidebar port badges accumulate dead ports for the lifetime of the app
because three independent mechanisms each leave a panel's scan
permanently incomplete, and PortScanSnapshotReconciler treats incomplete
scans as non-evidence that only ever unions ports:

- BSD `ps` aborts an entire batched `-t` query when any listed terminal
  device is gone, so one closed pty makes every panel's port scan look
  incomplete.
- `proc_pidinfo` refuses any process whose effective UID differs from
  ours, so the root-owned `/usr/bin/login` heading every terminal reads
  as unidentifiable, files under `incompletePIDs`, and scores its panel
  incomplete on every scan.
- A zombie answers `kill(pid, 0)` like a running process while holding
  no readable identity — the same incompleteness, reachable through any
  unreaped child, though a zombie has exited and can hold no socket.

An end-to-end test also drives the real scanner — registerTTY, kick,
coalesce, burst, reconcile, publish — and asserts a port is retired once
its process stops listening, since these bugs left every stage passing
its own unit test while the feature was fully broken. Identity and
presence deliberately stay on the real providers: an earlier draft
injected them and passed against the broken tree.

Covers github.com/manaflow-ai/cmux/issues/9152.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016pmrs1n9Z2UKquWcbXhmBe
2026-07-31 16:53:39 -07:00
Abdulaziz Albahar 933295559f Preserve Git subprocess environments 2026-07-31 03:39:46 -07:00
Abdulaziz Albahar 3fb22a1274 Update file status runner construction 2026-07-31 03:06:33 -07:00
Abdulaziz Albahar ff0ef8a4ba Keep Git status diff off the main actor 2026-07-31 02:47:53 -07:00
Abdulaziz Albahar 3af355ee96 Fix file explorer observer cleanup isolation 2026-07-31 02:35:36 -07:00
Abdulaziz Albahar 107075899c fix: restore selection after file hydration 2026-07-31 01:04:36 -07:00
Abdulaziz Albahar 08308dab84 Merge remote-tracking branch 'origin/main' into feat-sentry-file-explorer-hangs
# Conflicts:
#	Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Process/CommandResult.swift
#	Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Process/CommandRunner.swift
#	Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Process/CommandTimer.swift
#	Sources/FileExplorerStore.swift
2026-07-31 00:14:41 -07:00
ejc3 8358a7df40 cmuxTests: read the saved XDG_STATE_HOME through getenv, not ProcessInfo
ProcessInfo.processInfo.environment is captured once and does not reflect setenv calls
made after first access, so saving the prior value through it and restoring in a defer
writes back a stale snapshot whenever an earlier test changed the variable at runtime.
getenv reads the live value, which is also the level the setenv/unsetenv pair below
operates at.
2026-07-30 23:33:44 -07:00
ejc3 8c30fb259a cmuxTests: restore the browser-profile selection this suite pins
BrowserPanelRemoteStoreTests pins the built-in default profile in setUp because the
selection is persisted in UserDefaults and a leftover profile from another test breaks
its store-scoping checks. Pinning without restoring commits the same offense in the
other direction: every suite that runs after this one inherits the built-in default.
Save the selection in setUp and put it back in tearDown.
2026-07-30 23:33:44 -07:00
ejc3 9e416c0b19 cmuxTests: repair four more suites, including two shortcut tests pinning a retired model
KeyboardShortcutSettingsFileStoreTests asserted the older model where a shortcut
saved through Settings outranks one bound in cmux.json. The product deliberately
went the other way: a file-managed action is authoritative and read-only, with
file-first lookup, refused writes, and a Settings row that reports itself managed
instead of editing. The tests now pin that, which is what they were for.

RemoteTmuxMirrorFeedForwardTests, RenderableSystemSymbolTests and
TabManagerFocusedNotificationIndicatorTests were failing on their own fixtures.

Each run before and after on a macOS builder, on this branch with the sixteen
already here, so the whole set is verified together rather than in isolation.
2026-07-30 23:33:44 -07:00
ejc3 22b5e66ce6 cmuxTests: repair three more suites, one of which was killing the test host
MarkdownPanelTests fulfilled a one-shot XCTestExpectation twice: WebKit can
report a provisional failure and then a finish for a single load, and the second
fulfill() raises XCTest's API-violation NSException from inside a suspended
await fulfillment. That does not fail a test, it takes the shared app host down
and every suite batched with it, so this one was corrupting other suites'
results as well as its own. The load delegate now settles once.

CLIHookNoResponseTests and CmuxDurableDeepLinkRestoreTests were failing on their
own fixtures in the same way as the thirteen already in this branch.

Each verified on a macOS builder before and after, on the combined branch rather
than in isolation, so the three land on top of the existing thirteen with the
whole set re-run.
2026-07-30 23:33:43 -07:00
ejc3 a003053402 cmuxTests: repair thirteen suites that were failing on their own fixtures
Thirteen suites in the pre-existing red set fail for reasons inside the tests,
not the product. Each one here was run before and after on a macOS builder and
goes from a failing verdict to a passing one; no product behavior changes and no
assertion is weakened or deleted.

The recurring shapes:

- Oracles that could be satisfied before the thing under test had happened, so
  the wait returned early and the assertion read startup state. These now read
  live state that only exists after the operation commits.
- Fixtures that could not reach the state their test described, so an assertion
  waited on a condition that was unreachable rather than merely slow.
- Expectations pinned to a machine-dependent value (a resolver path, a config
  directory, a bundled binary layout) instead of deriving it the way the product
  does.
- Shared global state left behind for the next test in the same host.

Verified per suite rather than in bulk: applied to a clean tree off the CmuxGit
compile fix, then each suite run through the app host. All thirteen report a
passing suite verdict with a non-zero test count.
2026-07-30 23:33:37 -07:00
ejc3 f821f78784 browser: fix headless-broken Browser test suites and the bugs they caught
The Browser* suites in cmuxTests had 18 failures across 9 suites under a local
headless `xcodebuild test`. Three were real product bugs the tests had been
catching all along; the rest were tests asserting behavior the product had
deliberately moved away from, or waiting on the wrong signal.

Product fixes:

- A panel constructed with a URL but `renderInitialNavigation: false` kept the
  `.newTab` lifecycle state it was born with. The deferred path returns from
  `init` before any visibility or navigation transition runs, and nothing else
  seeds the state, so a restored deferred tab reported itself as a new tab.
  Seed it in `init` for both the request and URL paths.

- The legacy `browserForcedDarkModeEnabled` migration could never run. Fallback
  registration goes into the process-wide registration domain, so once any panel
  bootstrapped defaults, `browserThemeMode` always resolved to a value and
  `BrowserThemeSettings.mode(defaults:)` took its early return instead of
  migrating. Users upgrading with forced dark mode on silently lost the setting.
  The key does not need a registered fallback: the accessor already falls back to
  `defaultMode` and the SwiftUI binding carries its own default.

- A visible portal slot whose anchor was removed outright kept rendering against
  the dead anchor. The off-window-reparent branch already distinguished an anchor
  that is still parented (drag churn, keep it on screen) from one that is not,
  but the following line preserved the slot unconditionally, so the orphaned case
  never reached the hide-while-retrying path.

Test fixes:

- Under-page background and hidden-discard-delay expectations predated the
  behavior they assert: the terminal color is composited over the window
  background rather than alpha-blended, and an out-of-range stored delay is
  rejected in favor of the default rather than clamped to the maximum.

- The discard tests waited on `webView.isLoading` while the discard gate also
  reads the panel's own `isLoading`, which stays set for the minimum indicator
  duration after WebKit finishes. Wait for the condition the gate actually reads,
  and report the blockers when a discard is refused.

- `waitForBrowserPanel` accepted the omnibar URL, which the panel publishes as
  soon as a navigation is requested and before `isLoading` rises, so it could
  return before the page loaded at all. Wait for the web view's committed URL.

- The remote-store tests assumed the built-in default profile was ambient, but a
  panel without an explicit profile adopts the last-used one, and that selection
  is persisted. Pin the default profile, and delete temporary test profiles so
  they stop accumulating in the shared defaults.

- The portal reveal test still required a visibility change to cycle WebKit's
  `_exitInWindow`/`_enterInWindow` pair, which was removed on purpose because
  cycling it fires visibilitychange and broke the DevTools pane across workspace
  switches. It now asserts that invariant instead.

- The omnibar suggestions hit test built its point by flipping y by hand, but
  `hitTest` takes superview coordinates and the flipped hosting view disagrees
  with its unflipped slot about y. Convert through AppKit and host the slot in a
  window so the SwiftUI overlay answers hit tests.
- `testBackgroundPreloadIsConsumedByInitialNavigation` built an NSWindow with
  AppKit's default `isReleasedWhenClosed` and closed it, so the window was
  over-released and XCTest's memory checker walked the freed object at teardown
  and took the test host down with a SIGSEGV in `objc_release`. The host restart
  was also hiding tests: the suites now report 54 tests instead of 34.
2026-07-30 23:21:53 -07:00
lawrencecchen 65891a9c23 fix: preserve current iroh transport pin 2026-07-29 02:47:27 -07:00
lawrencecchen 6895a27db8 Merge remote-tracking branch 'origin/main' into feat-sentry-file-explorer-hangs 2026-07-29 02:03:42 -07:00
lawrencecchen 9d9c125412 fix: balance rejected dispatch sources 2026-07-29 01:36:03 -07:00
lawrencecchen 30b7fed319 test: balance rejected dispatch source activation 2026-07-29 01:34:40 -07:00
lawrencecchen c751e52c44 refactor: bind icon identity helpers to render keys 2026-07-29 01:31:43 -07:00
lawrencecchen 5b4a1b73b7 fix: compile explorer support sources 2026-07-29 01:23:56 -07:00
lawrencecchen 2bef46c0e2 Merge remote-tracking branch 'origin/main' into feat-sentry-file-explorer-hangs 2026-07-29 00:56:09 -07:00
lawrencecchen 98ba062f22 refactor: align explorer recovery with review policy 2026-07-29 00:55:54 -07:00
lawrencecchen 6f9ee8af2b fix: bound explorer and command completion work 2026-07-28 23:45:20 -07:00
lawrencecchen 7a22655796 test: bound explorer and command cancellation recovery 2026-07-28 23:45:03 -07:00
lawrencecchen 3d9f6d2990 fix: keep explorer revision and metrics deterministic 2026-07-28 22:40:15 -07:00
lawrencecchen 2028896330 fix: keep explorer outline batches responsive 2026-07-28 21:54:22 -07:00
lawrencecchen d5d83378e5 fix: drain explorer outline changes in order 2026-07-28 21:29:13 -07:00
lawrencecchen 4f42154ca1 test: keep explorer invalidations ordered and scoped 2026-07-28 21:13:37 -07:00
lawrencecchen aeca992c24 fix: keep command completion locks short 2026-07-28 20:55:46 -07:00
lawrencecchen 8373d6b1ca test: keep command cancellation nonblocking 2026-07-28 20:54:05 -07:00
lawrencecchen e1b7bd6ec4 fix: preserve explorer refresh ownership 2026-07-28 20:21:23 -07:00
lawrencecchen 907b65afb6 test: preserve explorer expansion and process ownership 2026-07-28 20:14:43 -07:00
lawrencecchen 3d15c64c2d fix: project git status into explorer paths 2026-07-28 20:06:32 -07:00
lawrencecchen 96e89d3d15 test: preserve explorer path namespace 2026-07-28 20:05:45 -07:00
lawrencecchen 0cd5d868b6 fix: own explorer command launch lifecycle 2026-07-28 19:36:54 -07:00
lawrencecchen a7ce972416 test: cover explorer cancellation promotion 2026-07-28 19:33:06 -07:00
lawrencecchen f4c1591880 fix: own explorer command process trees 2026-07-28 19:06:54 -07:00
lawrencecchen 4cd2828d33 test: cover explorer process ownership 2026-07-28 19:00:58 -07:00
lawrencecchen 30b112b444 test: make explorer review checks deterministic 2026-07-28 18:31:49 -07:00
lawrencecchen 135d63c2b9 fix: close explorer review races 2026-07-28 18:08:59 -07:00
lawrencecchen 44666636f5 test: cover explorer review regressions 2026-07-28 18:08:59 -07:00
lawrencecchen e5caca9205 fix: bound explorer refresh work 2026-07-28 17:36:54 -07:00
lawrencecchen 536c6efb91 fix: isolate explorer outline cell updates 2026-07-28 17:15:50 -07:00
lawrencecchen fd83e7e7ce test: reproduce uncancellable command runner 2026-07-28 16:59:11 -07:00
lawrencecchen 2c91988a5c test: reproduce explorer refresh burst hangs 2026-07-28 16:57:36 -07:00
lawrencecchen a4980043ea fix: refresh visible explorer cells directly 2026-07-28 16:48:49 -07:00
lawrencecchen 970a016482 fix: restore selection after explorer hydration 2026-07-28 16:31:37 -07:00
lawrencecchen 6093a9524e fix: construct icon context on main actor 2026-07-28 16:15:41 -07:00
lawrencecchen d5b6999bd0 test: reproduce selection loss after explorer hydration 2026-07-28 16:05:00 -07:00
lawrencecchen 84c947c33e fix: scope explorer load and icon ownership 2026-07-28 16:03:13 -07:00
lawrencecchen fe7b7e36ab fix: wire explorer icon context into app target 2026-07-28 16:01:51 -07:00
lawrencecchen 802383b106 fix: own icon cache per explorer 2026-07-28 15:49:11 -07:00
lawrencecchen e4277b5366 Merge remote-tracking branch 'origin/main' into feat-sentry-file-explorer-hangs 2026-07-28 15:30:03 -07:00
lawrencecchen 1e2ac57f14 fix: bound file explorer node invalidations 2026-07-28 15:29:49 -07:00
lawrencecchen 865c6092c7 fix: bound file explorer git status refreshes 2026-07-28 15:07:54 -07:00
lawrencecchen 9c11ceec21 Merge remote-tracking branch 'origin/main' into feat-sentry-file-explorer-hangs 2026-07-28 14:58:49 -07:00
lawrencecchen de5e11b50f fix: scope file explorer outline invalidation 2026-07-28 14:58:41 -07:00
lawrencecchen 8b2ee65791 refactor: give resolved icon cache explicit ownership 2026-07-28 13:49:32 -07:00
lawrencecchen 07da7335ec Merge remote-tracking branch 'origin/main' into feat-sentry-file-explorer-hangs 2026-07-28 13:35:05 -07:00
lawrencecchen e4bb351cd4 fix: retain icon cache identity objects 2026-07-27 17:48:24 -07:00
lawrencecchen d90c7c98da Merge remote-tracking branch 'origin/main' into feat-sentry-file-explorer-hangs 2026-07-27 17:40:34 -07:00
ejc3 4a11af6f08 cmuxTests: give the CLI mock servers an owned lifecycle and one shared loop
Follow-up cleanup on the mock control-socket rework.

The accept loops had no way to stop. Closing the listener FD does not wake a
thread already parked in poll/accept on Darwin, so every server leaked its
thread for the life of the test process — worse than the old bounded loops,
which at least self-terminated once they had accepted their quota. The registry
now owns each loop: it pairs the listener with a private stop pipe, and
`stop(listenerFD:)`/`stopAll()` signal the loop and join it. Both suites reap
their loops in tearDown.

Registry hardening:
- Hold the lock across retire-old and register-new. Two concurrent starts on one
  FD could each observe the same predecessor, each wait for it, then each spawn a
  loop, putting two loops back on one listener — the stealing bug returning by
  another door. The loop threads never take the lock (they only signal
  completion), so holding it across the join can't deadlock.
- The registry, not the loop, owns the stop pipe for the loop's whole life, so a
  stop byte can never land in an unrelated descriptor that reused the number.
- Check pipe(); without a stop pipe a loop would be unstoppable, so fail loudly
  rather than start one. A loop that ignores its stop byte now fails the test
  instead of being left running.

Consolidation:
- One `cliMockServeLineFramedConnection` reader replaces four copies of the
  read/frame/respond loop, and one `cliMockWriteAll` replaces the duplicated
  partial-write handling. `CLIMockOnceFlag` replaces the two identical latches.
- Drop `connectionCount`/`connectionLimit` from the servers that no longer bound
  connections, along with the dead default, and rewrite the comments that still
  described a fixed pool. Same-named helpers owned by other suites keep their
  live parameters.
2026-07-24 21:57:33 -07:00
ejc3 71eea9f69f cmuxTests: make CLI mock control sockets headless-robust; refresh vm-new expectations
The CLI hook integration suites drive the bundled cmux helper as a subprocess
against a mock control socket. Headless (piped stdio, no controlling TTY) the
helper always falls back to a `system.top` agent-process lookup on a second,
dedicated control connection because caller-TTY resolution can't succeed. The
mocks accepted only one connection, so that extra connection was starved: hooks
stalled for the 2s socket timeout, resolution fell back to unresolved routing,
and assertions saw the wrong RPC sequence (or empty output after a 5s process
timeout).

Rework the mock accept path so every connection the helper opens is serviced:

- A single poll-based accept loop per listener FD dispatches each connection to
  its own handler, and a new server on the same FD supersedes (stops and joins)
  the previous one so a leftover loop can't steal the next hook's connection and
  fulfill the wrong expectation.
- The loops run on raw threads instead of GCD queues. A blocking accept() parked
  on a GCD worker ties it up for the whole test; a suite that opens a server per
  hook drained the shared GCD pool that runProcess needs for its stdout/stderr
  readers and exit waiter, which looked exactly like the helper hanging.

Also:
- Bind the tmux-compat-env test's control socket under a short /tmp path. The
  AF_UNIX sun_path limit is 104 bytes and this machine's temp dir alone overflows
  a socket nested under it.
- Update the default-freestyle vm-new tests to expect vm.attach_info: `vm new`
  uses forceSSH:false, which resolves through vm.attach_info (already covered by
  the SSH startup suites), not the older vm.ssh_info path.
2026-07-24 21:57:33 -07:00
Abdulaziz Albahar 3d77b9bd7b Eliminate file explorer refresh hangs 2026-07-23 21:04:59 -07:00
Abdulaziz Albahar e3e9c7a486 Add regressions for file explorer hangs 2026-07-23 21:04:59 -07:00
austinpower1258 80723789d0 Merge remote-tracking branch 'origin/main' into fix/atd-sidebar-link-click-8596
# Conflicts:
#	Packages/macOS/CmuxGit/Sources/CmuxGit/Probe/GitHubPullRequestRequestCoordinator.swift
2026-07-21 18:53:13 -07:00
austinpower1258 83b3f31a02 Restrict sidebar description link schemes 2026-07-21 18:44:20 -07:00
austinpower1258 53880f2426 Cover wrapped sidebar description links 2026-07-21 18:14:33 -07:00
austinpower1258 00ab332503 Address sidebar link review feedback 2026-07-21 17:56:39 -07:00
austinpower1258 7776008d6a Tighten sidebar description link hit bounds 2026-07-21 17:48:11 -07:00
austinpower1258 acdd03d1c1 Open AppKit sidebar description links 2026-07-21 17:30:06 -07:00
austinpower1258 87e8565e1a Add sidebar description link click regression 2026-07-21 17:30:02 -07:00
austinpower1258 cee2d91102 Fix CmuxGit coordinator initializer 2026-07-21 17:29:54 -07:00
920 changed files with 78955 additions and 12012 deletions
+15
View File
@@ -101,6 +101,15 @@ jobs:
- name: Validate Python test harness syntax
run: git ls-files 'tests/*.py' 'tests_v2/*.py' 'scripts/*.py' | xargs python3 -m py_compile
- name: Validate CLI event source guards
run: python3 tests/test_cli_event_source_guards.py
- name: Validate Browser WebAuthn credential reply safety
run: python3 tests/test_browser_webauthn_credential_reply_guard.py
- name: Validate debug mobile transport source ownership
run: python3 tests/test_debug_mobile_transport_source_ownership.py
- name: Validate TUI npm package artifact transfer
run: python3 tests/test_tui_npm_package_artifact.py
@@ -259,6 +268,7 @@ jobs:
- name: Validate SwiftPM lockfile policy
run: |
python3 tests/test_package_resolved_policy_remote_inputs.py
python3 tests/test_check_package_resolved_policy.py
python3 scripts/check-package-resolved-policy.py
@@ -268,6 +278,9 @@ jobs:
- name: Validate sidebar lazy-layout guard
run: python3 tests/test_ci_sidebar_lazy_layout_guard.py
- name: Validate focused Dock shortcut routing guard
run: python3 tests/test_dock_shortcut_routing_guard.py
- name: Validate bash prompt bootstrap composes with user PROMPT_COMMAND (starship)
run: python3 tests/test_issue_5164_starship_prompt_composition.py
@@ -1228,6 +1241,7 @@ jobs:
name: cmux-ghostty-cli-helper
path: ghostty-cli-helper/ghostty
if-no-files-found: error
retention-days: 1
- name: Retry universal Ghostty CLI helper upload
if: steps.upload-ghostty-cli-helper.outcome == 'failure'
@@ -1236,6 +1250,7 @@ jobs:
name: cmux-ghostty-cli-helper
path: ghostty-cli-helper/ghostty
if-no-files-found: error
retention-days: 1
overwrite: true
- name: Select Xcode
+25 -4
View File
@@ -94,7 +94,7 @@ jobs:
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y binutils clang libclang-dev pkg-config
sudo apt-get install -y binutils clang libclang-dev musl-tools pkg-config
- name: Resolve Ghostty Zig version
id: ghostty-zig-version
@@ -120,7 +120,7 @@ jobs:
run: rustup target add ${{ matrix.target }}
- name: Install cargo-zigbuild
if: matrix.cross == true
if: matrix.cross == true && runner.os == 'Linux'
shell: bash
run: cargo install --locked [email protected]
@@ -145,8 +145,8 @@ jobs:
cargo build -p cmux-tui --bin cmux-tui --release --locked --target ${{ matrix.build_target }}
cargo build -p cmux-relay --bin cmux-relay --release --locked --target ${{ matrix.build_target }}
- name: Build cmux-tui (cross)
if: matrix.cross == true
- name: Build cmux-tui (Linux cross)
if: matrix.cross == true && runner.os == 'Linux'
env:
CMUX_TUI_DISTRIBUTION_VERSION: ${{ inputs.version }}
PACKAGE_NPM: ${{ inputs.package_npm }}
@@ -166,6 +166,27 @@ jobs:
cargo zigbuild -p cmux-tui --bin cmux-tui --release --locked --target ${{ matrix.build_target }}
cargo zigbuild -p cmux-relay --bin cmux-relay --release --locked --target ${{ matrix.build_target }}
- name: Build cmux-tui (macOS cross)
if: matrix.cross == true && runner.os == 'macOS'
env:
CMUX_TUI_DISTRIBUTION_VERSION: ${{ inputs.version }}
PACKAGE_NPM: ${{ inputs.package_npm }}
working-directory: cmux-tui
shell: bash
run: |
# Xcode's macOS SDK natively supports cross-architecture builds.
# cargo-zigbuild cannot resolve SDK frameworks when the host is arm64.
unset CMUX_GHOSTTY_SRC
CMUX_TUI_BUILD_COMMIT="$(git -C .. rev-parse HEAD)"
CMUX_TUI_GHOSTTY_COMMIT="$(git -C ../ghostty rev-parse HEAD)"
export CMUX_TUI_BUILD_COMMIT CMUX_TUI_GHOSTTY_COMMIT CMUX_TUI_DISTRIBUTION_VERSION
if [[ "$PACKAGE_NPM" == "true" ]]; then
CMUX_TUI_NPM_BOOTSTRAP_VERSION="$CMUX_TUI_DISTRIBUTION_VERSION"
export CMUX_TUI_NPM_BOOTSTRAP_VERSION
fi
cargo build -p cmux-tui --bin cmux-tui --release --locked --target ${{ matrix.build_target }}
cargo build -p cmux-relay --bin cmux-relay --release --locked --target ${{ matrix.build_target }}
- name: Stage binary
shell: bash
run: |
+172 -26
View File
@@ -42,9 +42,17 @@ jobs:
npm run build
npm test
valgrind-leak-check:
valgrind-leak-check-shard:
name: valgrind-leak-check (${{ matrix.shard }})
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 40
# The core, remote, and application suites are intentionally serialized
# under instrumentation. Isolate them so none can consume another test
# binary group's runtime budget.
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
shard: [core, remote, tui, remainder]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -76,32 +84,61 @@ jobs:
# SIMD codegen stays within what valgrind's instruction emulation
# supports (see crates/ghostty-vt-sys/build.rs).
CMUX_GHOSTTY_VT_ZIG_CPU: baseline
VALGRIND_SHARD: ${{ matrix.shard }}
run: |
mkdir -p target
cargo test --workspace --locked --no-run --message-format=json > target/cargo-test-binaries.jsonl
python3 <<'PY'
import json
import os
import re
import sys
shard = os.environ["VALGRIND_SHARD"]
known_shards = {"core", "remote", "tui", "remainder"}
if shard not in known_shards:
raise SystemExit(f"unknown Valgrind shard: {shard}")
def shard_for(executable):
name = os.path.basename(executable)
if re.fullmatch(r"cmux_tui_core-[0-9a-f]+", name):
return "core"
if re.fullmatch(r"cmux_remote-[0-9a-f]+", name):
return "remote"
if re.fullmatch(r"cmux_tui-[0-9a-f]+", name):
return "tui"
return "remainder"
seen = set()
selected = []
with open("target/cargo-test-binaries.jsonl", "r", encoding="utf-8") as messages:
with open("target/valgrind-test-binaries.txt", "w", encoding="utf-8") as output:
for line in messages:
try:
message = json.loads(line)
except json.JSONDecodeError:
continue
if not message.get("profile", {}).get("test"):
continue
executable = message.get("executable")
if not executable or executable in seen:
continue
seen.add(executable)
print(executable, file=output)
for line in messages:
try:
message = json.loads(line)
except json.JSONDecodeError:
continue
if not message.get("profile", {}).get("test"):
continue
executable = message.get("executable")
if not executable or executable in seen:
continue
seen.add(executable)
if shard_for(executable) == shard:
selected.append(executable)
if not seen:
raise SystemExit("cargo did not report any test binaries")
print(f"Collected {len(seen)} test binaries", file=sys.stderr)
if not selected:
raise SystemExit(f"Valgrind shard {shard} selected no test binaries")
with open("target/valgrind-test-binaries.txt", "w", encoding="utf-8") as output:
for executable in selected:
print(executable, file=output)
print(
f"Valgrind shard {shard} selected {len(selected)} of {len(seen)} test binaries",
file=sys.stderr,
)
PY
- name: Run test binaries under valgrind
@@ -111,25 +148,99 @@ jobs:
# otherwise-correct build; the guarded regression (events serialized
# behind a 100ms read poll) inflates far past this bound anyway.
CMUX_TEST_WS_LATENCY_BUDGET_MS: "2000"
# Process-exit and PTY-reader tests also use bounded polling. Keep
# their normal deadlines strict while allowing for instrumentation.
# Scale test-fixture deadlines and their observation windows together
# under instrumentation. Production defaults and normal CI remain strict.
CMUX_TEST_TIMEOUT_SCALE: "4"
# Retain the full 128 MiB fairness workload under Valgrind with
# explicit instrumentation-only bounds. Normal CI stays strict.
CMUX_TEST_PERF_INSTRUMENTED: "valgrind"
run: |
while IFS= read -r bin; do
[ -n "$bin" ] || continue
echo "Running valgrind for $bin"
if ! valgrind \
run_valgrind() {
local bin="$1"
shift
valgrind \
"${valgrind_args[@]}" \
--error-exitcode=1 \
--leak-check=full \
--show-leak-kinds=definite \
--errors-for-leak-kinds=definite \
--track-origins=yes \
-- "$bin"; then
-- "$bin" "$@"
}
while IFS= read -r bin; do
[ -n "$bin" ] || continue
echo "Running valgrind for $bin"
valgrind_args=(--track-origins=yes)
test_args=()
case "$(basename "$bin")" in
pty-*|cmux_tui_core-*|cmux_tui-[[:xdigit:]]*)
# These tests own bounded worker pools, PTYs, sockets, and
# deadline-sensitive readers. Valgrind serializes their CPU
# work internally, so test-harness parallelism only creates
# scheduler starvation and wall-clock timeout races.
test_args+=(--test-threads=1)
;;
cmux_remote-[[:xdigit:]]*)
# Remote-runtime tests also own real schedulers, sockets, and
# deadline checks. Serial execution prevents the instrumented
# harness from starving its own observation deadlines.
test_args+=(--test-threads=1)
;;
terminal_host_recovery-*)
# Valgrind instruments this client harness but not the hidden
# terminal-host child it launches. The normal-speed child can
# fill the socket while the instrumented reader is descheduled,
# correctly triggering the production stalled-client timeout
# mid-frame. Normal Linux and macOS CI retain this ordering test;
# every other recovery case remains under Valgrind.
test_args+=(--skip exit_follows_all_final_pty_bytes_on_the_live_stream)
;;
direct_wss_e2e-*|relay_wss_diagnostic-*)
# ring's AES-GCM backend exposes initialized output through a
# partially initialized SIMD buffer. Valgrind reports its
# padding at Rustls writev. Keep leak and address checks for
# TLS integration binaries while scoping undefined-value
# suppression to those binaries.
valgrind_args=(--undef-value-errors=no)
;;
esac
if [[ "$(basename "$bin")" == cmux_remote-[[:xdigit:]]* ]]; then
# Iroh's Rustls/ring and noq UDP paths expose initialized data
# through buffers with uninitialized SIMD or sockaddr padding.
# Run only those tests without undefined-value diagnostics while
# retaining address and leak checks. Every other remote test
# keeps the complete Valgrind diagnostic set.
if ! run_valgrind "$bin" --skip 'provider::iroh::' "${test_args[@]}"; then
echo "Valgrind failed for $bin outside the Iroh provider" >&2
exit 1
fi
valgrind_args=(--undef-value-errors=no)
if ! run_valgrind "$bin" 'provider::iroh::' "${test_args[@]}"; then
echo "Valgrind failed for $bin in the Iroh provider" >&2
exit 1
fi
continue
fi
if ! run_valgrind "$bin" "${test_args[@]}"; then
echo "Valgrind failed for $bin" >&2
exit 1
fi
done < target/valgrind-test-binaries.txt
valgrind-leak-check:
name: valgrind-leak-check
if: always()
needs: valgrind-leak-check-shard
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 2
steps:
- name: Require every Valgrind shard
env:
SHARD_RESULT: ${{ needs.valgrind-leak-check-shard.result }}
run: test "$SHARD_RESULT" = success
test:
name: test (${{ matrix.os }})
runs-on: ${{ matrix.os == 'macos' && (vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15') || 'ubuntu-latest' }}
@@ -195,6 +306,17 @@ jobs:
with:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22.14.0"
cache: npm
cache-dependency-path: cmux-tui/bindings/typescript/package-lock.json
- name: Install TypeScript binding dependencies
working-directory: cmux-tui/bindings/typescript
run: npm ci --no-audit --no-fund
- name: Init ghostty submodule
run: git submodule update --init --depth 1 ghostty
@@ -226,11 +348,35 @@ jobs:
working-directory: cmux-tui
run: cargo build -p cmux-tui
- name: Resolve Zig SDK version
id: zig-sdk-version
shell: bash
run: |
version="$(
sed -nE 's/^[[:space:]]*\.minimum_zig_version[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/p' \
cmux-tui/bindings/zig/build.zig.zon | head -1
)"
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Invalid Zig SDK version: $version" >&2
exit 1
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Install Zig for SDK conformance
env:
ZIG_REQUIRED: ${{ steps.zig-sdk-version.outputs.version }}
ZIG_FORCE_LOCAL_INSTALL: "1"
run: ./scripts/install-zig-ci.sh
- name: Python conformance fixtures
run: python3 cmux-tui/bindings/conformance/runner.py
run: |
test "$("$CMUX_ZIG" version)" = "${{ steps.zig-sdk-version.outputs.version }}"
python3 cmux-tui/bindings/conformance/runner.py
- name: Binding e2e
run: bash cmux-tui/bindings/conformance/e2e.sh --require python,typescript,rust,go,java
run: |
test "$("$CMUX_ZIG" version)" = "${{ steps.zig-sdk-version.outputs.version }}"
bash cmux-tui/bindings/conformance/e2e.sh --require python,typescript,rust,go,java
windows-experimental:
name: windows experimental (x86_64-gnu)
+9 -2
View File
@@ -452,7 +452,7 @@ jobs:
# The demo profile is fetched from the ASC API by name instead of a
# repository secret, so regenerating it in the developer portal
# needs no secret rotation. Same credentials the upload uses.
PROFILE_BASE64="$(python3 ./ios/scripts/asc_download_profile.py --name "cmux Demo Distribution")"
PROFILE_BASE64="$(python3 ./ios/scripts/asc_download_profile.py --name "cmux Demo Distribution Push")"
elif [ "$IOS_BETA_PROFILE_TYPE" = "internal" ]; then
PROFILE_BASE64="${IOS_BETA_PROVISIONING_PROFILE_INTERNAL_BASE64}"
else
@@ -475,9 +475,16 @@ jobs:
echo "$IOS_BETA_PROFILE_TYPE provisioning profile targets unexpected app ID: $APP_ID (expected $IOS_BETA_EXPECTED_APP_ID)" >&2
exit 1
fi
# TestFlight uses production APNs. Both capabilities must be present
# in the installed profile or export can silently strip them.
APS_ENVIRONMENT="$(/usr/libexec/PlistBuddy -c "Print :Entitlements:aps-environment" "$TMP_PLIST" 2>/dev/null || echo "")"
if [ -z "$APS_ENVIRONMENT" ] || [ "$APS_ENVIRONMENT" != "production" ]; then
echo "$IOS_BETA_PROFILE_TYPE provisioning profile aps-environment is '$APS_ENVIRONMENT', expected 'production'" >&2
echo "$IOS_BETA_PROFILE_TYPE provisioning profile aps-environment is '${APS_ENVIRONMENT:-<absent>}', expected 'production'" >&2
exit 1
fi
TIME_SENSITIVE="$(/usr/libexec/PlistBuddy -c "Print :Entitlements:com.apple.developer.usernotifications.time-sensitive" "$TMP_PLIST" 2>/dev/null || echo "")"
if [ "$TIME_SENSITIVE" != "true" ]; then
echo "$IOS_BETA_PROFILE_TYPE provisioning profile com.apple.developer.usernotifications.time-sensitive is '${TIME_SENSITIVE:-<absent>}', expected 'true'" >&2
exit 1
fi
PROFILE_NAME="$(/usr/libexec/PlistBuddy -c "Print :Name" "$TMP_PLIST")"
+1
View File
@@ -59,6 +59,7 @@ jobs:
name: cmux-ghostty-cli-helper
path: ghostty-cli-helper/ghostty
if-no-files-found: error
retention-days: 3
build-sign-notarize:
needs: build-ghostty-cli-helper
+1 -1
View File
@@ -246,5 +246,5 @@ jobs:
with:
name: reload-${{ inputs.tag }}-${{ inputs.platform }}
path: artifact/
retention-days: 3
retention-days: 1
if-no-files-found: error
+5 -1
View File
@@ -609,12 +609,16 @@ jobs:
echo "expected one tested crate, found ${#artifacts[@]}" >&2
exit 1
}
retry_missing_project=()
if [[ "$PACKAGE" == "cmux-sidebar" ]]; then
retry_missing_project=(--retry-missing-project)
fi
python3 cmux-tui/bindings/reconcile_registry_artifact.py check \
--registry crates \
--package "$PACKAGE" \
--version "$BOOTSTRAP_VERSION" \
--artifact "${artifacts[0]}" \
--retry-missing-project \
"${retry_missing_project[@]}" \
--wait-seconds 300 \
--require-match
sleep 1
+11 -4
View File
@@ -167,6 +167,7 @@ jobs:
--workflow .github/workflows/sdk-bootstrap-npm.yml \
--workflow-ref refs/heads/main \
--dist-tag bootstrap \
--require-dist-tag latest \
--publisher owner \
--artifact "${packages[0]}"
@@ -258,7 +259,7 @@ jobs:
exit 1
}
echo "npm lifecycle scripts are disabled in the credentialed publisher"
npm publish "${packages[0]}" \
npm publish "$(realpath "${packages[0]}")" \
--ignore-scripts \
--tag bootstrap \
--provenance \
@@ -297,7 +298,7 @@ jobs:
- name: Install pinned npm
run: npm install --global --ignore-scripts [email protected]
- name: Verify the prerelease did not claim latest
- name: Verify npm-required bootstrap tags
run: |
set -euo pipefail
tags="$RUNNER_TEMP/cmux-sdk-bootstrap-tags.json"
@@ -313,8 +314,13 @@ jobs:
const fs = require("node:fs");
const [path, expected] = process.argv.slice(2);
const tags = JSON.parse(fs.readFileSync(path, "utf8"));
if (tags.bootstrap !== expected || Object.hasOwn(tags, "latest")) {
throw new Error(`unexpected cmux-sdk dist-tags: ${JSON.stringify(tags)}`);
if (
tags.bootstrap !== expected ||
typeof tags.latest !== "string" ||
tags.latest.length === 0
) {
console.error("cmux-sdk dist-tag validation failed.");
process.exit(1);
}
NODE
@@ -336,5 +342,6 @@ jobs:
--workflow .github/workflows/sdk-bootstrap-npm.yml \
--workflow-ref refs/heads/main \
--dist-tag bootstrap \
--require-dist-tag latest \
--publisher owner \
--artifact "${packages[0]}"
+3 -3
View File
@@ -72,7 +72,7 @@ jobs:
fi
# The conventions lint (free-function ban, namespace-type rule, ...)
# covers every package, so it runs for any Packages/ change too.
if grep -Eq '^(ios/|Packages/|Sources/Mobile/|vendor/stack-auth-swift-sdk-prerelease/|scripts/lint-ios-package-conventions\.sh$|scripts/lint-namespace-types-baseline\.txt$)' /tmp/changed-files.txt; then
if grep -Eq '^(ios/|Packages/|Sources/Mobile/|vendor/stack-auth-swift-sdk-prerelease/|scripts/lint-ios-package-conventions\.sh$|scripts/lint-ios-package-conventions-baseline\.txt$|scripts/lint-namespace-types-baseline\.txt$)' /tmp/changed-files.txt; then
echo "should_lint=true" >> "$GITHUB_OUTPUT"
else
echo "No package-owned files changed; skipping conventions lint."
@@ -99,8 +99,8 @@ jobs:
# namespace-enums) plus the repo-wide namespace-type rule (no
# all-static "namespace" types in any package). Exits non-zero on any
# unjustified ERROR; sanctioned exceptions carry a lint:allow /
# TRANSITIONAL / carve-out marker, and pre-existing namespace-type
# debt is grandfathered in scripts/lint-namespace-types-baseline.txt.
# TRANSITIONAL / carve-out marker, and pre-existing debt is
# grandfathered in the lint baseline files under scripts/.
./scripts/lint-ios-package-conventions.sh
mobile-core-package:
+33 -7
View File
@@ -119,7 +119,11 @@ final class CLISocketSentryTelemetry {
processEnv["CMUX_CLI_SENTRY_DISABLED"] == "1" ||
processEnv["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] == "1"
self.noiseFilter = SentryNoiseFilter()
self.sentryPolicy = CLISocketSentryPolicy(environment: processEnv)
self.sentryPolicy = CLISocketSentryPolicy(
environment: processEnv,
command: self.command,
subcommand: self.subcommand
)
}
func breadcrumb(_ message: String, data: [String: Any] = [:]) {
@@ -132,11 +136,13 @@ final class CLISocketSentryTelemetry {
func captureError(stage: String, error: Error, data: [String: Any] = [:]) {
guard shouldEmit else { return }
let errorDescription = String(describing: error)
let allowPolicyDenial = policyDenialContext(stage: stage, error: error)
.map(sentryPolicy.shouldSuppressPolicyDenial) ?? false
guard !noiseFilter.isExpectedCLISocketTransportFailure(
stage: stage,
message: errorDescription,
error: error,
dataKeys: Set(data.keys),
allowSandboxPolicyDenial: sentryPolicy.allowsSandboxPolicyDenial
allowSandboxPolicyDenial: allowPolicyDenial
) else {
return
}
@@ -151,9 +157,11 @@ final class CLISocketSentryTelemetry {
for (key, value) in socketDiagnostics() {
context[key] = value
}
for (key, value) in data {
context[key] = value
}
context = CLISocketErrorTelemetryContext().merging(
base: context,
operation: data,
error: error
)
let subcommand = self.subcommand
let command = self.command
let event = Self.makeErrorEvent(
@@ -227,11 +235,17 @@ final class CLISocketSentryTelemetry {
#else
event.environment = "production-cli"
#endif
event.tags = [
var tags = [
"component": "cmux-cli",
"cli_command": command,
"cli_subcommand": subcommand
]
if let connectError = error as? CLISocketConnectError {
event.fingerprint = connectError.sentryFingerprint
tags["cli_socket_error_kind"] = "connect"
tags["cli_socket_errno"] = String(connectError.errnoCode)
}
event.tags = tags
event.context = ["cli_socket": context]
if !breadcrumbs.isEmpty {
event.breadcrumbs = breadcrumbs
@@ -311,6 +325,18 @@ final class CLISocketSentryTelemetry {
return context
}
private func policyDenialContext(
stage: String,
error: Error
) -> CLISocketPolicyDenialContext? {
guard let connectError = error as? CLISocketConnectError else { return nil }
return CLISocketPolicyDenialContext(
inspectingStage: stage,
error: connectError
)
}
private func socketDiagnostics() -> [String: Any] {
var context: [String: Any] = [
"cwd": FileManager.default.currentDirectoryPath,
@@ -18,6 +18,24 @@ extension CMUXCLI {
: mappedPID ?? inferredPID
}
/// Gives a newly launched agent surface one event-driven opportunity to
/// enter the app's authoritative surface index. Codex invokes SessionStart
/// from a detached hook process, which can connect before its launching
/// surface is published. The app-side waiter wakes on topology changes, so
/// the CLI keeps strict identity validation without timer polling.
func awaitAgentHookSessionStartTarget(
subcommand: String,
workspaceId: String,
surfaceId: String,
awaitPublication: (
_ workspaceId: String,
_ surfaceId: String
) -> (workspaceId: String, surfaceId: String)?
) -> (workspaceId: String, surfaceId: String)? {
guard subcommand == "session-start" else { return nil }
return awaitPublication(workspaceId, surfaceId)
}
/// Reports a persistently throttled hook failure without serializing raw transport details.
func reportAgentHookFailure(
stage: AgentHookFailureStage,
+54 -37
View File
@@ -3,6 +3,13 @@ import Darwin
import Foundation
extension CMUXCLI {
var restoreCommandUsageLine: String {
String(
localized: "cli.help.restore",
defaultValue: "restore [--surface <id|ref>] <kind> <checkpoint-id> | restore --surface [id|ref]"
)
}
func controlAgentLaunchCommandPayload(
_ command: AgentLaunchCommand
) -> [String: Any] {
@@ -193,21 +200,12 @@ extension CMUXCLI {
)
}
let resolution = AgentProcessBindingResolution.controllingTTY.rawValue
do {
let payload = try client.sendV2(
method: "agent.resolve_delivery_target",
params: [
"pid": Int(ProcessInfo.processInfo.processIdentifier),
"pid_resolution": resolution,
]
let payload = try implicitCallerIdentifyResponse(
client: client,
processEnvironment: processEnvironment
)
guard payload["source"] as? String == "pid",
payload["pid_resolution"] as? String == resolution,
let workspaceID = normalizedHandleValue(payload["workspace_id"] as? String),
isUUID(workspaceID),
let surfaceID = normalizedHandleValue(payload["surface_id"] as? String),
isUUID(surfaceID) else {
guard let surfaceID = identifiedCallerSurfaceID(in: payload) else {
throw currentRestoreSurfaceUnknownError()
}
return surfaceID
@@ -331,41 +329,60 @@ extension CMUXCLI {
}
private func restoreSelector(_ arguments: [String]) throws -> RestoreSelector {
if arguments.first == "--surface" {
if arguments.count == 1 {
return RestoreSelector(
surface: nil,
usesCurrentSurface: true,
kind: nil,
checkpointID: nil
)
}
guard arguments.count == 2, !arguments[1].isEmpty else {
if arguments == ["--surface"] {
return RestoreSelector(
surface: nil,
usesCurrentSurface: true,
kind: nil,
checkpointID: nil
)
}
let surfaceOptionCount = arguments.filter { argument in
argument == "--surface" || argument.hasPrefix("--surface=")
}.count
guard surfaceOptionCount <= 1 else {
throw CLIError(message: String(
localized: "cli.restore.usage.surface",
defaultValue: "Usage: cmux restore --surface [id|ref]"
))
}
let (surface, positionalArguments) = parseOption(arguments, name: "--surface")
if surfaceOptionCount == 1 {
guard let surface,
!surface.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw CLIError(message: String(
localized: "cli.restore.usage.surface",
defaultValue: "Usage: cmux restore --surface [id|ref]"
))
}
return RestoreSelector(
surface: arguments[1],
usesCurrentSurface: false,
kind: nil,
checkpointID: nil
)
if positionalArguments.isEmpty {
return RestoreSelector(
surface: surface,
usesCurrentSurface: false,
kind: nil,
checkpointID: nil
)
}
}
guard arguments.count == 2,
!arguments[0].trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
!arguments[1].trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
guard positionalArguments.count == 2,
!positionalArguments[0].trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
!positionalArguments[1].trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw CLIError(message: String(
localized: "cli.restore.usage.positional",
defaultValue: "Usage: cmux restore <kind> <checkpoint-id>"
defaultValue: """
Usage: cmux restore [--surface <id|ref>] <kind> <checkpoint-id>
cmux restore <kind> <checkpoint-id> --surface <id|ref>
cmux restore --surface=<id|ref> <kind> <checkpoint-id>
"""
))
}
return RestoreSelector(
surface: nil,
usesCurrentSurface: true,
kind: arguments[0],
checkpointID: arguments[1]
surface: surface,
usesCurrentSurface: surface == nil,
kind: positionalArguments[0],
checkpointID: positionalArguments[1]
)
}
+11 -5
View File
@@ -301,6 +301,9 @@ extension CMUXCLI {
let trimmedOneTimeCommand = oneTimeCommand?.trimmingCharacters(in: .whitespacesAndNewlines)
let hasOneTimeCommand = trimmedOneTimeCommand?.isEmpty == false
let authRetryPolicy = SSHForegroundAuthenticationRetryPolicy()
let authGroupCleanupBody = authRetryPolicy.authenticationGroupDirectoryCleanupShellBody(
terminatesPublishedGroup: hasOneTimeCommand
)
let backoffBuilder = SSHRetryBackoffScriptBuilder(context: .startup)
var scriptLines: [String] = []
if !shellFeaturesBootstrap.isEmpty {
@@ -365,24 +368,27 @@ extension CMUXCLI {
"cmux_ssh_auth_retry_limit=\(authRetryPolicy.maximumConsecutiveTransientFailures); cmux_ssh_auth_retry=0",
// Initial transient foreground-auth failures are a reconnect phase, so boot-time outages share this loop.
"cmux_ssh_reauth_required=\(hasOneTimeCommand ? 1 : 0)",
"CMUX_SSH_CHILD_PID=; CMUX_SSH_AUTH_PID=; CMUX_SSH_PENDING_SIGNAL=; CMUX_SSH_PENDING_SIGNAL_NAME=",
"CMUX_SSH_CHILD_PID=; CMUX_SSH_AUTH_PID=; CMUX_SSH_AUTH_GROUP_DIR=; CMUX_SSH_PENDING_SIGNAL=; CMUX_SSH_PENDING_SIGNAL_NAME=",
"export CMUX_SSH_AUTH_GROUP_DIR",
] + backoffBuilder.stateInitializationLines + [
"cmux_ssh_note() { if [ -t 2 ]; then printf \"$@\" >&2 || true; fi; }",
"cmux_ssh_register_attempt() { \(lifecycleLaunching); }",
"cmux_ssh_begin_attempt() { CMUX_SSH_ATTEMPT_ID=$(/usr/bin/uuidgen | /usr/bin/tr '[:upper:]' '[:lower:]') || return 1; export CMUX_SSH_ATTEMPT_ID; cmux_ssh_attempt_registration_retry=0; while ! cmux_ssh_register_attempt; do cmux_ssh_attempt_registration_retry=$((cmux_ssh_attempt_registration_retry + 1)); if [ \"$cmux_ssh_attempt_registration_retry\" -ge 3 ]; then return 1; fi; /bin/sleep 0.1; done; }",
"cmux_ssh_session_end() { if [ \"${CMUX_SSH_SESSION_ENDED:-0}\" = 1 ]; then return; fi; CMUX_SSH_SESSION_ENDED=1; cmux_ssh_cleanup_password; \(lifecycleCleanup); }",
"cmux_ssh_retire_for_signal() { cmux_ssh_signal_status=\"$1\"; CMUX_SSH_SESSION_ENDED=1; cmux_ssh_cleanup_password; \(lifecycleRetirement); trap - EXIT HUP INT TERM; exit \"$cmux_ssh_signal_status\"; }",
"cmux_ssh_signal_exit() { cmux_ssh_signal_status=\"$1\"; cmux_ssh_signal_name=\"$2\"; if [ -n \"${CMUX_SSH_AUTH_PID:-}\" ]; then cmux_ssh_terminate_auth_process_tree \"$CMUX_SSH_AUTH_PID\" \"$CMUX_SSH_STARTUP_PID\"; wait \"$CMUX_SSH_AUTH_PID\" 2>/dev/null || true; CMUX_SSH_AUTH_PID=; \(backoffBuilder.signalHandlerBranches) elif [ -z \"${CMUX_SSH_CHILD_PID:-}\" ]; then CMUX_SSH_PENDING_SIGNAL=\"$cmux_ssh_signal_status\"; CMUX_SSH_PENDING_SIGNAL_NAME=\"$cmux_ssh_signal_name\"; return; fi; cmux_ssh_retire_for_signal \"$cmux_ssh_signal_status\"; }",
"cmux_ssh_remove_auth_group_dir() { \(authGroupCleanupBody) }",
"cmux_ssh_session_end() { if [ \"${CMUX_SSH_SESSION_ENDED:-0}\" = 1 ]; then return; fi; CMUX_SSH_SESSION_ENDED=1; cmux_ssh_remove_auth_group_dir; cmux_ssh_cleanup_password; \(lifecycleCleanup); }",
"cmux_ssh_retire_for_signal() { cmux_ssh_signal_status=\"$1\"; CMUX_SSH_SESSION_ENDED=1; cmux_ssh_remove_auth_group_dir; cmux_ssh_cleanup_password; \(lifecycleRetirement); trap - EXIT HUP INT TERM; exit \"$cmux_ssh_signal_status\"; }",
"cmux_ssh_signal_exit() { cmux_ssh_signal_status=\"$1\"; cmux_ssh_signal_name=\"$2\"; if [ -n \"${CMUX_SSH_AUTH_PID:-}\" ]; then cmux_ssh_terminate_auth_process_tree \"$CMUX_SSH_AUTH_PID\" \"$CMUX_SSH_STARTUP_PID\"; wait \"$CMUX_SSH_AUTH_PID\" 2>/dev/null || true; CMUX_SSH_AUTH_PID=; cmux_ssh_remove_auth_group_dir; \(backoffBuilder.signalHandlerBranches) elif [ -z \"${CMUX_SSH_CHILD_PID:-}\" ]; then CMUX_SSH_PENDING_SIGNAL=\"$cmux_ssh_signal_status\"; CMUX_SSH_PENDING_SIGNAL_NAME=\"$cmux_ssh_signal_name\"; return; fi; cmux_ssh_retire_for_signal \"$cmux_ssh_signal_status\"; }",
"trap 'cmux_ssh_session_end' EXIT",
"trap 'cmux_ssh_signal_exit 129 HUP' HUP",
"trap 'cmux_ssh_signal_exit 130 INT' INT",
"trap 'cmux_ssh_signal_exit 143 TERM' TERM",
hasOneTimeCommand ? "cmux_ssh_resume_failed_auth_group_reapers" : ":",
"while :; do",
" if [ -n \"${CMUX_SSH_PENDING_SIGNAL:-}\" ]; then cmux_ssh_retire_for_signal \"$CMUX_SSH_PENDING_SIGNAL\"; fi",
]
if hasOneTimeCommand {
scriptLines.append(" if [ \"$cmux_ssh_reauth_required\" -eq 1 ]; then")
scriptLines += [" ( cmux_ssh_foreground_auth ) <&0 &", " CMUX_SSH_AUTH_PID=$!; if [ -n \"${CMUX_SSH_PENDING_SIGNAL:-}\" ]; then cmux_ssh_signal_exit \"$CMUX_SSH_PENDING_SIGNAL\" \"${CMUX_SSH_PENDING_SIGNAL_NAME:-TERM}\"; fi; wait \"$CMUX_SSH_AUTH_PID\"; cmux_ssh_status=$?; CMUX_SSH_AUTH_PID=; case \"$cmux_ssh_status\" in 129|130|143) cmux_ssh_retire_for_signal \"$cmux_ssh_status\" ;; esac; if [ -n \"${CMUX_SSH_PENDING_SIGNAL:-}\" ]; then cmux_ssh_session_end; trap - EXIT HUP INT TERM; exit \"$CMUX_SSH_PENDING_SIGNAL\"; fi", " if [ \"$cmux_ssh_status\" -eq 0 ]; then cmux_ssh_reauth_required=0; cmux_ssh_auth_retry=0; else case \"$cmux_ssh_status\" in 254) cmux_ssh_auth_retry=$((cmux_ssh_auth_retry + 1)); if [ \"$cmux_ssh_auth_retry\" -ge \"$cmux_ssh_auth_retry_limit\" ]; then cmux_ssh_status=255; break; fi ;; \(authRetryPolicy.unclassifiedFailureExitStatus)) cmux_ssh_status=255; break ;; *) break ;; esac; fi", " fi", " if [ \"$cmux_ssh_reauth_required\" -eq 0 ]; then"]
scriptLines += [" CMUX_SSH_AUTH_GROUP_DIR=$(umask 077; /usr/bin/mktemp -d \"${TMPDIR:-/tmp}/cmux-ssh-auth-group.XXXXXX\") || { cmux_ssh_status=255; break; }; export CMUX_SSH_AUTH_GROUP_DIR", " ( cmux_ssh_foreground_auth ) <&0 &", " CMUX_SSH_AUTH_PID=$!; if [ -n \"${CMUX_SSH_PENDING_SIGNAL:-}\" ]; then cmux_ssh_signal_exit \"$CMUX_SSH_PENDING_SIGNAL\" \"${CMUX_SSH_PENDING_SIGNAL_NAME:-TERM}\"; fi; wait \"$CMUX_SSH_AUTH_PID\"; cmux_ssh_status=$?; CMUX_SSH_AUTH_PID=; case \"$cmux_ssh_status\" in 129|130|143) cmux_ssh_retire_for_signal \"$cmux_ssh_status\" ;; esac; cmux_ssh_remove_auth_group_dir; if [ -n \"${CMUX_SSH_PENDING_SIGNAL:-}\" ]; then cmux_ssh_session_end; trap - EXIT HUP INT TERM; exit \"$CMUX_SSH_PENDING_SIGNAL\"; fi", " if [ \"$cmux_ssh_status\" -eq 0 ]; then cmux_ssh_reauth_required=0; cmux_ssh_auth_retry=0; else case \"$cmux_ssh_status\" in 254) cmux_ssh_auth_retry=$((cmux_ssh_auth_retry + 1)); if [ \"$cmux_ssh_auth_retry\" -ge \"$cmux_ssh_auth_retry_limit\" ]; then cmux_ssh_status=255; break; fi ;; \(authRetryPolicy.unclassifiedFailureExitStatus)) cmux_ssh_status=255; break ;; *) break ;; esac; fi", " fi", " if [ \"$cmux_ssh_reauth_required\" -eq 0 ]; then"]
}
if let trimmedControlPathPreflight, !trimmedControlPathPreflight.isEmpty,
!hasOneTimeCommand {
+190 -51
View File
@@ -1779,15 +1779,6 @@ final class SocketClient {
let port: UInt16
}
private struct SocketConnectError: Error, CustomStringConvertible {
let path: String
let errnoValue: Int32
var description: String {
"Failed to connect to socket at \(path) (\(String(cString: strerror(errnoValue))), errno \(errnoValue))"
}
}
private struct RelayCredentials {
let relayID: String
let relayToken: Data
@@ -1796,6 +1787,7 @@ final class SocketClient {
private let path: String
private(set) var socketFD: Int32 = -1
private var streamReadBuffer = Data()
private var streamLineSearchOffset = 0
private var lastConfiguredReceiveTimeout: TimeInterval?
private var lastOperationTelemetry: CLISocketOperationTelemetry.State?
private static let defaultResponseTimeoutSeconds: TimeInterval = 15.0
@@ -1961,6 +1953,7 @@ final class SocketClient {
socketFD = -1
}
streamReadBuffer.removeAll(keepingCapacity: true)
streamLineSearchOffset = 0
lastConfiguredReceiveTimeout = nil
}
@@ -2185,14 +2178,14 @@ final class SocketClient {
Darwin.close(socketFD)
socketFD = -1
throw SocketConnectError(path: path, errnoValue: connectErrno)
throw CLISocketConnectError(path: path, errnoCode: connectErrno)
}
private static func shouldRetryConnect(_ error: Error) -> Bool {
guard let error = error as? SocketConnectError else {
guard let error = error as? CLISocketConnectError else {
return false
}
switch error.errnoValue {
switch error.errnoCode {
case ECONNREFUSED, EAGAIN, EWOULDBLOCK:
return true
default:
@@ -2981,7 +2974,11 @@ final class SocketClient {
try configureReceiveTimeout(45)
}
while true {
if let newlineIndex = streamReadBuffer.firstIndex(of: 0x0A) {
let searchStart = streamReadBuffer.index(
streamReadBuffer.startIndex,
offsetBy: min(streamLineSearchOffset, streamReadBuffer.count)
)
if let newlineIndex = streamReadBuffer[searchStart...].firstIndex(of: 0x0A) {
let lineByteCount = streamReadBuffer.distance(
from: streamReadBuffer.startIndex,
to: newlineIndex
@@ -2994,8 +2991,10 @@ final class SocketClient {
throw CLIError(message: "Invalid UTF-8 event stream frame")
}
streamReadBuffer.removeSubrange(...newlineIndex)
streamLineSearchOffset = 0
return line.trimmingCharacters(in: .whitespacesAndNewlines)
}
streamLineSearchOffset = streamReadBuffer.count
guard streamReadBuffer.count < maxBytes else {
throw CLIError(message: "Event stream frame exceeded \(maxBytes) bytes")
}
@@ -4552,45 +4551,51 @@ struct CMUXCLI {
params["window_id"] = targetWindow
}
let includeCaller = !hasFlag(commandArgs, name: "--no-caller")
if includeCaller {
let idWsFlag = optionValue(commandArgs, name: "--workspace")
let idSurfaceFlag = optionValue(commandArgs, name: "--surface")
let workspaceArg = idWsFlag ?? (effectiveWindowRaw == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
let surfaceArg = idSurfaceFlag ?? (idWsFlag == nil && effectiveWindowRaw == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil)
if workspaceArg != nil || surfaceArg != nil {
let workspaceId = try normalizeWorkspaceHandle(
workspaceArg,
client: client,
windowHandle: targetWindow,
allowCurrent: surfaceArg != nil
)
var caller: [String: Any] = [:]
if let workspaceId {
caller["workspace_id"] = workspaceId
}
if surfaceArg != nil {
guard let surfaceId = try normalizeSurfaceHandle(
surfaceArg,
let idWsFlag = optionValue(commandArgs, name: "--workspace")
let idSurfaceFlag = optionValue(commandArgs, name: "--surface")
let usesImplicitCaller = includeCaller
&& effectiveWindowRaw == nil
&& idWsFlag == nil
&& idSurfaceFlag == nil
let response: [String: Any]
if usesImplicitCaller {
response = try implicitCallerIdentifyResponse(
client: client,
processEnvironment: processEnv
)
} else {
if includeCaller {
let workspaceArg = idWsFlag ?? (effectiveWindowRaw == nil ? processEnv["CMUX_WORKSPACE_ID"] : nil)
let surfaceArg = idSurfaceFlag ?? (idWsFlag == nil && effectiveWindowRaw == nil ? processEnv["CMUX_SURFACE_ID"] : nil)
if workspaceArg != nil || surfaceArg != nil {
let workspaceId = try normalizeWorkspaceHandle(
workspaceArg,
client: client,
workspaceHandle: workspaceId,
windowHandle: targetWindow
) else {
throw CLIError(message: "Invalid surface handle")
windowHandle: targetWindow,
allowCurrent: surfaceArg != nil
)
var caller: [String: Any] = [:]
if let workspaceId {
caller["workspace_id"] = workspaceId
}
if surfaceArg != nil {
guard let surfaceId = try normalizeSurfaceHandle(
surfaceArg,
client: client,
workspaceHandle: workspaceId,
windowHandle: targetWindow
) else {
throw CLIError(message: "Invalid surface handle")
}
caller["surface_id"] = surfaceId
}
if !caller.isEmpty {
params["caller"] = caller
}
caller["surface_id"] = surfaceId
}
if !caller.isEmpty {
params["caller"] = caller
}
}
if effectiveWindowRaw == nil,
idWsFlag == nil,
idSurfaceFlag == nil,
let callerTTY = resolveCallerDescriptorTTYName() {
params["caller_tty"] = callerTTY
}
response = try client.sendV2(method: "system.identify", params: params)
}
let response = try client.sendV2(method: "system.identify", params: params)
print(jsonString(formatIDs(response, mode: idFormat)))
case "list-windows":
@@ -15825,11 +15830,13 @@ struct CMUXCLI {
"""
case "restore":
return String(localized: "cli.restore.help", defaultValue: """
Usage: cmux restore <kind> <checkpoint-id>
Usage: cmux restore [--surface <id|ref>] <kind> <checkpoint-id>
cmux restore <kind> <checkpoint-id> --surface <id|ref>
cmux restore --surface=<id|ref> <kind> <checkpoint-id>
cmux restore --surface [id|ref]
Replace this CLI process with the persisted surface process. New
records preserve argv, environment, and cwd as structured values;
records preserve launch arguments and cwd as structured values;
command-only records from older builds use a compatibility shell.
With no id or ref, --surface uses the calling cmux surface.
""")
@@ -26262,6 +26269,97 @@ struct CMUXCLI {
return resolveCallerDescriptorTTYName()
}
func implicitCallerIdentifyResponse(
client: SocketClient,
processEnvironment: [String: String]
) throws -> [String: Any] {
let callerTTY = resolveCallerDescriptorTTYName()
?? resolveCallerTTYName(includeAmbientTTY: false)
if let callerTTY {
let ttyResponse = try client.sendV2(
method: "system.identify",
params: ["caller_tty": callerTTY]
)
if identifiedCallerSurfaceID(in: ttyResponse) != nil
|| identifyResponseHasMalformedCallerSurface(ttyResponse) {
return ttyResponse
}
if let environmentParams = try implicitCallerEnvironmentIdentifyParams(
client: client,
processEnvironment: processEnvironment
) {
return try client.sendV2(
method: "system.identify",
params: environmentParams
)
}
return ttyResponse
}
if let environmentParams = try implicitCallerEnvironmentIdentifyParams(
client: client,
processEnvironment: processEnvironment
) {
return try client.sendV2(
method: "system.identify",
params: environmentParams
)
}
return try client.sendV2(method: "system.identify")
}
func identifiedCallerSurfaceID(in response: [String: Any]) -> String? {
guard let caller = response["caller"] as? [String: Any],
let surfaceID = normalizedHandleValue(caller["surface_id"] as? String),
isUUID(surfaceID) else {
return nil
}
return surfaceID
}
private func implicitCallerEnvironmentIdentifyParams(
client: SocketClient,
processEnvironment: [String: String]
) throws -> [String: Any]? {
let workspaceArg = normalizedHandleValue(processEnvironment["CMUX_WORKSPACE_ID"])
let surfaceArg = normalizedHandleValue(processEnvironment["CMUX_SURFACE_ID"])
guard workspaceArg != nil || surfaceArg != nil else { return nil }
let workspaceID = try normalizeWorkspaceHandle(
workspaceArg,
client: client,
allowCurrent: surfaceArg != nil
)
var caller: [String: Any] = [:]
if let workspaceID {
caller["workspace_id"] = workspaceID
}
if let surfaceArg {
guard let surfaceID = try normalizeSurfaceHandle(
surfaceArg,
client: client,
workspaceHandle: workspaceID,
windowHandle: nil
) else {
return nil
}
caller["surface_id"] = surfaceID
}
guard !caller.isEmpty else { return nil }
return ["caller": caller]
}
private func identifyResponseHasMalformedCallerSurface(
_ response: [String: Any]
) -> Bool {
guard let caller = response["caller"] as? [String: Any],
let surface = caller["surface_id"],
!(surface is NSNull) else {
return false
}
return identifiedCallerSurfaceID(in: response) == nil
}
func resolveCallerDescriptorTTYName() -> String? {
for fileDescriptor in [STDIN_FILENO, STDOUT_FILENO, STDERR_FILENO] {
if let rawTTYName = ttyname(fileDescriptor),
@@ -31152,6 +31250,46 @@ export default CMUXSessionRestore;
telemetry: telemetry
)
}
func retryAmbientSessionStartTarget() -> (workspaceId: String, surfaceId: String)? {
guard hookWsFlag == nil,
explicitSurfaceFlag == nil,
let directWorkspaceArg = nonEmptyClaudeHookIdentifier(directWorkspaceArg),
let directSurfaceArg = nonEmptyClaudeHookIdentifier(directSurfaceArg) else {
return nil
}
telemetry.breadcrumb("\(def.name)-hook.session-start.target-await")
let target = awaitAgentHookSessionStartTarget(
subcommand: subcommand,
workspaceId: directWorkspaceArg,
surfaceId: directSurfaceArg
) { workspaceId, surfaceId in
guard let payload = try? client.sendV2(
method: "agent.wait_for_delivery_target",
params: [
"workspace_id": workspaceId,
"surface_id": surfaceId,
],
responseTimeout: 1.25
),
payload["source"] as? String == "surface",
let resolvedWorkspaceId = normalizedHandleValue(
payload["workspace_id"] as? String
),
isUUID(resolvedWorkspaceId),
let resolvedSurfaceId = normalizedHandleValue(
payload["surface_id"] as? String
),
isUUID(resolvedSurfaceId),
resolvedSurfaceId.caseInsensitiveCompare(surfaceId) == .orderedSame else {
return nil
}
return (resolvedWorkspaceId, resolvedSurfaceId)
}
if target != nil {
telemetry.breadcrumb("\(def.name)-hook.session-start.target-await-resolved")
}
return target
}
func resolveAgentHookTarget(mapped: ClaudeHookSessionRecord?) -> (workspaceId: String, surfaceId: String)? {
guard !hasUnusableDirectBinding else {
#if DEBUG
@@ -31283,7 +31421,8 @@ export default CMUXSessionRestore;
switch action {
case .sessionStart:
let mapped = sessionId.isEmpty ? nil : (try? store.lookup(sessionId: sessionId))
guard let target = resolveAgentHookTarget(mapped: mapped) else {
guard let target = resolveAgentHookTarget(mapped: mapped)
?? retryAmbientSessionStartTarget() else {
reportTargetResolutionFailure()
didSendFeedTelemetry = true
print("{}")
@@ -36061,7 +36200,7 @@ export default CMUXSessionRestore;
shortcuts
disable-browser | enable-browser | browser-status
agent-hibernation <on|off>
restore <kind> <checkpoint-id> | restore --surface [id|ref]
\(restoreCommandUsageLine)
restore-session
open <path-or-url>... [--workspace <id|ref|index>] [--surface <id|ref|index>] [--pane <id|ref|index>] [--window <id|ref|index>] [--focus <true|false>] [--no-focus]
diff [patch-file|-] [--source <unstaged|staged|branch|last-turn>] [--unstaged|--staged|--branch|--last-turn] [--workspace <id|ref|index>] [--surface <id|ref|index>] [--window <id|ref|index>] [--cwd <path>] [--base <ref>] [--focus <true|false>] [--no-focus] [--title <text>] [--layout <split|unified>] [--font-size <points>]
@@ -155,6 +155,7 @@ extension AuthCoordinator {
sessionCache.setHasTokens(true)
currentUser = fixtureUser
isAuthenticated = true
publishAuthenticatedSessionIdentity()
return
}
@@ -429,3 +429,87 @@ public struct AuthenticatedSessionSnapshot: Sendable, Equatable,
public var debugDescription: String { description }
}
/// Credential-free identity for synchronously binding queued work to the
/// current authenticated session.
public struct AuthenticatedSessionIdentity: Sendable, Equatable,
CustomStringConvertible, CustomDebugStringConvertible {
public let generation: UInt64
public let accountID: String
public init(generation: UInt64, accountID: String) {
self.generation = generation
self.accountID = accountID
}
public var description: String {
"AuthenticatedSessionIdentity(generation: \(generation), accountID: <redacted>)"
}
public var debugDescription: String { description }
}
public extension AuthCoordinator {
/// The current account plus session generation without either credential.
var authenticatedSessionIdentity: AuthenticatedSessionIdentity? {
guard isAuthenticated,
!sessionTokenTransitionIsActive,
let accountID = currentUser?.id,
!accountID.isEmpty else { return nil }
return AuthenticatedSessionIdentity(
generation: authSessionGeneration,
accountID: accountID
)
}
/// A credential-free lifecycle stream for consumers that must cancel work
/// at the exact auth transition instead of discovering stale authority on
/// their next request. The first element is always the current state.
func authenticatedSessionIdentities()
-> AsyncStream<AuthenticatedSessionIdentity?> {
let continuationID = UUID()
return AsyncStream(bufferingPolicy: .bufferingNewest(1)) {
continuation in
authenticatedSessionIdentityContinuations[continuationID] =
continuation
continuation.yield(publishedAuthenticatedSessionIdentity)
continuation.onTermination = { @Sendable [weak self] _ in
Task { @MainActor [weak self] in
self?.authenticatedSessionIdentityContinuations[
continuationID
] = nil
}
}
}
}
/// Whether a credential-free identity still names the published session.
/// This stays stable through same-account revalidation but flips false at
/// the synchronous start of sign-out.
func isAuthenticatedSessionIdentityCurrent(
_ identity: AuthenticatedSessionIdentity
) -> Bool {
publishedAuthenticatedSessionIdentity == identity
}
}
extension AuthCoordinator {
private var publishedAuthenticatedSessionIdentity:
AuthenticatedSessionIdentity? {
guard isAuthenticated,
!isCapturingSignOutCredentials,
let accountID = currentUser?.id,
!accountID.isEmpty else { return nil }
return AuthenticatedSessionIdentity(
generation: authSessionGeneration,
accountID: accountID
)
}
func publishAuthenticatedSessionIdentity() {
let identity = publishedAuthenticatedSessionIdentity
for continuation in authenticatedSessionIdentityContinuations.values {
continuation.yield(identity)
}
}
}
@@ -106,6 +106,9 @@ public final class AuthCoordinator {
@ObservationIgnored var signOutEpoch: UInt64 = 0
/// Monotonic sign-in attempt count, allocating each flow's attempt id.
@ObservationIgnored var signInAttemptCounter: UInt64 = 0
@ObservationIgnored var authenticatedSessionIdentityContinuations: [
UUID: AsyncStream<AuthenticatedSessionIdentity?>.Continuation
] = [:]
/// Sign-in attempts that currently own a possible write to the token store.
///
/// This ownership spans the whole flow, not just the credential-exchange
@@ -155,6 +158,7 @@ public final class AuthCoordinator {
private func finishSignInFlow(_ flow: SignInFlowContext) {
activeSignInFlows[flow.attempt] = nil
publishAuthenticatedSessionIdentity()
}
/// Creates an auth coordinator.
@@ -505,6 +509,7 @@ public final class AuthCoordinator {
// the local clear below).
advanceSessionGeneration()
signOutEpoch &+= 1
publishAuthenticatedSessionIdentity()
await phaseTimeoutRegistry.clear([.sendCode, .verifyCode, .passwordSignIn, .oauth, .validateSession])
// Capture the teardown credentials with raw stored reads (no refresh,
@@ -626,6 +631,7 @@ public final class AuthCoordinator {
currentUser = user
isAuthenticated = true
isRestoringSession = false
publishAuthenticatedSessionIdentity()
saveCachedUser(user)
sessionCache.setHasTokens(true)
await refreshTeams(generation: generation)
@@ -726,6 +732,7 @@ public final class AuthCoordinator {
currentUser = cachedUser
isAuthenticated = cachedUser != nil
isRestoringSession = false
publishAuthenticatedSessionIdentity()
}
func clearPersistedAuthForUITest() async {
@@ -757,6 +764,7 @@ public final class AuthCoordinator {
currentUser = state.currentUser
isAuthenticated = state.isAuthenticated
isRestoringSession = state.isRestoringSession
publishAuthenticatedSessionIdentity()
}
func loadCachedUser() -> CMUXAuthUser? {
@@ -8,6 +8,13 @@ import Foundation
/// that talk to the web API (e.g. ``PushRegistrationService``) so they never
/// reach for an auth singleton.
public protocol TokenProviding: Sendable {
/// Coherent account id + token pair pinned to one auth-session generation.
func authenticatedSessionSnapshot() async throws
-> AuthenticatedSessionSnapshot
/// Whether a previously captured snapshot still names the live session.
func isAuthenticatedSessionCurrent(
_ snapshot: AuthenticatedSessionSnapshot
) async -> Bool
/// The current access token, throwing when there is no valid session.
func accessToken() async throws -> String
/// The currently stored access token, without refresh or auth-state mutation.
@@ -28,4 +35,13 @@ public protocol TokenProviding: Sendable {
func forceRefreshAccessToken() async throws -> String
}
extension AuthCoordinator: TokenProviding {}
extension AuthCoordinator: TokenProviding {
public func isAuthenticatedSessionCurrent(
_ snapshot: AuthenticatedSessionSnapshot
) async -> Bool {
isAuthenticated
&& !sessionTokenTransitionIsActive
&& authSessionGeneration == snapshot.generation
&& currentUser?.id == snapshot.accountID
}
}
@@ -11,6 +11,12 @@ public protocol PushRegistering: Sendable {
/// Whether the user has opted into phone notifications.
var isEnabled: Bool { get async }
/// The furthest locally and remotely confirmed registration stage.
var snapshot: PushRegistrationSnapshot { get async }
/// A stream that immediately yields the current snapshot and every change.
func snapshots() async -> AsyncStream<PushRegistrationSnapshot>
/// Persist the opt-in flag, re-uploading any cached token on enable and
/// removing it server-side on disable.
func setEnabled(_ enabled: Bool) async
@@ -18,6 +24,10 @@ public protocol PushRegistering: Sendable {
/// Cache and (when opted in) upload a freshly registered APNs device token.
func register(deviceToken: Data) async
/// Records a terminal APNs token-registration callback failure without
/// retaining or exposing the system error description.
func deviceTokenRegistrationFailed() async
/// Re-upload the cached token (e.g. after sign-in). No-op unless opted in.
func syncTokenIfPossible() async
@@ -32,4 +42,11 @@ public protocol PushRegistering: Sendable {
/// live provider could authenticate as a NEXT account whose sign-in raced
/// the bounded teardown.
func unregisterFromServer(accessToken: String?, refreshToken: String?) async
/// Sign-out variant carrying the account id captured before local clear.
func unregisterFromServer(
accountID: String?,
accessToken: String?,
refreshToken: String?
) async
}
@@ -11,8 +11,10 @@ private let pushLog = Logger(subsystem: "ai.manaflow.cmux", category: "push")
/// URL, bundle id, `UserDefaults(suiteName:)`, and `URLSession`, then inject it
/// as `any PushRegistering`.
///
/// Privacy: notifications are **off by default**. Nothing (not even a device
/// token) is uploaded until the user enables them via ``setEnabled(_:)``.
/// Privacy: nothing (not even a device token) is uploaded until the app's
/// workspace-list permission flow is accepted or the user explicitly enables
/// notifications and the coordinator calls ``setEnabled(_:)``. An explicit
/// app opt-out remains persisted and authoritative.
public actor PushRegistrationService: PushRegistering {
private let tokenProvider: any TokenProviding
private let apiBaseURL: String
@@ -20,9 +22,24 @@ public actor PushRegistrationService: PushRegistering {
private let apnsEnvironment: String
private let defaults: UserDefaults
private let session: URLSession
private let retryDelays: [Duration]
private let retryJitter: @Sendable (ClosedRange<Double>) -> Double
private let retrySleep: @Sendable (Duration) async throws -> Void
private var retryTask: Task<Void, Never>?
private var unregisterDrainTask: Task<Void, Never>?
private var operationGeneration = UUID()
private var snapshotValue: PushRegistrationSnapshot
private var snapshotContinuations:
[UUID: AsyncStream<PushRegistrationSnapshot>.Continuation] = [:]
private static let enabledKey = "cmux.notifications.pushEnabled"
private static let cachedTokenKey = "cmux.notifications.deviceTokenHex"
private static let registeredAccountIDKey = "cmux.notifications.registeredAccountID"
private static let pendingUnregisterTokenKey = "cmux.notifications.pendingUnregisterToken"
private static let pendingUnregisterAccountIDKey = "cmux.notifications.pendingUnregisterAccountID"
private static let pendingUnregisterQueueKey =
"cmux.notifications.pendingUnregisters.v2"
private static let pendingUnregisterAttemptBudget = 4
/// Creates a push registration service.
///
@@ -43,7 +60,19 @@ public actor PushRegistrationService: PushRegistering {
bundleID: String,
apnsEnvironment: String,
suiteName: String? = nil,
session: sending URLSession = .shared
session: sending URLSession = .shared,
retryDelays: [Duration] = [
.seconds(1),
.seconds(4),
.seconds(15),
.seconds(60),
],
retryJitter: @escaping @Sendable (ClosedRange<Double>) -> Double = {
Double.random(in: $0)
},
retrySleep: @escaping @Sendable (Duration) async throws -> Void = {
try await ContinuousClock().sleep(for: $0)
}
) {
self.tokenProvider = tokenProvider
self.apiBaseURL = apiBaseURL
@@ -54,35 +83,126 @@ public actor PushRegistrationService: PushRegistering {
} else {
self.defaults = .standard
}
Self.migrateLegacyPendingUnregisters(in: self.defaults)
self.session = session
self.retryDelays = retryDelays
self.retryJitter = retryJitter
self.retrySleep = retrySleep
let enabled = self.defaults.bool(forKey: Self.enabledKey)
let hasToken = self.defaults.string(forKey: Self.cachedTokenKey)?.isEmpty == false
self.snapshotValue = PushRegistrationSnapshot(
isEnabled: enabled,
hasDeviceToken: hasToken,
backendState: enabled
? (hasToken ? .registrationRequired : .awaitingDeviceToken)
: .awaitingDeviceToken
)
}
public var isEnabled: Bool { defaults.bool(forKey: Self.enabledKey) }
public var snapshot: PushRegistrationSnapshot { snapshotValue }
public func snapshots() -> AsyncStream<PushRegistrationSnapshot> {
let id = UUID()
return AsyncStream { continuation in
snapshotContinuations[id] = continuation
continuation.yield(snapshotValue)
continuation.onTermination = { [weak self] _ in
Task { await self?.removeSnapshotContinuation(id) }
}
}
}
public func setEnabled(_ enabled: Bool) async {
let wasEnabled = isEnabled
cancelRetry()
defaults.set(enabled, forKey: Self.enabledKey)
if enabled {
await syncTokenIfPossible()
} else {
await unregisterFromServer()
publish(.disabled)
if wasEnabled {
await unregisterFromServer()
} else {
await retryPendingUnregisterIfPossible()
}
}
}
public func register(deviceToken: Data) async {
let hex = deviceToken.map { String(format: "%02x", $0) }.joined()
let previousToken = cachedTokenHex
if let previousToken,
previousToken != hex,
let previousOwner = defaults.string(
forKey: Self.registeredAccountIDKey
),
!previousOwner.isEmpty {
// Rotation does not prove the old row disappeared. Preserve its
// cleanup before replacing the cache, then make the new token
// ready before attempting the old-token DELETE.
persistPendingUnregister(
tokenHex: previousToken,
accountID: previousOwner
)
defaults.removeObject(forKey: Self.registeredAccountIDKey)
}
defaults.set(hex, forKey: Self.cachedTokenKey)
guard isEnabled else { return }
guard isEnabled else {
publish(.disabled)
return
}
cancelRetry()
await upload(tokenHex: hex)
if snapshotValue.backendState == .registered {
await retryPendingUnregisterIfPossible()
}
}
public func syncTokenIfPossible() async {
guard isEnabled, let hex = cachedTokenHex else { return }
guard isEnabled else {
await retryPendingUnregisterIfPossible()
publish(.disabled)
return
}
guard let hex = cachedTokenHex else {
publish(PushRegistrationSnapshot(
isEnabled: true,
hasDeviceToken: false,
backendState: .awaitingDeviceToken
))
// There is no current registration to prioritize, so an
// owner-matching privacy cleanup can proceed immediately.
await retryPendingUnregisterIfPossible()
return
}
cancelRetry()
await upload(tokenHex: hex)
// Current-account registration is the readiness-critical operation.
// Historical cleanup follows it, with its own bounded attempt budget.
if snapshotValue.backendState == .registered {
await retryPendingUnregisterIfPossible()
}
}
public func unregisterFromServer() async {
cancelRetry()
guard let hex = cachedTokenHex else { return }
await sendDelete(tokenHex: hex)
let session = try? await tokenProvider.authenticatedSessionSnapshot()
let ownerID = defaults.string(
forKey: Self.registeredAccountIDKey
) ?? 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.
persistPendingUnregister(tokenHex: hex, accountID: ownerID)
// A token acknowledged for account A must never be deleted using
// account B credentials. Its tombstone waits for A to return.
guard let session, session.accountID == ownerID else { return }
if await sendDelete(tokenHex: hex, sessionSnapshot: session) {
clearPendingUnregister(tokenHex: hex, accountID: ownerID)
clearRegisteredOwner(accountID: ownerID, tokenHex: hex)
}
}
/// Delete the device token from the server at sign-out, authenticating
@@ -93,7 +213,39 @@ public actor PushRegistrationService: PushRegistering {
/// - accessToken: The captured (or teardown-minted) access token.
/// - refreshToken: The captured refresh token.
public func unregisterFromServer(accessToken: String?, refreshToken: String?) async {
await unregisterFromServer(
accountID: nil,
accessToken: accessToken,
refreshToken: refreshToken
)
}
/// Sign-out variant with the account id captured before local auth clear.
public func unregisterFromServer(
accountID capturedAccountID: String?,
accessToken: String?,
refreshToken: String?
) async {
cancelRetry()
guard let hex = cachedTokenHex else { return }
let registeredOwnerID = defaults.string(
forKey: Self.registeredAccountIDKey
)
let ownerID = registeredOwnerID ?? capturedAccountID
if let ownerID, !ownerID.isEmpty {
// Persist the recovery record before validating credentials.
// Offline sign-out commonly has only the refresh token, but a
// later sign-in to this same account can safely finish the DELETE.
persistPendingUnregister(tokenHex: hex, accountID: ownerID)
}
if let registeredOwnerID,
capturedAccountID != registeredOwnerID {
// The legacy overload has no account identity, and a caller
// explicitly carrying B must never apply B's credentials to A's
// acknowledged token. Keep A's tombstone until A returns.
pushLog.info("Skipping push-token unregister: captured account does not prove registered ownership")
return
}
// Sign-out path: never fall back to the live token provider. The
// local-first sign-out cleared it, and a sign-in racing the bounded
// teardown can repopulate it with the NEXT account's tokens; the
@@ -104,7 +256,21 @@ public actor PushRegistrationService: PushRegistering {
pushLog.info("Skipping push-token unregister at sign-out: captured credentials incomplete")
return
}
await sendDelete(tokenHex: hex, capturedAccessToken: accessToken, capturedRefreshToken: refreshToken)
if await sendDelete(
tokenHex: hex,
capturedAccessToken: accessToken,
capturedRefreshToken: refreshToken
), let ownerID {
clearPendingUnregister(tokenHex: hex, accountID: ownerID)
clearRegisteredOwner(accountID: ownerID, tokenHex: hex)
}
if isEnabled {
publish(PushRegistrationSnapshot(
isEnabled: true,
hasDeviceToken: true,
backendState: .registrationRequired
))
}
}
private var cachedTokenHex: String? {
@@ -113,7 +279,28 @@ public actor PushRegistrationService: PushRegistering {
}
private func upload(tokenHex: String) async {
guard let request = await makeRequest(
operationGeneration = UUID()
let generation = operationGeneration
await attemptUpload(
tokenHex: tokenHex,
generation: generation,
remainingDelays: retryDelays
)
}
private func attemptUpload(
tokenHex: String,
generation: UUID,
remainingDelays: [Duration]
) async {
guard isEnabled, generation == operationGeneration,
cachedTokenHex == tokenHex else { return }
publish(PushRegistrationSnapshot(
isEnabled: true,
hasDeviceToken: true,
backendState: .registering
))
let request = await makeRequest(
method: "POST",
path: "/api/device-tokens",
body: [
@@ -122,23 +309,199 @@ public actor PushRegistrationService: PushRegistering {
"environment": apnsEnvironment,
"platform": "ios",
]
) else { return }
await perform(request, label: "register")
)
let result: RegistrationResult
let requestSession: AuthenticatedSessionSnapshot?
switch request {
case let .success(context):
requestSession = context.session
result = await performRegistration(context.request)
case let .failure(failure):
requestSession = nil
result = .failure(failure, retryAfter: nil)
}
let operationIsCurrent = isEnabled
&& generation == operationGeneration
&& cachedTokenHex == tokenHex
let sessionIsCurrent: Bool
if let requestSession {
sessionIsCurrent = await tokenProvider
.isAuthenticatedSessionCurrent(requestSession)
} else {
sessionIsCurrent = false
}
if case .success = result,
let requestSession,
(!operationIsCurrent || !sessionIsCurrent) {
await reconcileStaleSuccessfulRegistration(
tokenHex: tokenHex,
staleSession: requestSession
)
return
}
guard operationIsCurrent else { return }
if requestSession != nil, !sessionIsCurrent {
publish(PushRegistrationSnapshot(
isEnabled: true,
hasDeviceToken: true,
backendState: .failed(.authenticationRequired)
))
return
}
switch result {
case let .success(pushServiceConfigured):
if let requestSession {
defaults.set(
requestSession.accountID,
forKey: Self.registeredAccountIDKey
)
}
// The token is globally unique. A successful upsert onto the
// 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 pushServiceConfigured {
publish(PushRegistrationSnapshot(
isEnabled: true,
hasDeviceToken: true,
backendState: .registered
))
} else {
// The API committed ownership before reporting its provider
// readiness. Retain that cleanup identity while failing the
// user-facing readiness check closed and retrying recovery.
let failure = PushRegistrationFailure.serviceUnavailable
publish(PushRegistrationSnapshot(
isEnabled: true,
hasDeviceToken: true,
backendState: .failed(failure)
))
scheduleUploadRetry(
failure: failure,
retryAfter: nil,
tokenHex: tokenHex,
generation: generation,
remainingDelays: remainingDelays
)
}
case let .failure(failure, retryAfter):
publish(PushRegistrationSnapshot(
isEnabled: true,
hasDeviceToken: true,
backendState: .failed(failure)
))
scheduleUploadRetry(
failure: failure,
retryAfter: retryAfter,
tokenHex: tokenHex,
generation: generation,
remainingDelays: remainingDelays
)
}
}
private func scheduleUploadRetry(
failure: PushRegistrationFailure,
retryAfter: Duration?,
tokenHex: String,
generation: UUID,
remainingDelays: [Duration]
) {
guard failure.isRecoverable, !remainingDelays.isEmpty else { return }
let fallbackDelay = remainingDelays[0]
let delay = retryAfter ?? Self.jittered(
fallbackDelay,
multiplier: retryJitter(0.8...1.2)
)
let laterDelays = Array(remainingDelays.dropFirst())
retryTask = Task { [weak self, retrySleep] in
do {
try await retrySleep(delay)
} catch {
return
}
guard !Task.isCancelled else { return }
await self?.attemptUpload(
tokenHex: tokenHex,
generation: generation,
remainingDelays: laterDelays
)
}
}
/// Repairs the backend after an invalidated POST still succeeds.
///
/// URLSession cancellation cannot prove that the server did not commit the
/// request. Delete with the exact stale account credentials after its
/// acknowledgement, then re-upsert the token for whichever account is
/// current now. This orders A POST, A DELETE, B POST and therefore makes B
/// the final owner even when A's response arrives last.
private func reconcileStaleSuccessfulRegistration(
tokenHex: String,
staleSession: AuthenticatedSessionSnapshot
) async {
let currentSession = try? await tokenProvider
.authenticatedSessionSnapshot()
if isEnabled,
cachedTokenHex == tokenHex,
currentSession?.accountID == staleSession.accountID {
// A newer operation for the same account and token already
// represents the same backend ownership. Do not disturb it.
return
}
persistPendingUnregister(
tokenHex: tokenHex,
accountID: staleSession.accountID
)
if await sendDelete(
tokenHex: tokenHex,
capturedAccessToken: staleSession.accessToken,
capturedRefreshToken: staleSession.refreshToken
) {
clearPendingUnregister(
tokenHex: tokenHex,
accountID: staleSession.accountID
)
clearRegisteredOwner(
accountID: staleSession.accountID,
tokenHex: tokenHex
)
}
guard isEnabled, let currentToken = cachedTokenHex,
let currentSession = try? await tokenProvider
.authenticatedSessionSnapshot(),
await tokenProvider.isAuthenticatedSessionCurrent(currentSession)
else { return }
await upload(tokenHex: currentToken)
}
private func sendDelete(
tokenHex: String,
capturedAccessToken: String? = nil,
capturedRefreshToken: String? = nil
) async {
guard let request = await makeRequest(
capturedRefreshToken: String? = nil,
sessionSnapshot: AuthenticatedSessionSnapshot? = nil
) async -> Bool {
guard case let .success(context) = await makeRequest(
method: "DELETE",
path: "/api/device-tokens",
body: ["deviceToken": tokenHex],
capturedAccessToken: capturedAccessToken,
capturedRefreshToken: capturedRefreshToken
) else { return }
await perform(request, label: "unregister")
capturedRefreshToken: capturedRefreshToken,
sessionSnapshot: sessionSnapshot
) else { return false }
guard await performDelete(context.request) else { return false }
if let session = context.session {
return await tokenProvider.isAuthenticatedSessionCurrent(session)
}
return true
}
private func makeRequest(
@@ -146,42 +509,407 @@ public actor PushRegistrationService: PushRegistering {
path: String,
body: [String: String],
capturedAccessToken: String? = nil,
capturedRefreshToken: String? = nil
) async -> URLRequest? {
capturedRefreshToken: String? = nil,
sessionSnapshot: AuthenticatedSessionSnapshot? = nil
) async -> Result<PushRequest, PushRegistrationFailure> {
let accessToken: String
let refreshToken: String
if let capturedAccessToken, let capturedRefreshToken {
let authenticatedSession: AuthenticatedSessionSnapshot?
if let sessionSnapshot {
accessToken = sessionSnapshot.accessToken
refreshToken = sessionSnapshot.refreshToken
authenticatedSession = sessionSnapshot
} else if let capturedAccessToken, let capturedRefreshToken {
// Sign-out path: the live provider is already cleared by the
// local-first sign-out; the captured pair is the only credential.
accessToken = capturedAccessToken
refreshToken = capturedRefreshToken
authenticatedSession = nil
} else {
do {
accessToken = try await tokenProvider.accessToken()
let session = try await tokenProvider
.authenticatedSessionSnapshot()
accessToken = session.accessToken
refreshToken = session.refreshToken
authenticatedSession = session
} catch {
return nil
return .failure(.authenticationRequired)
}
guard let liveRefreshToken = await tokenProvider.refreshToken() else { return nil }
refreshToken = liveRefreshToken
}
guard let url = URL(string: apiBaseURL + path) else { return nil }
guard let url = URL(string: apiBaseURL + path) else {
return .failure(.invalidConfiguration)
}
var request = URLRequest(url: url)
request.httpMethod = method
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
request.setValue(refreshToken, forHTTPHeaderField: "X-Stack-Refresh-Token")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
return request
request.timeoutInterval = 15
return .success(PushRequest(
request: request,
session: authenticatedSession
))
}
private func perform(_ request: URLRequest, label: String) async {
private func performRegistration(_ request: URLRequest) async -> RegistrationResult {
let redirectDelegate = RedirectMethodPreservingDelegate()
do {
let (_, response) = try await session.data(for: request)
if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
pushLog.error("\(label, privacy: .public) failed status=\(http.statusCode, privacy: .public)")
let (data, response) = try await session.data(
for: request,
delegate: redirectDelegate
)
guard let http = response as? HTTPURLResponse else {
return .failure(.invalidServerResponse, retryAfter: nil)
}
guard (200...299).contains(http.statusCode) else {
return Self.failureResult(statusCode: http.statusCode, response: http, data: data)
}
guard let acknowledgement = try? JSONDecoder().decode(
RegistrationAcknowledgement.self,
from: data
), acknowledgement.ok else {
return .failure(.invalidServerResponse, retryAfter: nil)
}
return .success(
pushServiceConfigured:
acknowledgement.pushServiceConfigured != false
)
} catch {
pushLog.error("\(label, privacy: .public) error=\(error.localizedDescription, privacy: .private)")
if redirectDelegate.refusedRedirect {
return .failure(.invalidServerResponse, retryAfter: nil)
}
pushLog.error("register transport failure")
return .failure(.networkUnavailable, retryAfter: nil)
}
}
private func performDelete(_ request: URLRequest) async -> Bool {
let redirectDelegate = RedirectMethodPreservingDelegate()
do {
let (data, response) = try await session.data(
for: request,
delegate: redirectDelegate
)
if let http = response as? HTTPURLResponse,
!(200...299).contains(http.statusCode) {
pushLog.error(
"unregister failed status=\(http.statusCode, privacy: .public)"
)
return false
}
guard response is HTTPURLResponse,
let acknowledgement = try? JSONDecoder().decode(
RegistrationAcknowledgement.self,
from: data
),
acknowledgement.ok
else {
pushLog.error("unregister acknowledgement invalid")
return false
}
return true
} catch {
pushLog.error("unregister transport failure")
return false
}
}
private func retryPendingUnregisterIfPossible() async {
guard let session = try? await tokenProvider
.authenticatedSessionSnapshot() else { return }
let currentAccountID = session.accountID
let matching = pendingUnregisters.filter {
$0.accountID == currentAccountID
}
let batch = Array(
matching.prefix(Self.pendingUnregisterAttemptBudget)
)
let results = await withTaskGroup(
of: (PendingUnregister, Bool).self,
returning: [(PendingUnregister, Bool)].self
) { group in
for pending in batch {
group.addTask { [self] in
(
pending,
await sendDelete(
tokenHex: pending.tokenHex,
sessionSnapshot: session
)
)
}
}
var results: [(PendingUnregister, Bool)] = []
for await result in group {
results.append(result)
}
return results
}
for (pending, succeeded) in results where succeeded {
clearPendingUnregister(
tokenHex: pending.tokenHex,
accountID: pending.accountID
)
clearRegisteredOwner(
accountID: pending.accountID,
tokenHex: pending.tokenHex
)
}
if matching.count > batch.count,
results.contains(where: { $0.1 }) {
schedulePendingUnregisterContinuation()
}
}
private func persistPendingUnregister(tokenHex: String, accountID: String) {
let entry = PendingUnregister(tokenHex: tokenHex, accountID: accountID)
var queue = pendingUnregisters
if !queue.contains(entry) {
queue.append(entry)
}
// 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.
storePendingUnregisters(queue)
}
private func schedulePendingUnregisterContinuation() {
guard unregisterDrainTask == nil else { return }
unregisterDrainTask = Task { [weak self] in
await Task.yield()
guard !Task.isCancelled, let self else { return }
await self.runPendingUnregisterContinuation()
}
}
private func runPendingUnregisterContinuation() async {
unregisterDrainTask = nil
await retryPendingUnregisterIfPossible()
}
private func clearPendingUnregister(
tokenHex: String,
accountID: String
) {
let filtered = pendingUnregisters.filter { entry in
entry.tokenHex != tokenHex || entry.accountID != accountID
}
storePendingUnregisters(filtered)
}
private var pendingUnregisters: [PendingUnregister] {
let entries: [PendingUnregister]
if let data = defaults.data(forKey: Self.pendingUnregisterQueueKey),
let decoded = try? JSONDecoder().decode(
[PendingUnregister].self,
from: data
) {
entries = decoded
} else {
entries = []
}
var seen = Set<PendingUnregister>()
return entries.filter { seen.insert($0).inserted }
}
private static func migrateLegacyPendingUnregisters(
in defaults: UserDefaults
) {
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)
}
defaults.removeObject(forKey: pendingUnregisterTokenKey)
defaults.removeObject(forKey: pendingUnregisterAccountIDKey)
}
private func storePendingUnregisters(_ entries: [PendingUnregister]) {
if entries.isEmpty {
defaults.removeObject(forKey: Self.pendingUnregisterQueueKey)
defaults.removeObject(forKey: Self.pendingUnregisterTokenKey)
defaults.removeObject(forKey: Self.pendingUnregisterAccountIDKey)
return
}
if let data = try? JSONEncoder().encode(entries) {
defaults.set(data, forKey: Self.pendingUnregisterQueueKey)
}
defaults.removeObject(forKey: Self.pendingUnregisterTokenKey)
defaults.removeObject(forKey: Self.pendingUnregisterAccountIDKey)
}
private func clearRegisteredOwner(
accountID: String,
tokenHex: String
) {
guard cachedTokenHex == tokenHex,
defaults.string(
forKey: Self.registeredAccountIDKey
) == accountID else {
return
}
defaults.removeObject(forKey: Self.registeredAccountIDKey)
}
public func deviceTokenRegistrationFailed() {
cancelRetry()
guard isEnabled else {
publish(.disabled)
return
}
publish(PushRegistrationSnapshot(
isEnabled: true,
hasDeviceToken: cachedTokenHex != nil,
backendState: .deviceTokenRegistrationFailed
))
}
private func cancelRetry() {
operationGeneration = UUID()
retryTask?.cancel()
retryTask = nil
}
private func publish(_ snapshot: PushRegistrationSnapshot) {
guard snapshotValue != snapshot else { return }
snapshotValue = snapshot
for continuation in snapshotContinuations.values {
continuation.yield(snapshot)
}
}
private func removeSnapshotContinuation(_ id: UUID) {
snapshotContinuations.removeValue(forKey: id)
}
private static func failureResult(
statusCode: Int,
response: HTTPURLResponse,
data: Data
) -> RegistrationResult {
switch statusCode {
case 300...399:
return .failure(.invalidServerResponse, retryAfter: nil)
case 408, 425:
let seconds = retryAfterSeconds(response: response, body: data)
return .failure(
.serviceUnavailable,
retryAfter: seconds.map(Duration.seconds)
)
case 401:
return .failure(.authenticationRequired, retryAfter: nil)
case 409:
let body = try? JSONDecoder().decode(
RegistrationErrorResponse.self,
from: data
)
if body?.error == "push_delivery_in_progress" {
let seconds = retryAfterSeconds(
response: response,
body: data
)
return .failure(
.serviceUnavailable,
retryAfter: seconds.map(Duration.seconds)
)
}
return .failure(.accountDeletionInProgress, retryAfter: nil)
case 429:
let body = try? JSONDecoder().decode(
RegistrationErrorResponse.self,
from: data
)
if body?.error == "too_many_devices" {
return .failure(
.deviceLimitReached(limit: max(1, body?.limit ?? 200)),
retryAfter: nil
)
}
let seconds = retryAfterSeconds(
response: response,
body: data
)
return .failure(
.rateLimited(retryAfterSeconds: seconds),
retryAfter: seconds.map(Duration.seconds)
)
case 500...599:
return .failure(.serviceUnavailable, retryAfter: nil)
default:
return .failure(.rejected(statusCode: statusCode), retryAfter: nil)
}
}
private static func retryAfterSeconds(
response: HTTPURLResponse,
body: Data
) -> Int? {
let headerDelay = response.value(forHTTPHeaderField: "Retry-After")
.flatMap(Int.init)
let bodyDelay = try? JSONDecoder().decode(
RegistrationErrorResponse.self,
from: body
).retryAfterSeconds
guard let raw = headerDelay ?? bodyDelay else { return nil }
return min(max(raw, 0), 600)
}
private static func jittered(_ duration: Duration, multiplier: Double) -> Duration {
let components = duration.components
let seconds = Double(components.seconds)
+ Double(components.attoseconds) / 1_000_000_000_000_000_000
let nanoseconds = seconds * multiplier * 1_000_000_000
guard nanoseconds.isFinite else {
return .nanoseconds(nanoseconds.sign == .minus
? Int64.min
: Int64.max)
}
if nanoseconds >= Double(Int64.max) {
return .nanoseconds(Int64.max)
}
if nanoseconds <= Double(Int64.min) {
return .nanoseconds(Int64.min)
}
return .nanoseconds(Int64(nanoseconds))
}
}
private enum RegistrationResult {
case success(pushServiceConfigured: Bool)
case failure(PushRegistrationFailure, retryAfter: Duration?)
}
private struct PushRequest {
let request: URLRequest
let session: AuthenticatedSessionSnapshot?
}
private struct RegistrationAcknowledgement: Decodable {
let ok: Bool
let pushServiceConfigured: Bool?
}
private struct RegistrationErrorResponse: Decodable {
let error: String?
let retryAfterSeconds: Int?
let limit: Int?
}
private struct PendingUnregister: Codable, Hashable {
let tokenHex: String
let accountID: String
}
@@ -0,0 +1,90 @@
/// A backend device-token registration failure safe to expose in UI and logs.
public enum PushRegistrationFailure: Error, Sendable, Equatable {
/// The native session could not supply a valid access/refresh token pair.
case authenticationRequired
/// Account deletion currently blocks user-scoped mutations.
case accountDeletionInProgress
/// The server asked the client to wait before retrying.
case rateLimited(retryAfterSeconds: Int?)
/// The account already has the maximum number of unpruned device tokens.
case deviceLimitReached(limit: Int)
/// The request could not reach the API.
case networkUnavailable
/// The API or APNs relay is temporarily unavailable.
case serviceUnavailable
/// The configured API base URL is invalid.
case invalidConfiguration
/// A successful HTTP response did not contain the registration acknowledgement.
case invalidServerResponse
/// The API permanently rejected the registration request.
case rejected(statusCode: Int)
/// Whether repeating the same registration later can reasonably succeed.
public var isRecoverable: Bool {
switch self {
case .rateLimited, .networkUnavailable, .serviceUnavailable:
true
case .authenticationRequired, .accountDeletionInProgress, .deviceLimitReached,
.invalidConfiguration, .invalidServerResponse, .rejected:
false
}
}
}
/// The furthest backend stage a push-enabled iOS installation has confirmed.
public enum PushRegistrationBackendState: Sendable, Equatable {
/// The OS has not supplied an APNs device token yet.
case awaitingDeviceToken
/// A cached APNs token exists but has not been acknowledged this launch.
case registrationRequired
/// iOS failed to acquire a current APNs token. A user-triggered retry can
/// call `registerForRemoteNotifications()` again.
case deviceTokenRegistrationFailed
/// A device-token request is currently in flight.
case registering
/// The API acknowledged the current APNs token.
case registered
/// Registration stopped at a typed, user-visible failure.
case failed(PushRegistrationFailure)
/// Whether the current state can recover automatically.
public var isRecoverable: Bool {
if case let .failed(failure) = self {
return failure.isRecoverable
}
return false
}
}
/// Truthful local and backend push-registration readiness.
public struct PushRegistrationSnapshot: Sendable, Equatable {
/// Whether the user explicitly opted into phone notifications.
public let isEnabled: Bool
/// Whether this install has acquired an APNs device token.
public let hasDeviceToken: Bool
/// The backend acknowledgement stage for the current token.
public let backendState: PushRegistrationBackendState
/// Creates a push-registration snapshot.
///
/// - Parameters:
/// - isEnabled: Whether the user explicitly opted in.
/// - hasDeviceToken: Whether APNs supplied a token.
/// - backendState: The furthest confirmed backend stage.
public init(
isEnabled: Bool,
hasDeviceToken: Bool,
backendState: PushRegistrationBackendState
) {
self.isEnabled = isEnabled
self.hasDeviceToken = hasDeviceToken
self.backendState = backendState
}
/// The canonical disabled snapshot.
public static let disabled = PushRegistrationSnapshot(
isEnabled: false,
hasDeviceToken: false,
backendState: .awaitingDeviceToken
)
}
@@ -0,0 +1,127 @@
public import Foundation
import OSLog
nonisolated private let pushRedirectLog = Logger(
subsystem: "ai.manaflow.cmux",
category: "push.redirect"
)
/// Preserves mutating push requests across safe redirects and rejects unsafe hops.
///
/// Foundation normally rewrites `POST` and `DELETE` to a body-less `GET` for
/// 301/302 responses. That turns a successful redirect target into a false
/// registration/send acknowledgement. This delegate restores the original
/// method, body, and headers for same-origin 301/302 responses. Same-origin
/// 307/308 requests already preserve the method, but their credential headers
/// are re-applied defensively.
///
/// Every cross-origin redirect is refused before credentials or notification
/// data reach the target. A 303 from a mutating request is refused because its
/// body-less GET cannot acknowledge that the original mutation completed.
public final class RedirectMethodPreservingDelegate:
NSObject,
URLSessionTaskDelegate,
@unchecked Sendable
{
// URLSession's redirect delegate callback and the public refusal read are
// both synchronous on different executors, so an actor cannot own this
// single-bit handoff without changing either API to async.
private let refusalLock = NSLock()
private var refusedRedirectValue = false
/// Creates a stateless per-owner redirect delegate.
public override init() {
super.init()
}
/// Whether this request was stopped by the redirect policy.
public var refusedRedirect: Bool {
refusalLock.withLock { refusedRedirectValue }
}
/// Applies the redirect policy to one URL loading task.
public func urlSession(
_ session: URLSession,
task: URLSessionTask,
willPerformHTTPRedirection response: HTTPURLResponse,
newRequest proposedRequest: URLRequest,
completionHandler: @escaping (URLRequest?) -> Void
) {
guard let original = task.originalRequest,
Self.sameOrigin(original.url, proposedRequest.url) else {
markRefused()
pushRedirectLog.error(
"Refused cross-origin push redirect status=\(response.statusCode, privacy: .public)"
)
completionHandler(nil)
return
}
let originalMethod = original.httpMethod?.uppercased() ?? "GET"
let isMutation = Self.mutatingMethods.contains(originalMethod)
if response.statusCode == 303, isMutation {
markRefused()
pushRedirectLog.error("Refused mutating push 303 redirect")
completionHandler(nil)
return
}
guard isMutation, (301...302).contains(response.statusCode)
|| (307...308).contains(response.statusCode) else {
completionHandler(proposedRequest)
return
}
guard original.httpBodyStream == nil else {
markRefused()
pushRedirectLog.error("Refused non-replayable push redirect body")
completionHandler(nil)
return
}
var preserved = proposedRequest
preserved.httpMethod = originalMethod
preserved.httpBody = original.httpBody
for (field, value) in original.allHTTPHeaderFields ?? [:] {
preserved.setValue(value, forHTTPHeaderField: field)
}
pushRedirectLog.info(
"Preserved push \(originalMethod, privacy: .public) across status=\(response.statusCode, privacy: .public)"
)
completionHandler(preserved)
}
private func markRefused() {
refusalLock.withLock {
refusedRedirectValue = true
}
}
private static let mutatingMethods = Set(["POST", "PUT", "PATCH", "DELETE"])
static func sameOrigin(_ lhs: URL?, _ rhs: URL?) -> Bool {
guard let lhs, let rhs else { return false }
guard let lhsScheme = lhs.scheme?.lowercased(),
let rhsScheme = rhs.scheme?.lowercased(),
["http", "https"].contains(lhsScheme),
["http", "https"].contains(rhsScheme),
let lhsHost = lhs.host?.lowercased(),
let rhsHost = rhs.host?.lowercased(),
!lhsHost.isEmpty,
!rhsHost.isEmpty
else { return false }
return lhsScheme == rhsScheme
&& lhsHost == rhsHost
&& effectivePort(lhs) == effectivePort(rhs)
}
private static func effectivePort(_ url: URL) -> Int? {
if let port = url.port { return port }
switch url.scheme?.lowercased() {
case "https", "wss":
return 443
case "http", "ws":
return 80
default:
return nil
}
}
}
@@ -206,17 +206,16 @@ import Testing
}
await client.storedAccessDidPark()
// The late exchange's write is what sign-out's credential capture has to
// race, so wait for the store to hold exchange 2's tokens (or to have
// been cleared) before releasing the capture. The client resumes this
// from the write, so the wait does not compete with it for CPU.
await client.releaseParkedCredential()
let clock = ContinuousClock()
let deadline = clock.now.advanced(by: .seconds(2))
while true {
let refresh = await client.refreshToken()
if refresh == "refresh-2" || refresh == nil { break }
if clock.now >= deadline {
preconditionFailure("Timed out waiting for late exchange cleanup to reach the token store")
}
await Task.yield()
let cleanupWatchdog = failAfterDeadline(.seconds(30)) {
"Timed out waiting for late exchange cleanup to reach the token store"
}
await client.tokensDidSettle(afterExchange: 2)
cleanupWatchdog.cancel()
await client.releaseParkedStoredAccess()
await signOut.value
@@ -36,6 +36,36 @@ import Testing
#expect(coordinator.currentUser == nil)
}
@Test(.timeLimit(.minutes(1)))
func sessionIdentityStreamPublishesSignInAndImmediateSignOut() async throws {
let user = CMUXAuthUser(
id: "u1",
primaryEmail: "[email protected]",
displayName: "A"
)
let (coordinator, _) = makeCoordinator(
client: FakeAuthClient(user: user)
)
var identities = coordinator.authenticatedSessionIdentities()
.makeAsyncIterator()
let initial = await identities.next()
#expect(initial != nil)
#expect(initial! == nil)
try await coordinator.signInWithPassword(
email: "[email protected]",
password: "pw"
)
let signedIn = try #require(await identities.next())
#expect(signedIn?.accountID == user.id)
await coordinator.signOut()
let signedOut = await identities.next()
#expect(signedOut != nil)
#expect(signedOut! == nil)
}
@Test func passwordSignInAuthenticatesAndCaches() async throws {
let user = CMUXAuthUser(id: "u1", primaryEmail: "[email protected]", displayName: "A")
let client = FakeAuthClient(user: user)
@@ -50,6 +80,31 @@ import Testing
#expect(recorded?.email == "[email protected]")
}
@Test func emptyAccountIDNeverPublishesAnAuthenticatedIdentity() async throws {
let user = CMUXAuthUser(
id: "",
primaryEmail: "[email protected]",
displayName: "A"
)
let (coordinator, _) = makeCoordinator(
client: FakeAuthClient(user: user)
)
try await coordinator.signInWithPassword(
email: "[email protected]",
password: "pw"
)
#expect(coordinator.isAuthenticated)
#expect(coordinator.authenticatedSessionIdentity == nil)
#expect(!coordinator.isAuthenticatedSessionIdentityCurrent(
AuthenticatedSessionIdentity(
generation: coordinator.authSessionGeneration,
accountID: ""
)
))
}
@Test func everyAuthSessionTransitionClosesBeforeTheNextSessionPublishes() async throws {
let first = CMUXAuthUser(id: "u1", primaryEmail: "[email protected]", displayName: "A")
let second = CMUXAuthUser(id: "u2", primaryEmail: "[email protected]", displayName: "B")
@@ -28,6 +28,8 @@ actor GateableValidationAuthClient: AuthClient {
/// so tests can tell WHICH exchange's write the store currently holds:
/// exchange N stores `"access-N"` / `"refresh-N"` in write order.
private var exchangeCounter = 0
/// Tests awaiting a settled token store after a late exchange.
private var tokenWaiters: [(count: Int, continuation: CheckedContinuation<Void, Never>)] = []
private var currentUserStartCount = 0
private let validationGate = Gate()
private let teamsGate = Gate()
@@ -63,6 +65,29 @@ actor GateableValidationAuthClient: AuthClient {
gate.parked.removeFirst().resume()
}
// MARK: - Token-store settling
/// Suspends until exchange `count` has written its tokens, or until a clear
/// emptied the store. Those are the two outcomes a late exchange racing
/// sign-out can produce, and both are written inside this actor, so the
/// waiter is resumed by the write instead of polling for it.
func tokensDidSettle(afterExchange count: Int) async {
if tokensSettled(afterExchange: count) { return }
await withCheckedContinuation { tokenWaiters.append((count, $0)) }
}
private func tokensSettled(afterExchange count: Int) -> Bool {
exchangeCounter >= count || refresh == nil
}
private func resumeSettledTokenWaiters() {
tokenWaiters.removeAll { waiter in
guard tokensSettled(afterExchange: waiter.count) else { return false }
waiter.continuation.resume()
return true
}
}
private func parkIfArmed(_ gate: Gate) async {
guard gate.armed else { return }
gate.armed = false
@@ -146,6 +171,7 @@ actor GateableValidationAuthClient: AuthClient {
exchangeCounter += 1
access = "access-\(exchangeCounter)"
refresh = "refresh-\(exchangeCounter)"
resumeSettledTokenWaiters()
}
func accessToken() async -> String? { access }
@@ -159,6 +185,7 @@ actor GateableValidationAuthClient: AuthClient {
exchangeCounter += 1
access = "access-\(exchangeCounter)"
refresh = "refresh-\(exchangeCounter)"
resumeSettledTokenWaiters()
}
func signInWithOAuth(provider: String, anchor: any AuthPresentationAnchoring) async throws {}
@@ -171,6 +198,7 @@ actor GateableValidationAuthClient: AuthClient {
await parkIfArmed(clearGate)
access = nil
refresh = nil
resumeSettledTokenWaiters()
}
func clearLocalSession(ifRefreshTokenMatches refreshToken: String) async {
@@ -181,6 +209,7 @@ actor GateableValidationAuthClient: AuthClient {
guard refresh == refreshToken else { return }
access = nil
refresh = nil
resumeSettledTokenWaiters()
}
func revokeSession(accessToken: String?, refreshToken: String?) async throws {}
@@ -17,6 +17,7 @@ actor FlowFakeAuthClient: AuthClient {
private var currentUserError: (any Error)?
private var userGateClosed = false
private var userGateWaiters: [CheckedContinuation<Void, Never>] = []
private var pendingUserRequestWaiters: [CheckedContinuation<Void, Never>] = []
private var storedAccessGateArmed = false
private var storedAccessParked: [CheckedContinuation<Void, Never>] = []
private var storedAccessParkWaiters: [CheckedContinuation<Void, Never>] = []
@@ -38,6 +39,12 @@ actor FlowFakeAuthClient: AuthClient {
for waiter in waiters { waiter.resume() }
}
/// Suspends until a `currentUser` read is parked on the closed user gate.
func pendingUserRequestDidPark() async {
if pendingUserRequests > 0 { return }
await withCheckedContinuation { pendingUserRequestWaiters.append($0) }
}
func armStoredAccessTokenGate() { storedAccessGateArmed = true }
/// Suspends until a `storedAccessToken` read is parked on the armed gate.
@@ -59,7 +66,12 @@ actor FlowFakeAuthClient: AuthClient {
func currentUser(throwOnMissing: Bool) async throws -> CMUXAuthUser? {
if userGateClosed {
pendingUserRequests += 1
await withCheckedContinuation { userGateWaiters.append($0) }
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
userGateWaiters.append(continuation)
let waiters = pendingUserRequestWaiters
pendingUserRequestWaiters = []
for waiter in waiters { waiter.resume() }
}
pendingUserRequests -= 1
}
if let currentUserError {
@@ -140,6 +152,14 @@ actor FlowInMemoryTokenStore: StackAuthTokenStoreProtocol {
final class FakeBrowserAuthSessionFactory: HostBrowserAuthSessionFactory {
private(set) var sessions: [FakeBrowserAuthSession] = []
var nextStartResult = true
private var sessionWaiters: [(count: Int, continuation: CheckedContinuation<Void, Never>)] = []
/// Suspends until the attempt has created at least `count` sessions, so a
/// test acts on a session that exists rather than polling for one.
func sessionsDidReach(_ count: Int) async {
if sessions.count >= count { return }
await withCheckedContinuation { sessionWaiters.append((count, $0)) }
}
func makeSession(
signInURL: URL,
@@ -153,8 +173,19 @@ final class FakeBrowserAuthSessionFactory: HostBrowserAuthSessionFactory {
)
sessions.append(session)
nextStartResult = true
for waiter in takeSatisfiedSessionWaiters() { waiter.resume() }
return session
}
private func takeSatisfiedSessionWaiters() -> [CheckedContinuation<Void, Never>] {
var satisfied: [CheckedContinuation<Void, Never>] = []
sessionWaiters.removeAll { waiter in
guard sessions.count >= waiter.count else { return false }
satisfied.append(waiter.continuation)
return true
}
return satisfied
}
}
/// Delivers its completion exactly once, mirroring `ASWebAuthenticationSession`.
@@ -1,5 +1,6 @@
import CMUXAuthCore
import Foundation
import Observation
@testable import CmuxAuthRuntime
@MainActor
@@ -94,40 +95,48 @@ struct HostBrowserSignInFlowHarness {
.value ?? ""
}
func waitForSession(count: Int = 1, timeout: Duration = .seconds(2)) async {
// The attempt task runs on the same main actor; yielding lets it reach
// the browser-session continuation deterministically.
let clock = ContinuousClock()
let deadline = clock.now.advanced(by: timeout)
while factory.sessions.count < count {
if clock.now >= deadline {
preconditionFailure(
"Timed out waiting for \(count) host-browser session(s); got \(factory.sessions.count)"
)
}
await Task.yield()
/// Waits for the attempt to create `count` browser sessions. The factory
/// resumes this from the session it creates, so the wait costs no CPU while
/// the attempt runs.
func waitForSession(count: Int = 1, timeout: Duration = .seconds(30)) async {
let watchdog = failAfterDeadline(timeout) { [factory] in
"Timed out waiting for \(count) host-browser session(s); got \(factory.sessions.count)"
}
await factory.sessionsDidReach(count)
watchdog.cancel()
}
func waitForCondition(timeout: Duration = .seconds(2), until condition: @MainActor () -> Bool) async {
let clock = ContinuousClock()
let deadline = clock.now.advanced(by: timeout)
/// Waits until `condition` holds.
///
/// `condition` has to read observable state on the flow or the coordinator
/// (both are `@Observable` and main-actor isolated). The wait registers with
/// the observation system and suspends until one of the properties the
/// condition read is written, then re-checks; a condition over unobserved
/// state would never be woken and would hit the deadline below.
func waitForCondition(timeout: Duration = .seconds(30), until condition: @MainActor () -> Bool) async {
let watchdog = failAfterDeadline(timeout) {
"Timed out waiting for host-browser condition; it must read observable flow or coordinator state"
}
while !condition() {
if clock.now >= deadline {
preconditionFailure("Timed out waiting for host-browser condition")
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
withObservationTracking {
_ = condition()
} onChange: {
// Fires from the write itself, before the new value lands.
// Resuming here queues the re-check as a separate main-actor
// job, which cannot run until the write has finished.
continuation.resume()
}
}
await Task.yield()
}
watchdog.cancel()
}
func waitForPendingUserRequest(timeout: Duration = .seconds(2)) async {
let clock = ContinuousClock()
let deadline = clock.now.advanced(by: timeout)
while await client.pendingUserRequests == 0 {
if clock.now >= deadline {
preconditionFailure("Timed out waiting for a pending user request")
}
await Task.yield()
}
/// Waits for a `currentUser` read to park on the closed user gate. The fake
/// client resumes this as it parks.
func waitForPendingUserRequest(timeout: Duration = .seconds(30)) async {
let watchdog = failAfterDeadline(timeout) { "Timed out waiting for a pending user request" }
await client.pendingUserRequestDidPark()
watchdog.cancel()
}
}
@@ -0,0 +1,109 @@
import Foundation
/// End-to-end URL loading probe for mutating redirect behavior.
final class PushRedirectURLProtocol: URLProtocol, @unchecked Sendable {
enum Scenario: Sendable {
case sameOrigin301
case sameOrigin302
case sameOrigin303
case sameOrigin307
case sameOrigin308
case schemeDowngrade307
case crossHost308
case portChange302
var statusCode: Int {
switch self {
case .sameOrigin301:
301
case .sameOrigin302, .portChange302:
302
case .sameOrigin303:
303
case .sameOrigin307, .schemeDowngrade307:
307
case .sameOrigin308, .crossHost308:
308
}
}
}
static let state = PushRedirectState()
static let startHost = "push-start.test"
static let targetHost = "push-target.test"
static let startPath = "/api/device-tokens"
static let targetPath = "/canonical/device-tokens"
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
Task {
guard let url = request.url else {
client?.urlProtocolDidFinishLoading(self)
return
}
let scenario = await Self.state.scenario
if url.path == Self.startPath {
let target: URL
switch scenario {
case .sameOrigin301, .sameOrigin302, .sameOrigin303,
.sameOrigin307, .sameOrigin308:
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)!
components.path = Self.targetPath
target = components.url!
case .schemeDowngrade307:
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)!
components.scheme = "http"
components.path = Self.targetPath
target = components.url!
case .crossHost308:
target = URL(string: "https://\(Self.targetHost)\(Self.targetPath)")!
case .portChange302:
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)!
components.port = 444
components.path = Self.targetPath
target = components.url!
}
let status = scenario.statusCode
let response = HTTPURLResponse(
url: url,
statusCode: status,
httpVersion: "HTTP/1.1",
headerFields: ["Location": target.absoluteString]
)!
var proposed = URLRequest(url: target)
proposed.httpMethod = [307, 308].contains(status) ? request.httpMethod : "GET"
client?.urlProtocol(self, wasRedirectedTo: proposed, redirectResponse: response)
return
}
await Self.state.recordTarget(request)
let response = HTTPURLResponse(
url: url,
statusCode: 200,
httpVersion: "HTTP/1.1",
headerFields: nil
)!
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: Data(#"{"ok":true}"#.utf8))
client?.urlProtocolDidFinishLoading(self)
}
}
override func stopLoading() {}
}
actor PushRedirectState {
private(set) var scenario: PushRedirectURLProtocol.Scenario = .sameOrigin301
private(set) var targetRequests: [URLRequest] = []
func reset(_ scenario: PushRedirectURLProtocol.Scenario) {
self.scenario = scenario
targetRequests = []
}
func recordTarget(_ request: URLRequest) {
targetRequests.append(request)
}
}
@@ -0,0 +1,211 @@
import Foundation
private struct PushRegistrationLoadingContext: @unchecked Sendable {
let loadingProtocol: PushRegistrationURLProtocol
}
/// Scripted transport for push-registration lifecycle tests.
///
/// `URLProtocol` is configured by type, so one actor-backed script is shared by
/// this serialized suite. The actor owns both the response queue and request
/// capture, keeping test mutation out of process-global unsafe variables.
final class PushRegistrationURLProtocol: URLProtocol, @unchecked Sendable {
struct Stub: Sendable {
let statusCode: Int?
let headers: [String: String]
let body: Data
let error: URLError?
let started: TestPhaseSignal?
let blocker: TestContinuationBlocker?
static func response(
_ statusCode: Int,
headers: [String: String] = [:],
json: String = #"{"ok":true}"#
) -> Stub {
Stub(
statusCode: statusCode,
headers: headers,
body: Data(json.utf8),
error: nil,
started: nil,
blocker: nil
)
}
static func gatedResponse(
_ statusCode: Int,
started: TestPhaseSignal,
blocker: TestContinuationBlocker,
headers: [String: String] = [:],
json: String = #"{"ok":true}"#
) -> Stub {
Stub(
statusCode: statusCode,
headers: headers,
body: Data(json.utf8),
error: nil,
started: started,
blocker: blocker
)
}
static func failure(_ code: URLError.Code) -> Stub {
Stub(
statusCode: nil,
headers: [:],
body: Data(),
error: URLError(code),
started: nil,
blocker: nil
)
}
}
static let script = PushRegistrationURLScript()
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
let capturedRequest = request
let capturedBody = Self.bodyData(from: capturedRequest)
let stub = Self.script.take(capturedRequest, body: capturedBody)
let context = PushRegistrationLoadingContext(
loadingProtocol: self
)
if stub.error != nil {
Task.detached { [capturedRequest, context] in
await Task.yield()
context.complete(stub, request: capturedRequest)
}
return
}
guard stub.started != nil || stub.blocker != nil else {
context.complete(stub, request: capturedRequest)
return
}
Task.detached { [capturedRequest, context] in
await stub.started?.markStarted()
await stub.blocker?.wait()
context.complete(stub, request: capturedRequest)
}
}
override func stopLoading() {}
private static func bodyData(from request: URLRequest) -> Data? {
if let body = request.httpBody {
return body
}
guard let stream = request.httpBodyStream else { return nil }
stream.open()
defer { stream.close() }
var data = Data()
let bufferSize = 1_024
let buffer = UnsafeMutablePointer<UInt8>.allocate(
capacity: bufferSize
)
defer { buffer.deallocate() }
while stream.hasBytesAvailable {
let count = stream.read(buffer, maxLength: bufferSize)
if count <= 0 { break }
data.append(buffer, count: count)
}
return data
}
}
private extension PushRegistrationLoadingContext {
func complete(
_ stub: PushRegistrationURLProtocol.Stub,
request: URLRequest
) {
if let error = stub.error {
loadingProtocol.client?.urlProtocol(
loadingProtocol,
didFailWithError: error
)
return
}
let response = HTTPURLResponse(
url: request.url!,
statusCode: stub.statusCode ?? 500,
httpVersion: "HTTP/1.1",
headerFields: stub.headers
)!
loadingProtocol.client?.urlProtocol(
loadingProtocol,
didReceive: response,
cacheStoragePolicy: .notAllowed
)
if !stub.body.isEmpty {
loadingProtocol.client?.urlProtocol(
loadingProtocol,
didLoad: stub.body
)
}
loadingProtocol.client?.urlProtocolDidFinishLoading(
loadingProtocol
)
}
}
final class PushRegistrationURLScript: @unchecked Sendable {
private let lock = NSLock()
private var stubs: [PushRegistrationURLProtocol.Stub] = []
private var capturedRequests: [URLRequest] = []
private var capturedBodies: [Data?] = []
var requests: [URLRequest] {
get async {
lock.withLock { capturedRequests }
}
}
var requestBodies: [Data?] {
get async {
lock.withLock { capturedBodies }
}
}
func waitForRequestCount(
_ expectedCount: Int,
timeout: Duration = .seconds(1)
) async -> Bool {
let clock = ContinuousClock()
let deadline = clock.now.advanced(by: timeout)
while lock.withLock({ capturedRequests.count }) < expectedCount {
guard clock.now < deadline else { return false }
try? await clock.sleep(for: .milliseconds(1))
}
return true
}
func reset(
_ nextStubs: [PushRegistrationURLProtocol.Stub]
) async {
lock.withLock {
stubs = nextStubs
capturedRequests = []
capturedBodies = []
}
}
func take(
_ request: URLRequest,
body: Data?
) -> PushRegistrationURLProtocol.Stub {
lock.lock()
defer { lock.unlock() }
capturedRequests.append(request)
capturedBodies.append(body)
guard !stubs.isEmpty else {
return .response(
500,
json: #"{"error":"unscripted_request"}"#
)
}
return stubs.removeFirst()
}
}
@@ -0,0 +1,51 @@
import Foundation
import Testing
@testable import CmuxAuthRuntime
struct RedirectMethodPreservingDelegateTests {
@Test(arguments: [
("relative/path", "other/path"),
("opaque:first", "opaque:second"),
("file:///tmp/source", "file:///tmp/target"),
("ws://example.test/source", "ws://example.test/target"),
("wss://example.test/source", "wss://example.test/target"),
("https://example.test/source", "https://other.test/target"),
("https://example.test/source", "http://example.test/target"),
("https://example.test/source", "https://example.test:8443/target"),
("https://example.test/source", "https://sub.example.test/target"),
])
func nonHTTPOriginsFailClosed(
source: String,
target: String
) throws {
let sourceURL = try #require(URL(string: source))
let targetURL = try #require(URL(string: target))
#expect(
!RedirectMethodPreservingDelegate.sameOrigin(
sourceURL,
targetURL
)
)
}
@Test(arguments: [
("https://example.test/source", "https://example.test/target"),
("https://example.test:443/source", "https://example.test/target"),
("http://example.test:80/source", "http://EXAMPLE.test/target"),
])
func canonicalHTTPOriginsMatch(
source: String,
target: String
) throws {
let sourceURL = try #require(URL(string: source))
let targetURL = try #require(URL(string: target))
#expect(
RedirectMethodPreservingDelegate.sameOrigin(
sourceURL,
targetURL
)
)
}
}
@@ -0,0 +1,21 @@
import Foundation
/// Aborts the run with `message` unless the caller cancels the returned task
/// first.
///
/// The waits this guards are event-driven: they suspend until the fake they
/// wait on resumes them, so a run that reaches this deadline is one where the
/// awaited edge never arrived. Reporting that by name beats leaving the run
/// suspended forever. The deadline is generous on purpose it never bounds a
/// passing run, so machine load cannot push a healthy wait past it.
@MainActor
func failAfterDeadline(
_ timeout: Duration,
_ message: @escaping @MainActor () -> String
) -> Task<Void, Never> {
Task { @MainActor in
try? await Task.sleep(for: timeout)
guard !Task.isCancelled else { return }
preconditionFailure(message())
}
}
@@ -40,10 +40,49 @@ extension CmxIrohClientRuntime {
throw CmxIrohClientRuntimeError.relayFleetMismatch
}
let revision = lifecycleRevision
try await connectivityEngine.replaceRelayProfile(
profile,
expectedIdentity: binding.endpointID
)
await relayCoordinator?.deactivate()
relayCoordinator = nil
if profile.source == .managed, !profile.allowedRelayURLs.isEmpty {
let refreshSchedule = CmxIrohRelayRefreshSchedule(
role: .client,
endpointIdentity: binding.endpointID
)
let coordinator = CmxIrohRelayCredentialCoordinator(
supervisor: connectivityEngine,
broker: broker,
managedRelayURLs: replacementManagedURLs,
selectedRelayURLs: profile.allowedRelayURLs,
jitter: { now, refreshAfter in
refreshSchedule.deadline(now: now, refreshAfter: refreshAfter)
},
retrySchedule: .foregroundClient,
automaticRefreshEnabled: automaticRelayCredentialRefreshEnabled,
credentialDidInstall: { [handleRelayCredential] response in
await handleRelayCredential(response, binding)
}
)
relayCoordinator = coordinator
do {
try await coordinator.activateManagedPolicy(
bindingID: binding.bindingID,
endpointIdentity: binding.endpointID,
profile: profile,
bootstrap: relayBootstrap
)
} catch {
await coordinator.deactivate()
if relayCoordinator === coordinator {
relayCoordinator = nil
}
throw error
}
} else {
try await connectivityEngine.replaceRelayProfile(
profile,
expectedIdentity: binding.endpointID
)
}
try requireCurrent(revision)
managedRelayURLs = replacementManagedURLs
@@ -98,32 +137,5 @@ extension CmxIrohClientRuntime {
}
await contextRouter.install(provider)
try requireCurrent(revision)
await relayCoordinator?.deactivate()
relayCoordinator = nil
guard profile.source == .managed,
!profile.allowedRelayURLs.isEmpty else { return }
let coordinator = CmxIrohRelayCredentialCoordinator(
supervisor: connectivityEngine,
broker: broker,
managedRelayURLs: replacementManagedURLs,
selectedRelayURLs: profile.allowedRelayURLs,
retrySchedule: .foregroundClient,
automaticRefreshEnabled: automaticRelayCredentialRefreshEnabled,
credentialDidInstall: { [handleRelayCredential] response in
await handleRelayCredential(response, binding)
}
)
relayCoordinator = coordinator
do {
try await coordinator.activate(
bindingID: binding.bindingID,
endpointIdentity: binding.endpointID,
bootstrap: relayBootstrap
)
} catch {
// The verified allowlist is already live; direct paths remain usable
// while the coordinator retries a managed credential refresh.
}
}
}
@@ -42,42 +42,54 @@ extension CmxIrohHostRuntime {
throw CmxIrohHostRuntimeError.relayFleetMismatch
}
let revision = lifecycleRevision
try await connectivityEngine.replaceRelayProfile(
profile,
expectedIdentity: binding.endpointID
)
relayActivationTask?.cancel()
relayActivationTask = nil
await relayCoordinator?.deactivate()
relayCoordinator = nil
if profile.source == .managed, !profile.allowedRelayURLs.isEmpty {
let refreshSchedule = CmxIrohRelayRefreshSchedule(
role: .host,
endpointIdentity: binding.endpointID
)
let coordinator = CmxIrohRelayCredentialCoordinator(
supervisor: connectivityEngine,
broker: broker,
managedRelayURLs: replacementManagedURLs,
selectedRelayURLs: profile.allowedRelayURLs,
jitter: { now, refreshAfter in
refreshSchedule.deadline(now: now, refreshAfter: refreshAfter)
},
credentialDidInstall: { [handleRelayCredential] response in
await handleRelayCredential(response, binding)
}
)
relayCoordinator = coordinator
do {
try await coordinator.activateManagedPolicy(
bindingID: binding.bindingID,
endpointIdentity: binding.endpointID,
profile: profile,
bootstrap: relayBootstrap
)
} catch {
await coordinator.deactivate()
if relayCoordinator === coordinator {
relayCoordinator = nil
}
throw error
}
} else {
try await connectivityEngine.replaceRelayProfile(
profile,
expectedIdentity: binding.endpointID
)
}
try requireCurrent(revision)
managedRelayURLs = replacementManagedURLs
currentEndpointRelayProfile = profile
await admissionController?.updateManagedRelayURLs(replacementManagedURLs)
try requireCurrent(revision)
relayActivationTask?.cancel()
relayActivationTask = nil
await relayCoordinator?.deactivate()
relayCoordinator = nil
guard profile.source == .managed,
!profile.allowedRelayURLs.isEmpty else { return }
let coordinator = CmxIrohRelayCredentialCoordinator(
supervisor: connectivityEngine,
broker: broker,
managedRelayURLs: replacementManagedURLs,
selectedRelayURLs: profile.allowedRelayURLs,
credentialDidInstall: { [handleRelayCredential] response in
await handleRelayCredential(response, binding)
}
)
relayCoordinator = coordinator
do {
try await coordinator.activate(
bindingID: binding.bindingID,
endpointIdentity: binding.endpointID,
bootstrap: relayBootstrap
)
} catch {
// The verified allowlist is already live; direct paths remain usable
// while the coordinator retries a managed credential refresh.
}
}
}
@@ -92,14 +92,10 @@ public actor CmxIrohRelayCredentialCoordinator {
bootstrap: CmxIrohRelayTokenResponse? = nil,
waitForInitialCredential: Bool = false
) async throws {
lifecycleRevision &+= 1
let revision = lifecycleRevision
refreshTask?.cancel()
inFlightRefresh?.task.cancel()
inFlightRefresh = nil
let expectedBinding = Binding(id: bindingID, endpointIdentity: endpointIdentity)
binding = expectedBinding
installedCredential = nil
let (expectedBinding, revision) = beginActivation(
bindingID: bindingID,
endpointIdentity: endpointIdentity
)
if let bootstrap {
do {
@@ -167,6 +163,111 @@ public actor CmxIrohRelayCredentialCoordinator {
}
}
/// Replaces one live managed relay policy and starts credential refresh.
///
/// The coordinator owns the endpoint mutation so a policy bootstrap is
/// installed exactly once. This preserves active QUIC sessions while the
/// endpoint's relay client adopts the replacement credentials.
///
/// - Parameters:
/// - bindingID: The broker binding that owns the endpoint.
/// - endpointIdentity: The pinned endpoint identity being updated.
/// - profile: The complete managed relay profile to install.
/// - bootstrap: Credentials already represented by `profile`, when available.
/// - Throws: A policy mismatch, endpoint mutation failure, or cancellation.
public func activateManagedPolicy(
bindingID: String,
endpointIdentity: CmxIrohPeerIdentity,
profile: CmxIrohEndpointRelayProfile,
bootstrap: CmxIrohRelayTokenResponse?
) async throws {
guard profile.source == .managed,
!selectedRelayURLs.isEmpty,
selectedRelayURLs.isSubset(of: managedRelayURLs),
profile.allowedRelayURLs == selectedRelayURLs else {
throw CmxIrohRelayCredentialCoordinatorError.relayFleetMismatch
}
let bootstrapInstallation: (
response: CmxIrohRelayTokenResponse,
configurations: [CmxIrohRelayConfiguration]
)? = try bootstrap.map { response in
let selectedConfigurations = try validatedSelectedConfigurations(response)
guard profile.managedRelays.count == selectedConfigurations.count,
profile.managedRelays.allSatisfy(selectedConfigurations.contains) else {
throw CmxIrohRelayCredentialCoordinatorError.relayFleetMismatch
}
return (response, selectedConfigurations)
}
let (expectedBinding, revision) = beginActivation(
bindingID: bindingID,
endpointIdentity: endpointIdentity
)
try await supervisor.replaceRelayProfile(
profile,
expectedIdentity: endpointIdentity
)
try Task.checkCancellation()
guard isCurrent(revision), binding == expectedBinding else {
throw CancellationError()
}
if let bootstrapInstallation {
let installed = try recordInstallation(
bootstrapInstallation.response,
selectedConfigurations: bootstrapInstallation.configurations,
binding: expectedBinding,
revision: revision
)
startLoopIfEnabled(revision: revision, firstRefresh: installed.refreshAfter)
return
}
do {
let response = try await broker.issueRelayToken(
bindingID: bindingID,
endpointID: endpointIdentity
)
let installed = try await install(
response,
binding: expectedBinding,
revision: revision
)
startLoopIfEnabled(revision: revision, firstRefresh: installed.refreshAfter)
} catch {
guard isCurrent(revision), !Task.isCancelled else {
throw CancellationError()
}
let delay = retryDelay(failureCount: 0, error: error)
startLoopIfEnabled(
revision: revision,
firstRefresh: retryDeadline(
now: clock.now(),
backoff: delay,
honorsServerFloor: (error as? any CmxRetryAfterProviding)?
.retryAfterSeconds != nil
),
initialFailureCount: 1
)
}
}
private func beginActivation(
bindingID: String,
endpointIdentity: CmxIrohPeerIdentity
) -> (Binding, UInt64) {
lifecycleRevision &+= 1
let revision = lifecycleRevision
refreshTask?.cancel()
inFlightRefresh?.task.cancel()
inFlightRefresh = nil
let expectedBinding = Binding(id: bindingID, endpointIdentity: endpointIdentity)
binding = expectedBinding
installedCredential = nil
return (expectedBinding, revision)
}
private func installInitialCredentialAfterRetry(
binding: Binding,
revision: UInt64,
@@ -415,27 +516,14 @@ public actor CmxIrohRelayCredentialCoordinator {
guard isCurrent(revision), binding == expectedBinding else {
throw CancellationError()
}
guard response.relayFleet.count == managedRelayURLs.count,
Set(response.relayFleet) == managedRelayURLs else {
throw CmxIrohRelayCredentialCoordinatorError.relayFleetMismatch
}
let now = clock.now()
let configurations = try response.relayConfigurations(now: now)
let selectedConfigurations = configurations.filter {
selectedRelayURLs.contains($0.url)
}
guard !selectedRelayURLs.isEmpty,
selectedConfigurations.count == selectedRelayURLs.count,
selectedRelayURLs.isSubset(of: managedRelayURLs) else {
throw CmxIrohRelayCredentialCoordinatorError.relayFleetMismatch
}
let selectedConfigurations = try validatedSelectedConfigurations(response)
try Task.checkCancellation()
guard isCurrent(revision), binding == expectedBinding else {
throw CancellationError()
}
if selectedRelayURLs == managedRelayURLs {
try await supervisor.replaceRelays(
configurations,
selectedConfigurations,
expectedIdentity: expectedBinding.endpointIdentity
)
} else {
@@ -448,6 +536,39 @@ public actor CmxIrohRelayCredentialCoordinator {
expectedIdentity: expectedBinding.endpointIdentity
)
}
return try recordInstallation(
response,
selectedConfigurations: selectedConfigurations,
binding: expectedBinding,
revision: revision
)
}
private func validatedSelectedConfigurations(
_ response: CmxIrohRelayTokenResponse
) throws -> [CmxIrohRelayConfiguration] {
guard response.relayFleet.count == managedRelayURLs.count,
Set(response.relayFleet) == managedRelayURLs else {
throw CmxIrohRelayCredentialCoordinatorError.relayFleetMismatch
}
let configurations = try response.relayConfigurations(now: clock.now())
let selectedConfigurations = configurations.filter {
selectedRelayURLs.contains($0.url)
}
guard !selectedRelayURLs.isEmpty,
selectedConfigurations.count == selectedRelayURLs.count,
selectedRelayURLs.isSubset(of: managedRelayURLs) else {
throw CmxIrohRelayCredentialCoordinatorError.relayFleetMismatch
}
return selectedConfigurations
}
private func recordInstallation(
_ response: CmxIrohRelayTokenResponse,
selectedConfigurations: [CmxIrohRelayConfiguration],
binding expectedBinding: Binding,
revision: UInt64
) throws -> InstalledCredential {
try Task.checkCancellation()
guard isCurrent(revision), binding == expectedBinding,
let refreshAfter = selectedConfigurations.map(\.refreshAfter).min(),
@@ -0,0 +1,47 @@
import CMUXMobileCore
import Foundation
/// Assigns endpoint-stable, non-overlapping relay credential refresh slots.
struct CmxIrohRelayRefreshSchedule: Sendable {
enum Role: Sendable {
case host
case client
fileprivate var phaseStart: Int {
switch self {
case .host: 0
case .client: 30
}
}
}
private static let phaseWidth = 15
private static let minuteDuration: TimeInterval = 60
private static let fnvOffsetBasis: UInt64 = 14_695_981_039_346_656_037
private static let fnvPrime: UInt64 = 1_099_511_628_211
private let secondWithinMinute: Int
init(role: Role, endpointIdentity: CmxIrohPeerIdentity) {
var hash = Self.fnvOffsetBasis
for byte in endpointIdentity.endpointID.utf8 {
hash ^= UInt64(byte)
hash &*= Self.fnvPrime
}
secondWithinMinute = role.phaseStart + Int(hash % UInt64(Self.phaseWidth))
}
func deadline(now: Date, refreshAfter: Date) -> Date {
let refreshEpoch = refreshAfter.timeIntervalSince1970
let minuteStart = floor(refreshEpoch / Self.minuteDuration)
* Self.minuteDuration
var candidateEpoch = minuteStart + TimeInterval(secondWithinMinute)
if candidateEpoch > refreshEpoch {
candidateEpoch -= Self.minuteDuration
}
return min(
refreshAfter,
max(now, Date(timeIntervalSince1970: candidateEpoch))
)
}
}
@@ -1,8 +1,147 @@
import Foundation
import Testing
@testable import CmuxIrohTransport
@Suite
struct CmxIrohCustomRelayRuntimeTests {
@Test
func clientManagedPolicyRefreshMutatesEndpointExactlyOnce() async throws {
let fixture = try ClientRuntimeTestFixture()
let endpoint = TestIrohEndpoint(identity: fixture.endpointID)
let runtime = try CmxIrohClientRuntime(
factory: TestIrohEndpointFactory(endpoints: [endpoint]),
broker: TestIrohClientBroker(
binding: fixture.binding,
discovery: fixture.discovery,
relay: fixture.relayResponse()
),
configuration: fixture.configuration,
pendingRevocations: fixture.pendingRevocations(),
now: { fixture.now }
)
try await runtime.start()
try await Self.waitForRelayMutation(endpoint)
let initialCredentialUpdates = await endpoint.observedRelayUpdates().count
let initialProfileUpdates = await endpoint.observedRelayProfileUpdates().count
try await runtime.replaceRelayPolicy(try Self.managedPolicy(
response: fixture.relayResponse(),
relayURLs: Set(ClientRuntimeTestFixture.relayURLs),
now: fixture.now
))
let credentialUpdates = await endpoint.observedRelayUpdates().count
- initialCredentialUpdates
let profileUpdates = await endpoint.observedRelayProfileUpdates().count
- initialProfileUpdates
#expect(credentialUpdates + profileUpdates == 1)
#expect(await endpoint.observedCloseCallCount() == 0)
#expect(await runtime.snapshot().endpointID == fixture.endpointID)
await runtime.stop()
}
@Test
func hostManagedPolicyRefreshMutatesEndpointExactlyOnce() async throws {
let fixture = try HostRuntimeFixture()
let endpoint = TestIrohEndpoint(identity: fixture.endpointID)
let runtime = CmxIrohHostRuntime(
factory: TestIrohEndpointFactory(endpoints: [endpoint]),
broker: TestIrohHostBroker(
registrationBinding: fixture.binding,
discovery: fixture.discovery
),
configuration: fixture.configuration,
pendingRevocations: fixture.pendingRevocations(),
now: { Date(timeIntervalSince1970: 1_800_000_000) },
handleTransport: { session, _ in await session.close() }
)
try await runtime.start()
try await Self.waitForRelayMutation(endpoint)
let initialCredentialUpdates = await endpoint.observedRelayUpdates().count
let initialProfileUpdates = await endpoint.observedRelayProfileUpdates().count
let response = try ClientRuntimeTestFixture().relayResponse()
try await runtime.replaceRelayPolicy(try Self.managedPolicy(
response: response,
relayURLs: fixture.managedRelays,
now: Date(timeIntervalSince1970: 1_800_000_000)
))
let credentialUpdates = await endpoint.observedRelayUpdates().count
- initialCredentialUpdates
let profileUpdates = await endpoint.observedRelayProfileUpdates().count
- initialProfileUpdates
#expect(credentialUpdates + profileUpdates == 1)
#expect(await endpoint.observedCloseCallCount() == 0)
#expect(await runtime.snapshot().endpointID == fixture.endpointID)
await runtime.stop()
}
@Test
func clientManagedPolicyFailureDeactivatesUncommittedCoordinator() async throws {
let fixture = try ClientRuntimeTestFixture()
let endpoint = TestIrohEndpoint(identity: fixture.endpointID)
let runtime = try CmxIrohClientRuntime(
factory: TestIrohEndpointFactory(endpoints: [endpoint]),
broker: TestIrohClientBroker(
binding: fixture.binding,
discovery: fixture.discovery,
relay: fixture.relayResponse()
),
configuration: fixture.configuration,
pendingRevocations: fixture.pendingRevocations(),
now: { fixture.now }
)
try await runtime.start()
try await Self.waitForRelayMutation(endpoint)
await endpoint.setRelayUpdateShouldFail(true)
await #expect(throws: TestIrohTransportError.relayUpdateFailed) {
try await runtime.replaceRelayPolicy(try Self.managedPolicy(
response: fixture.relayResponse(),
relayURLs: Set(ClientRuntimeTestFixture.relayURLs),
now: fixture.now
))
}
#expect(await runtime.relayCoordinator == nil)
#expect(await endpoint.observedCloseCallCount() == 0)
await runtime.stop()
}
@Test
func hostManagedPolicyFailureDeactivatesUncommittedCoordinator() async throws {
let fixture = try HostRuntimeFixture()
let endpoint = TestIrohEndpoint(identity: fixture.endpointID)
let runtime = CmxIrohHostRuntime(
factory: TestIrohEndpointFactory(endpoints: [endpoint]),
broker: TestIrohHostBroker(
registrationBinding: fixture.binding,
discovery: fixture.discovery
),
configuration: fixture.configuration,
pendingRevocations: fixture.pendingRevocations(),
now: { Date(timeIntervalSince1970: 1_800_000_000) },
handleTransport: { session, _ in await session.close() }
)
try await runtime.start()
try await Self.waitForRelayMutation(endpoint)
await endpoint.setRelayUpdateShouldFail(true)
let response = try ClientRuntimeTestFixture().relayResponse()
await #expect(throws: TestIrohTransportError.relayUpdateFailed) {
try await runtime.replaceRelayPolicy(try Self.managedPolicy(
response: response,
relayURLs: fixture.managedRelays,
now: Date(timeIntervalSince1970: 1_800_000_000)
))
}
#expect(await runtime.relayCoordinator == nil)
#expect(await endpoint.observedCloseCallCount() == 0)
await runtime.stop()
}
@Test
func clientOverrideSkipsManagedTokenIssuance() async throws {
let fixture = try ClientRuntimeTestFixture()
@@ -153,4 +292,45 @@ struct CmxIrohCustomRelayRuntimeTests {
#expect(await runtime.snapshot().endpointID == fixture.endpointID)
await runtime.stop()
}
private static func managedPolicy(
response: CmxIrohRelayTokenResponse,
relayURLs: Set<String>,
now: Date
) throws -> CmxIrohEffectiveRelayPolicy {
let profile = try CmxIrohEndpointRelayProfile(
managedRelayURLs: relayURLs,
relays: response.relayConfigurations(now: now)
)
return CmxIrohEffectiveRelayPolicy(
endpointRelayProfile: profile,
managedSnapshot: nil,
managedPolicy: nil,
requestedConfiguration: nil,
effectivePreference: .automatic,
source: .managed,
usedCachedPolicy: false,
preferenceRevision: nil,
relayBootstrap: response
)
}
private static func waitForRelayMutation(_ endpoint: TestIrohEndpoint) async throws {
let clock = ContinuousClock()
let deadline = clock.now.advanced(by: .seconds(1))
while clock.now < deadline {
let credentialUpdates = await endpoint.observedRelayUpdates().count
let profileUpdates = await endpoint.observedRelayProfileUpdates().count
if credentialUpdates + profileUpdates > 0 { return }
await Task.yield()
}
let credentialUpdates = await endpoint.observedRelayUpdates().count
let profileUpdates = await endpoint.observedRelayProfileUpdates().count
let counts = "credential updates: \(credentialUpdates), "
+ "profile updates: \(profileUpdates)"
Issue.record("Timed out waiting for relay mutation (\(counts))")
throw RelayMutationTimeout()
}
}
private struct RelayMutationTimeout: Error {}
@@ -5,6 +5,84 @@ import Testing
@Suite
struct CmxIrohRelayCredentialCoordinatorTests {
@Test
func hostAndClientRefreshSlotsStaySeparatedAcrossCredentialCycles() throws {
let hostIdentity = try CmxIrohPeerIdentity(
endpointID: String(repeating: "1a", count: 32)
)
let clientIdentity = try CmxIrohPeerIdentity(
endpointID: String(repeating: "b7", count: 32)
)
let hostSchedule = CmxIrohRelayRefreshSchedule(
role: .host,
endpointIdentity: hostIdentity
)
let clientSchedule = CmxIrohRelayRefreshSchedule(
role: .client,
endpointIdentity: clientIdentity
)
let now = Date(timeIntervalSince1970: 1_700_000_000)
var hostSeconds: [Int] = []
var clientSeconds: [Int] = []
for cycle in 1 ... 8 {
let refreshAfter = now.addingTimeInterval(TimeInterval(cycle * 240))
let hostDeadline = hostSchedule.deadline(
now: now,
refreshAfter: refreshAfter
)
let clientDeadline = clientSchedule.deadline(
now: now,
refreshAfter: refreshAfter
)
let hostSecond = Int(hostDeadline.timeIntervalSince1970) % 60
let clientSecond = Int(clientDeadline.timeIntervalSince1970) % 60
hostSeconds.append(hostSecond)
clientSeconds.append(clientSecond)
#expect((0 ... 14).contains(hostSecond))
#expect((30 ... 44).contains(clientSecond))
#expect(hostDeadline >= now)
#expect(clientDeadline >= now)
#expect(hostDeadline <= refreshAfter)
#expect(clientDeadline <= refreshAfter)
}
#expect(Set(hostSeconds).count == 1)
#expect(Set(clientSeconds).count == 1)
}
@Test
func refreshSlotsSpreadEndpointsWithinEachRole() throws {
let refreshAfter = Date(timeIntervalSince1970: 1_700_000_240)
let now = refreshAfter.addingTimeInterval(-240)
let hostSlots = try (0 ..< 16).map { index in
let identity = try CmxIrohPeerIdentity(
endpointID: String(format: "%064x", index + 1)
)
return Int(
CmxIrohRelayRefreshSchedule(role: .host, endpointIdentity: identity)
.deadline(now: now, refreshAfter: refreshAfter)
.timeIntervalSince1970
) % 60
}
let clientSlots = try (0 ..< 16).map { index in
let identity = try CmxIrohPeerIdentity(
endpointID: String(format: "%064x", index + 1)
)
return Int(
CmxIrohRelayRefreshSchedule(role: .client, endpointIdentity: identity)
.deadline(now: now, refreshAfter: refreshAfter)
.timeIntervalSince1970
) % 60
}
#expect(Set(hostSlots).count > 1)
#expect(hostSlots.allSatisfy { (0 ... 14).contains($0) })
#expect(Set(clientSlots).count > 1)
#expect(clientSlots.allSatisfy { (30 ... 44).contains($0) })
}
@Test
func bootstrapInstallsCompleteFleetBeforeSleepingUntilRefresh() async throws {
let fixture = try RelayCoordinatorFixture()
@@ -66,10 +66,10 @@ public struct ScrubberDenylists: Sendable {
"bearer",
]
/// Short or marker-free credential key aliases matched WHOLE (not as
/// substrings), so they don't redact innocuous keys that merely contain them
/// (e.g. `sid` must not match `inside`/`aside`; `su` must not match
/// `issue`/`consumer`).
/// Short or marker-free credential key aliases matched as a whole key or a
/// punctuation-delimited key component (not as substrings), so they don't
/// redact innocuous keys that merely contain them (e.g. `sid` must not match
/// `inside`/`aside`; `su` must not match `issue`/`consumer`).
///
/// These are stored **already normalized** the same way
/// `SentryScrubber.isSensitiveKey(_:)` normalizes (lowercase, strip
@@ -122,12 +122,13 @@ public struct ScrubberDenylists: Sendable {
/// prefix in the output so the redacted field stays legible; patterns with no
/// capture group replace the whole match with the secret placeholder.
///
/// The first seven entries are cmux-original and partly exceed relay (relay
/// has no provider-prefix, JWT, or AWS-access-key rule); they are kept
/// verbatim, including their exact `options:` (JWT and AWS are
/// case-sensitive on purpose). The trailing entries are the relay `@common`
/// value rules cmux was missing, ported with no capture group so cmux's
/// "group 1 is a prefix to keep" loop redacts the whole match.
/// The first five entries are cmux-original and partly exceed relay (relay
/// has no provider-prefix, JWT, or AWS-access-key rule); JWT and AWS remain
/// case-sensitive on purpose. Sensitive `key=value` assignments are not
/// regexes here: ``SentryScrubber`` parses them with a single-pass scanner
/// so marker-rich HTTP queries cannot trigger regex backtracking. The
/// trailing entries are relay `@common` value rules, ported with no capture
/// group so cmux redacts the whole match.
///
/// Upstream for the relay-ported entries:
/// getsentry/relay @ 99c91d92845fe436713b51018a7f8d2b7b469be5,
@@ -146,44 +147,6 @@ public struct ScrubberDenylists: Sendable {
SentryRegexPattern(#"(Bearer\s+)[A-Za-z0-9\-._~+/]+=*"#),
// Authorization: <scheme> <token> (Basic / Digest / token / etc.)
SentryRegexPattern(#"(Authorization:\s*\w+\s+)\S+"#),
// `<sensitive-key> = value` in raw query strings, env-style assignments,
// or JSON ("key":"value"). The marker set is kept in sync with the
// key-aware dictionary path (``SentryScrubber/isSensitiveKey(_:)``) so a
// credential like `auth=`, `session_id=`, or `cookie=` is redacted
// whether it arrives as a dictionary entry or as raw text. The marker may
// be embedded in a longer identifier (e.g. AWS_SECRET_ACCESS_KEY,
// MY_API_KEY), so optional identifier characters are allowed around it.
// Conceptually the relay PASSWORD_KEY_REGEX / TOKEN_KEY_REGEX analogue
// (relay-pii/src/regexes.rs:341-345), hand-tuned for cmux's free text.
//
// Split into a QUOTED-value rule and an UNQUOTED-value rule because the
// value terminator is quote-context-dependent: an unquoted query value
// MUST stop at `&`/`,`/`}` (`?token=X&page=2` has to keep `&page=2`),
// but those same characters can legitimately appear INSIDE a quoted JSON
// value (`"password":"a&b"`), where stopping early would leak the
// suffix. The quoted rule captures the opening value quote in its prefix
// group and consumes `[^"']*` (everything up to the closing quote); the
// unquoted rule keeps the delimiter-bounded `[^\s"'&,}]+`. It does not
// attempt to model escaped quotes inside a quoted value (a regex JSON
// parser); the structured dict-key layer plus the wholesale Data/user/
// cookie drops are the real boundary, this free-text pass is best-effort
// defense-in-depth for raw event messages / stack lines / breadcrumbs.
// Quoted-value form: redact through the closing quote.
SentryRegexPattern(
#"([A-Za-z0-9.\-]*(?:access[_\-]?token|api[_\-]?key|access[_\-]?key|private[_\-]?key|session[_\-]?id|session|secret|token|password|passwd|pwd|credentials?|cookie|bearer|auth)[A-Za-z0-9.\-]*["']?\s*[:=]\s*["'])[^"']*"#
),
// The bare `sid` session alias (`?sid=…`, `&sid=…`, `sid:…`) carries a
// session credential but is too short to embed in the marker set above
// without matching innocuous substrings (`inside=`, `aside=`). A `\b`
// word boundary anchors it so only a standalone `sid` key is redacted.
// Quoted-value form (same quote-context split as the marker rule above).
SentryRegexPattern(#"(\bsid["']?\s*[:=]\s*["'])[^"']*"#),
// Unquoted-value form of the marker rule: delimiter-bounded value.
SentryRegexPattern(
#"([A-Za-z0-9.\-]*(?:access[_\-]?token|api[_\-]?key|access[_\-]?key|private[_\-]?key|session[_\-]?id|session|secret|token|password|passwd|pwd|credentials?|cookie|bearer|auth)[A-Za-z0-9.\-]*["']?\s*[:=]\s*)[^\s"'&,}]+"#
),
// Unquoted-value form of the bare `sid` alias.
SentryRegexPattern(#"(\bsid["']?\s*[:=]\s*)[^\s"'&,}]+"#),
// Provider-style keys: sk-..., pk-..., ghp_..., xoxb-..., and similar
// prefixes. No relay equivalent cmux-specific add for dev secrets.
SentryRegexPattern(#"\b(?:sk|pk|rk|ghp|gho|ghu|ghs|ghr|xox[baprs])[_\-][A-Za-z0-9_\-]{16,}"#),
@@ -289,8 +289,8 @@ public struct SentryScrubber: Sendable {
/// strings: adding a denylist key now covers query params automatically, with
/// no parallel free-text marker list to drift out of sync. The denylist's
/// EXACT-match aliases (`csrf`, `_csrf`, `xsrf`, `_vercel_jwt`, `su`,
/// `sentrysid`, `phpsessid`, `sid`, …) are therefore caught here even though
/// they are too short to embed safely in the free-text assignment regex.
/// `sentrysid`, `phpsessid`, `sid`, …) are therefore caught here and by the
/// free-text assignment scanner without ambiguous regex matching.
///
/// The key is URL-decoded before the sensitivity check (so `%5Fcsrf` matches
/// `_csrf`) but the original, still-encoded key text is emitted unchanged.
@@ -336,15 +336,21 @@ public struct SentryScrubber: Sendable {
/// - Parameter key: The dictionary or header key.
/// - Returns: `true` when the key's value should be redacted wholesale.
static func isSensitiveKey(_ key: String) -> Bool {
let normalized = key.lowercased().replacingOccurrences(of: "-", with: "")
.replacingOccurrences(of: "_", with: "")
.replacingOccurrences(of: " ", with: "")
let normalized = normalizedSensitiveKey(key)
if sensitiveKeyExactMarkers.contains(normalized) {
return true
}
for marker in sensitiveKeyMarkers where normalized.contains(marker) {
return true
}
// Exact aliases such as `sid` and `su` may appear as a leaf after a
// punctuation boundary (`x-sid`, `vendor.sid`). Match whole components
// without regressing to substring behavior that would redact `inside`.
for component in key.split(whereSeparator: { !$0.isLetter && !$0.isNumber }) {
if sensitiveKeyExactMarkers.contains(normalizedSensitiveKey(String(component))) {
return true
}
}
return false
}
@@ -359,7 +365,7 @@ public struct SentryScrubber: Sendable {
/// Short or marker-free credential key aliases matched WHOLE (not as
/// substrings), so they don't redact innocuous keys that merely contain them
/// (e.g. `sid` must not match `inside`/`aside`). The free-text scrubber
/// covers their `key=value` form via a `\b`-anchored pattern.
/// covers their `key=value` form through the shared key check.
///
/// Sourced from ``ScrubberDenylists/sensitiveKeyExactMarkers`` (ported from
/// sentry-python `DEFAULT_DENYLIST` + `DEFAULT_PII_DENYLIST` and relay's
@@ -406,7 +412,8 @@ public struct SentryScrubber: Sendable {
/// Replaces email addresses with ``redactedEmail``.
private func redactEmails(in text: String) -> String {
Self.email.replace(in: text) { _ in Self.redactedEmail }
guard text.contains("@") else { return text }
return Self.email.replace(in: text) { _ in Self.redactedEmail }
}
// MARK: - Secrets
@@ -424,6 +431,220 @@ public struct SentryScrubber: Sendable {
return Self.redactedSecret
}
}
return result
return redactSensitiveAssignments(in: result)
}
/// Redacts sensitive `key=value` and `key: value` assignments in one pass.
///
/// The previous regex family placed unbounded key quantifiers around a
/// sensitive-marker alternation. Marker-rich HTTP queries without a later
/// assignment delimiter made ICU reconsider the same suffix repeatedly,
/// blocking the thread that added a Sentry breadcrumb. This scanner visits
/// the input in linear time, reuses the structured key denylist, and
/// understands escaped quotes in JSON-style values.
private func redactSensitiveAssignments(in text: String) -> String {
let utf8 = text.utf8
guard utf8.contains(0x3A) || utf8.contains(0x3D) else { return text }
if let result = utf8.withContiguousStorageIfAvailable({ bytes in
redactSensitiveAssignments(in: text, bytes: bytes)
}) {
return result
}
return redactSensitiveAssignments(in: text, bytes: Array(utf8))
}
private func redactSensitiveAssignments<Bytes: RandomAccessCollection>(
in text: String,
bytes: Bytes
) -> String where Bytes.Element == UInt8, Bytes.Index == Int {
var replacementRanges: [Range<Int>] = []
var index = 0
while index < bytes.count {
guard bytes[index] == 0x3A || bytes[index] == 0x3D else {
index += 1
continue
}
guard let range = sensitiveAssignmentValueRange(
delimiterIndex: index,
bytes: bytes
) else {
index += 1
continue
}
replacementRanges.append(range)
index = max(index + 1, range.upperBound)
}
guard !replacementRanges.isEmpty else { return text }
let replacement = Array(Self.redactedSecret.utf8)
var output: [UInt8] = []
output.reserveCapacity(bytes.count)
var copiedThrough = 0
for range in replacementRanges {
output.append(contentsOf: bytes[copiedThrough ..< range.lowerBound])
output.append(contentsOf: replacement)
copiedThrough = range.upperBound
}
output.append(contentsOf: bytes[copiedThrough...])
return String(decoding: output, as: UTF8.self)
}
}
/// Resolves the value bytes belonging to a sensitive assignment delimiter.
private func sensitiveAssignmentValueRange<Bytes: RandomAccessCollection>(
delimiterIndex: Int,
bytes: Bytes
) -> Range<Int>? where Bytes.Element == UInt8, Bytes.Index == Int {
guard delimiterIndex > 0 else { return nil }
var keyCursor = delimiterIndex - 1
while keyCursor >= 0, isASCIIWhitespace(bytes[keyCursor]) {
keyCursor -= 1
}
if keyCursor >= 0, isQuote(bytes[keyCursor]) {
keyCursor -= 1
while keyCursor >= 0, isASCIIWhitespace(bytes[keyCursor]) {
keyCursor -= 1
}
}
guard keyCursor >= 0 else { return nil }
let keyEnd = keyCursor + 1
while keyCursor >= 0, isAssignmentKeyByte(bytes[keyCursor]) {
keyCursor -= 1
}
let keyStart = keyCursor + 1
guard keyStart < keyEnd else { return nil }
let key = String(decoding: bytes[keyStart ..< keyEnd], as: UTF8.self)
guard SentryScrubber.isSensitiveKey(key) else { return nil }
var valueStart = delimiterIndex + 1
while valueStart < bytes.count, isASCIIWhitespace(bytes[valueStart]) {
valueStart += 1
}
guard valueStart < bytes.count else { return nil }
if isQuote(bytes[valueStart]) {
let quote = bytes[valueStart]
valueStart += 1
var valueEnd = valueStart
while valueEnd < bytes.count {
if bytes[valueEnd] == quote {
break
}
if bytes[valueEnd] == 0x5C, valueEnd + 1 < bytes.count {
valueEnd += 2
} else {
valueEnd += 1
}
}
guard valueStart < valueEnd else { return nil }
return valueStart ..< valueEnd
}
if isAuthorizationAssignmentKey(key) {
let firstTokenStart = valueStart
while valueStart < bytes.count,
!isUnquotedValueTerminator(at: valueStart, in: bytes)
{
valueStart += 1
}
var secondTokenStart = valueStart
while secondTokenStart < bytes.count, isASCIIWhitespace(bytes[secondTokenStart]) {
secondTokenStart += 1
}
if secondTokenStart < bytes.count,
!isUnquotedValueTerminator(at: secondTokenStart, in: bytes)
{
valueStart = secondTokenStart
} else {
valueStart = firstTokenStart
}
}
var valueEnd = valueStart
while valueEnd < bytes.count,
!isUnquotedValueTerminator(at: valueEnd, in: bytes)
{
valueEnd += 1
}
guard valueStart < valueEnd else { return nil }
return valueStart ..< valueEnd
}
private func normalizedSensitiveKey(_ key: String) -> String {
key.lowercased().replacingOccurrences(of: "-", with: "")
.replacingOccurrences(of: "_", with: "")
.replacingOccurrences(of: " ", with: "")
}
private func isAuthorizationAssignmentKey(_ key: String) -> Bool {
let normalized = normalizedSensitiveKey(key)
return normalized == "authorization" || normalized == "proxyauthorization"
}
private func isAssignmentKeyByte(_ byte: UInt8) -> Bool {
(byte >= 0x30 && byte <= 0x39)
|| (byte >= 0x41 && byte <= 0x5A)
|| (byte >= 0x61 && byte <= 0x7A)
|| byte == 0x2D
|| byte == 0x2E
|| byte == 0x5F
}
private func isASCIIWhitespace(_ byte: UInt8) -> Bool {
byte == 0x20 || byte == 0x09 || byte == 0x0A || byte == 0x0D
}
private func isQuote(_ byte: UInt8) -> Bool {
byte == 0x22 || byte == 0x27
}
private func isUnquotedValueTerminator<Bytes: RandomAccessCollection>(
at index: Int,
in bytes: Bytes
) -> Bool where Bytes.Element == UInt8, Bytes.Index == Int {
let byte = bytes[index]
if isASCIIWhitespace(byte) {
return true
}
// Query and environment separators are also valid credential bytes. Only
// preserve one when the suffix proves that a new assignment starts there;
// otherwise redact through it so no secret suffix can escape.
if byte == 0x26 || byte == 0x2C || byte == 0x3B {
return startsAssignment(after: index, in: bytes)
}
// Quotes and closing delimiters are structural only at a visible boundary.
// Embedded punctuation such as `token=abc)def` remains part of the value.
if isQuote(byte) || byte == 0x29 || byte == 0x5D || byte == 0x7D {
let nextIndex = index + 1
return nextIndex == bytes.count
|| isASCIIWhitespace(bytes[nextIndex])
|| startsAssignment(after: index, in: bytes)
}
return false
}
private func startsAssignment<Bytes: RandomAccessCollection>(
after separatorIndex: Int,
in bytes: Bytes
) -> Bool where Bytes.Element == UInt8, Bytes.Index == Int {
var cursor = separatorIndex + 1
while cursor < bytes.count, isASCIIWhitespace(bytes[cursor]) {
cursor += 1
}
let keyStart = cursor
while cursor < bytes.count, isAssignmentKeyByte(bytes[cursor]) {
cursor += 1
}
guard cursor > keyStart else { return false }
while cursor < bytes.count, isASCIIWhitespace(bytes[cursor]) {
cursor += 1
}
return cursor < bytes.count && (bytes[cursor] == 0x3A || bytes[cursor] == 0x3D)
}
@@ -113,7 +113,7 @@ import Testing
("creds AKIAIOSFODNN7EXAMPLE rejected", "creds <redacted-secret> rejected"),
// Quoted JSON values whose secret contains a delimiter (`&`, `,`, `}`)
// must redact through the CLOSING quote, not stop at the delimiter and
// leak the tail (the quote-context split). These are the exact shapes a
// leak the tail. These are the exact shapes a
// raw event message / breadcrumb / NSError description arrives as.
(#"{"password":"abc&def"}"#, #"{"password":"<redacted-secret>"}"#),
(#"{"token":"a,b,c"}"#, #"{"token":"<redacted-secret>"}"#),
@@ -67,6 +67,21 @@ import Testing
)
}
@Test func redactsAuthorizationAssignmentsWithAndWithoutSchemes() {
#expect(
scrubber.scrub("Authorization: Basic dXNlcjpwYXNz")
== "Authorization: Basic <redacted-secret>"
)
#expect(
scrubber.scrub("Authorization: opaquetoken")
== "Authorization: <redacted-secret>"
)
#expect(
scrubber.scrub("Proxy-Authorization=Digest opaquetoken")
== "Proxy-Authorization=Digest <redacted-secret>"
)
}
@Test func redactsTokenQueryParameterButKeepsKey() {
#expect(
scrubber.scrub("GET https://api.example.com/v1?token=supersecretvalue123&page=2")
@@ -81,6 +96,22 @@ import Testing
)
}
@Test func redactsEscapedQuoteInsideQuotedSecretAssignment() {
#expect(
scrubber.scrub(#"{"password":"abc\"def"}"#)
== #"{"password":"<redacted-secret>"}"#
)
}
@Test
func markerRichTextWithoutAnAssignmentDoesNotWedgeScrubbing() {
// Sentry HTTP-query breadcrumbs can contain long branch names with
// "author" fragments. The free-text scanner must inspect this once,
// not repeatedly backtrack around the embedded "auth" substring.
let input = "state=all&head=" + String(repeating: "author-", count: 160) + "branch"
#expect(scrubber.scrub(input) == input)
}
@Test func redactsProviderApiKey() {
#expect(
scrubber.scrub("using sk-proj-abcdef0123456789ABCDEF to call")
@@ -108,8 +139,8 @@ import Testing
}
@Test func redactsBroaderCredentialMarkersInRawQueryStrings() {
// The free-text assignment markers stay in sync with the dictionary
// sensitive-key set, so auth/session/cookie params are caught as raw text.
// The free-text assignment scanner shares the dictionary sensitive-key
// set, so auth/session/cookie params are caught as raw text.
#expect(
scrubber.scrub("GET /x?auth=opaquesessionvalue&page=1")
== "GET /x?auth=<redacted-secret>&page=1"
@@ -124,6 +155,27 @@ import Testing
)
}
@Test func redactsPunctuationInsideUnquotedCredentialValues() {
for input in [
"token=abc;def",
"token=abc)def",
"token=abc]def",
"token=abc,def",
"token=abc}def",
] {
#expect(scrubber.scrub(input) == "token=<redacted-secret>")
}
#expect(
scrubber.scrub("token=abc;page=2")
== "token=<redacted-secret>;page=2"
)
#expect(
scrubber.scrub("env TOKEN=abc,KEEP=2")
== "env TOKEN=<redacted-secret>,KEEP=2"
)
}
@Test func redactsEnvStyleSecretAssignmentWithLongerKeyName() {
// The sensitive marker is embedded in a longer env identifier.
#expect(
@@ -157,6 +209,28 @@ import Testing
#expect(scrubber.scrub("aside=note") == "aside=note")
}
@Test func redactsSensitiveExactAliasesAtPunctuationBoundaries() {
#expect(
scrubber.scrub(#"{"connect.sid":"session-secret"}"#)
== #"{"connect.sid":"<redacted-secret>"}"#
)
#expect(
scrubber.scrub("x-sid=session-secret")
== "x-sid=<redacted-secret>"
)
#expect(
scrubber.scrubQueryString("x-sid=session-secret&inside=hallway")
== "x-sid=<redacted-secret>&inside=hallway"
)
let structured = scrubber.scrub(dictionary: [
"x-sid": "session-secret",
"x-inside": "hallway",
])
#expect(structured["x-sid"] as? String == "<redacted-secret>")
#expect(structured["x-inside"] as? String == "hallway")
#expect(scrubber.scrub("x-inside=hallway") == "x-inside=hallway")
}
@Test func sessionAndSidDictionaryKeysAreSensitiveWithoutOvermatching() {
let input: [String: Any] = [
"session": "abc",
@@ -248,7 +322,7 @@ import Testing
@Test func scrubsSensitiveQueryParamsByKeyKeepingNonSensitive() {
// The maintained denylist's EXACT aliases (csrf/_csrf, _vercel_jwt, su,
// phpsessid, sid) are redacted by key even though they are too short to
// live in the free-text assignment regex. Keys stay; non-sensitive params
// need ambiguous free-text regexes. Keys stay; non-sensitive params
// (page) are untouched.
#expect(
scrubber.scrubQueryString("_csrf=abc123&_vercel_jwt=xyz789&page=2")
@@ -0,0 +1,110 @@
import Foundation
/// The authenticated Mac-side gate for forwarding notifications to this phone.
///
/// The Mac includes this only after the caller proves same-account ownership.
/// Missing or unknown values must therefore be treated as unavailable, never as
/// ready.
public struct MobileHostPhonePushStatus: Decodable, Equatable, Sendable {
/// When the Mac forwards otherwise-qualifying notifications.
public enum Mode: String, Decodable, Equatable, Sendable {
/// Forward only while the Mac is locked, asleep, or idle.
case onlyWhenAway
/// Forward regardless of Mac presence.
case always
}
/// What the authenticated status exchange proves about account ownership.
public enum AccountScope: String, Decodable, Equatable, Sendable {
/// The Mac verified the phone's Stack token against its own account.
case verifiedSameAccount = "verified_same_account"
}
/// The Mac's sanitized current decision for a would-be notification.
public enum Admission: String, Decodable, Equatable, Sendable {
case allowed
case forwardingDisabled = "forwarding_disabled"
case suppressedMacActive = "suppressed_mac_active"
case unknown
}
/// Durability of the Mac's bounded retry queue. Failures degrade restart
/// reliability without claiming that the live APNs request path is down.
public enum QueuePersistence: String, Decodable, Equatable, Sendable {
case unknown
case healthy
case loadFailed = "load_failed"
case saveFailed = "save_failed"
case clearFailed = "clear_failed"
}
/// Whether the Mac's independent forwarding privacy gate is enabled.
public let forwardingEnabled: Bool
/// The Mac's live forwarding mode.
public let mode: Mode
/// Whether the current mode and presence admit a forward right now.
public let admission: Admission
/// Sanitized persistence health for queued Mac-to-phone events.
public let queuePersistence: QueuePersistence
/// Whether terminal title/body content is redacted before upload.
public let hideContent: Bool
/// The API base URL the Mac will send the notification through.
public let apiOrigin: String
/// The account relationship proven by the authenticated RPC.
public let accountScope: AccountScope
/// Creates an authenticated Mac push-status value.
public init(
forwardingEnabled: Bool,
mode: Mode,
admission: Admission = .unknown,
queuePersistence: QueuePersistence = .unknown,
hideContent: Bool = false,
apiOrigin: String,
accountScope: AccountScope
) {
self.forwardingEnabled = forwardingEnabled
self.mode = mode
self.admission = admission
self.queuePersistence = queuePersistence
self.hideContent = hideContent
self.apiOrigin = apiOrigin
self.accountScope = accountScope
}
private enum CodingKeys: String, CodingKey {
case forwardingEnabled = "forwarding_enabled"
case mode
case admission
case queuePersistence = "queue_persistence"
case hideContent = "hide_content"
case apiOrigin = "api_origin"
case accountScope = "account_scope"
}
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
forwardingEnabled = try container.decode(
Bool.self,
forKey: .forwardingEnabled
)
mode = try container.decode(Mode.self, forKey: .mode)
admission = try container.decodeIfPresent(
Admission.self,
forKey: .admission
) ?? .unknown
queuePersistence = try container.decodeIfPresent(
QueuePersistence.self,
forKey: .queuePersistence
) ?? .unknown
hideContent = try container.decodeIfPresent(
Bool.self,
forKey: .hideContent
) ?? false
apiOrigin = try container.decode(String.self, forKey: .apiOrigin)
accountScope = try container.decode(
AccountScope.self,
forKey: .accountScope
)
}
}
@@ -38,6 +38,10 @@ public struct MobileHostStatusResponse: Decodable, Sendable {
/// colors. `nil` from older Macs that predate the field, in which case the
/// phone keeps its built-in Monokai default.
public let theme: TerminalTheme?
/// Authenticated Mac-side phone-forwarding status. `nil` means the caller
/// could not prove same-account ownership, the Mac predates this field, or
/// the value was malformed. None of those states is ready.
public let phonePush: MobileHostPhonePushStatus?
private enum CodingKeys: String, CodingKey {
case capabilities
@@ -49,6 +53,7 @@ public struct MobileHostStatusResponse: Decodable, Sendable {
case macAppVersion = "mac_app_version"
case macAppBuild = "mac_app_build"
case theme
case phonePush = "phone_push"
}
public init(from decoder: any Decoder) throws {
@@ -69,6 +74,12 @@ public struct MobileHostStatusResponse: Decodable, Sendable {
// leniently: a bad theme object yields `nil` and the phone keeps its
// built-in Monokai default, exactly like an older Mac that omits it.
theme = (try? container.decodeIfPresent(TerminalTheme.self, forKey: .theme)) ?? nil
// Keep an unknown future mode/account value from invalidating the
// transport and identity fields in the same status response.
phonePush = (try? container.decodeIfPresent(
MobileHostPhonePushStatus.self,
forKey: .phonePush
)) ?? nil
}
/// Decode a host-status response from raw JSON data.
@@ -49,6 +49,57 @@ import Testing
#expect(response.terminalThemeRevisionEpoch == "boot-one")
}
@Test func hostStatusDecodesAuthenticatedPhonePushReadiness() throws {
let response = try MobileHostStatusResponse.decode(Data(
"""
{
"mac_device_id": "AAAAAAAA-BBBB-4CCC-8DDD-EEEEEEEEEEEE",
"phone_push": {
"forwarding_enabled": true,
"mode": "onlyWhenAway",
"admission": "suppressed_mac_active",
"queue_persistence": "healthy",
"hide_content": true,
"api_origin": "https://cmux-staging.vercel.app",
"account_scope": "verified_same_account"
}
}
""".utf8
))
#expect(response.phonePush == MobileHostPhonePushStatus(
forwardingEnabled: true,
mode: .onlyWhenAway,
admission: .suppressedMacActive,
queuePersistence: .healthy,
hideContent: true,
apiOrigin: "https://cmux-staging.vercel.app",
accountScope: .verifiedSameAccount
))
}
@Test func hostStatusKeepsMissingQueueHealthDistinctFromFailure() throws {
let missing = try MobileHostStatusResponse.decode(Data(
#"{"phone_push":{"forwarding_enabled":true,"mode":"always","admission":"allowed","api_origin":"https://cmux.com","account_scope":"verified_same_account"}}"#.utf8
))
let failed = try MobileHostStatusResponse.decode(Data(
#"{"phone_push":{"forwarding_enabled":true,"mode":"always","admission":"allowed","queue_persistence":"save_failed","api_origin":"https://cmux.com","account_scope":"verified_same_account"}}"#.utf8
))
#expect(missing.phonePush?.queuePersistence == .unknown)
#expect(failed.phonePush?.queuePersistence == .saveFailed)
}
@Test func hostStatusTreatsMissingOrUnknownPhonePushStateAsUnavailable() throws {
let missing = try MobileHostStatusResponse.decode(Data("{}".utf8))
let unknown = try MobileHostStatusResponse.decode(Data(
#"{"phone_push":{"forwarding_enabled":true,"mode":"future","api_origin":"x","account_scope":"future"}}"#.utf8
))
#expect(missing.phonePush == nil)
#expect(unknown.phonePush == nil)
}
@Test func hostStatusCanonicalizesOnlyUUIDDeviceIDs() throws {
let uppercaseUUID = "AAAAAAAA-BBBB-4CCC-8DDD-EEEEEEEEEEEE"
let uuidResponse = try MobileHostStatusResponse.decode(Data(
@@ -0,0 +1,14 @@
/// The furthest stage a user-triggered test alert has confirmed.
///
/// `queuedOnMac` deliberately does not claim APNs acceptance or visible iOS
/// presentation. Those later stages remain observable through correlated Mac,
/// backend, and device evidence rather than a false-success button label.
public enum MobilePhonePushTestStage: String, Equatable, Sendable {
case queuedOnMac = "queued"
case forwardingDisabled = "forwarding_disabled"
case macActive = "suppressed_mac_active"
case authenticationUnavailable = "authentication_unavailable"
case encodingFailed = "encoding_failed"
case queueFull = "queue_full"
case unavailable
}
@@ -0,0 +1,21 @@
import CmuxMobileShellModel
@MainActor
extension MobileShellComposite {
/// Observes the shared Settings/onboarding choice and replaces any live
/// foreground connection whose route was selected under the old method.
func startObservingConnectionMethodChanges() {
guard connectionMethodObservationTask == nil,
let connectionMethodStore else { return }
let initialMethod = connectionMethodStore.method
connectionMethodObservationTask = Task { @MainActor [weak self, connectionMethodStore] in
var observedMethod = initialMethod
for await method in connectionMethodStore.changes() {
guard let self, !Task.isCancelled else { return }
guard method != observedMethod else { continue }
observedMethod = method
self.recoverMobileConnection(trigger: .connectionMethodChanged)
}
}
}
}
@@ -85,7 +85,7 @@ extension MobileShellComposite {
}
if let accountID = identityProvider?.currentUserID {
switch trigger {
case .manual, .networkChange, .foreground:
case .manual, .networkChange, .foreground, .connectionMethodChanged:
clearTransientAutomaticReconnectBackoff(accountID: accountID)
case .presencePush:
guard !automaticIrohReconnectIsBlocked(accountID: accountID) else {
@@ -96,10 +96,24 @@ extension MobileShellComposite {
break
}
}
let connectionMethodChanged: Bool
if case .connectionMethodChanged = trigger {
connectionMethodChanged = true
// A method change invalidates every route decision made by an
// in-flight recovery. The replacement below owns a new generation
// and is the only attempt allowed to publish a foreground client.
connectionRecoveryOwner.cancel()
applyConnectionRecoveryOwnerState()
invalidateStoredMacReconnectAttempt()
} else {
connectionMethodChanged = false
}
beginConnectionRecovery(
trigger: trigger,
expectedClient: remoteClient,
probeCurrentConnection: connectionState == .connected && remoteClient != nil,
probeCurrentConnection: !connectionMethodChanged
&& connectionState == .connected
&& remoteClient != nil,
resyncAfterHealthy: true
)
// A disconnected redial has cleared its foreground identity. Starting
@@ -198,7 +212,8 @@ extension MobileShellComposite {
markMacConnectionReconnecting()
resyncTerminalOutput(reason: trigger.description, restartEventStream: true)
case .manual, .presencePush, .foreground, .eventStreamEnded,
.subscriptionStartFailed, .transportWriteTimedOut, .automaticBackoffExpired:
.subscriptionStartFailed, .transportWriteTimedOut, .automaticBackoffExpired,
.connectionMethodChanged:
markMacConnectionUnavailableIfNoStore()
}
return
@@ -615,8 +630,8 @@ extension MobileShellComposite {
routes,
supportedKinds: supportedKinds,
preferNonLoopback: Self.prefersNonLoopbackRoutes,
tailscalePreference: connectionMethodStore?.method == .tailscale
? Self.TailscaleRoutePreference(
tailscaleRequirement: connectionMethodStore?.method == .tailscale
? Self.TailscaleRouteRequirement(
macDeviceID: pairedMacDeviceID,
grantRoutes: legacyTailscaleRoutes
)
@@ -110,9 +110,9 @@ extension MobileShellComposite {
return nil
}
/// The Tailscale ordering preference for one paired Mac: which grant routes
/// may promote an exact stored Tailscale route ahead of the Iroh pin.
struct TailscaleRoutePreference {
/// The strict Tailscale policy for one paired Mac: only exact grant routes
/// remain dialable while the user has selected Tailscale.
struct TailscaleRouteRequirement {
let macDeviceID: String
let grantRoutes: [CmxAttachRoute]
}
@@ -127,16 +127,15 @@ extension MobileShellComposite {
/// or revocation failure could silently downgrade around the Iroh device
/// grant. Pairings without an authenticated Iroh identity remain fail-closed.
///
/// `tailscalePreference` (the user's explicit Tailscale connection-method
/// choice) relaxes only the ORDER of that pin: stored Tailscale routes that
/// carry a device-local grant dial first, and the Iroh routes stay as the
/// fallback instead of being exclusive. Unauthorized Tailscale routes are
/// still never dialable, so a preference flip alone grants nothing.
/// `tailscaleRequirement` represents the user's explicit Tailscale-only
/// connection method. Only stored Tailscale routes carrying a device-local
/// grant remain; Iroh is not retained as a fallback, and a method change
/// alone grants nothing.
static func storedReconnectRoutes(
_ routes: [CmxAttachRoute],
supportedKinds: [CmxAttachTransportKind],
preferNonLoopback: Bool = false,
tailscalePreference: TailscaleRoutePreference? = nil
tailscaleRequirement: TailscaleRouteRequirement? = nil
) -> [CmxAttachRoute] {
let supportedKinds = Set(supportedKinds)
var ordered = CmxAttachRoute.addingIrohPrivatePaths(
@@ -149,20 +148,15 @@ extension MobileShellComposite {
ordered.removeAll { $0.kind == .debugLoopback }
}
let irohRoutes = ordered.filter { $0.kind == .iroh }
if let tailscalePreference {
if let tailscaleRequirement {
let authorizedTailscale = ordered.filter { route in
legacyTailscaleAuthorizationEvidence(
for: route,
macDeviceID: tailscalePreference.macDeviceID,
persistedRoutes: tailscalePreference.grantRoutes
macDeviceID: tailscaleRequirement.macDeviceID,
persistedRoutes: tailscaleRequirement.grantRoutes
) != nil
}
if !authorizedTailscale.isEmpty {
let rest = ordered.filter { route in
route.kind != .iroh && route.kind != .tailscale
}
return authorizedTailscale + irohRoutes + rest
}
return authorizedTailscale
}
if !irohRoutes.isEmpty {
return irohRoutes
@@ -172,7 +166,7 @@ extension MobileShellComposite {
/// The dial order for one stored Mac, honoring the user's connection-method
/// choice. With the default automatic method this is exactly
/// ``storedReconnectRoutes(_:supportedKinds:preferNonLoopback:tailscalePreference:)``
/// ``storedReconnectRoutes(_:supportedKinds:preferNonLoopback:tailscaleRequirement:)``
/// without a preference.
func orderedReconnectRoutes(
for mac: MobilePairedMac,
@@ -182,8 +176,8 @@ extension MobileShellComposite {
mac.routes,
supportedKinds: supportedKinds,
preferNonLoopback: Self.prefersNonLoopbackRoutes,
tailscalePreference: connectionMethodStore?.method == .tailscale
? TailscaleRoutePreference(
tailscaleRequirement: connectionMethodStore?.method == .tailscale
? TailscaleRouteRequirement(
macDeviceID: mac.macDeviceID,
grantRoutes: mac.legacyTailscaleRoutes ?? []
)
@@ -54,6 +54,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
"workspace.updated", "mobile.sync.delta",
"terminal.bytes", "terminal.render_grid", "terminal.set_font",
"notification.dismissed", "notification.badge", "notification.feed.changed",
"phone_push.status.changed",
"browser.frame", "browser.state", "browser.closed", "browser.dialog", "browser.dialog.resolved",
]
case .renderGrid:
@@ -61,6 +62,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
"workspace.updated", "mobile.sync.delta",
"terminal.render_grid", "terminal.set_font",
"notification.dismissed", "notification.badge", "notification.feed.changed",
"phone_push.status.changed",
"browser.frame", "browser.state", "browser.closed", "browser.dialog", "browser.dialog.resolved",
]
case .rawBytes:
@@ -68,6 +70,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
"workspace.updated", "mobile.sync.delta",
"terminal.bytes", "terminal.set_font",
"notification.dismissed", "notification.badge", "notification.feed.changed",
"phone_push.status.changed",
"browser.frame", "browser.state", "browser.closed", "browser.dialog", "browser.dialog.resolved",
]
}
@@ -130,7 +133,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
static let dogfoodFeedbackCapability = "dogfood.v1"
static let workspaceGroupsCapability = "workspace.groups.v1"
static let notificationFeedCapability = "notification.feed.v1"
private static let terminalOutputCapabilityTimeoutNanoseconds: UInt64 = 750_000_000
static let phonePushSettingsCapability = "phone_push.settings.v1"
static let phonePushTestCapability = "phone_push.test.v1"
nonisolated private static let terminalOutputCapabilityTimeoutNanoseconds: UInt64 = 750_000_000
/// How long the render-grid stream may stay silent (no event of any topic)
/// before the liveness watchdog suspects the push subscription is dead and
/// runs a bounded host probe; only repeated failed probes force the
@@ -446,6 +451,21 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
}
}
}
/// Authenticated phone-forwarding readiness from the focused Mac. `nil`
/// means no attached Mac has proved same-account ownership and exposed the
/// independent Mac privacy gate.
public internal(set) var phonePushMacStatus: MobileHostPhonePushStatus?
/// Whether the authenticated Mac supports changing its independent phone
/// forwarding privacy gates from iOS.
public var supportsPhonePushSettings: Bool {
supportedHostCapabilities.contains(Self.phonePushSettingsCapability)
}
/// Whether the authenticated Mac can enqueue a correlated test alert.
public var supportsPhonePushTest: Bool {
supportedHostCapabilities.contains(Self.phonePushTestCapability)
}
/// Published workspace-list chip snapshots keyed by Mac-local workspace id.
///
/// Like ``workspaces``, this is a materialized immutable-value surface on the
@@ -717,8 +737,12 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
let runtime: (any MobileSyncRuntime)?
let pairedMacStore: (any MobilePairedMacStoring)?
/// The user's connection-method choice. `nil` (previews/tests without one)
/// behaves like the default automatic method.
/// The user's connection-method choice. The shipping app always injects
/// this at the composition root (`AppCompositionRoot` holds it
/// non-optional), so a user-selected Tailscale Only choice can never be
/// dropped at runtime. `nil` exists only for DEBUG previews, the
/// hide-computers verifier, and unit-test fixtures, which have no user
/// preference and behave like the default automatic method.
let connectionMethodStore: MobileConnectionMethodStore?
/// Single compatibility authority shared by registry, persistence, and live connections.
let buildCompatibilityPolicy: MobileMacBuildCompatibilityPolicy?
@@ -1511,6 +1535,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
browserStreamEvents?.configureBrowserStreamRestart { [weak self] panelID in
await self?.forceRestartMobileBrowserStream(panelID: panelID)
}
startObservingConnectionMethodChanges()
}
isolated deinit {
@@ -1518,6 +1543,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
automaticReconnectRetryTask?.cancel()
presenceTask?.cancel()
networkPathObservationTask?.cancel()
connectionMethodObservationTask?.cancel()
terminalEventListenerTask?.cancel()
terminalSubscriptionStartTask?.cancel()
renderGridLivenessTimer?.cancel()
@@ -1975,6 +2001,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
var networkPathObservationStarted = false
var networkPathObservationTask: Task<Void, Never>?
var connectionMethodObservationTask: Task<Void, Never>?
let connectionRecoveryOwner = MobileConnectionRecoveryOwner()
var lastReconnectStackUserID: String?
/// Whether the scene is in the active phase. Set by
@@ -1999,6 +2026,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
case subscriptionStartFailed
case transportWriteTimedOut
case automaticBackoffExpired
case connectionMethodChanged
var reschedulesSecondaryAggregation: Bool { self != .presencePush }
@@ -2016,6 +2044,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
case .subscriptionStartFailed: 7
case .transportWriteTimedOut: 8
case .automaticBackoffExpired: 9
case .connectionMethodChanged: 10
}
}
@@ -2030,6 +2059,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
case .subscriptionStartFailed: return "subscriptionStartFailed"
case .transportWriteTimedOut: return "transportWriteTimedOut"
case .automaticBackoffExpired: return "automaticBackoffExpired"
case .connectionMethodChanged: return "connectionMethodChanged"
}
}
}
@@ -2450,7 +2480,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
if hasKnownStoredMac {
setHasKnownPairedMac(true, generation: generation)
}
let irohReconnectIsBlocked = automaticIrohReconnectIsBlocked(accountID: scope.userID)
let tailscaleOnly = connectionMethodStore?.method == .tailscale
let irohReconnectIsBlocked = tailscaleOnly
|| automaticIrohReconnectIsBlocked(accountID: scope.userID)
// Capture one coherent post-request view of the registry and paired-Mac
// store. The store read happens after the registry await, so an
// authenticated Presence write that lands during the request wins. The
@@ -2480,7 +2512,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
instanceTag: mac.instanceTag,
scope: scope
) else { break }
let irohReconnectIsBlocked = automaticIrohReconnectIsBlocked(accountID: scope.userID)
let irohReconnectIsBlocked = tailscaleOnly
|| automaticIrohReconnectIsBlocked(accountID: scope.userID)
let localRoutes = storedReconnectRoutes(mac).filter {
!irohReconnectIsBlocked || $0.kind != .iroh
}
@@ -2514,7 +2547,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
}
)
}
if connectionState != .connected,
if connectionState != .connected, !tailscaleOnly,
!automaticIrohReconnectIsBlocked(accountID: scope.userID) {
switch await freshReconnectRoutesAfterLocalFailure(
for: mac,
@@ -2553,7 +2586,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
// saved candidate failed. This keeps a healthy saved Mac from sitting
// behind an unrelated account-wide discovery request.
var zeroTouchCandidates: [MobilePairedMac] = []
if connectionState != .connected,
if connectionState != .connected, !tailscaleOnly,
!automaticIrohReconnectIsBlocked(accountID: scope.userID) {
zeroTouchCandidates = await discoverZeroTouchIrohCandidates(
scope: scope,
@@ -8272,6 +8305,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
foregroundMacDeviceID = resolvedForegroundMacID
}
supportedHostCapabilities = authenticatedCapabilities
phonePushMacStatus = status.phonePush
// Publish transport selection with the authenticated
// capability snapshot before exposing `.connected`.
// The listener reuses this same status below, but starts in
@@ -8423,10 +8457,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
let irohRoutes = supportedRoutes.filter { route in
route.kind == .iroh
}
// The user's explicit Tailscale method relaxes only the Iroh pin's
// ORDER: authorized Tailscale routes dial first and Iroh remains the
// fallback. Routes without a grant or a user-entered code stay
// undialable regardless of the preference.
// The explicit Tailscale method is strict: only authorized Tailscale
// destinations may be dialed, and an unavailable route leaves the app
// disconnected instead of silently switching to Iroh.
if connectionMethodStore?.method == .tailscale {
let authorizedTailscale = supportedRoutes.filter { route in
Self.legacyTailscaleAuthorizationEvidence(
@@ -8439,12 +8472,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
authorizations: userTailscalePairingAuthorizations
) != nil
}
if !authorizedTailscale.isEmpty {
let rest = supportedRoutes.filter { route in
route.kind != .iroh && route.kind != .tailscale
}
return authorizedTailscale + irohRoutes + rest
}
return authorizedTailscale
}
return irohRoutes.isEmpty ? supportedRoutes : irohRoutes
}
@@ -9002,6 +9030,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
terminalOutputTransport = .rawBytes
deactivateAllTerminalLanes()
supportedHostCapabilities = []
phonePushMacStatus = nil
clearMacUpdateHint()
terminalSubscriptionRefreshTask?.cancel()
terminalSubscriptionRefreshTask = nil
@@ -10221,6 +10250,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
return .rawBytes
}
supportedHostCapabilities = Set(payload.capabilities)
phonePushMacStatus = payload.phonePush
restartActiveMobileBrowserStreams()
refreshVisibleMobileBrowserPanels()
prepareTerminalThemeRevisionAuthority(
@@ -10441,6 +10471,11 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
macDeviceID: macDeviceID
)
)
} else if event.topic == "phone_push.status.changed" {
await self.refreshPhonePushStatus(
client: client,
generation: self.connectionGeneration
)
} else if event.topic == "browser.frame" {
self.handleMobileBrowserFrameEvent(event)
} else if event.topic == "browser.state" {
@@ -10467,6 +10502,152 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
}
}
private func refreshPhonePushStatus(
client: MobileCoreRPCClient,
generation: UUID
) async {
let exchange: (response: Data, hostStatusResponse: Data)
do {
exchange = try await client.sendRequestAndAuthenticatedHostStatus(
MobileCoreRPCClient.requestData(
method: "phone_push.status.get",
params: [:]
),
timeoutNanoseconds: Self.terminalOutputCapabilityTimeoutNanoseconds,
hostStatusTimeoutNanoseconds: {
Self.terminalOutputCapabilityTimeoutNanoseconds
}
)
} catch {
guard isCurrentRemoteConnection(
client: client,
generation: generation
) else { return }
// This status probe is authenticated: a definitive authorization
// failure here means the session itself is revoked or mismatched,
// not merely that push readiness is unknown. Route it to the
// shared reauth disconnect instead of staying connected with a
// silently cleared readiness.
guard !disconnectForAuthorizationFailureIfNeeded(error) else {
return
}
phonePushMacStatus = nil
return
}
guard isCurrentRemoteConnection(
client: client,
generation: generation
) else { return }
guard let status = try? MobileHostStatusResponse.decode(
exchange.hostStatusResponse
) else {
phonePushMacStatus = nil
return
}
phonePushMacStatus = status.phonePush
}
/// Applies one or more Mac-owned phone-forwarding settings over the current
/// authenticated attach channel, then reads the authoritative status back.
///
/// The mutation fails closed when the Mac is unavailable, predates the
/// capability, rejects the caller, or the connection changes mid-flight.
/// Local UI never writes a speculative Mac value into this store.
@discardableResult
public func updatePhonePushSettings(
forwardingEnabled: Bool? = nil,
mode: MobileHostPhonePushStatus.Mode? = nil,
hideContent: Bool? = nil
) async -> Bool {
guard supportsPhonePushSettings,
let client = remoteClient,
forwardingEnabled != nil || mode != nil || hideContent != nil
else { return false }
var params: [String: Any] = [:]
if let forwardingEnabled {
params["forwarding_enabled"] = forwardingEnabled
}
if let mode {
params["mode"] = mode.rawValue
}
if let hideContent {
params["hide_content"] = hideContent
}
let generation = connectionGeneration
do {
let exchange = try await client.sendRequestAndAuthenticatedHostStatus(
MobileCoreRPCClient.requestData(
method: "phone_push.settings.update",
params: params
),
hostStatusTimeoutNanoseconds: {
Self.terminalOutputCapabilityTimeoutNanoseconds
}
)
let status = try MobileHostStatusResponse.decode(
exchange.hostStatusResponse
)
guard isCurrentRemoteConnection(
client: client,
generation: generation
) else { return false }
phonePushMacStatus = status.phonePush
return true
} catch {
guard generation == connectionGeneration else { return false }
guard !disconnectForAuthorizationFailureIfNeeded(error) else {
return false
}
handleMacAvailabilityFailureIfCurrent(
after: error,
expectedClient: client,
expectedGeneration: generation
)
return false
}
}
/// Requests a test alert through the Mac's real durable queue and returns
/// only the furthest stage the synchronous RPC can prove.
public func sendPhonePushTest() async -> MobilePhonePushTestStage {
guard supportsPhonePushTest, let client = remoteClient else {
return .unavailable
}
let generation = connectionGeneration
do {
let data = try await client.sendRequest(
MobileCoreRPCClient.requestData(
method: "phone_push.test",
params: [:]
)
)
guard isCurrentRemoteConnection(
client: client,
generation: generation
), let object = try JSONSerialization.jsonObject(with: data)
as? [String: Any],
let rawStage = object["stage"] as? String,
let stage = MobilePhonePushTestStage(rawValue: rawStage)
else { return .unavailable }
return stage
} catch {
guard generation == connectionGeneration else {
return .unavailable
}
guard !disconnectForAuthorizationFailureIfNeeded(error) else {
return .unavailable
}
handleMacAvailabilityFailureIfCurrent(
after: error,
expectedClient: client,
expectedGeneration: generation
)
return .unavailable
}
}
/// Run the `mobile.events.subscribe` (reason `start`) handshake for one
/// listener generation, concurrently with that generation's consumer loop.
///
@@ -1,6 +1,7 @@
import CMUXMobileCore
import CmuxMobilePairedMac
import CmuxMobileRPC
import CmuxMobileShellModel
import Foundation
import Testing
@testable import CmuxMobileShell
@@ -541,51 +542,123 @@ import Testing
return store
}
@Test func tailscalePreferencePromotesGrantedRouteAheadOfIrohPin() throws {
@Test func tailscaleMethodUsesOnlyGrantedTailscaleRoute() throws {
let tailscale = try tailscale()
let routes = MobileShellComposite.storedReconnectRoutes(
[tailscale, try iroh()],
supportedKinds: [.iroh, .tailscale],
preferNonLoopback: true,
tailscalePreference: MobileShellComposite.TailscaleRoutePreference(
tailscaleRequirement: MobileShellComposite.TailscaleRouteRequirement(
macDeviceID: "test-mac",
grantRoutes: [tailscale]
)
)
// The granted Tailscale destination dials first; Iroh stays as the
// fallback instead of being exclusive.
#expect(routes.map(\.kind) == [.tailscale, .iroh])
#expect(routes.map(\.kind) == [.tailscale])
}
@Test func tailscalePreferenceWithoutGrantKeepsIrohExclusivePin() throws {
// A preference flip alone grants nothing: without a device-local grant
// the Iroh pin still drops every raw host/port fallback.
@Test func tailscaleMethodWithoutGrantRejectsEveryRoute() throws {
let routes = MobileShellComposite.storedReconnectRoutes(
[try tailscale(), try iroh()],
supportedKinds: [.iroh, .tailscale],
preferNonLoopback: true,
tailscalePreference: MobileShellComposite.TailscaleRoutePreference(
tailscaleRequirement: MobileShellComposite.TailscaleRouteRequirement(
macDeviceID: "test-mac",
grantRoutes: []
)
)
#expect(routes.map(\.kind) == [.iroh])
#expect(routes.isEmpty)
}
@Test func tailscalePreferenceIgnoresGrantForDifferentDestination() throws {
@Test func tailscaleMethodRejectsMismatchedGrantWithoutIrohFallback() throws {
let otherDestination = try tailscale(50907)
let routes = MobileShellComposite.storedReconnectRoutes(
[try tailscale(), try iroh()],
supportedKinds: [.iroh, .tailscale],
preferNonLoopback: true,
tailscalePreference: MobileShellComposite.TailscaleRoutePreference(
tailscaleRequirement: MobileShellComposite.TailscaleRouteRequirement(
macDeviceID: "test-mac",
grantRoutes: [otherDestination]
)
)
#expect(routes.map(\.kind) == [.iroh])
#expect(routes.isEmpty)
}
@Test func changingToUnavailableTailscaleDropsLiveIrohWithoutFallback() async throws {
let clock = TestClock()
let router = LivenessHostRouter()
// The factory boxes the live Iroh transport it hands out, so the test
// can observe physical teardown, not just the store's logical route.
let liveTransportBox = TransportBox()
let factory = KindRecordingTransportFactory(
router: router,
box: liveTransportBox,
failingKinds: [.tailscale]
)
let tailscale = try tailscale()
let iroh = try iroh()
let (pairedStore, directory) = try makePairedMacStore()
defer { try? FileManager.default.removeItem(at: directory) }
try await pairedStore.upsert(
macDeviceID: "test-mac",
displayName: "Test Mac",
routes: [tailscale, iroh],
instanceTag: "default",
markActive: true,
stackUserID: "user-1",
teamID: nil,
now: clock.now
)
try await pairedStore.authorizeUserTailscaleRoutes(
macDeviceID: "test-mac",
instanceTag: "default",
stackUserID: "user-1",
teamID: nil,
routes: [tailscale]
)
let methodDefaults = UserDefaults(
suiteName: "connection-method-live-switch-\(UUID().uuidString)"
)!
let methodStore = MobileConnectionMethodStore(defaults: methodDefaults)
let store = MobileShellComposite(
runtime: LivenessTestRuntime(
transportFactory: factory,
now: { clock.now },
supportedRouteKinds: [.iroh, .tailscale]
),
isSignedIn: true,
pairedMacStore: pairedStore,
connectionMethodStore: methodStore,
identityProvider: StaticIdentityProvider(userID: "user-1"),
reachability: AlwaysOnlineReachability(),
pairingHintDefaults: UserDefaults(
suiteName: "connection-method-pairing-hint-\(UUID().uuidString)"
)!,
hiddenMacStore: InMemoryPairedMacHiddenStore()
)
await store.loadPairedMacs()
#expect(await store.reconnectActiveMacIfAvailable(stackUserID: "user-1"))
#expect(store.activeRoute?.kind == .iroh)
#expect(factory.attemptedKinds().filter { $0 == .iroh }.count == 1)
methodStore.method = .tailscale
// `activeRoute == nil` only proves the store cleared its logical
// route; the dropped live Iroh transport must also finish closing so
// no physical cleanup work is still pending when the test completes.
let applied = try await pollUntil {
let liveTransportClosed =
await liveTransportBox.get()?.isClosedForTesting() == true
return factory.attemptedKinds().contains(.tailscale)
&& store.connectionState == .disconnected
&& store.activeRoute == nil
&& liveTransportClosed
}
#expect(applied)
#expect(store.activeRoute == nil)
#expect(factory.attemptedKinds().filter { $0 == .iroh }.count == 1)
}
}
@@ -6,18 +6,18 @@ public enum MobileConnectionMethod: String, CaseIterable, Sendable {
/// Dial the built-in encrypted peer-to-peer transport (direct paths with
/// managed relays as fallback). The default; no setup required.
case automatic
/// Prefer the user's Tailscale network. Requires entering the Tailscale
/// pairing code shown on the Mac once, which authorizes that exact peer.
/// Require the user's Tailscale network. Requires entering the Tailscale
/// pairing code shown on the Mac once, which authorizes that exact peer;
/// Iroh is never used as a fallback while this method is selected.
case tailscale
}
/// Persists the user's connection-method choice.
///
/// The preference only reorders dialing: `tailscale` puts authorized Tailscale
/// routes ahead of the automatic transport instead of the default pin that
/// dials the automatic transport exclusively. It never manufactures Tailscale
/// authorization by itself; a pairing code entry remains the authorization
/// event for each Mac.
/// The choice is exclusive: `automatic` uses the built-in encrypted transport,
/// while `tailscale` dials only an authorized Tailscale route. It never
/// manufactures Tailscale authorization by itself; a pairing code entry remains
/// the authorization event for each Mac.
///
/// The backing `UserDefaults` is injected so the store is testable without
/// touching `.standard`; the app constructs it at the composition root.
@@ -29,12 +29,17 @@ public final class MobileConnectionMethodStore {
// UserDefaults is Apple-documented thread-safe; OK to hold nonisolated.
private nonisolated(unsafe) let defaults: UserDefaults
@ObservationIgnored private var continuations:
[UUID: AsyncStream<MobileConnectionMethod>.Continuation] = [:]
/// The user's current connection-method choice.
public var method: MobileConnectionMethod {
didSet {
guard method != oldValue else { return }
defaults.set(method.rawValue, forKey: Self.methodKey)
for continuation in continuations.values {
continuation.yield(method)
}
}
}
@@ -48,4 +53,22 @@ public final class MobileConnectionMethodStore {
self.method = .automatic
}
}
/// Observes connection-method changes, beginning with the current method.
///
/// Each subscriber owns an independent stream. Cancelling iteration removes
/// that subscriber without affecting Settings or other connection owners.
public func changes() -> AsyncStream<MobileConnectionMethod> {
let id = UUID()
let current = method
return AsyncStream { continuation in
continuations[id] = continuation
continuation.yield(current)
continuation.onTermination = { [weak self] _ in
Task { @MainActor in
self?.continuations[id] = nil
}
}
}
}
}
@@ -25,6 +25,7 @@ let package = Package(
.package(path: "../CmuxMobileChanges"),
.package(path: "../CmuxMobileDiagnostics"),
.package(path: "../CmuxMobilePairedMac"),
.package(path: "../CmuxMobileRPC"),
.package(path: "../CmuxMobileShell"),
.package(path: "../CmuxMobileShellModel"),
.package(path: "../CmuxMobileSupport"),
@@ -48,6 +49,7 @@ let package = Package(
"CmuxMobileChanges",
"CmuxMobileDiagnostics",
"CmuxMobilePairedMac",
"CmuxMobileRPC",
"CmuxMobileShell",
"CmuxMobileShellModel",
"CmuxMobileSupport",
@@ -69,6 +71,7 @@ let package = Package(
"CMUXMobileCore",
"CmuxAuthRuntime",
"CmuxMobilePairedMac",
"CmuxMobileRPC",
"CmuxMobileShellUI",
"CmuxAgentChat",
"CmuxMobileShell",
@@ -123,6 +123,14 @@ struct CMUXMobileRootView: View {
#endif
}
private var shouldShowPushReadinessPreview: Bool {
#if os(iOS) && DEBUG
return UITestConfig.pushReadinessPreviewState != nil
#else
return false
#endif
}
#if os(iOS)
/// A configured launch attach route (dev/UITest auto-pair) owns startup
/// connections outright; background onboarding discovery must not race it.
@@ -167,6 +175,16 @@ struct CMUXMobileRootView: View {
#endif
}
@ViewBuilder private var pushReadinessPreview: some View {
#if os(iOS) && DEBUG
MobilePushReadinessPreviewView(
state: UITestConfig.pushReadinessPreviewState ?? "healthy"
)
#else
EmptyView()
#endif
}
@ViewBuilder private var hiddenComputersPreview: some View {
#if os(iOS) && DEBUG
HiddenComputersPreviewView()
@@ -308,7 +326,9 @@ struct CMUXMobileRootView: View {
@ViewBuilder
private var rootContent: some View {
if shouldShowChangesPreview {
if shouldShowPushReadinessPreview {
pushReadinessPreview
} else if shouldShowChangesPreview {
changesPreview
} else if shouldShowHideComputersVerifier {
hideComputersVerifier
@@ -364,7 +384,10 @@ struct CMUXMobileRootView: View {
signOut: signOut,
showAddDevice: showAddDevice,
showPairingScanner: showPairingScanner,
reconnectStoredMac: reconnectStoredMacIfNeeded
reconnectStoredMac: reconnectStoredMacIfNeeded,
workspaceListDidBecomeVisible: {
await pushCoordinator.workspaceListDidBecomeVisible()
}
)
}
}
@@ -504,6 +527,8 @@ struct CMUXMobileRootView: View {
connectionPhase: UITestConfig.onboardingConnectionFallbackEnabled
? .fallback
: .searching,
connectionMethod: connectionMethodStore?.method ?? .automatic,
onSelectConnectionMethod: { connectionMethodStore?.method = $0 },
onReachedConnection: markOnboardingReadyToConnect,
onSkip: completeOnboarding,
onRetryConnection: {},
@@ -0,0 +1,199 @@
#if os(iOS) && DEBUG
import CmuxAuthRuntime
import CmuxMobileRPC
import CmuxMobileSupport
import SwiftUI
/// Deterministic production-settings harness for XCUITest.
///
/// It mounts the same ``MobilePushSettingsContent`` used by Settings. Only the
/// network/OS seams are fixtures, so accessibility, localization, optimistic
/// mutation, rollback, and every rendered repair action remain production code.
struct MobilePushReadinessPreviewView: View {
private let fixture: Fixture
private let rejectsMacMutations: Bool
@State private var phoneEnabled: Bool
@State private var authorization: MobilePushAuthorization
@State private var registration: PushRegistrationSnapshot
@State private var macStatus: MobileHostPhonePushStatus?
init(state: String, environment: [String: String] = ProcessInfo.processInfo.environment) {
let fixture = Fixture(rawValue: state) ?? .healthy
self.fixture = fixture
self.rejectsMacMutations = environment["CMUX_UITEST_PUSH_MUTATION_FAILURE"] == "1"
self._phoneEnabled = State(initialValue: fixture.registration.isEnabled)
self._authorization = State(initialValue: fixture.authorization)
self._registration = State(initialValue: fixture.registration)
self._macStatus = State(initialValue: fixture.macStatus)
}
var body: some View {
NavigationStack {
Form {
Section(L10n.string(
"mobile.settings.notifications",
defaultValue: "Push Alerts"
)) {
MobilePushSettingsContent(
readiness: readiness,
phoneEnabled: $phoneEnabled,
macStatus: macStatus,
supportsMacSettings: macStatus != nil,
supportsMacTest: macStatus != nil,
onPhoneEnabledChange: setPhoneEnabled,
onRepair: repair,
onMacMutation: mutateMac,
onSendTest: { .queuedOnMac }
)
}
}
.navigationTitle(L10n.string(
"mobile.workspaces.settings",
defaultValue: "Settings"
))
}
.accessibilityIdentifier("MobilePushReadinessPreview")
}
private var readiness: MobilePushReadiness {
MobilePushReadiness.resolve(
authorization: authorization,
registration: registration,
mac: macStatus.map(MobilePushReadiness.MacStatus.init),
systemSettings: .authorizationOnly(authorization),
phoneAPIOrigin: Self.apiOrigin
)
}
@MainActor
private func setPhoneEnabled(_ enabled: Bool) async -> Bool {
phoneEnabled = enabled
registration = enabled
? Self.registered
: .disabled
return true
}
@MainActor
private func repair(_ repair: MobilePushReadiness.Repair) async -> Bool {
switch repair {
case .enableOnPhone:
return await setPhoneEnabled(true)
case .retryDeviceTokenRegistration, .retryRegistration:
registration = Self.registered
return true
case .connectMac:
macStatus = Self.healthyMac
return true
case .enableOnMac:
return await mutateMac(.forwardingEnabled(true))
case .leaveMacOrUseAlwaysMode:
return await mutateMac(.mode(.always))
case .openSystemSettings, .signInAgain, .finishAccountDeletion,
.disablePushOnAnotherDevice, .signIntoMatchingAccount,
.rebuildMatchingApps, .waitForDeviceToken:
return true
}
}
@MainActor
private func mutateMac(_ mutation: MobilePushMacMutation) async -> Bool {
guard !rejectsMacMutations, let current = macStatus else {
return false
}
let forwardingEnabled: Bool
let mode: MobileHostPhonePushStatus.Mode
let hideContent: Bool
switch mutation {
case let .forwardingEnabled(value):
forwardingEnabled = value
mode = current.mode
hideContent = current.hideContent
case let .mode(value):
forwardingEnabled = current.forwardingEnabled
mode = value
hideContent = current.hideContent
case let .hideContent(value):
forwardingEnabled = current.forwardingEnabled
mode = current.mode
hideContent = value
}
macStatus = MobileHostPhonePushStatus(
forwardingEnabled: forwardingEnabled,
mode: mode,
admission: forwardingEnabled ? .allowed : .forwardingDisabled,
queuePersistence: current.queuePersistence,
hideContent: hideContent,
apiOrigin: current.apiOrigin,
accountScope: current.accountScope
)
return true
}
private enum Fixture: String {
case healthy
case osDenied = "os_denied"
case backendRetry = "backend_retry"
case macForwardingOff = "mac_forwarding_off"
case macUnavailable = "mac_unavailable"
case limitedProvisional = "limited_provisional"
var authorization: MobilePushAuthorization {
switch self {
case .osDenied: .denied
case .limitedProvisional: .provisional
case .healthy, .backendRetry, .macForwardingOff, .macUnavailable:
.authorized
}
}
var registration: PushRegistrationSnapshot {
switch self {
case .backendRetry:
PushRegistrationSnapshot(
isEnabled: true,
hasDeviceToken: true,
backendState: .failed(.serviceUnavailable)
)
case .healthy, .osDenied, .macForwardingOff, .macUnavailable,
.limitedProvisional:
MobilePushReadinessPreviewView.registered
}
}
var macStatus: MobileHostPhonePushStatus? {
switch self {
case .macUnavailable:
nil
case .macForwardingOff:
MobileHostPhonePushStatus(
forwardingEnabled: false,
mode: .onlyWhenAway,
admission: .forwardingDisabled,
queuePersistence: .healthy,
apiOrigin: MobilePushReadinessPreviewView.apiOrigin,
accountScope: .verifiedSameAccount
)
case .healthy, .osDenied, .backendRetry, .limitedProvisional:
MobilePushReadinessPreviewView.healthyMac
}
}
}
private static let apiOrigin = "https://cmux.com"
private static let registered = PushRegistrationSnapshot(
isEnabled: true,
hasDeviceToken: true,
backendState: .registered
)
private static let healthyMac = MobileHostPhonePushStatus(
forwardingEnabled: true,
mode: .onlyWhenAway,
admission: .allowed,
queuePersistence: .healthy,
apiOrigin: apiOrigin,
accountScope: .verifiedSameAccount
)
}
#endif
@@ -27,7 +27,7 @@ struct MobileConnectionMethodSection: View {
.tag(MobileConnectionMethod.automatic)
Text(L10n.string(
"mobile.settings.connectionMethod.tailscale",
defaultValue: "Tailscale"
defaultValue: "Tailscale Only"
))
.tag(MobileConnectionMethod.tailscale)
}
@@ -62,8 +62,9 @@ struct MobileConnectionMethodSection: View {
L10n.string(
"mobile.settings.connectionMethod.tailscaleFooter",
defaultValue: """
Install Tailscale on this iPhone and your Mac, then connect both to the same Tailscale network. \
On your Mac, open Tailscale Pairing in cmux to show the QR, then scan it here once.
Connects only over Tailscale. If unavailable, cmux stays disconnected instead of using Iroh. \
Install Tailscale on both devices, join the same network, then scan the pairing code shown by \
cmux on your Mac once.
"""
)
}
@@ -1,6 +1,7 @@
#if os(iOS)
import CMUXMobileCore
import CmuxAuthRuntime
import CmuxMobileRPC
import CmuxMobileShell
import CmuxMobileShellModel
import Foundation
@@ -36,6 +37,7 @@ public final class MobilePushCoordinator {
// the opt-in flag for the menu UI without awaiting the actor service.
private nonisolated(unsafe) let defaults: UserDefaults
private static let enabledKey = "cmux.notifications.pushEnabled"
private var enabledMirror: Bool
/// APNs `aps.category` the web sets on every cmux terminal push (see
/// `CMUX_APNS_CATEGORY` in `web/services/apns/payload.ts`). The matching
@@ -68,6 +70,30 @@ public final class MobilePushCoordinator {
/// launch plus sign-in plus a slow attach.
private static let pendingDeeplinkLifetime: TimeInterval = 120
@ObservationIgnored private let now: () -> Date
/// The iOS API endpoint that accepted this installation's APNs token.
public let phoneAPIOrigin: String
/// Live OS authorization, refreshed at launch, on foreground, and when
/// Settings opens or performs a repair.
public private(set) var authorization: MobilePushAuthorization = .notDetermined
/// Live independent iOS presentation policies. Authorization alone is not
/// enough to promise a visible, audible, timely banner.
public private(set) var systemSettings = MobilePushSystemSettings
.authorizationOnly(.notDetermined)
/// Local/APNs/backend registration stage streamed from the actor service.
public private(set) var registrationSnapshot: PushRegistrationSnapshot = .disabled
@ObservationIgnored private let notificationSettings:
@MainActor () async -> MobilePushSystemSettings
@ObservationIgnored private let requestAuthorization:
@MainActor () async -> Bool
@ObservationIgnored private let registerForRemoteNotifications:
@MainActor () -> Void
@ObservationIgnored private let unregisterForRemoteNotifications:
@MainActor () -> Void
@ObservationIgnored private var registrationSnapshotTask: Task<Void, Never>?
@ObservationIgnored private var registrationRecoveryTask:
Task<PushRegistrationSnapshot, Never>?
@ObservationIgnored private var workspaceAuthorizationRequestInFlight = false
@ObservationIgnored private var hasRequestedRemoteRegistration = false
/// Creates a push coordinator.
/// - Parameters:
@@ -87,21 +113,55 @@ public final class MobilePushCoordinator {
public init(
registration: any PushRegistering,
analytics: any AnalyticsEmitting = NoopAnalytics(),
phoneAPIOrigin: String = "https://cmux.com",
defaults: UserDefaults = .standard,
deliveredNotificationClearer: any DeliveredNotificationClearing = SystemDeliveredNotificationClearer(),
pendingDismissQueue: PendingNotificationDismissQueue = PendingNotificationDismissQueue(),
now: @escaping () -> Date = Date.init
now: @escaping () -> Date = Date.init,
authorizationStatus: (@MainActor () async -> UNAuthorizationStatus)? = nil,
notificationSettings: (@MainActor () async -> MobilePushSystemSettings)? = nil,
requestAuthorization: @escaping @MainActor () async -> Bool = {
(try? await UNUserNotificationCenter.current()
.requestAuthorization(options: [.alert, .sound, .badge])) ?? false
},
registerForRemoteNotifications: @escaping @MainActor () -> Void = {
UIApplication.shared.registerForRemoteNotifications()
},
unregisterForRemoteNotifications: @escaping @MainActor () -> Void = {
UIApplication.shared.unregisterForRemoteNotifications()
}
) {
self.registration = registration
self.analytics = analytics
self.phoneAPIOrigin = phoneAPIOrigin
self.defaults = defaults
self.enabledMirror = defaults.bool(forKey: Self.enabledKey)
self.deliveredNotificationClearer = deliveredNotificationClearer
self.pendingDismissQueue = pendingDismissQueue
self.now = now
if let notificationSettings {
self.notificationSettings = notificationSettings
} else if let authorizationStatus {
self.notificationSettings = {
.authorizationOnly(
Self.authorization(from: await authorizationStatus())
)
}
} else {
self.notificationSettings = {
Self.systemSettings(
from: await UNUserNotificationCenter.current()
.notificationSettings()
)
}
}
self.requestAuthorization = requestAuthorization
self.registerForRemoteNotifications = registerForRemoteNotifications
self.unregisterForRemoteNotifications = unregisterForRemoteNotifications
}
/// Whether the user has opted into phone notifications (synchronous mirror).
public var isEnabled: Bool { defaults.bool(forKey: Self.enabledKey) }
public var isEnabled: Bool { enabledMirror }
/// Point routing at the active store (called by the root view on appear).
public func bind(store: CMUXMobileShellStore) {
@@ -116,10 +176,10 @@ public final class MobilePushCoordinator {
applyPendingDeeplinkIfReady()
}
/// Install the notification-center delegate, register the dismiss-sync
/// notification category, and, if already opted in, re-assert remote
/// registration so a rotated token re-uploads. Call once at launch from the
/// AppDelegate.
/// Install the notification-center delegate and dismiss-sync category, then
/// start live readiness observation. The workspace/foreground lifecycle
/// requests APNs registration after system authorization permits delivery.
/// Call once at launch from the AppDelegate.
public func configure(delegate: any UNUserNotificationCenterDelegate) {
let center = UNUserNotificationCenter.current()
center.delegate = delegate
@@ -133,55 +193,285 @@ public final class MobilePushCoordinator {
options: [.customDismissAction]
)
center.setNotificationCategories([dismissSyncCategory])
if isEnabled {
UIApplication.shared.registerForRemoteNotifications()
}
startRegistrationSnapshotObservation()
Task { await refreshReadiness() }
}
/// Opt in: request system authorization, register for remote notifications,
/// and persist the flag. Returns whether authorization was granted.
@discardableResult
public func enable() async -> Bool {
let priorStatus = await UNUserNotificationCenter.current()
.notificationSettings().authorizationStatus
await enable(trigger: "settings_toggle")
}
/// Requests or recovers push only after the authenticated workspace shell
/// is mounted. An explicit app opt-out remains authoritative.
public func workspaceListDidBecomeVisible() async {
if defaults.object(forKey: Self.enabledKey) as? Bool == false {
return
}
let settings = await notificationSettings()
apply(settings: settings)
switch settings.authorization {
case .authorized, .provisional, .ephemeral:
persistEnabledIntent()
await activateRegistrationIfNeeded()
await recoverRegistrationIfNeeded()
case .denied:
// Preserve intent so Settings can explain the blocked OS gate and
// a later foreground return can recover without another app launch.
persistEnabledIntent()
case .notDetermined:
guard !workspaceAuthorizationRequestInFlight else { return }
workspaceAuthorizationRequestInFlight = true
defer { workspaceAuthorizationRequestInFlight = false }
_ = await enable(trigger: "workspace_list")
case .unsupported:
break
}
}
private func enable(trigger: String) async -> Bool {
let priorSettings = await notificationSettings()
apply(settings: priorSettings)
let priorStatus = priorSettings.authorization
persistEnabledIntent()
// Only an undetermined status produces a real OS prompt; gate the
// "shown" event on it so a re-toggle of an already-decided status does
// not log a phantom prompt.
if priorStatus == .notDetermined {
analytics.capture("ios_push_optin_prompt_shown", [
"trigger": .string("settings_toggle"),
"trigger": .string(trigger),
"prior_authorization_status": .string("not_determined"),
])
}
let granted = (try? await UNUserNotificationCenter.current()
.requestAuthorization(options: [.alert, .sound, .badge])) ?? false
let granted: Bool
switch priorStatus {
case .authorized, .provisional, .ephemeral:
granted = true
case .notDetermined:
granted = await requestAuthorization()
case .denied, .unsupported:
granted = false
}
guard granted else {
await refreshReadiness()
analytics.capture("ios_push_optin_declined", [
"trigger": .string("settings_toggle"),
"trigger": .string(trigger),
"was_os_level_predenied": .bool(priorStatus == .denied),
])
return false
}
analytics.capture("ios_push_optin_granted", ["trigger": .string("settings_toggle")])
await registration.setEnabled(true)
UIApplication.shared.registerForRemoteNotifications()
if priorStatus == .notDetermined {
apply(settings: await notificationSettings())
}
analytics.capture("ios_push_optin_granted", ["trigger": .string(trigger)])
await activateRegistrationIfNeeded()
await recoverRegistrationIfNeeded()
return true
}
/// Opt out: stop receiving pushes and remove the token server-side.
public func disable() async {
enabledMirror = false
registrationSnapshot = .disabled
hasRequestedRemoteRegistration = false
unregisterForRemoteNotifications()
// The production registration service owns this same persisted key
// and checks its previous value to decide whether server cleanup is
// required. Let it observe the prior `true` before mirroring the final
// preference here; writing `false` first would skip token removal.
await registration.setEnabled(false)
UIApplication.shared.unregisterForRemoteNotifications()
defaults.set(false, forKey: Self.enabledKey)
registrationSnapshot = await registration.snapshot
}
/// Hand a freshly-registered APNs token to the network layer.
public func handleDeviceToken(_ token: Data) async {
await registration.register(deviceToken: token)
registrationSnapshot = await registration.snapshot
}
/// Make the APNs callback failure visible without retaining Apple's
/// free-form error text, which can contain unstable device details.
public func handleDeviceTokenFailure() async {
await registration.deviceTokenRegistrationFailed()
registrationSnapshot = await registration.snapshot
}
/// User-triggered repair for a failed APNs token callback.
public func retryDeviceTokenRegistration() {
hasRequestedRemoteRegistration = true
registerForRemoteNotifications()
}
/// Re-upload the cached token when possible (e.g. after sign-in).
public func syncTokenIfPossible() async {
await registration.syncTokenIfPossible()
registrationSnapshot = await registration.snapshot
}
/// Refreshes live OS authorization and the current registration stage.
///
/// Call on every foreground transition because users can revoke permission
/// in iOS Settings while cmux is suspended.
public func refreshReadiness() async {
let settings = await notificationSettings()
apply(settings: settings)
if enabledMirror, Self.permitsDelivery(settings.authorization) {
await activateRegistrationIfNeeded()
}
await recoverRegistrationIfNeeded()
}
private func persistEnabledIntent() {
enabledMirror = true
defaults.set(true, forKey: Self.enabledKey)
}
private func apply(settings: MobilePushSystemSettings) {
systemSettings = settings
authorization = settings.authorization
}
private func activateRegistrationIfNeeded() async {
guard enabledMirror, Self.permitsDelivery(authorization) else { return }
let current = await registration.snapshot
registrationSnapshot = PushRegistrationSnapshot(
isEnabled: true,
hasDeviceToken: current.hasDeviceToken,
backendState: current.hasDeviceToken
? .registrationRequired
: .awaitingDeviceToken
)
requestRemoteRegistrationIfNeeded()
if !current.isEnabled {
await registration.setEnabled(true)
}
registrationSnapshot = await registration.snapshot
}
private func requestRemoteRegistrationIfNeeded() {
guard !hasRequestedRemoteRegistration else { return }
hasRequestedRemoteRegistration = true
registerForRemoteNotifications()
}
private static func permitsDelivery(
_ authorization: MobilePushAuthorization
) -> Bool {
switch authorization {
case .authorized, .provisional, .ephemeral:
true
case .notDetermined, .denied, .unsupported:
false
}
}
/// Retries an exhausted registration when a meaningful network path
/// change reports that the API may be reachable again.
public func networkDidBecomeReachable() async {
await recoverRegistrationIfNeeded()
}
private func recoverRegistrationIfNeeded() async {
let current = await registration.snapshot
registrationSnapshot = current
guard current.isEnabled, current.hasDeviceToken,
current.backendState == .registrationRequired
|| current.backendState.isRecoverable
else { return }
let recovery: Task<PushRegistrationSnapshot, Never>
let ownsRecovery: Bool
if let registrationRecoveryTask {
recovery = registrationRecoveryTask
ownsRecovery = false
} else {
let registration = self.registration
recovery = Task {
await registration.syncTokenIfPossible()
return await registration.snapshot
}
registrationRecoveryTask = recovery
ownsRecovery = true
}
let recovered = await recovery.value
if ownsRecovery {
registrationRecoveryTask = nil
}
registrationSnapshot = recovered
}
/// Computes readiness against the currently focused Mac's authenticated
/// status. A missing Mac status fails closed.
public func readiness(
macStatus: MobileHostPhonePushStatus?,
macAccountMismatch: Bool = false
) -> MobilePushReadiness {
MobilePushReadiness.resolve(
authorization: authorization,
registration: registrationSnapshot,
mac: macStatus.map(MobilePushReadiness.MacStatus.init),
macAccountMismatch: macAccountMismatch,
systemSettings: systemSettings,
phoneAPIOrigin: phoneAPIOrigin
)
}
/// Opens this app's iOS notification settings for a denied authorization.
public func openSystemSettings() {
guard let url = URL(
string: UIApplication.openNotificationSettingsURLString
) else { return }
UIApplication.shared.open(url)
}
private func startRegistrationSnapshotObservation() {
registrationSnapshotTask?.cancel()
registrationSnapshotTask = Task { [weak self, registration] in
let snapshots = await registration.snapshots()
for await snapshot in snapshots {
guard !Task.isCancelled, let self else { return }
self.registrationSnapshot = snapshot
}
}
}
private static func authorization(
from status: UNAuthorizationStatus
) -> MobilePushAuthorization {
switch status {
case .notDetermined:
.notDetermined
case .denied:
.denied
case .authorized:
.authorized
case .provisional:
.provisional
case .ephemeral:
.ephemeral
@unknown default:
.unsupported
}
}
private static func systemSettings(
from settings: UNNotificationSettings
) -> MobilePushSystemSettings {
MobilePushSystemSettings(
authorization: authorization(from: settings.authorizationStatus),
alertsEnabled: settings.alertSetting == .enabled,
soundsEnabled: settings.soundSetting == .enabled,
badgesEnabled: settings.badgeSetting == .enabled,
lockScreenEnabled: settings.lockScreenSetting == .enabled,
notificationCenterEnabled:
settings.notificationCenterSetting == .enabled,
timeSensitiveEnabled: settings.timeSensitiveSetting == .enabled,
scheduledDeliveryEnabled:
settings.scheduledDeliverySetting == .enabled
)
}
/// Remove the cached token from the server (on sign-out), authenticating
@@ -191,6 +481,19 @@ public final class MobilePushCoordinator {
await registration.unregisterFromServer(accessToken: accessToken, refreshToken: refreshToken)
}
/// Sign-out cleanup pinned to the user id captured before auth clear.
public func unregisterFromServer(
accountID: String?,
accessToken: String?,
refreshToken: String?
) async {
await registration.unregisterFromServer(
accountID: accountID,
accessToken: accessToken,
refreshToken: refreshToken
)
}
/// Whether to show a banner while the app is foreground. Suppressed when the
/// user is already viewing the terminal the notification is about.
public func shouldPresentInForeground(workspaceId: String?, surfaceId: String?) -> Bool {
@@ -0,0 +1,421 @@
import CmuxAuthRuntime
import CmuxMobileRPC
import Foundation
/// The live iOS notification authorization state used by push readiness.
public enum MobilePushAuthorization: Equatable, Sendable {
case notDetermined
case denied
case authorized
case provisional
case ephemeral
case unsupported
fileprivate var permitsDelivery: Bool {
switch self {
case .authorized, .provisional, .ephemeral:
true
case .notDetermined, .denied, .unsupported:
false
}
}
}
/// User-controlled notification presentation switches read live from iOS.
///
/// Authorization can remain `.authorized` while the user independently turns
/// off alerts, sound, badges, Lock Screen, Notification Center, Time Sensitive,
/// or enables Scheduled Delivery. Keeping those controls separate prevents a
/// healthy APNs pipeline from being mislabeled as a fully visible banner path.
public struct MobilePushSystemSettings: Equatable, Sendable {
public let authorization: MobilePushAuthorization
public let alertsEnabled: Bool
public let soundsEnabled: Bool
public let badgesEnabled: Bool
public let lockScreenEnabled: Bool
public let notificationCenterEnabled: Bool
public let timeSensitiveEnabled: Bool
public let scheduledDeliveryEnabled: Bool
public init(
authorization: MobilePushAuthorization,
alertsEnabled: Bool,
soundsEnabled: Bool,
badgesEnabled: Bool,
lockScreenEnabled: Bool,
notificationCenterEnabled: Bool,
timeSensitiveEnabled: Bool,
scheduledDeliveryEnabled: Bool
) {
self.authorization = authorization
self.alertsEnabled = alertsEnabled
self.soundsEnabled = soundsEnabled
self.badgesEnabled = badgesEnabled
self.lockScreenEnabled = lockScreenEnabled
self.notificationCenterEnabled = notificationCenterEnabled
self.timeSensitiveEnabled = timeSensitiveEnabled
self.scheduledDeliveryEnabled = scheduledDeliveryEnabled
}
public static func authorizationOnly(
_ authorization: MobilePushAuthorization
) -> Self {
Self(
authorization: authorization,
alertsEnabled: true,
soundsEnabled: true,
badgesEnabled: true,
lockScreenEnabled: true,
notificationCenterEnabled: true,
timeSensitiveEnabled: true,
scheduledDeliveryEnabled: false
)
}
public var presentationLimitations: Set<MobilePushPresentationLimitation> {
var result: Set<MobilePushPresentationLimitation> = []
if !alertsEnabled { result.insert(.alertsDisabled) }
if !soundsEnabled { result.insert(.soundsDisabled) }
if !badgesEnabled { result.insert(.badgesDisabled) }
if !lockScreenEnabled { result.insert(.lockScreenDisabled) }
if !notificationCenterEnabled {
result.insert(.notificationCenterDisabled)
}
if !timeSensitiveEnabled { result.insert(.timeSensitiveDisabled) }
if scheduledDeliveryEnabled && !timeSensitiveEnabled {
result.insert(.scheduledDeliveryEnabled)
}
return result
}
}
/// An iOS policy that can make a healthy pipeline quiet, delayed, or partial.
public enum MobilePushPresentationLimitation:
String,
CaseIterable,
Hashable,
Sendable
{
case alertsDisabled
case soundsDisabled
case badgesDisabled
case lockScreenDisabled
case notificationCenterDisabled
case timeSensitiveDisabled
case scheduledDeliveryEnabled
}
/// End-to-end readiness for Mac notification delivery on this iOS device.
///
/// A local opt-in is deliberately insufficient. Readiness requires the live OS
/// permission, an APNs token acknowledged by the backend, and authenticated
/// status from the currently attached Mac proving its independent privacy gate,
/// account scope, mode, and API endpoint all agree.
public enum MobilePushReadiness: Equatable, Sendable {
/// Every gate passed, including the attached Mac's current presence mode.
case ready(mode: MobileHostPhonePushStatus.Mode)
/// APNs can deliver, but provisional/ephemeral authorization is quiet or
/// temporary rather than a fully authorized banner path.
case limited(
mode: MobileHostPhonePushStatus.Mode,
authorization: MobilePushAuthorization
)
/// Every transport gate passed, but one or more independent iOS
/// presentation policies can make delivery quiet, delayed, or invisible.
case presentationLimited(
mode: MobileHostPhonePushStatus.Mode,
limitations: Set<MobilePushPresentationLimitation>
)
/// The live path is eligible, but the Mac cannot currently prove that its
/// bounded retry queue will survive a process restart.
case reliabilityLimited(
mode: MobileHostPhonePushStatus.Mode,
queuePersistence: MobileHostPhonePushStatus.QueuePersistence
)
/// Delivery stopped at the named gate.
case blocked(Blocker)
/// Authenticated readiness fields read from the attached Mac.
public struct MacStatus: Equatable, Sendable {
public let forwardingEnabled: Bool
public let mode: MobileHostPhonePushStatus.Mode
public let admission: MobileHostPhonePushStatus.Admission
public let queuePersistence:
MobileHostPhonePushStatus.QueuePersistence
public let apiOrigin: String
public let accountVerified: Bool
public init(
forwardingEnabled: Bool,
mode: MobileHostPhonePushStatus.Mode,
admission: MobileHostPhonePushStatus.Admission = .allowed,
queuePersistence:
MobileHostPhonePushStatus.QueuePersistence = .healthy,
apiOrigin: String,
accountVerified: Bool
) {
self.forwardingEnabled = forwardingEnabled
self.mode = mode
self.admission = admission
self.queuePersistence = queuePersistence
self.apiOrigin = apiOrigin
self.accountVerified = accountVerified
}
public init(_ status: MobileHostPhonePushStatus) {
self.init(
forwardingEnabled: status.forwardingEnabled,
mode: status.mode,
admission: status.admission,
queuePersistence: status.queuePersistence,
apiOrigin: status.apiOrigin,
accountVerified: status.accountScope == .verifiedSameAccount
)
}
}
/// The first gate that currently prevents end-to-end delivery.
public enum Blocker: Equatable, Sendable {
case phoneOptInDisabled
case systemPermissionNotRequested
case systemPermissionDenied
case systemNotificationsUnsupported
case awaitingDeviceToken
case deviceTokenRegistrationFailed
case registeringDevice
case backendRegistrationRequired
case authenticationRequired
case accountDeletionInProgress
case registrationRateLimited
case deviceLimitReached(limit: Int)
case networkUnavailable
case pushServiceUnavailable
case invalidConfiguration
case invalidServerResponse
case registrationRejected
case macStatusUnavailable
case macAccountMismatch
case macForwardingDisabled
case macCurrentlyActive
case macAdmissionUnavailable
case apiOriginMismatch
}
/// The concrete next action that repairs the current blocker.
public enum Repair: Equatable, Sendable {
case enableOnPhone
case openSystemSettings
case waitForDeviceToken
case retryDeviceTokenRegistration
case retryRegistration
case signInAgain
case finishAccountDeletion
case disablePushOnAnotherDevice
case connectMac
case signIntoMatchingAccount
case enableOnMac
case leaveMacOrUseAlwaysMode
case rebuildMatchingApps
}
/// The repair action for a blocked state, or `nil` when already ready.
public var repair: Repair? {
switch self {
case .ready:
return nil
case .limited, .presentationLimited:
return .openSystemSettings
case .reliabilityLimited:
return nil
case let .blocked(blocker):
return Self.repair(for: blocker)
}
}
private static func repair(for blocker: Blocker) -> Repair {
switch blocker {
case .phoneOptInDisabled, .systemPermissionNotRequested:
.enableOnPhone
case .systemPermissionDenied, .systemNotificationsUnsupported:
.openSystemSettings
case .awaitingDeviceToken, .registeringDevice:
.waitForDeviceToken
case .deviceTokenRegistrationFailed:
.retryDeviceTokenRegistration
case .authenticationRequired:
.signInAgain
case .accountDeletionInProgress:
.finishAccountDeletion
case .deviceLimitReached:
.disablePushOnAnotherDevice
case .backendRegistrationRequired, .registrationRateLimited,
.networkUnavailable,
.pushServiceUnavailable, .invalidServerResponse,
.registrationRejected:
.retryRegistration
case .invalidConfiguration, .apiOriginMismatch:
.rebuildMatchingApps
case .macStatusUnavailable:
.connectMac
case .macAccountMismatch:
.signIntoMatchingAccount
case .macForwardingDisabled:
.enableOnMac
case .macCurrentlyActive:
.leaveMacOrUseAlwaysMode
case .macAdmissionUnavailable:
.connectMac
}
}
/// Resolves the furthest confirmed stage in deterministic gate order.
public static func resolve(
authorization: MobilePushAuthorization,
registration: PushRegistrationSnapshot,
mac: MacStatus?,
macAccountMismatch: Bool = false,
systemSettings: MobilePushSystemSettings? = nil,
phoneAPIOrigin: String
) -> MobilePushReadiness {
let liveAuthorization = systemSettings?.authorization ?? authorization
switch liveAuthorization {
case .denied:
return .blocked(.systemPermissionDenied)
case .unsupported:
return .blocked(.systemNotificationsUnsupported)
case .notDetermined, .authorized, .provisional, .ephemeral:
break
}
guard registration.isEnabled else {
return .blocked(.phoneOptInDisabled)
}
guard liveAuthorization.permitsDelivery else {
switch liveAuthorization {
case .notDetermined:
return .blocked(.systemPermissionNotRequested)
case .denied:
return .blocked(.systemPermissionDenied)
case .unsupported:
return .blocked(.systemNotificationsUnsupported)
case .authorized, .provisional, .ephemeral:
break
}
return .blocked(.systemNotificationsUnsupported)
}
switch registration.backendState {
case .deviceTokenRegistrationFailed:
return .blocked(.deviceTokenRegistrationFailed)
case .awaitingDeviceToken:
return .blocked(.awaitingDeviceToken)
case .registrationRequired:
return .blocked(.backendRegistrationRequired)
case .registering:
return .blocked(.registeringDevice)
case .registered:
break
case let .failed(failure):
return .blocked(blocker(for: failure))
}
guard registration.hasDeviceToken else {
return .blocked(.awaitingDeviceToken)
}
if macAccountMismatch {
return .blocked(.macAccountMismatch)
}
guard let mac else {
return .blocked(.macStatusUnavailable)
}
guard mac.accountVerified else {
return .blocked(.macAccountMismatch)
}
guard let macEndpoint = canonicalEndpoint(mac.apiOrigin),
let phoneEndpoint = canonicalEndpoint(phoneAPIOrigin),
macEndpoint == phoneEndpoint else {
return .blocked(.apiOriginMismatch)
}
guard mac.forwardingEnabled else {
return .blocked(.macForwardingDisabled)
}
switch mac.admission {
case .allowed:
break
case .suppressedMacActive:
return .blocked(.macCurrentlyActive)
case .forwardingDisabled:
return .blocked(.macForwardingDisabled)
case .unknown:
return .blocked(.macAdmissionUnavailable)
}
switch liveAuthorization {
case .provisional, .ephemeral:
return .limited(
mode: mac.mode,
authorization: liveAuthorization
)
case .notDetermined, .denied, .unsupported:
return .blocked(.systemNotificationsUnsupported)
case .authorized:
break
}
if let limitations = systemSettings?.presentationLimitations,
!limitations.isEmpty {
return .presentationLimited(
mode: mac.mode,
limitations: limitations
)
}
guard mac.queuePersistence == .healthy else {
return .reliabilityLimited(
mode: mac.mode,
queuePersistence: mac.queuePersistence
)
}
return .ready(mode: mac.mode)
}
private static func blocker(for failure: PushRegistrationFailure) -> Blocker {
switch failure {
case .authenticationRequired:
.authenticationRequired
case .accountDeletionInProgress:
.accountDeletionInProgress
case .rateLimited:
.registrationRateLimited
case let .deviceLimitReached(limit):
.deviceLimitReached(limit: limit)
case .networkUnavailable:
.networkUnavailable
case .serviceUnavailable:
.pushServiceUnavailable
case .invalidConfiguration:
.invalidConfiguration
case .invalidServerResponse:
.invalidServerResponse
case .rejected:
.registrationRejected
}
}
private static func canonicalEndpoint(_ rawValue: String) -> String? {
guard var components = URLComponents(string: rawValue),
let scheme = components.scheme?.lowercased(),
["http", "https"].contains(scheme),
let host = components.host?.lowercased(),
!host.isEmpty
else {
return nil
}
components.scheme = scheme
components.host = host
if (scheme == "https" && components.port == 443)
|| (scheme == "http" && components.port == 80) {
components.port = nil
}
components.query = nil
components.fragment = nil
while components.path.hasSuffix("/") {
components.path.removeLast()
}
return components.string
}
}
@@ -0,0 +1,672 @@
#if os(iOS)
import CmuxAuthRuntime
import CmuxMobileRPC
import CmuxMobileShell
import CmuxMobileSupport
import SwiftUI
enum MobilePushMacMutation: Equatable, Sendable {
case forwardingEnabled(Bool)
case mode(MobileHostPhonePushStatus.Mode)
case hideContent(Bool)
}
/// The production Push Alerts settings surface.
///
/// Its status is computed from the full readiness pipeline, while Mac-owned
/// privacy controls are optimistic only until the authenticated Mac confirms
/// the mutation. A failed mutation rolls every control back to the last
/// authoritative status instead of leaving a misleading local value behind.
struct MobilePushSettingsContent: View {
let readiness: MobilePushReadiness
@Binding var phoneEnabled: Bool
let macStatus: MobileHostPhonePushStatus?
let supportsMacSettings: Bool
let supportsMacTest: Bool
let onPhoneEnabledChange: @MainActor (Bool) async -> Bool
let onRepair: @MainActor (MobilePushReadiness.Repair) async -> Bool
let onMacMutation: @MainActor (MobilePushMacMutation) async -> Bool
let onSendTest: @MainActor () async -> MobilePhonePushTestStage
@State private var macForwardingEnabled: Bool
@State private var macMode: MobileHostPhonePushStatus.Mode
@State private var macHideContent: Bool
@State private var confirmedMacStatus: MobileHostPhonePushStatus?
@State private var isMutatingPhone = false
@State private var isMutatingMac = false
@State private var mutationFailed = false
@State private var testStage: MobilePhonePushTestStage?
@State private var isSendingTest = false
init(
readiness: MobilePushReadiness,
phoneEnabled: Binding<Bool>,
macStatus: MobileHostPhonePushStatus?,
supportsMacSettings: Bool,
supportsMacTest: Bool,
onPhoneEnabledChange: @escaping @MainActor (Bool) async -> Bool,
onRepair: @escaping @MainActor (MobilePushReadiness.Repair) async -> Bool,
onMacMutation: @escaping @MainActor (MobilePushMacMutation) async -> Bool,
onSendTest: @escaping @MainActor () async -> MobilePhonePushTestStage
) {
self.readiness = readiness
self._phoneEnabled = phoneEnabled
self.macStatus = macStatus
self.supportsMacSettings = supportsMacSettings
self.supportsMacTest = supportsMacTest
self.onPhoneEnabledChange = onPhoneEnabledChange
self.onRepair = onRepair
self.onMacMutation = onMacMutation
self.onSendTest = onSendTest
self._macForwardingEnabled = State(
initialValue: macStatus?.forwardingEnabled ?? false
)
self._macMode = State(initialValue: macStatus?.mode ?? .onlyWhenAway)
self._macHideContent = State(
initialValue: macStatus?.hideContent ?? false
)
self._confirmedMacStatus = State(initialValue: macStatus)
}
var body: some View {
Group {
statusRow
Toggle(
L10n.string(
"mobile.notifications.phoneEnabled",
defaultValue: "Allow Push Alerts on This iPhone"
),
isOn: phoneEnabledBinding
)
.accessibilityIdentifier("MobileSettingsNotifications")
.disabled(isMutatingPhone)
if let repair = readiness.repair,
let repairPresentation = repairPresentation(for: repair) {
Button {
guard !isMutatingPhone else { return }
isMutatingPhone = true
Task {
defer { isMutatingPhone = false }
let succeeded = await onRepair(repair)
if repair == .enableOnPhone {
phoneEnabled = succeeded
}
}
} label: {
Label(
repairPresentation.title,
systemImage: repairPresentation.systemImage
)
}
.accessibilityIdentifier(repairPresentation.identifier)
.disabled(isMutatingPhone || isMutatingMac)
}
if let macStatus {
Toggle(
L10n.string(
"mobile.notifications.macForwarding",
defaultValue: "Forward Alerts from This Mac"
),
isOn: macForwardingBinding
)
.accessibilityIdentifier("MobileSettingsPushMacForwardingToggle")
.disabled(!supportsMacSettings || isMutatingMac)
VStack(alignment: .leading, spacing: 8) {
Text(L10n.string(
"mobile.notifications.macMode",
defaultValue: "Forwarding Mode"
))
.font(.subheadline)
HStack(spacing: 8) {
modeButton(
.onlyWhenAway,
title: L10n.string(
"mobile.notifications.mode.onlyWhenAway",
defaultValue: "Only When Away"
),
identifier: "MobileSettingsPushModeOnlyWhenAway"
)
modeButton(
.always,
title: L10n.string(
"mobile.notifications.mode.always",
defaultValue: "Always"
),
identifier: "MobileSettingsPushModeAlways"
)
}
}
Toggle(
L10n.string(
"mobile.notifications.hideContent",
defaultValue: "Hide Notification Content"
),
isOn: macHideContentBinding
)
.accessibilityIdentifier("MobileSettingsPushHideContentToggle")
.disabled(!supportsMacSettings || isMutatingMac)
if !supportsMacSettings {
Text(L10n.string(
"mobile.notifications.macUpdateRequired",
defaultValue: "Update cmux on this Mac to change forwarding from iPhone."
))
.font(.footnote)
.foregroundStyle(.secondary)
}
if mutationFailed {
Text(L10n.string(
"mobile.notifications.macMutationFailed",
defaultValue: "The Mac did not save that change. Its last confirmed settings were restored."
))
.font(.footnote)
.foregroundStyle(.red)
.accessibilityIdentifier("MobileSettingsPushMutationError")
}
if macStatus.mode == .onlyWhenAway {
Text(L10n.string(
"mobile.notifications.awayExplanation",
defaultValue: "Only When Away sends after the Mac is locked, asleep, or inactive."
))
.font(.footnote)
.foregroundStyle(.secondary)
}
Button {
isSendingTest = true
testStage = nil
Task {
testStage = await onSendTest()
isSendingTest = false
}
} label: {
Label(
L10n.string(
"mobile.notifications.test.send",
defaultValue: "Send Test Alert"
),
systemImage: "paperplane"
)
}
.disabled(!supportsMacTest || isSendingTest || isMutatingMac)
.accessibilityIdentifier("MobileSettingsPushSendTest")
if let testStage {
Text(testStageText(testStage))
.font(.footnote)
.foregroundStyle(.secondary)
.accessibilityIdentifier("MobileSettingsPushTestResult")
}
}
}
.onChange(of: macStatus) { _, confirmed in
confirmedMacStatus = confirmed
guard let confirmed else { return }
macForwardingEnabled = confirmed.forwardingEnabled
macMode = confirmed.mode
macHideContent = confirmed.hideContent
mutationFailed = false
}
}
private var statusRow: some View {
HStack(spacing: 10) {
Image(systemName: readinessSymbol)
.foregroundStyle(readinessTint)
VStack(alignment: .leading, spacing: 2) {
Text(L10n.string(
"mobile.notifications.readiness",
defaultValue: "Delivery Status"
))
.font(.subheadline.weight(.semibold))
Text(readinessText)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(readinessText)
.accessibilityIdentifier("MobileSettingsPushReadinessStatus")
}
private var phoneEnabledBinding: Binding<Bool> {
Binding(
get: { phoneEnabled },
set: { requested in
guard !isMutatingPhone else { return }
let confirmed = phoneEnabled
phoneEnabled = requested
isMutatingPhone = true
Task {
let succeeded = await onPhoneEnabledChange(requested)
if !succeeded {
phoneEnabled = confirmed
}
isMutatingPhone = false
}
}
)
}
private var macForwardingBinding: Binding<Bool> {
Binding(
get: { macForwardingEnabled },
set: { requested in
guard !isMutatingMac else { return }
macForwardingEnabled = requested
performMacMutation(.forwardingEnabled(requested))
}
)
}
private var macHideContentBinding: Binding<Bool> {
Binding(
get: { macHideContent },
set: { requested in
guard !isMutatingMac else { return }
macHideContent = requested
performMacMutation(.hideContent(requested))
}
)
}
private func modeButton(
_ mode: MobileHostPhonePushStatus.Mode,
title: String,
identifier: String
) -> some View {
Button {
guard !isMutatingMac, macMode != mode else { return }
macMode = mode
performMacMutation(.mode(mode))
} label: {
HStack(spacing: 5) {
Image(systemName: macMode == mode ? "checkmark.circle.fill" : "circle")
Text(title)
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.disabled(!supportsMacSettings || isMutatingMac)
.accessibilityIdentifier(identifier)
.accessibilityValue(
macMode == mode
? L10n.string(
"mobile.accessibility.selected",
defaultValue: "selected"
)
: L10n.string(
"mobile.accessibility.notSelected",
defaultValue: "not selected"
)
)
}
private func performMacMutation(_ mutation: MobilePushMacMutation) {
mutationFailed = false
isMutatingMac = true
Task {
let succeeded = await onMacMutation(mutation)
isMutatingMac = false
if !succeeded {
if let confirmedMacStatus {
macForwardingEnabled = confirmedMacStatus.forwardingEnabled
macMode = confirmedMacStatus.mode
macHideContent = confirmedMacStatus.hideContent
}
mutationFailed = true
}
}
}
}
private extension MobilePushSettingsContent {
private var readinessText: String {
switch readiness {
case let .ready(mode):
switch mode {
case .onlyWhenAway:
return L10n.string(
"mobile.notifications.status.readyAway",
defaultValue: "Ready, Only When Away"
)
case .always:
return L10n.string(
"mobile.notifications.status.readyAlways",
defaultValue: "Ready, Always"
)
}
case .limited:
return L10n.string(
"mobile.notifications.status.limitedAuthorization",
defaultValue: "Limited, Delivered Quietly"
)
case .presentationLimited:
return L10n.string(
"mobile.notifications.status.presentationLimited",
defaultValue: "Limited, Check iOS Presentation Settings"
)
case let .reliabilityLimited(_, queuePersistence):
switch queuePersistence {
case .unknown:
return L10n.string(
"mobile.notifications.status.queueUnconfirmed",
defaultValue: "Limited, Delivery Recovery Unconfirmed"
)
case .loadFailed, .saveFailed, .clearFailed:
return L10n.string(
"mobile.notifications.status.queueFailed",
defaultValue: "Limited, Delivery Recovery Unavailable"
)
case .healthy:
return L10n.string(
"mobile.notifications.status.readyAlways",
defaultValue: "Ready, Always"
)
}
case let .blocked(blocker):
return blockedText(blocker)
}
}
func blockedText(_ blocker: MobilePushReadiness.Blocker) -> String {
switch blocker {
case .phoneOptInDisabled, .systemPermissionNotRequested,
.systemPermissionDenied, .systemNotificationsUnsupported,
.awaitingDeviceToken, .deviceTokenRegistrationFailed:
phoneBlockerText(blocker)
case .registeringDevice, .backendRegistrationRequired,
.authenticationRequired, .accountDeletionInProgress,
.registrationRateLimited, .deviceLimitReached,
.networkUnavailable, .pushServiceUnavailable,
.invalidServerResponse, .registrationRejected,
.invalidConfiguration:
registrationBlockerText(blocker)
case .macStatusUnavailable, .macAdmissionUnavailable,
.macAccountMismatch, .macForwardingDisabled,
.macCurrentlyActive, .apiOriginMismatch:
macBlockerText(blocker)
}
}
func phoneBlockerText(_ blocker: MobilePushReadiness.Blocker) -> String {
switch blocker {
case .phoneOptInDisabled:
return L10n.string(
"mobile.notifications.status.phoneOff",
defaultValue: "Blocked, Off on This iPhone"
)
case .systemPermissionNotRequested:
return L10n.string(
"mobile.notifications.status.permissionNotRequested",
defaultValue: "Blocked, iOS Permission Not Requested"
)
case .systemPermissionDenied:
return L10n.string(
"mobile.notifications.status.permissionDenied",
defaultValue: "Blocked, iOS Permission Denied"
)
case .systemNotificationsUnsupported:
return L10n.string(
"mobile.notifications.status.unsupported",
defaultValue: "Blocked, Notifications Unsupported"
)
case .awaitingDeviceToken:
return L10n.string(
"mobile.notifications.status.awaitingToken",
defaultValue: "Blocked, Waiting for Notification Setup"
)
case .deviceTokenRegistrationFailed:
return L10n.string(
"mobile.notifications.status.tokenFailed",
defaultValue: "Blocked, Notification Setup Failed"
)
default:
assertionFailure("Expected a phone-side push blocker")
return ""
}
}
func registrationBlockerText(
_ blocker: MobilePushReadiness.Blocker
) -> String {
switch blocker {
case .registeringDevice:
return L10n.string(
"mobile.notifications.status.registering",
defaultValue: "Blocked, Registering This Device"
)
case .backendRegistrationRequired:
return L10n.string(
"mobile.notifications.status.backendRequired",
defaultValue: "Blocked, Finishing Notification Setup"
)
case .authenticationRequired:
return L10n.string(
"mobile.notifications.status.authenticationRequired",
defaultValue: "Blocked, Sign In Again"
)
case .accountDeletionInProgress:
return L10n.string(
"mobile.notifications.status.accountDeletion",
defaultValue: "Blocked, Account Deletion in Progress"
)
case .registrationRateLimited:
return L10n.string(
"mobile.notifications.status.rateLimited",
defaultValue: "Blocked, Registration Rate Limited"
)
case let .deviceLimitReached(limit):
return String.localizedStringWithFormat(
L10n.string(
"mobile.notifications.status.deviceLimitFormat",
defaultValue: "Blocked, %d-Device Limit Reached"
),
limit
)
case .networkUnavailable:
return L10n.string(
"mobile.notifications.status.offline",
defaultValue: "Blocked, Network Unavailable"
)
case .pushServiceUnavailable, .invalidServerResponse,
.registrationRejected:
return L10n.string(
"mobile.notifications.status.registrationFailed",
defaultValue: "Blocked, Registration Failed"
)
case .invalidConfiguration:
return L10n.string(
"mobile.notifications.status.invalidConfiguration",
defaultValue: "Blocked, Invalid Push Configuration"
)
default:
assertionFailure("Expected a registration push blocker")
return ""
}
}
func macBlockerText(_ blocker: MobilePushReadiness.Blocker) -> String {
switch blocker {
case .macStatusUnavailable, .macAdmissionUnavailable:
return L10n.string(
"mobile.notifications.status.macUnavailable",
defaultValue: "Blocked, Mac Status Unavailable"
)
case .macAccountMismatch:
return L10n.string(
"mobile.notifications.status.accountMismatch",
defaultValue: "Blocked, Mac Account Does Not Match"
)
case .macForwardingDisabled:
return L10n.string(
"mobile.notifications.status.macForwardingOff",
defaultValue: "Blocked, Mac Forwarding Is Off"
)
case .macCurrentlyActive:
return L10n.string(
"mobile.notifications.status.macActive",
defaultValue: "Paused, Mac Is Active"
)
case .apiOriginMismatch:
return L10n.string(
"mobile.notifications.status.originMismatch",
defaultValue: "Blocked, Mac and iPhone Servers Differ"
)
default:
assertionFailure("Expected a Mac-side push blocker")
return ""
}
}
private func testStageText(_ stage: MobilePhonePushTestStage) -> String {
switch stage {
case .queuedOnMac:
L10n.string(
"mobile.notifications.test.queued",
defaultValue: "Queued on Mac. iOS delivery is still pending."
)
case .forwardingDisabled:
L10n.string(
"mobile.notifications.test.forwardingOff",
defaultValue: "Not queued because Mac forwarding is off."
)
case .macActive:
L10n.string(
"mobile.notifications.test.macActive",
defaultValue: "Not queued because Only When Away is active and the Mac is in use."
)
case .authenticationUnavailable:
L10n.string(
"mobile.notifications.test.authentication",
defaultValue: "Not queued because the Mac is not signed in."
)
case .encodingFailed:
L10n.string(
"mobile.notifications.test.encodingFailed",
defaultValue: "The alert could not be prepared for delivery."
)
case .queueFull:
L10n.string(
"mobile.notifications.test.queueFull",
defaultValue: "Delivery is busy. Try again shortly."
)
case .unavailable:
L10n.string(
"mobile.notifications.test.unavailable",
defaultValue: "The Mac could not confirm a queue stage."
)
}
}
private var readinessSymbol: String {
switch readiness {
case .ready: "checkmark.circle.fill"
case .limited, .presentationLimited, .reliabilityLimited:
"exclamationmark.triangle.fill"
case .blocked: "xmark.circle.fill"
}
}
private var readinessTint: Color {
switch readiness {
case .ready: .green
case .limited, .presentationLimited, .reliabilityLimited: .orange
case .blocked: .red
}
}
}
private extension MobilePushSettingsContent {
struct RepairPresentation {
let title: String
let systemImage: String
let identifier: String
}
func repairPresentation(
for repair: MobilePushReadiness.Repair
) -> RepairPresentation? {
switch repair {
case .enableOnPhone:
RepairPresentation(
title: L10n.string(
"mobile.notifications.repair.enablePhone",
defaultValue: "Enable on This iPhone"
),
systemImage: "bell.badge",
identifier: "MobileSettingsPushRepairEnablePhone"
)
case .openSystemSettings:
RepairPresentation(
title: L10n.string(
"mobile.notifications.repair.openSettings",
defaultValue: "Open iOS Notification Settings"
),
systemImage: "gear",
identifier: "MobileSettingsPushRepairOpenSettings"
)
case .retryDeviceTokenRegistration:
RepairPresentation(
title: L10n.string(
"mobile.notifications.repair.retryAPNs",
defaultValue: "Retry Notification Setup"
),
systemImage: "arrow.clockwise",
identifier: "MobileSettingsPushRepairRetryAPNs"
)
case .retryRegistration:
RepairPresentation(
title: L10n.string(
"mobile.notifications.repair.retryRegistration",
defaultValue: "Retry Registration"
),
systemImage: "arrow.clockwise",
identifier: "MobileSettingsPushRepairRetryRegistration"
)
case .signInAgain, .signIntoMatchingAccount:
RepairPresentation(
title: L10n.string(
"mobile.notifications.repair.signInAgain",
defaultValue: "Sign In with the Matching Account"
),
systemImage: "person.crop.circle.badge.exclamationmark",
identifier: "MobileSettingsPushRepairSignIn"
)
case .connectMac:
RepairPresentation(
title: L10n.string(
"mobile.notifications.repair.connectMac",
defaultValue: "Connect a Mac"
),
systemImage: "desktopcomputer",
identifier: "MobileSettingsPushRepairConnectMac"
)
case .leaveMacOrUseAlwaysMode:
RepairPresentation(
title: L10n.string(
"mobile.notifications.repair.useAlways",
defaultValue: "Use Always Mode"
),
systemImage: "bell.fill",
identifier: "MobileSettingsPushRepairUseAlways"
)
case .waitForDeviceToken, .finishAccountDeletion,
.disablePushOnAnotherDevice, .enableOnMac,
.rebuildMatchingApps:
nil
}
}
}
#endif
@@ -1,4 +1,5 @@
#if os(iOS)
import CMUXMobileCore
import CmuxAuthRuntime
import CmuxMobileShell
import CmuxMobileShellModel
@@ -120,6 +121,18 @@ struct MobileSettingsView: View {
value: connectedHostName
)
}
if let store,
store.connectionState == .connected,
let routeKind = store.activeRoute?.kind {
LabeledContent(
L10n.string(
"mobile.settings.activeTransport",
defaultValue: "Active Transport"
),
value: activeTransportName(routeKind)
)
.accessibilityIdentifier("MobileSettingsActiveTransport")
}
}
}
if hasConnectionSection {
@@ -380,24 +393,20 @@ struct MobileSettingsView: View {
}
Section(L10n.string("mobile.settings.notifications", defaultValue: "Push Alerts")) {
Button {
Task {
if notificationsEnabled {
await pushCoordinator.disable()
notificationsEnabled = false
} else {
notificationsEnabled = await pushCoordinator.enable()
}
}
} label: {
Label(
notificationsEnabled
? L10n.string("mobile.notifications.disable", defaultValue: "Turn Off Push Alerts")
: L10n.string("mobile.notifications.enable", defaultValue: "Notify Me When Agents Need Me"),
systemImage: notificationsEnabled ? "bell.slash" : "bell"
)
}
.accessibilityIdentifier("MobileSettingsNotifications")
MobilePushSettingsContent(
readiness: pushCoordinator.readiness(
macStatus: store?.phonePushMacStatus,
macAccountMismatch: store?.connectionRequiresReauth == true
),
phoneEnabled: $notificationsEnabled,
macStatus: store?.phonePushMacStatus,
supportsMacSettings: store?.supportsPhonePushSettings == true,
supportsMacTest: store?.supportsPhonePushTest == true,
onPhoneEnabledChange: updatePhonePushEnabled,
onRepair: repairPhonePush,
onMacMutation: updateMacPhonePush,
onSendTest: sendPhonePushTest
)
}
Section {
@@ -441,7 +450,13 @@ struct MobileSettingsView: View {
.accessibilityIdentifier("MobileSettingsVersionRow")
}
}
.onAppear { notificationsEnabled = pushCoordinator.isEnabled }
.task {
notificationsEnabled = pushCoordinator.isEnabled
await pushCoordinator.refreshReadiness()
}
.onChange(of: pushCoordinator.isEnabled) { _, enabled in
notificationsEnabled = enabled
}
.navigationTitle(L10n.string("mobile.workspaces.settings", defaultValue: "Settings"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
@@ -501,6 +516,102 @@ struct MobileSettingsView: View {
.accessibilityIdentifier("MobileSettingsView")
}
private func activeTransportName(_ kind: CmxAttachTransportKind) -> String {
switch kind {
case .tailscale:
L10n.string(
"mobile.settings.activeTransport.tailscale",
defaultValue: "Tailscale"
)
case .iroh:
L10n.string(
"mobile.settings.activeTransport.iroh",
defaultValue: "Iroh"
)
case .websocket:
L10n.string(
"mobile.settings.activeTransport.websocket",
defaultValue: "WebSocket"
)
case .debugLoopback:
L10n.string(
"mobile.settings.activeTransport.simulator",
defaultValue: "Simulator"
)
}
}
@MainActor
private func updatePhonePushEnabled(_ enabled: Bool) async -> Bool {
if enabled {
_ = await pushCoordinator.enable()
// A denied OS authorization still accepts the user's app-level
// intent. Keep the toggle on so readiness can surface the Settings
// recovery action instead of rolling the preference back.
return pushCoordinator.isEnabled
}
await pushCoordinator.disable()
return !pushCoordinator.isEnabled
}
@MainActor
private func repairPhonePush(
_ repair: MobilePushReadiness.Repair
) async -> Bool {
switch repair {
case .enableOnPhone:
return await updatePhonePushEnabled(true)
case .openSystemSettings:
pushCoordinator.openSystemSettings()
return true
case .retryDeviceTokenRegistration:
pushCoordinator.retryDeviceTokenRegistration()
await pushCoordinator.refreshReadiness()
return true
case .retryRegistration:
await pushCoordinator.syncTokenIfPossible()
await pushCoordinator.refreshReadiness()
return true
case .signInAgain, .signIntoMatchingAccount:
signOut?()
return signOut != nil
case .connectMac:
startPairingScanner?()
return startPairingScanner != nil
case .leaveMacOrUseAlwaysMode:
return await store?.updatePhonePushSettings(mode: .always) == true
case .enableOnMac:
return await store?.updatePhonePushSettings(
forwardingEnabled: true
) == true
case .waitForDeviceToken, .finishAccountDeletion,
.disablePushOnAnotherDevice, .rebuildMatchingApps:
return false
}
}
@MainActor
private func updateMacPhonePush(
_ mutation: MobilePushMacMutation
) async -> Bool {
guard let store else { return false }
switch mutation {
case let .forwardingEnabled(enabled):
return await store.updatePhonePushSettings(
forwardingEnabled: enabled
)
case let .mode(mode):
return await store.updatePhonePushSettings(mode: mode)
case let .hideContent(hidden):
return await store.updatePhonePushSettings(hideContent: hidden)
}
}
@MainActor
private func sendPhonePushTest() async -> MobilePhonePushTestStage {
await store?.sendPhonePushTest() ?? .unavailable
}
private static var crashReportingEnabled: Bool {
switch Bundle.main.object(forInfoDictionaryKey: "CMUXCrashReportingEnabled") {
case let enabled as Bool:
@@ -29,11 +29,11 @@ struct OnboardingConnectionMethodPicker: View {
.tailscale,
title: L10n.string(
"mobile.onboarding.connect.method.tailscale",
defaultValue: "Tailscale"
defaultValue: "Tailscale Only"
),
subtitle: L10n.string(
"mobile.onboarding.connect.method.tailscaleDetail",
defaultValue: "Uses your Tailscale network. Scan the pairing code on your Mac."
defaultValue: "Uses only your Tailscale network. Scan the pairing code on your Mac."
),
systemImage: "qrcode",
accessibilityIdentifier: "MobileOnboardingConnectionMethodTailscale"
@@ -72,8 +72,8 @@ struct OnboardingConnectionView: View {
return L10n.string(
"mobile.onboarding.connect.tailscaleBody",
defaultValue: """
Install Tailscale on this iPhone and your Mac, then connect both to the same Tailscale network. \
On your Mac, open Tailscale Pairing in cmux to show the QR, then scan it here.
Connect only over Tailscale. Install it on both devices, join the same network, then scan the \
pairing code shown by cmux on your Mac.
"""
)
}
@@ -22,6 +22,7 @@ struct WorkspaceShellHost: View {
let showAddDevice: (() -> Void)?
let showPairingScanner: (() -> Void)?
let reconnectStoredMac: () -> Void
let workspaceListDidBecomeVisible: @MainActor @Sendable () async -> Void
@Environment(AuthCoordinator.self) private var authManager
@State private var loadingTimedOut = false
@@ -40,6 +41,9 @@ struct WorkspaceShellHost: View {
.task(id: deadlineTaskID) {
await updateLoadingDeadline()
}
.task {
await workspaceListDidBecomeVisible()
}
}
private struct DeadlineTaskID: Equatable {
@@ -0,0 +1,566 @@
import CmuxAuthRuntime
import Foundation
import Testing
import UserNotifications
@testable import CmuxMobileShellUI
private actor LifecyclePushRegistration: PushRegistering {
private var value: PushRegistrationSnapshot
private let setEnabledGate: LifecycleSetEnabledGate?
private let syncGate: LifecycleSyncGate?
init(
enabled: Bool = true,
snapshot: PushRegistrationSnapshot? = nil,
setEnabledGate: LifecycleSetEnabledGate? = nil,
syncGate: LifecycleSyncGate? = nil
) {
value = snapshot
?? (enabled ? PushRegistrationSnapshot(
isEnabled: true,
hasDeviceToken: false,
backendState: .awaitingDeviceToken
)
: .disabled)
self.setEnabledGate = setEnabledGate
self.syncGate = syncGate
}
var isEnabled: Bool { value.isEnabled }
var snapshot: PushRegistrationSnapshot { value }
func snapshots() -> AsyncStream<PushRegistrationSnapshot> {
AsyncStream { continuation in
continuation.yield(value)
continuation.finish()
}
}
func setEnabled(_ enabled: Bool) async {
await setEnabledGate?.pause()
value = enabled
? PushRegistrationSnapshot(
isEnabled: true,
hasDeviceToken: value.hasDeviceToken,
backendState: value.hasDeviceToken
? .registrationRequired
: .awaitingDeviceToken
)
: .disabled
}
func register(deviceToken: Data) {
value = PushRegistrationSnapshot(
isEnabled: true,
hasDeviceToken: true,
backendState: .registered
)
}
func deviceTokenRegistrationFailed() {
value = PushRegistrationSnapshot(
isEnabled: true,
hasDeviceToken: false,
backendState: .deviceTokenRegistrationFailed
)
}
func syncTokenIfPossible() async {
await syncGate?.pause()
guard value.isEnabled, value.hasDeviceToken else { return }
value = PushRegistrationSnapshot(
isEnabled: true,
hasDeviceToken: true,
backendState: .registered
)
}
func unregisterFromServer() {}
func unregisterFromServer(accessToken: String?, refreshToken: String?) {}
func unregisterFromServer(
accountID: String?,
accessToken: String?,
refreshToken: String?
) {}
}
private actor LifecycleSetEnabledGate {
private var didStart = false
private var released = false
private var startWaiters: [CheckedContinuation<Void, Never>] = []
private var releaseWaiters: [CheckedContinuation<Void, Never>] = []
func pause() async {
didStart = true
let waiters = startWaiters
startWaiters.removeAll()
for waiter in waiters {
waiter.resume()
}
guard !released else { return }
await withCheckedContinuation { continuation in
releaseWaiters.append(continuation)
}
}
func waitUntilStarted() async {
guard !didStart else { return }
await withCheckedContinuation { continuation in
startWaiters.append(continuation)
}
}
func release() {
released = true
let waiters = releaseWaiters
releaseWaiters.removeAll()
for waiter in waiters {
waiter.resume()
}
}
}
private actor LifecycleSyncGate {
private(set) var starts = 0
private var released = false
private var startWaiters: [CheckedContinuation<Void, Never>] = []
private var releaseWaiters: [CheckedContinuation<Void, Never>] = []
func pause() async {
starts += 1
let waiters = startWaiters
startWaiters.removeAll()
for waiter in waiters {
waiter.resume()
}
guard !released else { return }
await withCheckedContinuation { continuation in
releaseWaiters.append(continuation)
}
}
func waitUntilStarted() async {
guard starts == 0 else { return }
await withCheckedContinuation { continuation in
startWaiters.append(continuation)
}
}
func release() {
released = true
let waiters = releaseWaiters
releaseWaiters.removeAll()
for waiter in waiters {
waiter.resume()
}
}
}
private struct LifecycleTokenProvider: TokenProviding {
private let session = AuthenticatedSessionSnapshot(
generation: 1,
accountID: "push-lifecycle-user",
accessToken: "push-lifecycle-access",
refreshToken: "push-lifecycle-refresh"
)
func authenticatedSessionSnapshot() async throws
-> AuthenticatedSessionSnapshot {
session
}
func isAuthenticatedSessionCurrent(
_ snapshot: AuthenticatedSessionSnapshot
) async -> Bool {
snapshot == session
}
func accessToken() async throws -> String { session.accessToken }
func storedAccessToken() async -> String? { session.accessToken }
func refreshToken() async -> String? { session.refreshToken }
func forceRefreshAccessToken() async throws -> String {
session.accessToken
}
}
private final class LifecycleRequestRecorder: @unchecked Sendable {
private let lock = NSLock()
private var storedMethods: [String] = []
var methods: [String] { lock.withLock { storedMethods } }
func record(_ request: URLRequest) {
lock.withLock { storedMethods.append(request.httpMethod ?? "?") }
}
func reset() {
lock.withLock { storedMethods.removeAll() }
}
}
private final class LifecyclePushURLProtocol: URLProtocol,
@unchecked Sendable {
static let recorder = LifecycleRequestRecorder()
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(
for request: URLRequest
) -> URLRequest {
request
}
override func startLoading() {
Self.recorder.record(request)
let response = HTTPURLResponse(
url: request.url!,
statusCode: 200,
httpVersion: nil,
headerFields: nil
)!
client?.urlProtocol(
self,
didReceive: response,
cacheStoragePolicy: .notAllowed
)
client?.urlProtocol(self, didLoad: Data(#"{"ok":true}"#.utf8))
client?.urlProtocolDidFinishLoading(self)
}
override func stopLoading() {}
}
@Suite struct MobilePushCoordinatorLifecycleTests {
@MainActor
@Test func callbackFailureOffersRetryAndSuccessfulTokenRecoversReadiness() async {
let registration = LifecyclePushRegistration()
var registrationRequests = 0
let suiteName = "push-coordinator-callback-\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defer { defaults.removePersistentDomain(forName: suiteName) }
let coordinator = MobilePushCoordinator(
registration: registration,
defaults: defaults,
authorizationStatus: { .authorized },
requestAuthorization: { true },
registerForRemoteNotifications: { registrationRequests += 1 }
)
await coordinator.refreshReadiness()
await coordinator.handleDeviceTokenFailure()
#expect(
coordinator.registrationSnapshot.backendState
== .deviceTokenRegistrationFailed
)
#expect(
coordinator.readiness(macStatus: nil)
== .blocked(.deviceTokenRegistrationFailed)
)
coordinator.retryDeviceTokenRegistration()
#expect(registrationRequests == 1)
await coordinator.handleDeviceToken(Data(repeating: 0xCD, count: 32))
#expect(coordinator.registrationSnapshot.backendState == .registered)
}
@MainActor
@Test func enableRegistersWithOSBeforeBackendSyncCompletes() async {
let gate = LifecycleSetEnabledGate()
let registration = LifecyclePushRegistration(
enabled: false,
setEnabledGate: gate
)
let suiteName = "push-coordinator-enable-\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
var registrationRequests = 0
let coordinator = MobilePushCoordinator(
registration: registration,
defaults: defaults,
authorizationStatus: { .authorized },
requestAuthorization: { true },
registerForRemoteNotifications: { registrationRequests += 1 }
)
let enabling = Task { await coordinator.enable() }
await gate.waitUntilStarted()
#expect(registrationRequests == 1)
#expect(coordinator.isEnabled)
#expect(coordinator.registrationSnapshot.isEnabled)
await gate.release()
#expect(await enabling.value)
}
@MainActor
@Test func authorizedEnableRecoversWithoutRequestingAuthorizationAgain() async {
let registration = LifecyclePushRegistration(enabled: false)
let suiteName = "push-coordinator-authorized-recovery-\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defer { defaults.removePersistentDomain(forName: suiteName) }
var authorizationRequests = 0
var registrationRequests = 0
let coordinator = MobilePushCoordinator(
registration: registration,
defaults: defaults,
authorizationStatus: { .authorized },
requestAuthorization: {
authorizationRequests += 1
return false
},
registerForRemoteNotifications: { registrationRequests += 1 }
)
#expect(await coordinator.enable())
#expect(authorizationRequests == 0)
#expect(registrationRequests == 1)
#expect(coordinator.isEnabled)
#expect(await registration.snapshot.isEnabled)
}
@MainActor
@Test func deniedEnablePersistsIntentAndDoesNotReaskTheSystem() async {
let registration = LifecyclePushRegistration(enabled: false)
let suiteName = "push-coordinator-denied-intent-\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defer { defaults.removePersistentDomain(forName: suiteName) }
var authorizationRequests = 0
let coordinator = MobilePushCoordinator(
registration: registration,
defaults: defaults,
authorizationStatus: { .denied },
requestAuthorization: {
authorizationRequests += 1
return false
}
)
#expect(!(await coordinator.enable()))
#expect(authorizationRequests == 0)
#expect(coordinator.isEnabled)
#expect(
defaults.object(forKey: "cmux.notifications.pushEnabled") as? Bool
== true
)
#expect(
coordinator.readiness(macStatus: nil)
== .blocked(.systemPermissionDenied)
)
}
@MainActor
@Test func foregroundRefreshRegistersAfterPermissionIsEnabledInSettings() async {
let registration = LifecyclePushRegistration(enabled: false)
let suiteName = "push-coordinator-settings-return-\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defer { defaults.removePersistentDomain(forName: suiteName) }
defaults.set(true, forKey: "cmux.notifications.pushEnabled")
var status = UNAuthorizationStatus.denied
var registrationRequests = 0
let coordinator = MobilePushCoordinator(
registration: registration,
defaults: defaults,
authorizationStatus: { status },
registerForRemoteNotifications: { registrationRequests += 1 }
)
await coordinator.refreshReadiness()
#expect(registrationRequests == 0)
#expect(!(await registration.snapshot.isEnabled))
status = .authorized
await coordinator.refreshReadiness()
#expect(registrationRequests == 1)
#expect(await registration.snapshot.isEnabled)
#expect(coordinator.authorization == .authorized)
}
@MainActor
@Test func foregroundRefreshPreservesExplicitAppOptOut() async {
let registration = LifecyclePushRegistration(enabled: false)
let suiteName = "push-coordinator-explicit-optout-\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defer { defaults.removePersistentDomain(forName: suiteName) }
defaults.set(false, forKey: "cmux.notifications.pushEnabled")
var registrationRequests = 0
let coordinator = MobilePushCoordinator(
registration: registration,
defaults: defaults,
authorizationStatus: { .provisional },
registerForRemoteNotifications: { registrationRequests += 1 }
)
await coordinator.refreshReadiness()
#expect(registrationRequests == 0)
#expect(!(await registration.snapshot.isEnabled))
#expect(!coordinator.isEnabled)
}
@MainActor
@Test func repeatedForegroundAndWorkspaceActivationRequestsAPNsOnce() async {
let registration = LifecyclePushRegistration(enabled: true)
let suiteName = "push-coordinator-apns-dedupe-\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defer { defaults.removePersistentDomain(forName: suiteName) }
defaults.set(true, forKey: "cmux.notifications.pushEnabled")
var registrationRequests = 0
let coordinator = MobilePushCoordinator(
registration: registration,
defaults: defaults,
authorizationStatus: { .authorized },
registerForRemoteNotifications: { registrationRequests += 1 }
)
await coordinator.refreshReadiness()
await coordinator.workspaceListDidBecomeVisible()
await coordinator.refreshReadiness()
#expect(registrationRequests == 1)
}
@MainActor
@Test func sharedDefaultsDisableInvokesProductionBackendUnregister() async {
LifecyclePushURLProtocol.recorder.reset()
let suiteName = "push-coordinator-shared-disable-\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defer { defaults.removePersistentDomain(forName: suiteName) }
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [LifecyclePushURLProtocol.self]
let registration = PushRegistrationService(
tokenProvider: LifecycleTokenProvider(),
apiBaseURL: "https://push-lifecycle.test",
bundleID: "dev.cmux.ios.push-lifecycle",
apnsEnvironment: "sandbox",
suiteName: suiteName,
session: URLSession(configuration: configuration),
retryDelays: []
)
let coordinator = MobilePushCoordinator(
registration: registration,
defaults: defaults,
authorizationStatus: { .authorized },
registerForRemoteNotifications: {},
unregisterForRemoteNotifications: {}
)
#expect(await coordinator.enable())
await coordinator.handleDeviceToken(Data([0xAB, 0xCD]))
await coordinator.disable()
#expect(LifecyclePushURLProtocol.recorder.methods == ["POST", "DELETE"])
#expect(!defaults.bool(forKey: "cmux.notifications.pushEnabled"))
#expect(await registration.snapshot == .disabled)
}
@MainActor
@Test func optInPersistsAcrossCoordinatorRecreation() async {
let suiteName = "push-coordinator-persistence-\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defer { defaults.removePersistentDomain(forName: suiteName) }
let registration = LifecyclePushRegistration(enabled: false)
let enabled = MobilePushCoordinator(
registration: registration,
defaults: defaults,
authorizationStatus: { .authorized },
requestAuthorization: { true }
)
#expect(await enabled.enable())
#expect(
defaults.object(
forKey: "cmux.notifications.pushEnabled"
) as? Bool == true
)
#expect(MobilePushCoordinator(
registration: registration,
defaults: defaults,
authorizationStatus: { .authorized }
).isEnabled)
await enabled.disable()
#expect(
defaults.object(
forKey: "cmux.notifications.pushEnabled"
) as? Bool == false
)
#expect(!MobilePushCoordinator(
registration: registration,
defaults: defaults,
authorizationStatus: { .authorized }
).isEnabled)
}
@MainActor
@Test func disableUnregistersWithOSBeforeBackendCleanupCompletes() async {
let gate = LifecycleSetEnabledGate()
let registration = LifecyclePushRegistration(
enabled: true,
setEnabledGate: gate
)
let suiteName = "push-coordinator-disable-\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defaults.set(true, forKey: "cmux.notifications.pushEnabled")
var unregistrationRequests = 0
let coordinator = MobilePushCoordinator(
registration: registration,
defaults: defaults,
authorizationStatus: { .authorized },
unregisterForRemoteNotifications: {
unregistrationRequests += 1
}
)
let disabling = Task { await coordinator.disable() }
await gate.waitUntilStarted()
#expect(unregistrationRequests == 1)
#expect(!coordinator.isEnabled)
#expect(coordinator.registrationSnapshot == .disabled)
await gate.release()
await disabling.value
}
@MainActor
@Test func foregroundAndReachabilityRecoveryShareOneExhaustedRegistrationRetry() async {
let gate = LifecycleSyncGate()
let registration = LifecyclePushRegistration(
snapshot: PushRegistrationSnapshot(
isEnabled: true,
hasDeviceToken: true,
backendState: .failed(.networkUnavailable)
),
syncGate: gate
)
let suiteName = "push-coordinator-shared-retry-\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defer { defaults.removePersistentDomain(forName: suiteName) }
let coordinator = MobilePushCoordinator(
registration: registration,
defaults: defaults,
authorizationStatus: { .authorized }
)
let firstRefresh = Task { @MainActor in
await coordinator.refreshReadiness()
}
await gate.waitUntilStarted()
let secondRefresh = Task { @MainActor in
await coordinator.networkDidBecomeReachable()
}
await Task.yield()
#expect(await gate.starts == 1)
await gate.release()
await firstRefresh.value
await secondRefresh.value
#expect(await gate.starts == 1)
#expect(coordinator.registrationSnapshot.backendState == .registered)
}
}
@@ -0,0 +1,440 @@
import CmuxAuthRuntime
import CmuxMobileRPC
import Testing
@testable import CmuxMobileShellUI
@Suite struct MobilePushReadinessTests {
private let registered = PushRegistrationSnapshot(
isEnabled: true,
hasDeviceToken: true,
backendState: .registered
)
@Test func localOptInAloneIsNeverReportedAsReady() {
let readiness = MobilePushReadiness.resolve(
authorization: .authorized,
registration: PushRegistrationSnapshot(
isEnabled: true,
hasDeviceToken: false,
backendState: .awaitingDeviceToken
),
mac: nil,
phoneAPIOrigin: "https://cmux.com"
)
#expect(readiness == .blocked(.awaitingDeviceToken))
}
@Test func cachedTokenAwaitingBackendAcknowledgementOffersRetry() {
let readiness = MobilePushReadiness.resolve(
authorization: .authorized,
registration: PushRegistrationSnapshot(
isEnabled: true,
hasDeviceToken: true,
backendState: .registrationRequired
),
mac: nil,
phoneAPIOrigin: "https://cmux.com"
)
#expect(readiness == .blocked(.backendRegistrationRequired))
#expect(readiness.repair == .retryRegistration)
}
@Test func liveSystemDenialOverridesPersistedOptIn() {
let readiness = MobilePushReadiness.resolve(
authorization: .denied,
registration: registered,
mac: .init(
forwardingEnabled: true,
mode: .always,
apiOrigin: "https://cmux.com",
accountVerified: true
),
phoneAPIOrigin: "https://cmux.com"
)
#expect(readiness == .blocked(.systemPermissionDenied))
#expect(readiness.repair == .openSystemSettings)
}
@Test(arguments: [
MobilePushAuthorization.denied,
.unsupported,
])
func terminalOSStateOverridesLocalOptOut(
authorization: MobilePushAuthorization
) {
let readiness = MobilePushReadiness.resolve(
authorization: authorization,
registration: .disabled,
mac: nil,
phoneAPIOrigin: "https://cmux.com"
)
#expect(
readiness == .blocked(
authorization == .denied
? .systemPermissionDenied
: .systemNotificationsUnsupported
)
)
#expect(readiness.repair == .openSystemSettings)
}
@Test func undeterminedOSStateWithLocalOptOutStillOffersEnable() {
let readiness = MobilePushReadiness.resolve(
authorization: .notDetermined,
registration: .disabled,
mac: nil,
phoneAPIOrigin: "https://cmux.com"
)
#expect(readiness == .blocked(.phoneOptInDisabled))
#expect(readiness.repair == .enableOnPhone)
}
@Test func registeredPhoneWithoutAttachedMacReportsMacUnavailable() {
let readiness = MobilePushReadiness.resolve(
authorization: .authorized,
registration: registered,
mac: nil,
phoneAPIOrigin: "https://cmux.com"
)
#expect(readiness == .blocked(.macStatusUnavailable))
#expect(readiness.repair == .connectMac)
}
@Test func failedAPNsTokenCallbackHasItsOwnRetryAction() {
let readiness = MobilePushReadiness.resolve(
authorization: .authorized,
registration: PushRegistrationSnapshot(
isEnabled: true,
hasDeviceToken: false,
backendState: .deviceTokenRegistrationFailed
),
mac: nil,
phoneAPIOrigin: "https://cmux.com"
)
#expect(readiness == .blocked(.deviceTokenRegistrationFailed))
#expect(readiness.repair == .retryDeviceTokenRegistration)
}
@Test func backendDeviceCeilingNamesTheLimitAndItsOnlySafeRepair() {
let readiness = MobilePushReadiness.resolve(
authorization: .authorized,
registration: PushRegistrationSnapshot(
isEnabled: true,
hasDeviceToken: true,
backendState: .failed(.deviceLimitReached(limit: 200))
),
mac: nil,
phoneAPIOrigin: "https://cmux.com"
)
#expect(readiness == .blocked(.deviceLimitReached(limit: 200)))
#expect(readiness.repair == .disablePushOnAnotherDevice)
}
@Test(arguments: [
MobilePushAuthorization.provisional,
.ephemeral,
])
func quietOrTemporaryOSAuthorizationDoesNotMasqueradeAsFullReadiness(
authorization: MobilePushAuthorization
) {
let readiness = MobilePushReadiness.resolve(
authorization: authorization,
registration: registered,
mac: .init(
forwardingEnabled: true,
mode: .always,
apiOrigin: "https://cmux.com",
accountVerified: true
),
phoneAPIOrigin: "https://cmux.com"
)
#expect(readiness == .limited(mode: .always, authorization: authorization))
#expect(readiness.repair == .openSystemSettings)
}
@Test(arguments: MobilePushPresentationLimitation.allCases.filter {
$0 != .scheduledDeliveryEnabled
})
func eachSystemPresentationPolicyIsReportedIndividually(
limitation: MobilePushPresentationLimitation
) {
let settings = MobilePushSystemSettings(
authorization: .authorized,
alertsEnabled: limitation != .alertsDisabled,
soundsEnabled: limitation != .soundsDisabled,
badgesEnabled: limitation != .badgesDisabled,
lockScreenEnabled: limitation != .lockScreenDisabled,
notificationCenterEnabled:
limitation != .notificationCenterDisabled,
timeSensitiveEnabled: limitation != .timeSensitiveDisabled,
scheduledDeliveryEnabled: false
)
let readiness = MobilePushReadiness.resolve(
authorization: .authorized,
registration: registered,
mac: .init(
forwardingEnabled: true,
mode: .always,
apiOrigin: "https://cmux.com",
accountVerified: true
),
systemSettings: settings,
phoneAPIOrigin: "https://cmux.com"
)
#expect(
readiness == .presentationLimited(
mode: .always,
limitations: [limitation]
)
)
#expect(readiness.repair == .openSystemSettings)
}
@Test func scheduledDeliveryIsAWarningWhenTimeSensitiveIsDisabled() {
let settings = MobilePushSystemSettings(
authorization: .authorized,
alertsEnabled: true,
soundsEnabled: true,
badgesEnabled: true,
lockScreenEnabled: true,
notificationCenterEnabled: true,
timeSensitiveEnabled: false,
scheduledDeliveryEnabled: true
)
let readiness = MobilePushReadiness.resolve(
authorization: .authorized,
registration: registered,
mac: .init(
forwardingEnabled: true,
mode: .always,
apiOrigin: "https://cmux.com",
accountVerified: true
),
systemSettings: settings,
phoneAPIOrigin: "https://cmux.com"
)
#expect(
readiness == .presentationLimited(
mode: .always,
limitations: [.timeSensitiveDisabled, .scheduledDeliveryEnabled]
)
)
}
@Test func timeSensitiveDeliveryBypassesScheduledSummary() {
let settings = MobilePushSystemSettings(
authorization: .authorized,
alertsEnabled: true,
soundsEnabled: true,
badgesEnabled: true,
lockScreenEnabled: true,
notificationCenterEnabled: true,
timeSensitiveEnabled: true,
scheduledDeliveryEnabled: true
)
let readiness = MobilePushReadiness.resolve(
authorization: .authorized,
registration: registered,
mac: .init(
forwardingEnabled: true,
mode: .always,
apiOrigin: "https://cmux.com",
accountVerified: true
),
systemSettings: settings,
phoneAPIOrigin: "https://cmux.com"
)
#expect(readiness == .ready(mode: .always))
}
@Test(arguments: [
MobileHostPhonePushStatus.QueuePersistence.loadFailed,
.saveFailed,
.clearFailed,
])
func retryQueuePersistenceFailuresDegradeRatherThanClaimFullReadiness(
queuePersistence: MobileHostPhonePushStatus.QueuePersistence
) {
let readiness = MobilePushReadiness.resolve(
authorization: .authorized,
registration: registered,
mac: .init(
forwardingEnabled: true,
mode: .always,
admission: .allowed,
queuePersistence: queuePersistence,
apiOrigin: "https://cmux.com",
accountVerified: true
),
phoneAPIOrigin: "https://cmux.com"
)
#expect(
readiness == .reliabilityLimited(
mode: .always,
queuePersistence: queuePersistence
)
)
#expect(readiness.repair == nil)
}
@Test func uninitializedRetryQueueIsReportedAsUnconfirmed() {
let readiness = MobilePushReadiness.resolve(
authorization: .authorized,
registration: registered,
mac: .init(
forwardingEnabled: true,
mode: .always,
admission: .allowed,
queuePersistence: .unknown,
apiOrigin: "https://cmux.com",
accountVerified: true
),
phoneAPIOrigin: "https://cmux.com"
)
#expect(
readiness == .reliabilityLimited(
mode: .always,
queuePersistence: .unknown
)
)
}
@Test func unknownAdmissionFailsClosedInsteadOfReportingReady() {
let readiness = MobilePushReadiness.resolve(
authorization: .authorized,
registration: registered,
mac: .init(
forwardingEnabled: true,
mode: .always,
admission: .unknown,
apiOrigin: "https://cmux.com",
accountVerified: true
),
phoneAPIOrigin: "https://cmux.com"
)
#expect(readiness == .blocked(.macAdmissionUnavailable))
#expect(readiness.repair == .connectMac)
}
@Test func authenticatedConnectionAccountMismatchIsDistinctFromUnavailableMac() {
let readiness = MobilePushReadiness.resolve(
authorization: .authorized,
registration: registered,
mac: nil,
macAccountMismatch: true,
phoneAPIOrigin: "https://cmux.com"
)
#expect(readiness == .blocked(.macAccountMismatch))
#expect(readiness.repair == .signIntoMatchingAccount)
}
@Test func attachedMacWithForwardingOffReportsTheSecondGate() {
let readiness = MobilePushReadiness.resolve(
authorization: .authorized,
registration: registered,
mac: .init(
forwardingEnabled: false,
mode: .onlyWhenAway,
apiOrigin: "https://cmux.com",
accountVerified: true
),
phoneAPIOrigin: "https://cmux.com"
)
#expect(readiness == .blocked(.macForwardingDisabled))
#expect(readiness.repair == .enableOnMac)
}
@Test func mismatchedAPIOriginsCannotReportReady() {
let readiness = MobilePushReadiness.resolve(
authorization: .authorized,
registration: registered,
mac: .init(
forwardingEnabled: true,
mode: .always,
apiOrigin: "http://localhost:4381",
accountVerified: true
),
phoneAPIOrigin: "https://cmux-staging.vercel.app"
)
#expect(readiness == .blocked(.apiOriginMismatch))
#expect(readiness.repair == .rebuildMatchingApps)
}
@Test(arguments: [
"ws://cmux.example",
"wss://cmux.example",
"file:///tmp/cmux",
"relative/cmux",
])
func nonHTTPAPIOriginsFailClosedEvenWhenBothSidesMatch(
origin: String
) {
let readiness = MobilePushReadiness.resolve(
authorization: .authorized,
registration: registered,
mac: .init(
forwardingEnabled: true,
mode: .always,
apiOrigin: origin,
accountVerified: true
),
phoneAPIOrigin: origin
)
#expect(readiness == .blocked(.apiOriginMismatch))
#expect(readiness.repair == .rebuildMatchingApps)
}
@Test func everyGateMustPassBeforeReadyIncludesTheLiveMode() {
let readiness = MobilePushReadiness.resolve(
authorization: .authorized,
registration: registered,
mac: .init(
forwardingEnabled: true,
mode: .onlyWhenAway,
apiOrigin: "https://cmux.com/",
accountVerified: true
),
phoneAPIOrigin: "https://cmux.com"
)
#expect(readiness == .ready(mode: .onlyWhenAway))
}
@Test func activeMacInOnlyWhenAwayModeNeverReportsReady() {
let readiness = MobilePushReadiness.resolve(
authorization: .authorized,
registration: registered,
mac: .init(
forwardingEnabled: true,
mode: .onlyWhenAway,
admission: .suppressedMacActive,
apiOrigin: "https://cmux.com",
accountVerified: true
),
phoneAPIOrigin: "https://cmux.com"
)
#expect(readiness == .blocked(.macCurrentlyActive))
#expect(readiness.repair == .leaveMacOrUseAlwaysMode)
}
}
@@ -110,6 +110,31 @@ public struct UITestConfig {
#endif
}
/// Push readiness preview state selected by
/// `CMUX_UITEST_PUSH_READINESS_PREVIEW`. A set value routes the root view
/// to the readiness preview and names its fixture state. DEBUG-only.
public static var pushReadinessPreviewState: String? {
pushReadinessPreviewState(
from: ProcessInfo.processInfo.environment,
arguments: ProcessInfo.processInfo.arguments
)
}
/// Resolves the push-readiness preview fixture from explicit process inputs.
public static func pushReadinessPreviewState(
from env: [String: String],
arguments: [String] = []
) -> String? {
#if DEBUG
return env["CMUX_UITEST_PUSH_READINESS_PREVIEW"]
?? arguments.first(where: {
$0.hasPrefix("CMUX_UITEST_PUSH_READINESS_PREVIEW=")
})?.split(separator: "=", maxSplits: 1).last.map(String.init)
#else
return nil
#endif
}
/// Changes preview mode selected by `CMUX_UITEST_CHANGES_PREVIEW`.
///
/// Supported DEBUG-only values are `1`, `diff`, `empty`, and `states`.
@@ -158,6 +158,23 @@ import Testing
#endif
}
@Test func pushReadinessPreviewUsesExplicitInputsWithEnvironmentPrecedence() {
#if DEBUG
#expect(UITestConfig.pushReadinessPreviewState(
from: ["CMUX_UITEST_PUSH_READINESS_PREVIEW": "healthy"],
arguments: ["CMUX_UITEST_PUSH_READINESS_PREVIEW=unavailable"]
) == "healthy")
#expect(UITestConfig.pushReadinessPreviewState(
from: [:],
arguments: ["CMUX_UITEST_PUSH_READINESS_PREVIEW=permission-denied"]
) == "permission-denied")
#else
#expect(UITestConfig.pushReadinessPreviewState(
from: ["CMUX_UITEST_PUSH_READINESS_PREVIEW": "healthy"]
) == nil)
#endif
}
@Test func notificationFeedPreviewFlagIsDebugOnly() {
let env = ["CMUX_UITEST_NOTIFICATION_FEED_PREVIEW": "1"]
#if DEBUG
@@ -0,0 +1,38 @@
/// Materializes agent launch-policy metadata before a resource-pressure path needs it.
public struct AgentLaunchPolicyPrewarmer: Sendable {
private let loadPolicyCount: @Sendable () -> Int
/// Creates a prewarmer for cmux's registered agent launch policies.
public init() {
loadPolicyCount = {
let policies = [
AgentLaunchSanitizer.claudePolicy,
AgentLaunchSanitizer.codexPolicy,
AgentLaunchSanitizer.piPolicy,
AgentLaunchSanitizer.ampPolicy,
AgentLaunchSanitizer.geminiPolicy,
AgentLaunchSanitizer.antigravityPolicy,
AgentLaunchSanitizer.cursorPolicy,
AgentLaunchSanitizer.openCodePolicy,
AgentLaunchSanitizer.grokPolicy,
AgentLaunchSanitizer.kimiPolicy,
AgentLaunchSanitizer.copilotPolicy,
AgentLaunchSanitizer.codeBuddyPolicy,
AgentLaunchSanitizer.factoryPolicy,
AgentLaunchSanitizer.qoderPolicy,
AgentLaunchSanitizer.kiroPolicy,
AgentLaunchSanitizer.rovoDevPolicy,
AgentLaunchSanitizer.hermesAgentPolicy,
]
return policies.count
}
}
/// Loads immutable policy metadata on the caller's executor.
///
/// - Returns: The number of registered launch policies that were loaded.
@discardableResult
public func prewarmPolicies() -> Int {
loadPolicyCount()
}
}
@@ -641,4 +641,20 @@ struct AgentLaunchSanitizerTests {
) == ["qoder", "--model", "best"]
)
}
@Test("Prewarms every sanitizer policy away from UI work")
func prewarmsEverySanitizerPolicyAwayFromUIWork() async {
let preparedPolicyCount = await Task.detached(priority: .utility) {
AgentLaunchPolicyPrewarmer().prewarmPolicies()
}.value
#expect(preparedPolicyCount == 17)
#expect(
AgentLaunchSanitizer.sanitizedLaunchArguments(
["claude", "--model", "sonnet", "--resume", "old-session"],
launcher: "claude",
fallbackKind: "claude"
) == ["claude", "--model", "sonnet"]
)
}
}
@@ -7,25 +7,66 @@ struct XcodeProjectAdapterTests {
private let workspaceURL: URL
private let projectURL: URL
init() {
/// How many directories the search below looks at, counting the one holding
/// this file. This file sits five levels below the worktree root today, so
/// six candidates cover the current layout and eight leaves a little room.
private static let searchedDirectoryCount = 8
init() throws {
let env = ProcessInfo.processInfo.environment
if let override = env["CMUX_PROJECT_FIXTURE"] {
let base = URL(fileURLWithPath: override)
self.workspaceURL = base.pathExtension.lowercased() == "xcworkspace" ? base : base.appendingPathComponent("cmux.xcworkspace")
self.projectURL = base.pathExtension.lowercased() == "xcodeproj" ? base : base.appendingPathComponent("cmux.xcodeproj")
// The override may name the directory, the workspace bundle, or the project
// bundle. Siblings are derived from the containing directory, or a workspace
// override would nest projectURL inside the .xcworkspace bundle (and the
// symmetric bug for a .xcodeproj override).
let ext = base.pathExtension.lowercased()
let root = ["xcworkspace", "xcodeproj"].contains(ext) ? base.deletingLastPathComponent() : base
self.workspaceURL = ext == "xcworkspace" ? base : root.appendingPathComponent("cmux.xcworkspace")
self.projectURL = ext == "xcodeproj" ? base : root.appendingPathComponent("cmux.xcodeproj")
} else {
let here = URL(fileURLWithPath: #filePath)
let worktreeRoot = here
.deletingLastPathComponent()
.deletingLastPathComponent()
.deletingLastPathComponent()
.deletingLastPathComponent()
.deletingLastPathComponent()
let start = URL(fileURLWithPath: #filePath).resolvingSymlinksInPath().deletingLastPathComponent()
guard let worktreeRoot = Self.worktreeRootContainingProject(startingAt: start) else {
throw ProjectNotFoundError(searchStart: start, searchedDirectoryCount: Self.searchedDirectoryCount)
}
self.workspaceURL = worktreeRoot.appendingPathComponent("cmux.xcworkspace")
self.projectURL = worktreeRoot.appendingPathComponent("cmux.xcodeproj")
}
}
/// Returns the nearest directory at or above `start` that holds cmux.xcodeproj,
/// or nil when none of the `searchedDirectoryCount` directories it looks at has one.
///
/// Packages move between the Packages/Shared, Packages/iOS and Packages/macOS
/// group folders, which changes how deep this file sits, and counting parent
/// directories silently points at the wrong root when that happens. The search
/// stops after a fixed number of levels so that a checkout nested inside another
/// checkout cannot bind these tests to the outer checkout's project.
private static func worktreeRootContainingProject(startingAt start: URL) -> URL? {
var directory = start
for _ in 0..<searchedDirectoryCount {
if FileManager.default.fileExists(atPath: directory.appendingPathComponent("cmux.xcodeproj").path) {
return directory
}
directory.deleteLastPathComponent()
}
return nil
}
private struct ProjectNotFoundError: Error, CustomStringConvertible {
let searchStart: URL
let searchedDirectoryCount: Int
var description: String {
"""
cmux.xcodeproj is not in \(searchStart.path) or in any of the \
\(searchedDirectoryCount - 1) directories above it, so these tests have no \
project to load. Run them from a cmux checkout, or point \
CMUX_PROJECT_FIXTURE at a directory that contains cmux.xcodeproj.
"""
}
}
@Test
func loadsCmuxXcodeprojIntoOneModule() throws {
let adapter = XcodeProjectAdapter()
@@ -4,14 +4,20 @@ public import AppKit
@MainActor
public final class CmuxResolvedIconImageView: NSView {
private let imageView = NSImageView(frame: .zero)
private let renderer = CmuxResolvedIconRenderer()
private let renderContext: CmuxResolvedIconRenderContext
private var request: CmuxResolvedIconRequest?
private var renderKey: RenderKey?
private var lastVisibleRenderKey: RenderKey?
private var blankRenderKey: RenderKey?
private var renderKey: CmuxResolvedIconRenderKey?
private var lastVisibleRenderKey: CmuxResolvedIconRenderKey?
private var blankRenderKey: CmuxResolvedIconRenderKey?
/// Creates the resolved icon view.
public override init(frame frameRect: NSRect) {
public override convenience init(frame frameRect: NSRect) {
self.init(frame: frameRect, renderContext: CmuxResolvedIconRenderContext())
}
/// Creates a resolved icon view backed by an explicit render owner.
public init(frame frameRect: NSRect, renderContext: CmuxResolvedIconRenderContext) {
self.renderContext = renderContext
super.init(frame: frameRect)
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.imageScaling = .scaleProportionallyDown
@@ -57,10 +63,15 @@ public final class CmuxResolvedIconImageView: NSView {
imageView.image = nil
return
}
let nextKey = RenderKey(request: request, appearance: effectiveAppearance)
guard force || renderKey != nextKey else { return }
let nextKey = CmuxResolvedIconRenderKey(request: request, appearance: effectiveAppearance)
guard force || renderKey?.shouldSkipRender(for: nextKey) != true else { return }
guard force || blankRenderKey?.shouldSkipBlankRetry(for: nextKey) != true else { return }
switch renderer.render(for: request, appearance: effectiveAppearance) {
switch renderContext.render(
for: request,
appearance: effectiveAppearance,
renderKey: nextKey,
bypassCache: force
) {
case .success(let image):
renderKey = nextKey
lastVisibleRenderKey = nextKey
@@ -93,81 +104,4 @@ public final class CmuxResolvedIconImageView: NSView {
imageView.setAccessibilityRole(.image)
imageView.setAccessibilityLabel(description)
}
private struct RenderKey: Equatable {
private let source: SourceKey
private let canReuseRenderedImage: Bool
private let width: CGFloat
private let height: CGFloat
private let tint: NSColor?
private let symbolWeight: CGFloat
private let appearanceName: NSAppearance.Name
private let appearanceIdentity: ObjectIdentifier
init(request: CmuxResolvedIconRequest, appearance: NSAppearance) {
self.source = SourceKey(request.source)
self.canReuseRenderedImage = source.canReuseRenderedImage
self.width = request.size.width
self.height = request.size.height
self.tint = request.tintColor
self.symbolWeight = request.symbolWeight.rawValue
self.appearanceName = appearance.name
self.appearanceIdentity = ObjectIdentifier(appearance)
}
static func == (lhs: RenderKey, rhs: RenderKey) -> Bool {
lhs.canReuseRenderedImage && rhs.canReuseRenderedImage && lhs.matchesRequestAndAppearance(rhs)
}
func matchesRequestAndAppearance(_ other: RenderKey) -> Bool {
source == other.source &&
width == other.width &&
height == other.height &&
symbolWeight == other.symbolWeight &&
appearanceName == other.appearanceName &&
appearanceIdentity == other.appearanceIdentity &&
Self.colorsEqual(tint, other.tint)
}
func shouldSkipBlankRetry(for other: RenderKey) -> Bool {
canReuseRenderedImage && other.canReuseRenderedImage && matchesRequestAndAppearance(other)
}
private static func colorsEqual(_ lhs: NSColor?, _ rhs: NSColor?) -> Bool {
switch (lhs, rhs) {
case (.none, .none):
return true
case let (lhs?, rhs?):
return lhs.isEqual(rhs)
default:
return false
}
}
private enum SourceKey: Equatable {
case systemSymbol(name: String, accessibilityDescription: String?)
case asset(name: String, bundle: ObjectIdentifier)
case image(ObjectIdentifier)
init(_ source: CmuxResolvedIconSource) {
switch source {
case .systemSymbol(let name, let accessibilityDescription):
self = .systemSymbol(name: name, accessibilityDescription: accessibilityDescription)
case .asset(let name, let bundle):
self = .asset(name: name, bundle: ObjectIdentifier(bundle))
case .image(let image):
self = .image(ObjectIdentifier(image))
}
}
var canReuseRenderedImage: Bool {
switch self {
case .systemSymbol, .asset:
return true
case .image:
return false
}
}
}
}
}
@@ -0,0 +1,88 @@
import AppKit
/// Owns a renderer and a bounded raster cache for one icon-view hierarchy.
@MainActor
public final class CmuxResolvedIconRenderContext {
private typealias Entry = (
image: NSImage,
appearance: NSAppearance,
assetBundle: Bundle?
)
private let renderer = CmuxResolvedIconRenderer()
private let limit: Int
private var entries: [CmuxResolvedIconReusableRenderKey: Entry] = [:]
private var insertionOrder: [CmuxResolvedIconReusableRenderKey] = []
/// Creates an isolated render owner with a bounded reusable-image cache.
///
/// Nonpositive limits disable cross-view caching while preserving rendering.
public init(cacheLimit: Int = 128) {
self.limit = max(0, cacheLimit)
}
func render(
for request: CmuxResolvedIconRequest,
appearance: NSAppearance,
renderKey: CmuxResolvedIconRenderKey,
bypassCache: Bool = false
) -> Result<NSImage, CmuxResolvedIconRenderFailure> {
guard limit > 0 else {
return renderer.render(for: request, appearance: appearance)
}
if !bypassCache,
let reusableKey = renderKey.reusableKey,
let cachedImage = image(for: reusableKey, matching: renderKey) {
return .success(cachedImage)
}
let result = renderer.render(for: request, appearance: appearance)
if case .success(let image) = result, let reusableKey = renderKey.reusableKey {
insert(
image,
for: reusableKey,
renderKey: renderKey,
replacingExisting: bypassCache
)
}
return result
}
private func image(
for key: CmuxResolvedIconReusableRenderKey,
matching renderKey: CmuxResolvedIconRenderKey
) -> NSImage? {
guard let entry = entries[key],
entry.appearance === renderKey.appearance,
renderKey.matchesAssetBundle(entry.assetBundle) else {
return nil
}
return entry.image
}
private func insert(
_ image: NSImage,
for key: CmuxResolvedIconReusableRenderKey,
renderKey: CmuxResolvedIconRenderKey,
replacingExisting: Bool
) {
let entry: Entry = (
image: image,
appearance: renderKey.appearance,
assetBundle: renderKey.assetBundle
)
if entries[key] != nil {
if replacingExisting {
entries[key] = entry
}
return
}
if entries.count >= limit, let oldestKey = insertionOrder.first {
insertionOrder.removeFirst()
entries.removeValue(forKey: oldestKey)
}
entries[key] = entry
insertionOrder.append(key)
}
}
@@ -0,0 +1,86 @@
import AppKit
/// Identity of one icon render request under one effective appearance.
struct CmuxResolvedIconRenderKey {
private let source: CmuxResolvedIconSourceKey
private let width: CGFloat
private let height: CGFloat
private let tint: NSColor?
private let symbolWeight: CGFloat
private let appearanceName: NSAppearance.Name
private let appearanceIdentity: ObjectIdentifier
let appearance: NSAppearance
let assetBundle: Bundle?
private func tintMatches(_ other: NSColor?) -> Bool {
switch (tint, other) {
case (.none, .none):
return true
case let (tint?, other?):
return tint.isEqual(other)
default:
return false
}
}
func matchesAssetBundle(_ other: Bundle?) -> Bool {
switch (assetBundle, other) {
case (.none, .none):
return true
case let (assetBundle?, other?):
return assetBundle === other
default:
return false
}
}
init(request: CmuxResolvedIconRequest, appearance: NSAppearance) {
source = CmuxResolvedIconSourceKey(request.source)
width = request.size.width
height = request.size.height
tint = request.tintColor
symbolWeight = request.symbolWeight.rawValue
appearanceName = appearance.name
appearanceIdentity = ObjectIdentifier(appearance)
self.appearance = appearance
if case .asset(_, let bundle) = request.source {
assetBundle = bundle
} else {
assetBundle = nil
}
}
func shouldSkipRender(for other: CmuxResolvedIconRenderKey) -> Bool {
source.canReuseRenderedImage &&
other.source.canReuseRenderedImage &&
matchesRequestAndAppearance(other)
}
func matchesRequestAndAppearance(_ other: CmuxResolvedIconRenderKey) -> Bool {
source == other.source &&
width == other.width &&
height == other.height &&
symbolWeight == other.symbolWeight &&
appearanceName == other.appearanceName &&
appearanceIdentity == other.appearanceIdentity &&
tintMatches(other.tint)
}
func shouldSkipBlankRetry(for other: CmuxResolvedIconRenderKey) -> Bool {
shouldSkipRender(for: other)
}
var reusableKey: CmuxResolvedIconReusableRenderKey? {
guard source.canReuseRenderedImage else { return nil }
return CmuxResolvedIconReusableRenderKey(
source: source,
width: width,
height: height,
tint: tint,
symbolWeight: symbolWeight,
appearanceName: appearanceName,
appearanceIdentity: appearanceIdentity
)
}
}
@@ -0,0 +1,12 @@
import AppKit
/// Hashable subset of a render key that can safely share a cached raster.
struct CmuxResolvedIconReusableRenderKey: Hashable {
let source: CmuxResolvedIconSourceKey
let width: CGFloat
let height: CGFloat
let tint: NSColor?
let symbolWeight: CGFloat
let appearanceName: NSAppearance.Name
let appearanceIdentity: ObjectIdentifier
}
@@ -0,0 +1,28 @@
import AppKit
/// Stable source identity used to compare icon render requests.
enum CmuxResolvedIconSourceKey: Hashable {
case systemSymbol(name: String, accessibilityDescription: String?)
case asset(name: String, bundle: ObjectIdentifier)
case image(ObjectIdentifier)
init(_ source: CmuxResolvedIconSource) {
switch source {
case .systemSymbol(let name, let accessibilityDescription):
self = .systemSymbol(name: name, accessibilityDescription: accessibilityDescription)
case .asset(let name, let bundle):
self = .asset(name: name, bundle: ObjectIdentifier(bundle))
case .image(let image):
self = .image(ObjectIdentifier(image))
}
}
var canReuseRenderedImage: Bool {
switch self {
case .systemSymbol, .asset:
return true
case .image:
return false
}
}
}
@@ -79,6 +79,87 @@ import Testing
#expect(renderedImage(in: view) === firstImage)
}
@Test func reusableIconsShareRenderedImageAcrossViews() throws {
let appearance = try #require(NSAppearance(named: .aqua))
let renderContext = CmuxResolvedIconRenderContext()
let request = CmuxResolvedIconRequest(
source: .systemSymbol(name: "folder.fill", accessibilityDescription: nil),
size: NSSize(width: 16, height: 16),
tintColor: .secondaryLabelColor,
symbolWeight: .regular
)
let frame = NSRect(x: 0, y: 0, width: 16, height: 16)
let firstView = CmuxResolvedIconImageView(frame: frame, renderContext: renderContext)
firstView.appearance = appearance
firstView.apply(request)
let firstImage = try #require(renderedImage(in: firstView))
let secondView = CmuxResolvedIconImageView(frame: frame, renderContext: renderContext)
secondView.appearance = appearance
secondView.apply(request)
let secondImage = try #require(renderedImage(in: secondView))
#expect(secondImage === firstImage)
let isolatedView = CmuxResolvedIconImageView(
frame: frame,
renderContext: CmuxResolvedIconRenderContext()
)
isolatedView.appearance = appearance
isolatedView.apply(request)
#expect(renderedImage(in: isolatedView) !== firstImage)
}
@Test func forcedRefreshReplacesTheSharedCachedRaster() throws {
let appearance = try #require(NSAppearance(named: .aqua))
let renderContext = CmuxResolvedIconRenderContext()
let request = CmuxResolvedIconRequest(
source: .systemSymbol(name: "folder.fill", accessibilityDescription: nil),
size: NSSize(width: 16, height: 16),
tintColor: .secondaryLabelColor,
symbolWeight: .regular
)
let frame = NSRect(x: 0, y: 0, width: 16, height: 16)
let firstView = CmuxResolvedIconImageView(frame: frame, renderContext: renderContext)
firstView.appearance = appearance
firstView.apply(request)
let firstImage = try #require(renderedImage(in: firstView))
firstView.viewDidMoveToWindow()
let refreshedImage = try #require(renderedImage(in: firstView))
#expect(refreshedImage !== firstImage)
let secondView = CmuxResolvedIconImageView(frame: frame, renderContext: renderContext)
secondView.appearance = appearance
secondView.apply(request)
#expect(renderedImage(in: secondView) === refreshedImage)
}
@Test func zeroCacheLimitDisablesCrossViewReuseWithoutDisablingRendering() throws {
let appearance = try #require(NSAppearance(named: .aqua))
let renderContext = CmuxResolvedIconRenderContext(cacheLimit: 0)
let request = CmuxResolvedIconRequest(
source: .systemSymbol(name: "folder.fill", accessibilityDescription: nil),
size: NSSize(width: 16, height: 16),
tintColor: .secondaryLabelColor,
symbolWeight: .regular
)
let frame = NSRect(x: 0, y: 0, width: 16, height: 16)
let firstView = CmuxResolvedIconImageView(frame: frame, renderContext: renderContext)
firstView.appearance = appearance
firstView.apply(request)
let firstImage = try #require(renderedImage(in: firstView))
let secondView = CmuxResolvedIconImageView(frame: frame, renderContext: renderContext)
secondView.appearance = appearance
secondView.apply(request)
let secondImage = try #require(renderedImage(in: secondView))
#expect(secondImage !== firstImage)
#expect(visiblePixelCount(in: firstImage) > 0)
#expect(visiblePixelCount(in: secondImage) > 0)
}
@Test func imageViewRerendersWhenImagePixelsChangeInPlace() throws {
let view = CmuxResolvedIconImageView(frame: NSRect(x: 0, y: 0, width: 16, height: 16))
view.appearance = NSAppearance(named: .aqua)
@@ -57,7 +57,7 @@ public struct BrowserUserAgentPolicy: Sendable {
/// Resolves the browser identity policy for a top-level destination.
///
/// Google Sheets intentionally uses WebKit's default embedded identity.
/// Every web destination receives the current Safari-compatible identity.
/// Non-web destinations have no applicable user-agent policy.
///
/// - Parameter url: The destination of the top-level navigation.
@@ -68,16 +68,7 @@ public struct BrowserUserAgentPolicy: Sendable {
scheme == "http" || scheme == "https" else {
return .notApplicable
}
guard let host = url.host?.lowercased() else {
return .custom(safariCompatibleUserAgent)
}
let isSheetsHost = host == "sheets.google.com" || host == "spreadsheets.google.com"
let isSheetsPath = host == "docs.google.com"
&& url.path.split(separator: "/", omittingEmptySubsequences: true).first?
.lowercased() == "spreadsheets"
return isSheetsHost || isSheetsPath
? .webKitDefault
: .custom(safariCompatibleUserAgent)
return .custom(safariCompatibleUserAgent)
}
/// Parses a numeric dot-separated browser version.
@@ -3,9 +3,6 @@ public enum BrowserUserAgentPolicyResolution: Equatable, Sendable {
/// Use the supplied custom user-agent identity for an HTTP or HTTPS destination.
case custom(String)
/// Use WebKit's default embedded identity for an HTTP or HTTPS destination.
case webKitDefault
/// User-agent identity does not apply to this non-web destination.
case notApplicable
}
@@ -0,0 +1,280 @@
import Foundation
/// JavaScript evaluation wrapping and bridge-safe result handling.
extension BrowserControlService {
/// Builds the complete function body used to evaluate browser JavaScript.
///
/// The wrapper awaits promise-like results, converts DOM rectangles and
/// JSON containers before they cross WebKit, duplicates repeated aliases,
/// and emits deterministic error envelopes for cycles or results whose
/// normalization would exceed bounded work.
///
/// - Parameters:
/// - script: User or automation JavaScript to execute.
/// - useEval: Whether `script` is data passed to `eval`; when false it is
/// inserted as a trusted expression built by cmux.
/// - frameSelector: Optional selector for a same-origin frame execution realm.
/// - Returns: A JavaScript async-function body for WebKit evaluation.
public func evaluationScript(
script: String,
useEval: Bool,
frameSelector: String?
) -> String {
let typeKey = jsonLiteral(evalEnvelope.typeKey)
let valueKey = jsonLiteral(evalEnvelope.valueKey)
let typeUndefined = jsonLiteral(evalEnvelope.typeUndefined)
let typeValue = jsonLiteral(evalEnvelope.typeValue)
let typeError = jsonLiteral(evalEnvelope.typeError)
let errorCodeKey = jsonLiteral(evalEnvelope.errorCodeKey)
let errorMessageKey = jsonLiteral(evalEnvelope.errorMessageKey)
let frameUnavailableCode = jsonLiteral("frame_unavailable")
let operationFailedMessage = jsonLiteral(String(
localized: "cli.browser.error.operationFailed",
defaultValue: "Browser operation failed"
))
let framePrelude: String
if let frameSelector {
let selectorLiteral = jsonLiteral(frameSelector)
framePrelude = """
let __cmuxDoc;
let __cmuxWindow;
const __cmuxFrameUnavailable = () => ({
[\(typeKey)]: \(typeError),
[\(errorCodeKey)]: \(frameUnavailableCode),
[\(errorMessageKey)]: \(operationFailedMessage)
});
try {
const __cmuxFrame = document.querySelector(\(selectorLiteral));
if (!__cmuxFrame || !('contentDocument' in __cmuxFrame)) {
return __cmuxFrameUnavailable();
}
const __cmuxFrameDocument = __cmuxFrame.contentDocument;
const __cmuxFrameWindow = __cmuxFrame.contentWindow;
if (!__cmuxFrameDocument || !__cmuxFrameWindow) {
return __cmuxFrameUnavailable();
}
__cmuxDoc = __cmuxFrameDocument;
__cmuxWindow = __cmuxFrameWindow;
} catch (_) {
return __cmuxFrameUnavailable();
}
"""
} else {
framePrelude = "const __cmuxDoc = document;"
}
let executionBlock: String
if frameSelector != nil {
// Member-call eval is indirect. The eval function belongs to the
// selected frame, so every global lookup uses that frame's realm.
executionBlock = "const __r = __cmuxWindow.eval(\(jsonLiteral(script)));"
} else {
executionBlock = useEval
? "const __r = eval(\(jsonLiteral(script)));"
: "const __r = \(script);"
}
let circularReferenceCode = jsonLiteral(evalEnvelope.circularReferenceCode)
let circularReferenceMessage = jsonLiteral(evalEnvelope.circularReferenceMessage)
let resultTooComplexCode = jsonLiteral("result_too_complex")
return """
\(framePrelude)
const __cmuxMaybeAwait = async (__r) => {
if (__r !== null && (typeof __r === 'object' || typeof __r === 'function') && typeof __r.then === 'function') {
return await __r;
}
return __r;
};
const __cmuxCircularReference = Symbol('cmux.circularReference');
const __cmuxResultTooComplex = Symbol('cmux.resultTooComplex');
const __cmuxChargeText = (__text, __budget) => {
if (__text.length > __budget.remainingTextCodeUnits) {
throw __cmuxResultTooComplex;
}
__budget.remainingTextCodeUnits -= __text.length;
};
const __cmuxBridgeSafeValue = (
__value,
__ancestors = new WeakSet(),
__budget = {remainingNodes: 10000, remainingTextCodeUnits: 1000000},
__depth = 0
) => {
if (__budget.remainingNodes <= 0 || __depth > 100) {
throw __cmuxResultTooComplex;
}
__budget.remainingNodes -= 1;
if (__value === null) {
return null;
}
if (typeof __value === 'undefined') {
return null;
}
const __valueType = typeof __value;
if (__valueType === 'number') {
return Number.isFinite(__value) ? __value : null;
}
if (__valueType === 'string') {
__cmuxChargeText(__value, __budget);
return __value;
}
if (__valueType === 'bigint' || __valueType === 'symbol') {
const __stringValue = String(__value);
__cmuxChargeText(__stringValue, __budget);
return __stringValue;
}
if (__valueType !== 'object' && __valueType !== 'function') {
return __value;
}
if (__ancestors.has(__value)) {
throw __cmuxCircularReference;
}
let __objectTag = '';
try {
__objectTag = Object.prototype.toString.call(__value);
} catch (_) {}
// iframe values have different constructors, so instanceof alone does
// not recognize their DOMRect brand.
const __isDOMRect =
(typeof DOMRectReadOnly !== 'undefined' && __value instanceof DOMRectReadOnly) ||
__objectTag === '[object DOMRect]' ||
__objectTag === '[object DOMRectReadOnly]';
if (__isDOMRect) {
__ancestors.add(__value);
try {
return {
x: __cmuxBridgeSafeValue(__value.x, __ancestors, __budget, __depth + 1),
y: __cmuxBridgeSafeValue(__value.y, __ancestors, __budget, __depth + 1),
width: __cmuxBridgeSafeValue(__value.width, __ancestors, __budget, __depth + 1),
height: __cmuxBridgeSafeValue(__value.height, __ancestors, __budget, __depth + 1),
top: __cmuxBridgeSafeValue(__value.top, __ancestors, __budget, __depth + 1),
right: __cmuxBridgeSafeValue(__value.right, __ancestors, __budget, __depth + 1),
bottom: __cmuxBridgeSafeValue(__value.bottom, __ancestors, __budget, __depth + 1),
left: __cmuxBridgeSafeValue(__value.left, __ancestors, __budget, __depth + 1)
};
} finally {
__ancestors.delete(__value);
}
}
if (Array.isArray(__value)) {
__ancestors.add(__value);
try {
const __copy = [];
for (let __index = 0; __index < __value.length; __index += 1) {
if (__budget.remainingNodes <= 0) {
throw __cmuxResultTooComplex;
}
__copy.push(__cmuxBridgeSafeValue(
__value[__index],
__ancestors,
__budget,
__depth + 1
));
}
return __copy;
} finally {
__ancestors.delete(__value);
}
}
__ancestors.add(__value);
try {
const __copy = {};
let __enumeratedKeys = 0;
for (const __key in __value) {
__enumeratedKeys += 1;
if (__enumeratedKeys > 10000 || __budget.remainingNodes <= 0) {
throw __cmuxResultTooComplex;
}
if (!Object.prototype.hasOwnProperty.call(__value, __key)) {
continue;
}
__cmuxChargeText(__key, __budget);
Object.defineProperty(__copy, __key, {
value: __cmuxBridgeSafeValue(
__value[__key],
__ancestors,
__budget,
__depth + 1
),
enumerable: true,
configurable: true,
writable: true
});
}
return __copy;
} finally {
__ancestors.delete(__value);
}
};
const __cmuxEvalInFrame = async function() {
const document = __cmuxDoc;
\(executionBlock)
const __value = await __cmuxMaybeAwait(__r);
if (typeof __value === 'undefined') {
return { [\(typeKey)]: \(typeUndefined) };
}
let __cmuxSafeValue;
try {
__cmuxSafeValue = __cmuxBridgeSafeValue(__value);
} catch (__error) {
if (__error === __cmuxCircularReference) {
return {
[\(typeKey)]: \(typeError),
[\(errorCodeKey)]: \(circularReferenceCode),
[\(errorMessageKey)]: \(circularReferenceMessage)
};
}
if (__error === __cmuxResultTooComplex) {
return {
[\(typeKey)]: \(typeError),
[\(errorCodeKey)]: \(resultTooComplexCode),
[\(errorMessageKey)]: \(operationFailedMessage)
};
}
throw __error;
}
return {
[\(typeKey)]: \(typeValue),
[\(valueKey)]: __cmuxSafeValue
};
};
return await __cmuxEvalInFrame();
"""
}
/// Resolves a raw WebKit value into its browser-eval envelope meaning.
/// - Parameter rawValue: Value returned by WebKit.
/// - Returns: The unwrapped value, `undefined`, or stable bridge error.
public func resolveEvaluationEnvelope(_ rawValue: Any?) -> BrowserEvalEnvelopeResolution {
guard let dictionary = rawValue as? [String: Any],
let type = dictionary[evalEnvelope.typeKey] as? String else {
return .unwrapped(rawValue)
}
switch type {
case evalEnvelope.typeUndefined:
return .undefined
case evalEnvelope.typeValue:
return .value(dictionary[evalEnvelope.valueKey])
case evalEnvelope.typeError:
guard let code = dictionary[evalEnvelope.errorCodeKey] as? String,
let message = dictionary[evalEnvelope.errorMessageKey] as? String else {
return .unwrapped(rawValue)
}
return .error(code: code, message: message)
default:
return .unwrapped(rawValue)
}
}
}
@@ -7,10 +7,10 @@ import Foundation
/// lifecycle nor any AppKit/WebKit object. Specifically it builds the JavaScript
/// strings for the semantic element locators (`find.role`, `find.text`, and the
/// other `find.*` actions), canonical keyboard events, the not-found diagnostics
/// probe, and the `find.first`/`find.last`/`find.nth` selector scripts; it
/// normalizes raw JavaScript results into JSON-serializable values; it classifies
/// JavaScript failures; and it composes the human-readable element-not-found
/// message.
/// probe, the browser-eval bridge wrapper, and the
/// `find.first`/`find.last`/`find.nth` selector scripts; it normalizes raw
/// JavaScript results into JSON-serializable values; it classifies JavaScript
/// failures; and it composes the human-readable element-not-found message.
///
/// The owning `@MainActor` controller keeps the per-surface mutable state
/// (element-ref table, dialog queue, init scripts) and the WebKit evaluation seam;
@@ -5,15 +5,17 @@ import Foundation
/// When a page-world script returns `undefined`, WebKit collapses it to `nil`,
/// which is indistinguishable from a script that returned JSON `null`. The
/// browser eval path therefore wraps every result in a small JSON object whose
/// `typeKey` is either `typeUndefined` or `typeValue`, with the real payload (for
/// the value case) under `valueKey`. ``BrowserControlService/normalizeJSValue(_:isUndefinedSentinel:)``
/// `typeKey` is `typeUndefined`, `typeValue`, or `typeError`, with the real
/// payload (for the value case) under `valueKey`. Error envelopes carry a stable
/// code and message so values that cannot cross WebKit never reach response
/// encoding. ``BrowserControlService/normalizeJSValue(_:isUndefinedSentinel:)``
/// re-materializes the `undefined` sentinel back into this envelope shape so the
/// RPC reply distinguishes the two.
/// RPC reply preserves the distinction.
///
/// The default values are the exact strings the cmux v2 browser RPC wire format
/// has always used; do not change them without a coordinated protocol bump.
public struct BrowserEvalEnvelope: Sendable, Equatable {
/// JSON key carrying the envelope discriminator (`typeUndefined` or `typeValue`).
/// JSON key carrying the envelope discriminator.
public let typeKey: String
/// JSON key carrying the real value when the discriminator is `typeValue`.
public let valueKey: String
@@ -21,6 +23,16 @@ public struct BrowserEvalEnvelope: Sendable, Equatable {
public let typeUndefined: String
/// Discriminator value indicating the script produced a concrete value.
public let typeValue: String
/// Discriminator value indicating result sanitization failed.
public let typeError: String
/// JSON key carrying a stable error code when the discriminator is `typeError`.
public let errorCodeKey: String
/// JSON key carrying an error message when the discriminator is `typeError`.
public let errorMessageKey: String
/// Error code returned when the evaluated result contains a true cycle.
public let circularReferenceCode: String
/// Error message returned when the evaluated result contains a true cycle.
public let circularReferenceMessage: String
/// Creates an envelope descriptor.
/// - Parameters:
@@ -28,15 +40,34 @@ public struct BrowserEvalEnvelope: Sendable, Equatable {
/// - valueKey: JSON key for the payload. Defaults to the wire value `"__cmux_v"`.
/// - typeUndefined: discriminator for `undefined`. Defaults to `"undefined"`.
/// - typeValue: discriminator for a concrete value. Defaults to `"value"`.
/// - typeError: discriminator for a sanitization failure. Defaults to `"error"`.
/// - errorCodeKey: JSON key for an error code. Defaults to `"__cmux_error_code"`.
/// - errorMessageKey: JSON key for an error message. Defaults to `"__cmux_error_message"`.
/// - circularReferenceCode: stable circular-reference error code.
/// - circularReferenceMessage: stable circular-reference error message;
/// `nil` uses the localized default.
public init(
typeKey: String = "__cmux_t",
valueKey: String = "__cmux_v",
typeUndefined: String = "undefined",
typeValue: String = "value"
typeValue: String = "value",
typeError: String = "error",
errorCodeKey: String = "__cmux_error_code",
errorMessageKey: String = "__cmux_error_message",
circularReferenceCode: String = "circular_reference",
circularReferenceMessage: String? = nil
) {
self.typeKey = typeKey
self.valueKey = valueKey
self.typeUndefined = typeUndefined
self.typeValue = typeValue
self.typeError = typeError
self.errorCodeKey = errorCodeKey
self.errorMessageKey = errorMessageKey
self.circularReferenceCode = circularReferenceCode
self.circularReferenceMessage = circularReferenceMessage ?? String(
localized: "cli.browser.error.circularReference",
defaultValue: "browser.eval result contains a circular reference"
)
}
}
@@ -0,0 +1,11 @@
/// Meaning of a raw WebKit value after inspecting the browser-eval envelope.
public enum BrowserEvalEnvelopeResolution {
/// The value did not use the browser-eval envelope and should pass through.
case unwrapped(Any?)
/// The evaluated script returned JavaScript `undefined`.
case undefined
/// The evaluated script returned the associated concrete value.
case value(Any?)
/// Sanitizing the evaluated value failed with a stable protocol error.
case error(code: String, message: String)
}
@@ -27,14 +27,17 @@ struct BrowserUserAgentPolicyTests {
#expect(newerPolicy.safariCompatibleUserAgent.contains("Version/27.0 Safari/605.1.15"))
}
@Test func googleSheetsKeepsEmbeddedWebKitIdentity() {
@Test func googleSheetsReceivesCurrentSafariCompatibleIdentity() {
let sheetURL = URL(string: "https://docs.google.com/spreadsheets/d/example/edit")!
let sheetsRedirectURL = URL(string: "https://sheets.google.com/")!
let legacyRedirectURL = URL(string: "https://spreadsheets.google.com/")!
let expectedResolution = BrowserUserAgentPolicyResolution.custom(
policy.safariCompatibleUserAgent
)
#expect(policy.resolution(for: sheetURL) == .webKitDefault)
#expect(policy.resolution(for: sheetsRedirectURL) == .webKitDefault)
#expect(policy.resolution(for: legacyRedirectURL) == .webKitDefault)
#expect(policy.resolution(for: sheetURL) == expectedResolution)
#expect(policy.resolution(for: sheetsRedirectURL) == expectedResolution)
#expect(policy.resolution(for: legacyRedirectURL) == expectedResolution)
}
@Test func otherGoogleWorkspaceEditorsRemainSafariCompatible() {
@@ -0,0 +1,440 @@
import Foundation
import JavaScriptCore
import Testing
import WebKit
@testable import CmuxBrowser
@Suite("BrowserControlService evaluation script")
struct BrowserControlServiceEvaluationScriptTests {
private let service = BrowserControlService()
@Test("repeated aliases become independent JSON-safe values")
func repeatedAliasesAreJSONSafe() throws {
let envelope = try evaluate(
"""
(() => {
const shared = {answer: 42};
return {first: shared, second: shared, items: [shared, shared]};
})()
"""
)
let value = try #require(envelope[service.evalEnvelope.valueKey] as? [String: Any])
let first = try #require(value["first"] as? [String: Any])
let second = try #require(value["second"] as? [String: Any])
let items = try #require(value["items"] as? [[String: Any]])
#expect(envelope[service.evalEnvelope.typeKey] as? String == service.evalEnvelope.typeValue)
#expect(first["answer"] as? Int == 42)
#expect(second["answer"] as? Int == 42)
#expect(items.count == 2)
#expect(items.allSatisfy { $0["answer"] as? Int == 42 })
}
@Test("true cycles produce a deterministic error envelope")
func cycleProducesExplicitError() throws {
let envelope = try evaluate(
"""
(() => {
const value = {};
value.self = value;
return value;
})()
"""
)
#expect(envelope[service.evalEnvelope.typeKey] as? String == service.evalEnvelope.typeError)
#expect(
envelope[service.evalEnvelope.errorCodeKey] as? String
== service.evalEnvelope.circularReferenceCode
)
#expect(
envelope[service.evalEnvelope.errorMessageKey] as? String
== service.evalEnvelope.circularReferenceMessage
)
switch service.resolveEvaluationEnvelope(envelope) {
case .error(let code, let message):
#expect(code == "circular_reference")
#expect(message == service.evalEnvelope.circularReferenceMessage)
default:
Issue.record("Expected an explicit browser-eval error resolution")
}
}
@Test("shared alias expansion is rejected before exponential growth")
func sharedAliasExpansionProducesExplicitError() throws {
let envelope = try evaluate(
"""
(() => {
var value = {answer: 42};
for (let depth = 0; depth < 14; depth += 1) {
value = {left: value, right: value};
}
return value;
})()
"""
)
#expect(envelope[service.evalEnvelope.typeKey] as? String == service.evalEnvelope.typeError)
#expect(envelope[service.evalEnvelope.errorCodeKey] as? String == "result_too_complex")
switch service.resolveEvaluationEnvelope(envelope) {
case .error(let code, let message):
#expect(code == "result_too_complex")
#expect(!message.isEmpty)
default:
Issue.record("Expected a bounded browser-eval error resolution")
}
}
@Test("an unavailable selected frame never falls back to the top document")
func unavailableFrameProducesExplicitError() throws {
let documentSetups = [
"var document = {secret: 'top-secret', querySelector: () => null};",
"var document = {secret: 'top-secret', querySelector: () => ({})};",
"""
var document = {
secret: 'top-secret',
querySelector: () => ({
get contentDocument() { throw new Error('cross-origin'); }
})
};
""",
]
for documentSetup in documentSetups {
let envelope = try evaluate(
"document.secret",
frameSelector: "#selected-frame",
documentSetup: documentSetup
)
#expect(envelope[service.evalEnvelope.typeKey] as? String == service.evalEnvelope.typeError)
#expect(envelope[service.evalEnvelope.errorCodeKey] as? String == "frame_unavailable")
}
}
@Test("result budget rejects before reading a property beyond the node cap")
func nodeBudgetStopsBeforeOverflowGetter() throws {
let envelope = try evaluate(
"""
(() => {
const value = {};
for (let index = 0; index < 9999; index += 1) {
value[`p${index}`] = index;
}
Object.defineProperty(value, 'overflow', {
enumerable: true,
get() { throw new Error('overflow getter must not run'); }
});
return value;
})()
"""
)
#expect(envelope[service.evalEnvelope.typeKey] as? String == service.evalEnvelope.typeError)
#expect(envelope[service.evalEnvelope.errorCodeKey] as? String == "result_too_complex")
}
@Test("aggregate string and key payloads are bounded")
func oversizedTextProducesExplicitError() throws {
let oversizedString = try evaluate("'x'.repeat(1000001)")
#expect(oversizedString[service.evalEnvelope.typeKey] as? String == service.evalEnvelope.typeError)
#expect(oversizedString[service.evalEnvelope.errorCodeKey] as? String == "result_too_complex")
let oversizedKey = try evaluate("({['k'.repeat(1000001)]: 1})")
#expect(oversizedKey[service.evalEnvelope.typeKey] as? String == service.evalEnvelope.typeError)
#expect(oversizedKey[service.evalEnvelope.errorCodeKey] as? String == "result_too_complex")
let aggregateStrings = try evaluate(
"({first: 'a'.repeat(400000), second: 'b'.repeat(400000), third: 'c'.repeat(400000)})"
)
#expect(aggregateStrings[service.evalEnvelope.typeKey] as? String == service.evalEnvelope.typeError)
#expect(aggregateStrings[service.evalEnvelope.errorCodeKey] as? String == "result_too_complex")
}
@Test("DOMRect-branded values are flattened before bridging")
func domRectIsFlattened() throws {
let envelope = try evaluate(
"""
(() => {
const rect = {};
const fields = {x: 1, y: 2, width: 30, height: 40, top: 2, right: 31, bottom: 42, left: 1};
for (const [key, value] of Object.entries(fields)) {
Object.defineProperty(rect, key, {value, enumerable: false});
}
Object.defineProperty(rect, Symbol.toStringTag, {value: 'DOMRect'});
return rect;
})()
"""
)
let value = try #require(envelope[service.evalEnvelope.valueKey] as? [String: Any])
#expect(value["width"] as? Int == 30)
#expect(value["height"] as? Int == 40)
#expect(value["right"] as? Int == 31)
#expect(value["bottom"] as? Int == 42)
}
@MainActor
@Test("top-level undefined omits the value property before WebKit bridging")
func topLevelUndefinedEnvelopeOmitsValueProperty() throws {
let (context, envelope) = try evaluateRaw("undefined")
context.setObject(envelope, forKeyedSubscript: "__cmuxTestResult" as NSString)
context.setObject(
service.evalEnvelope.valueKey,
forKeyedSubscript: "__cmuxTestValueKey" as NSString
)
let hasValueProperty = context.evaluateScript(
"Object.prototype.hasOwnProperty.call(__cmuxTestResult, __cmuxTestValueKey)"
)
#expect(hasValueProperty?.toBool() == false)
}
@MainActor
@Test("top-level undefined crosses the real WebKit result bridge without a value")
func topLevelUndefinedIsBridgeSafe() async throws {
let webView = WKWebView()
let (loaded, loadedContinuation) = AsyncStream<Void>.makeStream()
let navigationDelegate = BrowserEvaluationScriptNavigationDelegate {
loadedContinuation.yield()
loadedContinuation.finish()
}
webView.navigationDelegate = navigationDelegate
webView.loadHTMLString("<!doctype html><title>bridge test</title>", baseURL: nil)
var loadedIterator = loaded.makeAsyncIterator()
_ = await loadedIterator.next()
let body = service.evaluationScript(
script: "undefined",
useEval: true,
frameSelector: nil
)
let rawValue = try await webView.callAsyncJavaScript(
body,
arguments: [:],
in: nil,
contentWorld: .page
)
let envelope = try #require(rawValue as? [String: Any])
#expect(envelope[service.evalEnvelope.typeKey] as? String == service.evalEnvelope.typeUndefined)
#expect(envelope[service.evalEnvelope.valueKey] == nil)
switch service.resolveEvaluationEnvelope(envelope) {
case .undefined:
break
default:
Issue.record("Expected a top-level undefined browser-eval resolution")
}
_ = navigationDelegate
}
@MainActor
@Test("nested undefined values cross the real WebKit result bridge as null")
func nestedUndefinedValuesAreBridgeSafe() async throws {
let webView = WKWebView()
let (loaded, loadedContinuation) = AsyncStream<Void>.makeStream()
let navigationDelegate = BrowserEvaluationScriptNavigationDelegate {
loadedContinuation.yield()
loadedContinuation.finish()
}
webView.navigationDelegate = navigationDelegate
webView.loadHTMLString("<!doctype html><title>bridge test</title>", baseURL: nil)
var loadedIterator = loaded.makeAsyncIterator()
_ = await loadedIterator.next()
let body = service.evaluationScript(
script: "({objectValue: undefined, items: [1, undefined, , 4]})",
useEval: true,
frameSelector: nil
)
let rawValue = try await webView.callAsyncJavaScript(
body,
arguments: [:],
in: nil,
contentWorld: .page
)
let envelope = try #require(rawValue as? [String: Any])
let value = try #require(envelope[service.evalEnvelope.valueKey] as? [String: Any])
let items = try #require(value["items"] as? [Any])
#expect(envelope[service.evalEnvelope.typeKey] as? String == service.evalEnvelope.typeValue)
#expect(value["objectValue"] is NSNull)
#expect(items.count == 4)
#expect(items[1] is NSNull)
#expect(items[2] is NSNull)
_ = navigationDelegate
}
@MainActor
@Test("a selected frame owns the complete JavaScript global realm")
func selectedFrameOwnsCompleteGlobalRealm() async throws {
let webView = WKWebView()
let (loaded, loadedContinuation) = AsyncStream<Void>.makeStream()
let navigationDelegate = BrowserEvaluationScriptNavigationDelegate {
loadedContinuation.yield()
loadedContinuation.finish()
}
webView.navigationDelegate = navigationDelegate
webView.loadHTMLString("<!doctype html><title>top-title</title>", baseURL: nil)
var loadedIterator = loaded.makeAsyncIterator()
_ = await loadedIterator.next()
_ = try await webView.callAsyncJavaScript(
"""
window.__cmuxRealmMarker = 'top';
window.__cmuxFrameOnly = 'top-global';
const frame = document.createElement('iframe');
frame.id = 'selected-frame';
document.body.appendChild(frame);
frame.contentDocument.title = 'frame-title';
frame.contentWindow.__cmuxRealmMarker = 'frame';
frame.contentWindow.__cmuxFrameOnly = 'frame-global';
frame.contentWindow.location.hash = 'frame';
return true;
""",
arguments: [:],
in: nil,
contentWorld: .page
)
let body = service.evaluationScript(
script: "({marker: window.__cmuxRealmMarker, frameOnly: __cmuxFrameOnly, href: location.href, title: document.title})",
useEval: true,
frameSelector: "#selected-frame"
)
let rawValue = try await webView.callAsyncJavaScript(
body,
arguments: [:],
in: nil,
contentWorld: .page
)
let envelope = try #require(rawValue as? [String: Any])
let value = try #require(envelope[service.evalEnvelope.valueKey] as? [String: Any])
#expect(value["marker"] as? String == "frame")
#expect(value["frameOnly"] as? String == "frame-global")
#expect((value["href"] as? String)?.hasSuffix("#frame") == true)
#expect(value["title"] as? String == "frame-title")
_ = navigationDelegate
}
@Test("custom prototypes are copied without invoking serialization hooks")
func customPrototypeIsCopiedAsPlainObject() throws {
let envelope = try evaluate(
"""
(() => {
function Payload() {
this.answer = 42;
}
Payload.prototype.toJSON = function() { return 'prototype-hook'; };
return new Payload();
})()
"""
)
let value = try #require(envelope[service.evalEnvelope.valueKey] as? [String: Any])
#expect(value["answer"] as? Int == 42)
}
@Test("Date and BigInt values have stable JSON-safe representations")
func nonJSONBuiltinsAreNormalized() throws {
let envelope = try evaluate(
"""
({date: new Date('2026-01-02T03:04:05Z'), integer: 9007199254740993n})
"""
)
let value = try #require(envelope[service.evalEnvelope.valueKey] as? [String: Any])
#expect((value["date"] as? [String: Any])?.isEmpty == true)
#expect(value["integer"] as? String == "9007199254740993")
}
@Test("cycles on exotic prototypes produce the stable error envelope")
func exoticPrototypeCycleProducesExplicitError() throws {
let envelope = try evaluate(
"""
(() => {
const value = new Map();
Object.defineProperty(value, 'self', {value, enumerable: true});
return value;
})()
"""
)
#expect(envelope[service.evalEnvelope.typeKey] as? String == service.evalEnvelope.typeError)
#expect(
envelope[service.evalEnvelope.errorCodeKey] as? String
== service.evalEnvelope.circularReferenceCode
)
}
@Test("an own proto property remains ordinary serialized data")
func ownProtoPropertyIsPreserved() throws {
let envelope = try evaluate(
"""
(() => {
const value = Object.create(null);
Object.defineProperty(value, '__proto__', {
value: 'ordinary-value',
enumerable: true
});
return value;
})()
"""
)
let value = try #require(envelope[service.evalEnvelope.valueKey] as? [String: Any])
#expect(value["__proto__"] as? String == "ordinary-value")
}
private func evaluate(
_ script: String,
frameSelector: String? = nil,
documentSetup: String = "var document = {};"
) throws -> [String: Any] {
let (context, result) = try evaluateRaw(
script,
frameSelector: frameSelector,
documentSetup: documentSetup
)
context.setObject(result, forKeyedSubscript: "__cmuxTestResult" as NSString)
let json = try #require(context.evaluateScript("JSON.stringify(__cmuxTestResult)")?.toString())
let data = try #require(json.data(using: .utf8))
return try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
}
private func evaluateRaw(
_ script: String,
frameSelector: String? = nil,
documentSetup: String = "var document = {};"
) throws -> (JSContext, JSValue) {
let context = try #require(JSContext())
var exceptionMessage: String?
context.exceptionHandler = { _, exception in
exceptionMessage = exception?.toString()
}
context.evaluateScript(documentSetup)
let body = service.evaluationScript(
script: script,
useEval: true,
frameSelector: frameSelector
)
let promise = try #require(context.evaluateScript("(async () => {\n\(body)\n})()"))
var resolved: JSValue?
var rejectionMessage: String?
let fulfilled: @convention(block) (JSValue) -> Void = { value in
resolved = value
}
let rejected: @convention(block) (JSValue) -> Void = { value in
rejectionMessage = value.toString()
}
promise.invokeMethod("then", withArguments: [fulfilled, rejected])
#expect(exceptionMessage == nil)
#expect(rejectionMessage == nil)
let result = try #require(resolved)
return (context, result)
}
}
@@ -0,0 +1,15 @@
import Foundation
import WebKit
@MainActor
final class BrowserEvaluationScriptNavigationDelegate: NSObject, WKNavigationDelegate {
private let onFinish: () -> Void
init(onFinish: @escaping () -> Void) {
self.onFinish = onFinish
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation?) {
onFinish()
}
}
@@ -14,12 +14,14 @@ let package = Package(
),
],
dependencies: [
.package(path: "../CmuxFoundation"),
.package(path: "../CmuxSettings"),
],
targets: [
.target(
name: "CmuxControlSocket",
dependencies: [
.product(name: "CmuxFoundation", package: "CmuxFoundation"),
.product(name: "CmuxSettings", package: "CmuxSettings"),
],
swiftSettings: [
@@ -39,14 +39,11 @@ public protocol ControlCommandContext:
/// of the coordinator's worker-lane resolution bodies (`surface.list`,
/// `system.tree`, `surface.send_text`, ).
///
/// The conformer MUST refresh its known `kind:N` refs before running the
/// closure (the app forwards to `v2MainSync { v2RefreshKnownRefs(); }`),
/// mirroring the main-lane dispatch preamble byte-for-byte so
/// caller-supplied refs resolve through the registry. The refresh covers
/// only main-window workspace topology dock-hosted surfaces are
/// first-minted by each body's in-hop mint pass, so mint passes MUST
/// preserve their payload's literal mint order; that ordering (not the
/// refresh) is what keeps ordinals identical across lanes.
/// Handle refs are minted when a response first exposes an object. A
/// caller-supplied `kind:N` ref therefore already exists in the registry;
/// the conformer must not walk unrelated app topology before this closure.
/// Each body's in-hop mint pass preserves its payload's literal order so
/// main-actor and worker-lane responses assign identical ordinals.
/// Like `controlSidebarOnMain`, the hop collapses to an inline call
/// when the caller is already on the main thread (mainThreadCallable
/// in-process dispatch), and the closure receives the seam back as its
@@ -103,9 +103,9 @@ public final class ControlCommandCoordinator {
///
/// Each body is `nonisolated`: pure parse and the JSON payload build/
/// encode run on the calling thread, and every main-actor touch
/// known-ref refresh, routing resolution through the handle registry,
/// the context snapshot witness, and ref minting in payload order is
/// one `controlResolveOnMain` hop. The same bodies serve the main-actor
/// routing resolution through the handle registry, the context snapshot
/// witness, and ref minting in payload order is one
/// `controlResolveOnMain` hop. The same bodies serve the main-actor
/// `handle(_:)` dispatch, where the hop collapses inline, so both lanes
/// run identical code.
///
@@ -277,8 +277,8 @@ public protocol ControlSidebarContext: AnyObject {
/// Focuses a surface by panel UUID (`focus_surface_by_panel`).
func controlSidebarFocusSurfaceByPanel(panelID: UUID) -> Bool
/// Refreshes the `kind:N` handle registry from live app state (the legacy
/// `v2RefreshKnownRefs` pre-pass of `drag_surface_to_split`).
/// Refreshes the `kind:N` handle registry from live app state for the
/// legacy `drag_surface_to_split` command.
func controlSidebarRefreshKnownRefs()
/// Forwards a stable-ref `drag_surface_to_split` to the shared app-side
@@ -106,8 +106,7 @@ extension ControlCommandCoordinator {
// context (the socket worker lane always passes its live seam),
// and the legacy body ran this flow inline on main params still
// parse, the routing still validates, and the walk resolves an
// empty world. No known-ref refresh, exactly as before (the
// main lane's refresh lives app-side, ahead of the dispatch).
// empty world, matching the main lane.
outcome = MainActor.assumeIsolated {
self.systemTreeHopBody(params, seam: nil)
}
@@ -24,6 +24,12 @@ public struct SocketListenerPolicy: Sendable {
public let acceptFailureMinimumRearmDelayMs: Int
/// Consecutive-failure count at which the listener rearms instead of resuming.
public let acceptFailureRearmThreshold: Int
/// First listener-start retry delay in milliseconds.
public let startupFailureBaseBackoffMs: Int
/// Upper bound for listener-start retry delays in milliseconds.
public let startupFailureMaxBackoffMs: Int
/// Maximum transient listener-start failures retried before escalation.
public let startupFailureRetryLimit: Int
/// Creates a policy.
///
@@ -32,16 +38,32 @@ public struct SocketListenerPolicy: Sendable {
/// - acceptFailureMaxBackoffMs: Backoff cap (default 5s).
/// - acceptFailureMinimumRearmDelayMs: Rearm-delay floor (default 100ms).
/// - acceptFailureRearmThreshold: Failure streak that forces a rearm (default 50).
/// - startupFailureBaseBackoffMs: First startup retry delay (default 100ms);
/// negative values normalize to zero.
/// - startupFailureMaxBackoffMs: Startup retry-delay cap (default 2s);
/// values below the normalized base are raised to the base.
/// - startupFailureRetryLimit: Transient failures retried before reporting
/// (default 6); negative values normalize to zero.
public init(
acceptFailureBaseBackoffMs: Int = 10,
acceptFailureMaxBackoffMs: Int = 5_000,
acceptFailureMinimumRearmDelayMs: Int = 100,
acceptFailureRearmThreshold: Int = 50
acceptFailureRearmThreshold: Int = 50,
startupFailureBaseBackoffMs: Int = 100,
startupFailureMaxBackoffMs: Int = 2_000,
startupFailureRetryLimit: Int = 6
) {
self.acceptFailureBaseBackoffMs = acceptFailureBaseBackoffMs
self.acceptFailureMaxBackoffMs = acceptFailureMaxBackoffMs
self.acceptFailureMinimumRearmDelayMs = acceptFailureMinimumRearmDelayMs
self.acceptFailureRearmThreshold = acceptFailureRearmThreshold
let normalizedStartupBaseBackoffMs = max(startupFailureBaseBackoffMs, 0)
self.startupFailureBaseBackoffMs = normalizedStartupBaseBackoffMs
self.startupFailureMaxBackoffMs = max(
startupFailureMaxBackoffMs,
normalizedStartupBaseBackoffMs
)
self.startupFailureRetryLimit = max(startupFailureRetryLimit, 0)
}
/// Classifies an `accept(2)` `errno` into a recovery class.
@@ -160,6 +182,74 @@ public struct SocketListenerPolicy: Sendable {
return (consecutiveFailures & (consecutiveFailures - 1)) == 0
}
/// Whether a listener-start failure should retry before it is reported.
///
/// Retries are limited to transport occupancy, interrupted setup, temporary
/// filesystem I/O failure (including post-bind identity capture), and
/// resource pressure. Permission, path-shape, and inconclusive bound-path
/// ownership proofs remain immediately actionable.
///
/// - Parameters:
/// - stage: Stable listener-start stage identifier.
/// - errnoCode: The failing `errno`.
/// - consecutiveFailures: Failure count including the current attempt.
/// - Returns: `true` when another bounded retry should be scheduled.
public func shouldRetryStartupFailure(
stage: String,
errnoCode: Int32,
consecutiveFailures: Int
) -> Bool {
guard consecutiveFailures > 0,
consecutiveFailures <= startupFailureRetryLimit else {
return false
}
if stage == "verify_bound_path_pending" {
return isPendingSocketConnectErrno(errnoCode)
}
if stage == "verify_bound_path_drain" {
return errnoCode == EAGAIN || errnoCode == EINTR
}
// Once the retained descriptor is listening for an ownership proof,
// any inconclusive result must close it instead of leaving an
// unverified listener reachable during another retry delay.
guard stage != "verify_bound_path" else { return false }
switch errnoCode {
case EADDRINUSE:
return stage == "bind"
case EAGAIN:
return stage == "lock" || stage == "open_lock"
case EINTR, EIO, EMFILE, ENFILE, ENOBUFS, ENOMEM:
return true
default:
return false
}
}
/// Exponential listener-start retry delay, capped by
/// ``startupFailureMaxBackoffMs``.
///
/// - Parameter consecutiveFailures: Failure count including the current attempt.
/// - Returns: Retry delay in milliseconds, or zero for no failures.
public func startupFailureRetryDelayMilliseconds(consecutiveFailures: Int) -> Int {
guard consecutiveFailures > 0 else { return 0 }
guard startupFailureBaseBackoffMs > 0 else { return 0 }
var delay = startupFailureBaseBackoffMs
var remaining = consecutiveFailures - 1
while remaining > 0 {
if delay >= startupFailureMaxBackoffMs {
return startupFailureMaxBackoffMs
}
let (doubled, overflow) = delay.multipliedReportingOverflow(by: 2)
if overflow || doubled >= startupFailureMaxBackoffMs {
return startupFailureMaxBackoffMs
}
delay = doubled
remaining -= 1
}
return delay
}
/// Whether accept-loop cleanup may unlink the socket path: only when the
/// path still belongs to this listener, nothing is running or starting, and
/// no newer accept-loop generation exists.
@@ -214,6 +304,9 @@ public struct SocketListenerPolicy: Sendable {
errnoCode: Int32,
currentUserID: uid_t = getuid()
) -> String? {
// Only the stable path detours. Once an attempt is on the user-scoped
// fallback, transient occupancy is retried in place by
// `shouldRetryStartupFailure` instead of selecting another path.
guard requestedPath == SocketControlSettings.stableDefaultSocketPath else {
return nil
}
@@ -0,0 +1,14 @@
/// Ownership proof for a pathname created by `bind(2)`.
enum BoundSocketPathOwnership: Equatable, Sendable {
case none
/// `bind(2)` succeeded while the immediate `lstat(2)` failed. The server
/// retains both the bound descriptor and path lock until a later identity
/// capture or safe teardown.
case identityPending
case identified(SocketPathIdentity)
var identity: SocketPathIdentity? {
guard case .identified(let identity) = self else { return nil }
return identity
}
}
@@ -0,0 +1,8 @@
internal import CmuxSettings
/// Immutable request retained across a bounded listener-start retry.
struct ListenerStartRequest: Equatable, Sendable {
let socketPath: String
let accessMode: SocketControlMode
let preserveAcceptFailureStreak: Bool
}
@@ -0,0 +1,32 @@
/// The authoritative listener-start lifecycle.
///
/// Generation checks make delayed wakes harmless after stop or an explicit
/// restart. The waiting case owns the request and failure count, so the timer
/// task carries no lifecycle state and can only request a wakeup.
enum ListenerStartupState: Equatable, Sendable {
/// No startup operation is active. A listener may already be running.
case idle(generation: UInt64)
/// One synchronous startup attempt owns the request on the main actor.
case starting(generation: UInt64, request: ListenerStartRequest, failureCount: Int)
/// A bounded delay is pending before the same request may retry.
case waiting(generation: UInt64, request: ListenerStartRequest, failureCount: Int)
var generation: UInt64 {
switch self {
case .idle(let generation),
.starting(let generation, _, _),
.waiting(let generation, _, _):
return generation
}
}
var isStarting: Bool {
if case .starting = self { return true }
return false
}
var isWaiting: Bool {
if case .waiting = self { return true }
return false
}
}
@@ -0,0 +1,23 @@
internal import Dispatch
internal import CmuxSettings
/// Main-actor transport resources and listener state.
struct ListenerState {
var socketPath: String
var boundSocketPathOwnership: BoundSocketPathOwnership = .none
var serverSocket: Int32 = -1
var isRunning = false
var acceptLoopAlive = false
var activeAcceptLoopGeneration: UInt64 = 0
var nextAcceptLoopGeneration: UInt64 = 0
var pendingAcceptLoopRearmGeneration: UInt64?
var reservedStartupSocketPath: String?
var reservedStartupSocketPathCanReplaceRefusedSocket = false
var listenerState: ListenerStartupState = .idle(generation: 0)
var socketPathLockFD: Int32 = -1
var listenerReadSource: (any DispatchSourceRead)?
var listenerReadSourceSuspended = false
var socketPathMonitorSource: (any DispatchSourceFileSystemObject)?
var accessMode: SocketControlMode = .cmuxOnly
var configuredPreferredSocketPath: String?
}
@@ -1,7 +1,14 @@
internal import CmuxFoundation
/// Bounds concurrent socket clients waiting to prove authorization.
public actor SocketClientPreauthorizationLimiter {
///
/// Admission is synchronous because it runs on the listener's Dispatch queue
/// before a dedicated client thread is started. An atomic counter keeps this
/// ingress path independent of Swift's cooperative executor, including when
/// background filesystem or process scans occupy every cooperative worker.
public final class SocketClientPreauthorizationLimiter: Sendable {
private let maximumConcurrentClaims: Int
private var activeClaims = 0
private let activeClaims = AtomicUInt64Value()
/// Creates a limiter with a fixed concurrent claim budget.
///
@@ -14,14 +21,11 @@ public actor SocketClientPreauthorizationLimiter {
///
/// - Returns: `true` when a slot was reserved; otherwise `false`.
public func claim() -> Bool {
guard activeClaims < maximumConcurrentClaims else { return false }
activeClaims += 1
return true
activeClaims.incrementIfBelow(UInt64(maximumConcurrentClaims))
}
/// Releases one previously claimed reader slot.
public func release() {
guard activeClaims > 0 else { return }
activeClaims -= 1
_ = activeClaims.decrementIfPositive()
}
}
@@ -118,27 +118,40 @@ extension SocketControlServer {
return
}
let authorization = acceptedConnectionAuthorization()
let yielded = connectionsContinuation.yield(
ControlConnection(
socket: clientSocket,
peerProcessID: peerPid,
authorizationGeneration: authorization.generation,
authorizationRevocationSignal: authorization.revocationSignal
)
let connection = ControlConnection(
socket: clientSocket,
peerProcessID: peerPid,
authorizationGeneration: authorization.generation,
authorizationRevocationSignal: authorization.revocationSignal
)
switch yielded {
case .enqueued:
break
case let .dropped(connection):
close(connection.socket)
case .terminated:
close(clientSocket)
@unknown default:
close(clientSocket)
}
deliverAcceptedConnection(connection)
}
}
/// Transfers an accepted descriptor through the configured ingress path.
/// Tests use this same entry point for synthetic socket pairs, so switching
/// between direct delivery and the AsyncStream fallback cannot strand them
/// on an unconsumed transport.
@discardableResult
nonisolated func deliverAcceptedConnection(_ connection: ControlConnection) -> Bool {
if let acceptedConnectionHandler {
acceptedConnectionHandler(connection)
return true
}
let yielded = connectionsContinuation.yield(connection)
switch yielded {
case .enqueued:
return true
case let .dropped(connection):
close(connection.socket)
case .terminated:
close(connection.socket)
@unknown default:
close(connection.socket)
}
return false
}
private nonisolated func shouldContinueAcceptLoop(listenerSocket: Int32, generation: UInt64) -> Bool {
let snapshot = listenerStateSnapshot()
return snapshot.isRunning
@@ -42,8 +42,14 @@ extension SocketControlServer {
if accessMode == .off {
stop()
} else if isRunning, !applySocketPermissions() {
} else if isRunning, let errnoCode = applySocketPermissions() {
let recoveryPath = currentSocketPath
stop()
schedulePermissionRecovery(
socketPath: recoveryPath,
accessMode: accessMode,
errnoCode: errnoCode
)
events.breadcrumb(
"socket.listener.configuration.failed_closed",
socketListenerEventData(
@@ -1,6 +1,12 @@
public import CmuxSettings
internal import Darwin
internal import Foundation
internal import os
nonisolated private let socketControlServerLogger = Logger(
subsystem: "com.cmux.socket",
category: "Listener"
)
extension SocketControlServer {
/// Reserves `path` (or its policy fallback) before the listener starts, so
@@ -14,7 +20,7 @@ extension SocketControlServer {
/// unchanged when no reservation was possible.
@discardableResult
public func reserveStartupSocketPath(_ path: String) -> String {
guard withListenerState({ Self.canReserveStartupSocketPath(state: $0) }) else {
guard withListenerState({ canReserveStartupSocketPath(state: $0) }) else {
return path
}
@@ -46,7 +52,7 @@ extension SocketControlServer {
var didReserve = false
withListenerState { state in
guard Self.canReserveStartupSocketPath(state: state) else {
guard canReserveStartupSocketPath(state: state) else {
return
}
state.socketPath = reservationPath
@@ -62,17 +68,6 @@ extension SocketControlServer {
return path
}
private static func canReserveStartupSocketPath(state: ListenerState) -> Bool {
!state.isRunning &&
!state.acceptLoopAlive &&
!state.listenerStartInProgress &&
state.pendingAcceptLoopRearmGeneration == nil &&
state.socketPathLockFD < 0 &&
state.listenerReadSource == nil &&
state.socketPathMonitorSource == nil &&
state.serverSocket < 0
}
/// Starts (or restarts) the listener on `socketPath`.
///
/// Faithful lift of the legacy `TerminalController.start`: idempotent when
@@ -81,21 +76,39 @@ extension SocketControlServer {
/// retained inactive listener state, binds with stale/refused replacement
/// rules and a one-shot policy fallback path, then commits the running
/// state under a fresh accept-loop generation and arms the path monitor
/// and accept source. Failures are reported through the events seam.
/// and accept source. Transient startup failures schedule bounded recovery;
/// permanent or exhausted failures are reported through the events seam.
/// - Parameters:
/// - socketPath: The path to bind.
/// - accessMode: Socket access mode; drives file permissions, client
/// ancestry checks, and password auth.
/// - preserveAcceptFailureStreak: Keeps the consecutive accept-failure
/// counter across a rearm restart so backoff continues to escalate.
/// - Returns: `true` when the listener activated.
/// - Returns: `true` when the listener activated synchronously. `false`
/// may mean bounded transient-failure recovery is pending.
@discardableResult
public func start(
socketPath: String,
accessMode: SocketControlMode,
preserveAcceptFailureStreak: Bool = false
) -> Bool {
configureConnectionAuthorization(accessMode: accessMode)
let request = ListenerStartRequest(
socketPath: socketPath,
accessMode: accessMode,
preserveAcceptFailureStreak: preserveAcceptFailureStreak
)
startupWakeTask?.cancel()
startupWakeTask = nil
if accessMode == .off {
withListenerState { state in
state.accessMode = .off
}
configureConnectionAuthorization(accessMode: .off)
stop()
return false
}
let existing = withListenerState { state in
if state.accessMode != accessMode {
state.accessMode = accessMode
@@ -106,7 +119,7 @@ extension SocketControlServer {
reservedStartupSocketPath: state.reservedStartupSocketPath,
socketPathLockHeld: state.socketPathLockFD >= 0,
hasRetainedInactiveListenerState: !state.isRunning && (
state.pendingAcceptLoopRearmGeneration != nil ||
state.boundSocketPathOwnership != .none ||
state.socketPathLockFD >= 0 ||
state.acceptLoopAlive ||
state.serverSocket >= 0 ||
@@ -116,16 +129,24 @@ extension SocketControlServer {
)
}
if accessMode == .off {
stop()
return false
}
if existing.isRunning && SocketControlSettings.pathsMatch(existing.socketPath, socketPath) {
guard applySocketPermissions() else {
configureConnectionAuthorization(accessMode: accessMode)
if let errnoCode = applySocketPermissions() {
stop()
let generation = beginStart(request)
_ = handleStartupFailure(
message: "socket.listener.start.failed",
stage: "chmod",
errnoCode: errnoCode,
request: request,
generation: generation
)
return false
}
withListenerState { state in
let generation = state.listenerState.generation &+ 1
state.listenerState = .idle(generation: generation)
}
return true
}
@@ -136,12 +157,78 @@ extension SocketControlServer {
stop()
}
var activeSocketPath = socketPath
let generation = beginStart(request)
return startAttempt(generation: generation)
}
private func beginStart(_ request: ListenerStartRequest) -> UInt64 {
withListenerState { state in
let generation = state.listenerState.generation &+ 1
state.listenerState = .starting(
generation: generation,
request: request,
failureCount: 0
)
return generation
}
}
/// Applies the startup retry policy after a live listener was stopped
/// because a permission update failed closed.
func schedulePermissionRecovery(
socketPath: String,
accessMode: SocketControlMode,
errnoCode: Int32
) {
let request = ListenerStartRequest(
socketPath: socketPath,
accessMode: accessMode,
preserveAcceptFailureStreak: false
)
let generation = beginStart(request)
_ = handleStartupFailure(
message: "socket.listener.start.failed",
stage: "chmod",
errnoCode: errnoCode,
request: request,
generation: generation
)
}
private func startAttempt(generation: UInt64) -> Bool {
guard let attempt = withListenerState({ state -> (ListenerStartRequest, Int)? in
guard case .starting(let currentGeneration, let request, let failureCount) = state.listenerState,
currentGeneration == generation else {
return nil
}
if state.accessMode != request.accessMode {
state.accessMode = request.accessMode
}
return (request, failureCount)
}) else { return false }
let (request, _) = attempt
configureConnectionAuthorization(accessMode: request.accessMode)
var activeSocketPath = request.socketPath
var activeSocketPathLockFD: Int32 = -1
var activeSocketPathCanReplaceRefusedSocket = false
var activeBoundSocketPathIdentity: SocketPathIdentity?
var activeServerSocket: Int32 = -1
var activeBoundSocketPathOwnership = BoundSocketPathOwnership.none
var resumedIdentityPendingBind = false
withListenerState { state in
if state.socketPathLockFD >= 0,
if state.boundSocketPathOwnership == .identityPending,
state.serverSocket >= 0,
state.socketPathLockFD >= 0,
SocketControlSettings.pathsMatch(state.socketPath, activeSocketPath) {
activeSocketPath = state.socketPath
activeServerSocket = state.serverSocket
activeSocketPathLockFD = state.socketPathLockFD
activeBoundSocketPathOwnership = .identityPending
state.serverSocket = -1
state.socketPathLockFD = -1
state.boundSocketPathOwnership = .none
resumedIdentityPendingBind = true
} else if state.socketPathLockFD >= 0,
state.reservedStartupSocketPath.map({ SocketControlSettings.pathsMatch($0, activeSocketPath) }) == true,
!state.isRunning,
!state.acceptLoopAlive,
@@ -151,154 +238,243 @@ extension SocketControlServer {
state.socketPathLockFD = -1
}
state.socketPath = activeSocketPath
state.boundSocketPathIdentity = nil
if !resumedIdentityPendingBind {
state.boundSocketPathOwnership = .none
}
state.reservedStartupSocketPath = nil
state.reservedStartupSocketPathCanReplaceRefusedSocket = false
state.listenerStartInProgress = true
}
var listenerActivated = false
defer {
if !listenerActivated {
if let activeBoundSocketPathIdentity,
listenerPolicy.shouldUnlinkSocketPathAfterListenerStop(
currentIdentity: transport.pathIdentity(at: activeSocketPath),
boundIdentity: activeBoundSocketPathIdentity
) {
unlink(activeSocketPath)
unlinkOwnedSocketPath(
activeSocketPath,
ownership: activeBoundSocketPathOwnership
)
if activeServerSocket >= 0 {
close(activeServerSocket)
activeServerSocket = -1
}
transport.releaseSocketPathLock(activeSocketPathLockFD)
activeSocketPathLockFD = -1
withListenerState { state in
if state.boundSocketPathIdentity == activeBoundSocketPathIdentity {
state.boundSocketPathIdentity = nil
if state.serverSocket < 0, state.socketPathLockFD < 0 {
state.boundSocketPathOwnership = .none
}
state.listenerStartInProgress = false
}
}
}
// Create socket
let (newServerSocket, createSocketErrno) = transport.makeListenerSocket()
guard newServerSocket >= 0 else {
let errnoCode = createSocketErrno ?? EIO
print("SocketControlServer: Failed to create socket")
reportSocketListenerFailure(
message: "socket.listener.start.failed",
stage: "create_socket",
errnoCode: errnoCode
)
return false
}
if resumedIdentityPendingBind {
switch transport.verifyRetainedBoundPath(
at: activeSocketPath,
listenerSocket: activeServerSocket
) {
case .verified(let identity):
activeBoundSocketPathOwnership = .identified(identity)
case .pending(let failure), .failed(let failure):
let disposition = handleStartupFailure(
message: "socket.listener.start.failed",
stage: failure.stage,
errnoCode: failure.errnoCode,
request: ListenerStartRequest(
socketPath: activeSocketPath,
accessMode: request.accessMode,
preserveAcceptFailureStreak: request.preserveAcceptFailureStreak
),
generation: generation,
retainedSocket: activeServerSocket,
retainedPathLockFD: activeSocketPathLockFD,
retainedOwnership: .identityPending
)
if disposition == .retryScheduled {
activeServerSocket = -1
activeSocketPathLockFD = -1
activeBoundSocketPathOwnership = .none
}
return false
}
} else {
let (newServerSocket, createSocketErrno) = transport.makeListenerSocket()
guard newServerSocket >= 0 else {
let errnoCode = createSocketErrno ?? EIO
socketControlServerLogger.error("Failed to create listener socket")
_ = handleStartupFailure(
message: "socket.listener.start.failed",
stage: "create_socket",
errnoCode: errnoCode,
request: request,
generation: generation
)
return false
}
activeServerSocket = newServerSocket
func acquireActiveSocketPathLock() -> SocketBindAttemptResult? {
if activeSocketPathLockFD >= 0 {
return nil
func acquireActiveSocketPathLock() -> SocketBindAttemptResult? {
if activeSocketPathLockFD >= 0 {
return nil
}
switch transport.acquireSocketPathLock(for: activeSocketPath) {
case .acquired(let fd, let canReplaceRefusedSocket):
activeSocketPathLockFD = fd
activeSocketPathCanReplaceRefusedSocket = canReplaceRefusedSocket
return nil
case .failed(let failure):
return .failure(path: activeSocketPath, failure: failure)
}
}
switch transport.acquireSocketPathLock(for: activeSocketPath) {
case .acquired(let fd, let canReplaceRefusedSocket):
activeSocketPathLockFD = fd
activeSocketPathCanReplaceRefusedSocket = canReplaceRefusedSocket
return nil
case .failed(let failure):
return .failure(path: activeSocketPath, failure: failure)
}
}
var bindAttempt = acquireActiveSocketPathLock()
?? transport.bindListenerSocket(
newServerSocket,
path: activeSocketPath,
canReplaceRefusedSocket: activeSocketPathCanReplaceRefusedSocket
)
if case .failure(let failedPath, let bindFailure) = bindAttempt,
let fallbackPath = listenerPolicy.fallbackSocketPathAfterBindFailure(
requestedPath: failedPath,
stage: bindFailure.stage,
errnoCode: bindFailure.errnoCode
),
fallbackPath != failedPath {
events.breadcrumb(
"socket.listener.path.fallback",
[
"requestedPath": failedPath,
"fallbackPath": fallbackPath,
"stage": bindFailure.stage,
"errno": Int(bindFailure.errnoCode),
]
)
transport.releaseSocketPathLock(activeSocketPathLockFD)
activeSocketPathLockFD = -1
activeSocketPathCanReplaceRefusedSocket = false
activeSocketPath = fallbackPath
withListenerState { state in
state.socketPath = activeSocketPath
}
bindAttempt = acquireActiveSocketPathLock()
var bindAttempt = acquireActiveSocketPathLock()
?? transport.bindListenerSocket(
newServerSocket,
activeServerSocket,
path: activeSocketPath,
canReplaceRefusedSocket: activeSocketPathCanReplaceRefusedSocket
)
}
if case .failure(let failedPath, let bindFailure) = bindAttempt,
bindFailure.stage != "stat_bound_path",
let fallbackPath = listenerPolicy.fallbackSocketPathAfterBindFailure(
requestedPath: failedPath,
stage: bindFailure.stage,
errnoCode: bindFailure.errnoCode
),
fallbackPath != failedPath {
events.breadcrumb(
"socket.listener.path.fallback",
[
"requestedPath": failedPath,
"fallbackPath": fallbackPath,
"stage": bindFailure.stage,
"errno": Int(bindFailure.errnoCode),
]
)
transport.releaseSocketPathLock(activeSocketPathLockFD)
activeSocketPathLockFD = -1
activeSocketPathCanReplaceRefusedSocket = false
activeSocketPath = fallbackPath
withListenerState { state in
state.socketPath = activeSocketPath
}
bindAttempt = acquireActiveSocketPathLock()
?? transport.bindListenerSocket(
activeServerSocket,
path: activeSocketPath,
canReplaceRefusedSocket: activeSocketPathCanReplaceRefusedSocket
)
}
switch bindAttempt {
case .success(let boundPath, let identity):
activeSocketPath = boundPath
activeBoundSocketPathOwnership = .identified(identity)
case .pathTooLong(let failedPath):
_ = handleStartupFailure(
message: "socket.listener.start.failed",
stage: "bind_path_too_long",
errnoCode: ENAMETOOLONG,
extra: [
"path": failedPath,
"pathLength": failedPath.utf8.count,
"maxPathLength": SocketTransport.unixSocketPathMaxLength,
],
request: ListenerStartRequest(
socketPath: activeSocketPath,
accessMode: request.accessMode,
preserveAcceptFailureStreak: request.preserveAcceptFailureStreak
),
generation: generation
)
return false
case .failure(let failedPath, let bindFailure) where bindFailure.stage == "stat_bound_path":
activeSocketPath = failedPath
activeBoundSocketPathOwnership = .identityPending
let disposition = handleStartupFailure(
message: "socket.listener.start.failed",
stage: bindFailure.stage,
errnoCode: bindFailure.errnoCode,
extra: ["path": failedPath],
request: ListenerStartRequest(
socketPath: activeSocketPath,
accessMode: request.accessMode,
preserveAcceptFailureStreak: request.preserveAcceptFailureStreak
),
generation: generation,
retainedSocket: activeServerSocket,
retainedPathLockFD: activeSocketPathLockFD,
retainedOwnership: .identityPending
)
if disposition == .retryScheduled {
activeServerSocket = -1
activeSocketPathLockFD = -1
activeBoundSocketPathOwnership = .none
}
return false
case .failure(let failedPath, let bindFailure):
socketControlServerLogger.error("Failed to bind listener socket")
_ = handleStartupFailure(
message: "socket.listener.start.failed",
stage: bindFailure.stage,
errnoCode: bindFailure.errnoCode,
extra: ["path": failedPath],
request: ListenerStartRequest(
socketPath: activeSocketPath,
accessMode: request.accessMode,
preserveAcceptFailureStreak: request.preserveAcceptFailureStreak
),
generation: generation
)
return false
}
switch bindAttempt {
case .success(let boundPath, let identity):
activeSocketPath = boundPath
activeBoundSocketPathIdentity = identity
withListenerState { state in
state.socketPath = activeSocketPath
state.boundSocketPathIdentity = identity
}
case .pathTooLong(let failedPath):
close(newServerSocket)
reportSocketListenerFailure(
}
if let errnoCode = applySocketPermissions() {
_ = handleStartupFailure(
message: "socket.listener.start.failed",
stage: "bind_path_too_long",
errnoCode: ENAMETOOLONG,
extra: [
"path": failedPath,
"pathLength": failedPath.utf8.count,
"maxPathLength": SocketTransport.unixSocketPathMaxLength,
]
)
return false
case .failure(let failedPath, let bindFailure):
print("SocketControlServer: Failed to bind socket")
close(newServerSocket)
reportSocketListenerFailure(
message: "socket.listener.start.failed",
stage: bindFailure.stage,
errnoCode: bindFailure.errnoCode,
extra: ["path": failedPath]
stage: "chmod",
errnoCode: errnoCode,
request: ListenerStartRequest(
socketPath: activeSocketPath,
accessMode: request.accessMode,
preserveAcceptFailureStreak: request.preserveAcceptFailureStreak
),
generation: generation
)
return false
}
guard applySocketPermissions() else {
close(newServerSocket)
return false
}
if let errnoCode = transport.configureNonBlocking(newServerSocket) {
print("SocketControlServer: Failed to configure socket")
close(newServerSocket)
reportSocketListenerFailure(
if let errnoCode = transport.configureNonBlocking(activeServerSocket) {
socketControlServerLogger.error("Failed to configure listener socket")
_ = handleStartupFailure(
message: "socket.listener.start.failed",
stage: "configure_nonblocking",
errnoCode: errnoCode
errnoCode: errnoCode,
request: ListenerStartRequest(
socketPath: activeSocketPath,
accessMode: request.accessMode,
preserveAcceptFailureStreak: request.preserveAcceptFailureStreak
),
generation: generation
)
return false
}
// Listen
guard listen(newServerSocket, transport.listenBacklog) >= 0 else {
guard listen(activeServerSocket, transport.listenBacklog) >= 0 else {
let errnoCode = errno
print("SocketControlServer: Failed to listen on socket")
close(newServerSocket)
reportSocketListenerFailure(
socketControlServerLogger.error("Failed to listen on socket")
_ = handleStartupFailure(
message: "socket.listener.start.failed",
stage: "listen",
errnoCode: errnoCode
errnoCode: errnoCode,
request: ListenerStartRequest(
socketPath: activeSocketPath,
accessMode: request.accessMode,
preserveAcceptFailureStreak: request.preserveAcceptFailureStreak
),
generation: generation
)
return false
}
@@ -308,58 +484,183 @@ extension SocketControlServer {
var displacedSocketPathLockFD: Int32 = -1
let transferredSocketPathLockFD = activeSocketPathLockFD
let generation = withListenerState { state in
let acceptGeneration = withListenerState { state in
state.isRunning = true
state.pendingAcceptLoopRearmGeneration = nil
state.nextAcceptLoopGeneration &+= 1
let generation = state.nextAcceptLoopGeneration
state.activeAcceptLoopGeneration = generation
state.serverSocket = newServerSocket
let acceptGeneration = state.nextAcceptLoopGeneration
state.activeAcceptLoopGeneration = acceptGeneration
state.serverSocket = activeServerSocket
displacedSocketPathLockFD = state.socketPathLockFD
state.socketPathLockFD = activeSocketPathLockFD
state.listenerStartInProgress = false
return generation
state.boundSocketPathOwnership = activeBoundSocketPathOwnership
state.listenerState = .idle(generation: generation)
return acceptGeneration
}
activateConnectionAuthorizations()
acceptRecovery.withLock { recovery in
recovery = AcceptRecoveryState(
generation: generation,
consecutiveFailures: preserveAcceptFailureStreak ? recovery.consecutiveFailures : 0,
generation: acceptGeneration,
consecutiveFailures: request.preserveAcceptFailureStreak ? recovery.consecutiveFailures : 0,
recoveryHopInFlight: false
)
}
if displacedSocketPathLockFD >= 0, displacedSocketPathLockFD != transferredSocketPathLockFD {
transport.releaseSocketPathLock(displacedSocketPathLockFD)
}
activeServerSocket = -1
activeSocketPathLockFD = -1
activeBoundSocketPathOwnership = .none
listenerActivated = true
let listenerSocket = newServerSocket
print("SocketControlServer: Listening on \(activeSocketPath)")
let listenerSocket = withListenerState { $0.serverSocket }
socketControlServerLogger.info("Listening on \(activeSocketPath, privacy: .private)")
events.breadcrumb(
"socket.listener.listening",
[
"path": activeSocketPath,
"mode": accessMode.rawValue,
"generation": generation,
"mode": request.accessMode.rawValue,
"generation": acceptGeneration,
"backlog": transport.listenBacklog,
]
)
events.listenerDidStart(activeSocketPath, generation)
events.listenerDidStart(activeSocketPath, acceptGeneration)
startSocketPathMonitor(path: activeSocketPath, generation: generation)
startAcceptSource(listenerSocket: listenerSocket, generation: generation)
startSocketPathMonitor(path: activeSocketPath, generation: acceptGeneration)
startAcceptSource(listenerSocket: listenerSocket, generation: acceptGeneration)
return true
}
@discardableResult
private func handleStartupFailure(
message: String,
stage: String,
errnoCode: Int32,
extra: [String: any Sendable] = [:],
request: ListenerStartRequest,
generation: UInt64,
retainedSocket: Int32 = -1,
retainedPathLockFD: Int32 = -1,
retainedOwnership: BoundSocketPathOwnership = .none
) -> StartupFailureDisposition {
guard let failureCount = withListenerState({ state -> Int? in
guard case .starting(let currentGeneration, _, let currentFailureCount) = state.listenerState,
currentGeneration == generation else { return nil }
return currentFailureCount + 1
}) else { return .terminal }
guard listenerPolicy.shouldRetryStartupFailure(
stage: stage,
errnoCode: errnoCode,
consecutiveFailures: failureCount
) else {
var reportExtra = extra
reportExtra["startupFailureCount"] = failureCount
let shouldReport = withListenerState { state -> Bool in
guard case .starting(let currentGeneration, _, _) = state.listenerState,
currentGeneration == generation else { return false }
state.listenerState = .idle(generation: generation)
return true
}
guard shouldReport else { return .terminal }
reportSocketListenerFailure(
message: message,
stage: stage,
errnoCode: errnoCode,
extra: reportExtra
)
return .terminal
}
let delayMs = listenerPolicy.startupFailureRetryDelayMilliseconds(
consecutiveFailures: failureCount
)
var retryExtra = extra
retryExtra["startupFailureCount"] = failureCount
retryExtra["retryDelayMs"] = delayMs
events.breadcrumb(
"socket.listener.start.retry_scheduled",
socketListenerEventData(
stage: stage,
errnoCode: errnoCode,
extra: retryExtra
)
)
let didSchedule = withListenerState { state -> Bool in
guard case .starting(let currentGeneration, _, _) = state.listenerState,
currentGeneration == generation else { return false }
state.socketPath = request.socketPath
state.listenerState = .waiting(
generation: generation,
request: request,
failureCount: failureCount
)
if retainedSocket >= 0,
retainedPathLockFD >= 0,
retainedOwnership == .identityPending {
state.serverSocket = retainedSocket
state.socketPathLockFD = retainedPathLockFD
state.boundSocketPathOwnership = retainedOwnership
}
return true
}
guard didSchedule else { return .terminal }
startupWakeTask?.cancel()
// The task owns only the bounded delay. ListenerState owns every
// lifecycle value and wakeStartupRetry atomically claims it.
startupWakeTask = Task { [weak self, recoveryClock] in
do {
try await recoveryClock.sleep(forMilliseconds: delayMs)
try Task.checkCancellation()
} catch {
return
}
self?.wakeStartupRetry(generation: generation)
}
return .retryScheduled
}
/// Atomically claims a matching delayed retry before starting any syscall work.
private func wakeStartupRetry(generation: UInt64) {
let didClaim = withListenerState { state -> Bool in
guard case .waiting(let currentGeneration, let request, let failureCount) = state.listenerState,
currentGeneration == generation else { return false }
state.listenerState = .starting(
generation: generation,
request: request,
failureCount: failureCount
)
return true
}
guard didClaim else { return }
startupWakeTask = nil
_ = startAttempt(generation: generation)
}
/// Removes a bound path only with the identity captured after a proven bind.
/// Identity-pending paths are always preserved during teardown.
func unlinkOwnedSocketPath(
_ path: String,
ownership: BoundSocketPathOwnership
) {
// Never promote during teardown. A later identity read cannot prove
// that another process did not replace the original directory entry.
guard case .identified(let identity) = ownership else { return }
guard listenerPolicy.shouldUnlinkSocketPathAfterListenerStop(
currentIdentity: transport.pathIdentity(at: path),
boundIdentity: identity
) else { return }
unlink(path)
}
/// Applies the access mode's file permissions to the current socket path.
@discardableResult
func applySocketPermissions() -> Bool {
func applySocketPermissions() -> Int32? {
let (currentSocketPath, mode) = withListenerState { ($0.socketPath, $0.accessMode) }
let permissions = mode_t(mode.socketFilePermissions)
if chmod(currentSocketPath, permissions) != 0 {
let errnoCode = errno
print(
"TerminalController: Failed to set socket permissions to \(String(permissions, radix: 8)) for \(currentSocketPath)"
if let errnoCode = transport.applySocketPermissions(permissions, at: currentSocketPath) {
let permissionsDescription = String(permissions, radix: 8)
socketControlServerLogger.error(
"Failed to set socket permissions to \(permissionsDescription, privacy: .public) for \(currentSocketPath, privacy: .private)"
)
events.breadcrumb(
"socket.listener.permissions.failed",
@@ -369,9 +670,21 @@ extension SocketControlServer {
extra: ["permissions": String(permissions, radix: 8)]
)
)
return false
return errnoCode
}
return true
return nil
}
}
private func canReserveStartupSocketPath(state: ListenerState) -> Bool {
!state.isRunning &&
!state.acceptLoopAlive &&
!state.listenerState.isStarting &&
!state.listenerState.isWaiting &&
state.pendingAcceptLoopRearmGeneration == nil &&
state.socketPathLockFD < 0 &&
state.listenerReadSource == nil &&
state.socketPathMonitorSource == nil &&
state.serverSocket < 0
}
@@ -3,9 +3,9 @@ internal import Foundation
extension SocketControlServer {
/// Stops the listener: tears down the accept and path-monitor sources,
/// cancels any pending accept-source resume, shuts down and closes the
/// server socket, unlinks the socket path when the listener still owns
/// it, and releases the path lock.
/// cancels any pending startup retry or accept-source resume, shuts down
/// and closes the server socket, unlinks the socket path when the listener
/// still owns it, and releases the path lock.
///
/// Synchronous on the main actor, where every caller already lives the
/// app's termination and updater-relaunch paths call it directly, so the
@@ -14,6 +14,8 @@ extension SocketControlServer {
deactivateConnectionAuthorizations()
acceptResumeTask?.cancel()
acceptResumeTask = nil
startupWakeTask?.cancel()
startupWakeTask = nil
let (
sourceToCancel,
sourceWasSuspended,
@@ -21,7 +23,7 @@ extension SocketControlServer {
socketToShutdown,
socketToClose,
socketPathToUnlink,
boundSocketPathIdentityToUnlink,
boundSocketPathOwnershipToUnlink,
socketPathLockFDToClose
) = withListenerState { state in
state.isRunning = false
@@ -29,7 +31,8 @@ extension SocketControlServer {
state.pendingAcceptLoopRearmGeneration = nil
state.reservedStartupSocketPath = nil
state.reservedStartupSocketPathCanReplaceRefusedSocket = false
state.listenerStartInProgress = false
let startupGeneration = state.listenerState.generation &+ 1
state.listenerState = .idle(generation: startupGeneration)
state.nextAcceptLoopGeneration &+= 1
state.activeAcceptLoopGeneration = 0
let sourceToCancel = state.listenerReadSource
@@ -40,8 +43,8 @@ extension SocketControlServer {
state.socketPathMonitorSource = nil
let socketToClose = state.serverSocket
state.serverSocket = -1
let identity = state.boundSocketPathIdentity
state.boundSocketPathIdentity = nil
let ownership = state.boundSocketPathOwnership
state.boundSocketPathOwnership = .none
let lockFD = state.socketPathLockFD
state.socketPathLockFD = -1
return (
@@ -51,7 +54,7 @@ extension SocketControlServer {
socketToClose,
sourceToCancel == nil ? socketToClose : Int32(-1),
state.socketPath,
identity,
ownership,
lockFD
)
}
@@ -63,15 +66,13 @@ extension SocketControlServer {
}
sourceToCancel?.cancel()
monitorToCancel?.cancel()
unlinkOwnedSocketPath(
socketPathToUnlink,
ownership: boundSocketPathOwnershipToUnlink
)
if socketToClose >= 0 {
close(socketToClose)
}
if listenerPolicy.shouldUnlinkSocketPathAfterListenerStop(
currentIdentity: transport.pathIdentity(at: socketPathToUnlink),
boundIdentity: boundSocketPathIdentityToUnlink
) {
unlink(socketPathToUnlink)
}
transport.releaseSocketPathLock(socketPathLockFDToClose)
}
}

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