acpmux: the owner's own daemon stops a chat started on its peer, and a refused Stop says why
Leo's asks:
i literally can't stop you i clicked the stop button and it didnt do anything so this is still busted didnt we say we were going to fix this in acpmux?
this is so awkward the stop button doesnt really work and resume doesnt really appear!?
What was wrong
check_cancel_ownership (#21134) refused every session/cancel from a Web or Peer link for a chat that was started on the daemon's own machine. Any lane that acpmux or lane-place starts on a host is such a chat. Leo's Mac reaches a lane host as its peer, over that host's peer token, so pressing Stop on such a lane was always refused with remote.session_cancel_ownership. The pane sends Stop as a notification, and a refused notification only reached the daemon's debug log. The pane stayed on "Stopping" and the turn ran on.
The rule was out of step with the rest of the guard. The same peer link may already start a chat there under any policy, end one (_acpmux/kill), and close one (acpmux session stop --host). session_control_peer_cli.rs showed both sides: session stop of a peer's local chat passed, and session cancel of the same chat was asserted refused.
The change
Who may stop it (server/remote_guard/cancel_owner.rs, moved out of remote_guard.rs):
- Still allowed, as before:
- a chat a remote caller started (
remote_origin); - a peer's chat (that peer judges it).
- a chat a remote caller started (
- Now also allowed, for a chat started on this machine: the owner's own daemon. That is
Origin::PeerwithConnAuth::PeerToken, asking for itself (its person's CLI) or for its person's app (via: app). It is the existinghub::vm_boundary::ownerrule, the same proof that already decides the VM boundary and approve-all. No new scheme. A forwardedpersonStop(#28141) rides on the same link, so the owner's Stop holds the agent messages there too. - Still refused, now with "This chat runs on the machine that started it, and only its owner can stop it". The message names no host, so a guest learns nothing about the machine:
- Web clients (the shared dashboard token);
- paired devices (
ConnAuth::Device; another member's machine is paired this way); - a peer forwarding for its own Web client (
via: web) or for a paired machine (via: device).
Never silent (server/requests/cancel_refused.rs):
- A refused cancel notification sends the sender
_acpmux/cancel_refused {sessionId, message, reason, peer?}. This covers all three refusal points inhandle_notification: the VM boundary before and after the guard, and the guard itself. - A Stop this daemon forwards to a peer now goes as a request. Every acpmux answers one with
{}. The peer's refusal comes back, and the sender hears it under the key it sent (b/lane).- An unreachable peer, an error (its
connection.busyincluded), or no answer within 10 s (peer.cancel_unanswered) is reported the same way. It is never followed by a second cancel: the request may have stopped this turn before its answer was lost, and a second cancel would stop the next one. The person presses Stop again. - The request is awaited on the connection's own notification worker for at most 10 s (the peer's own cancel grace), so it stays inside that connection's bound (
NOTIFICATION_WORK_LIMIT), and a peer that never answers holds no slot past that. Dropping the request clears its pending entry.
- An unreachable peer, an error (its
- Pane (
webviews/.../direct.ts): on_acpmux/cancel_refusedfor the chat it stopped, the pane stops showing "Stopping" and adds the notice "Not stopped: " (new stringqueue.stopRefused, all 21 locales).
Protocol: _acpmux/cancel_refused is registered as a notification (protocol/mod.rs, spec/acpmux-protocol.json, regenerated methods.ts) and documented in docs/acpmux-schema.json.
With #28141 (merged first): the forward sends person_stop::mark first, then cancel_refused::forward. So the person's Stop still carries personStop to a peer that takes it, and a client-written personStop is still stripped. It now goes as a request, and the peer's request arm runs the same guard and person_stop::stop as its notification arm. personStop only narrows what runs after a cancel the link may already send. It never widens who may cancel: the owner rule decides that first.
Threat model
This is socket/auth policy, so risk:high.
- Asset: the person's running turn on a machine, which no one else may interrupt (#21134, D13).
- Who is now allowed: a WebSocket link that presented this launch's peer token. The handshake accepts it only in the
x-acpmux-peer-tokenheader, compared in constant time, from a loopback address (anssh -Wtunnel or a local TLS terminator), alongside the dashboard token (peer_auth.rs). The token is 32 random bytes made at each daemon start, kept only inACPMUX_HOME/run/peer.token(0600), and never returned by any RPC. Anyone who holds it can read this user'sACPMUX_HOME, so they can already stop the chat over the unix socket, kill it, or close it over this same link. Allowing cancel adds no capability. - Guest (dashboard token, browser):
Origin::Webcomes from the handshake, never from the request, so a guest cannot claim Peer. Refused, also when it writes_meta.acpmux.via: "app"orpersonStop: true(tested). - Another member: they reach this daemon as a paired machine (
ConnAuth::Device, served as Peer), or through a peer that forwards for them (via: device/via: web). Both are refused.vm_boundary::owneralready treats a Device link as not the owner, and that does not change when it claimsvia: app(tested). - Forged claims:
viaonly narrows. A peer link that writesvia: deviceorvia: webloses the owner rule. A non-peer link that writesvia: appgains nothing, because the rule first requiresOrigin::Peer+ConnAuth::PeerTokenfrom the handshake. - Replayed claims: the peer token changes at every daemon start, so a token from an earlier run fails the handshake. A connection that proved nothing (
ConnAuth::None) is refused before any request runs, and its notifications never reach a handler (tested). - Mesh relay: its relay connection is
Origin::Peer+PeerToken, but its control operations are start, resume and stop (session/close). It never sendssession/cancel, and the owner'smeshRelay.controlFromallowlist gates it. - Disclosure: the refusal names no host (a test asserts that).
_acpmux/cancel_refusedgoes only to the connection that sent the Stop. Nothing is written to the chat's log, so a guest cannot write into it by sending refused Stops.
Review
-
gpt-6.1-sol at
751fab1912a: BLOCK. The owner predicate itself was found sound (no bypass for a guest, a paired device, or a forgedvia). It raised three issues: a request-then-notify fallback could cancel the next turn; there was one detached task per forwarded Stop; the refusal named the host. All three are fixed in1c34fda486a. -
gpt-6.1-sol at
1c34fda486a: BLOCK. It confirmed those three fixed and raised two more: a peer that never answers could hold the cancel worker for the 600 s request timeout, and theconnection.busyfallback lost its refusal. Both are fixed inbe14eb4eec6, with a test using a fake peer that never answers and one that answers busy. -
gpt-6.1-sol at
be14eb4eec6: SHIP. Two P2s went to follow-up #28286. -
gpt-6.1-sol, delta review of the merge with #28141 plus the new
peer_person_stoptest, diff against merge-base6225e77e165(three-dot): SHIP at6c9583f387a5682c2fbef4c869f09cc784b09fc1, no findings.
Tests
cmux-tui/crates/acpmux/tests/cancel_owner.rs (new):
- The owner's peer link stops a local-origin chat, as notification and request, for itself,
via: app, andvia: app+personStop. - A guest, a guest claiming
via: apporpersonStop, a paired device, a paired device claimingvia: app, and a peer forwardingvia: web/via: device(with and withoutpersonStop) are all refused withremote.session_cancel_ownershipand a message that says only the owner can stop it and does not name the host. The notification form gets_acpmux/cancel_refused, and the turn keeps running. - A link that proved nothing stops nothing.
- Two real daemons. B runs a chat it started itself. A's app (
LocalApp) and A's CLI stopb/lane, and B's turn ends. - A fake peer that never answers, and one that answers
connection.busy: the sender hearspeer.cancel_unanswered/connection.busy, and the peer gets the Stop exactly once. - Two real daemons. A's Web client stops
b/lane, B refuses, and the Web client receives_acpmux/cancel_refused {sessionId: "b/lane", peer: "b", reason}. - Unit test in
cancel_owner.rs: the owner predicate over everyConnAuthandvia.
tests/peer_person_stop.rs (#28141's): its two-daemon hold test now also runs against a lane B started itself, the case only the owner rule lets A stop. A's person's Stop, as notification and as request, reaches B with personStop, passes the owner rule, and holds the other agents' messages there.
Changed: tests/remote_guard.rs (the Peer case moved to cancel_owner.rs) and tests/session_control_peer_cli.rs. The CLI's session cancel b/remote-cancel from the owner's machine now succeeds, as session stop does.
webviews/src/agent-session/acpmux/stopping.test.ts: a refused Stop ends Stopping and adds "Not stopped: ". Another chat's refusal changes nothing.
Red on the test commits and green on the head: runs below.
Observed / Not run
- Observed: the cmux-ci runs linked below.
- Not run: a live Mac app against a real lane VM. The daemon side is covered by two in-process daemons over a real WebSocket peer link, and the pane by the webview test.
Changelog
- Fixed: Stop on a chat that runs on another machine of yours now stops it, and a Stop that cannot be taken says why instead of doing nothing.
No capture: the only visible change is one notice line in the transcript, shown in the webview test; no layout changes.
Agent session: agent session
š¤ Generated with Claude Code