cmux NEXT: a launch that loses the acpmux start race enrolls its person key (dead Allow until restart)

Merged
TE
12d ago
+180-11

The ask (claude-sr2's queue item 0): "First: the dead permission card in a Full-access chat (Allow did nothing; a restart fixed it)."

Leo, 2026-10-24: "clicking the allow buttons didn't do anything. Restarting it did something."

Cause

The app never gave its person key to the acpmux daemon it was using, so the daemon refused every Allow from it.

What happened on cosmic-gray (from ~/.acpmux/daemon.log, the app's unified log and the chat's event log):

  1. 21:32:42Z: an update handed off the old daemon. The app's own launch then exited before it was ready, because the old daemon still held daemon.lock. Another start won the socket (daemon pid 52764). The app log says acpmux launcher exited before ready.
  2. AgentPaneHost.findOrStart's Failure.exited branch ("ask the winner") returned the winner's endpoint without AcpmuxPersonKey.enroll. Every other path enrolls before a pane connects. A Team-signed daemon gets the key only through _acpmux/person_enroll, so the app held no key for it. The panes connected without a proof (agent pane transport: no person key for this daemon; allows stay refused, twice at 21:32:48Z).
  3. 21:53:24Z: the Full-access chat (claude-10, Claude Code in bypassPermissions) showed a card. Claude Code asked even in bypass mode: it was its own safety check (decision_reason_type: safetyCheck, "Dangerous rm operation on statically-unresolvable target"). The daemon's policy didn't add it. Allow came back permission.person_required ("Only the cmux app can approve this. Approve it in cmux on the Mac that runs this chat.").
  4. 21:54:44Z: Leo quit the app. The new launch found the daemon running and enrolled its key. At 21:55:01Z his Always went through (permission_chat_allowance, permission_decision allow_once). The daemon was never restarted in between; only the app was.

The same thing happened again on the 21:58Z update relaunch: the launcher exited, and the transport logged "no person key".

What changed

  • AgentPaneHost.swift, the Failure.exited branch: the branch now checks the winner the way the paths that find a daemon running do. First it reads the winner's status, waiting up to 3 s while the socket binds. A daemon of another acpmux home gets the same foreign-home refusal (now a shared foreignHome helper). Otherwise the app enrolls its person key (mayHandOff: false, so the branch never stops the winner), then Computer Use, and only then verifies and returns the endpoint, so a pane connects only after the enroll.
  • The launch-success path's single try? status before enroll now uses the same bounded wait and the same home check.
  • DEV (unsigned daemons) is unchanged: a winner holding another launch's key still refuses its allows, as before. Nothing changes in the daemon, and nothing changes who may allow what.

Not covered by #24809 ("withdraw Claude permission cards safely"). That PR is about cards that Claude Code withdraws; this card was live and Claude Code was waiting on it.

Tests

AcpmuxLostStartRaceTests (same-tree acpmux). The app's acpmux is a stand-in that starts the real daemon at the app's path, as another client would, then exits before its own ready line. The app is set up like a Team-signed one (passesPersonKeyAtSpawn = false).

  • aLaunchThatLosesTheStartRaceStillKeysTheWinner: after prewarm, the app holds a person key, bound to the winner's pid. Without it, the transport sends no proof and every Allow is refused.
  • aWinnerOfAnotherHomeIsRefusedAndNeverKeyed: a winner that serves another home is refused (daemonRefused) and never gets the key.
  • The fixture stops its daemon, waits for that pid to exit, and only then removes its folders.

Red (base plus these tests, scratch commit 4eb3ebdfb64, fleet step d2e1fb792267055b09011566): both tests fail (key → nil; "a winner of another home was used: nil").
Green at 5afeee86f67 (fleet step 28d7bf04c5f6f4945b2e5ede): all 5 suites pass: AcpmuxLostStartRaceTests, AcpmuxCrashRestartTests, AcpmuxBundleReplacedTests, AcpmuxOtherHomeDaemonTests, AcpmuxLaunchSecurityTests. prepush-gate.py: no new violations.

Review

Codex gpt-6-astra, xhigh, independent:

  • Round 1 at a407258907c: BLOCK. (1) A winner of another home was returned. (2) A TOCTOU across the three connections. (3) A launch-success path failed open on a single try? status. (4) The test didn't bind the key to the winner. (5) The test's cleanup could leave its daemon running. Fixed: (1), (3), (4) and (5), each with a test where it can be tested. (2) now matches the main path's order and checks exactly; that path has had the same three-connection shape and documented residual since cx-fcaq.
  • Round 2 at 5afeee86f672e42418ec93f1a8f03bb9fbb8bdd7: SHIP ("No remaining reportable defects found").

No new timer or observer. The one new wait (startedStatus) is bounded at 3 s and ends on cancellation.

No capture: nothing visible changed. The card looks the same; only its Allow now reaches the daemon with the person proof.

Agent session: big-red
šŸ¤– Generated with Claude Code