acpmux: a refused remote new says why and names the command that works

Merged
TE
12d ago
+922-31

Leo, 2026-10-24, after seeing acpmux new --host refused remotely: "This feels like, like the ergonomics should be improved overall. Like either we improve the commands fundamentally, and we improve the instructions and the error messages and the hints and whatever, or we just, like, do things more automatically so that you don't have to, like, think about this. ... Just make it more cohesive, more nice to use, more useful."

claude-sr2's concrete piece: "from cosmic-gray, acpmux new --host big-red ... --policy approve-all is refused, and the workaround is reach HOST 'acpmux new ...'. Make the remote new work, or make its refusal print the exact command that does."

Before (reproduced from big-red today)

$ acpmux new --host gcp-agent-lane-1 --cwd /home/cmux/hb-acpmux/x -m codex -d
acpmux: usage: cwd is refused: from a WebSocket connection it must be an absolute path to an existing directory
$ acpmux new --host cmux16 --policy approve-all -m codex -d
acpmux: usage: a permission policy other than ask or deny-all is accepted only from the local app or the unix socket, never from a remote WebSocket connection
$ acpmux new --host cmux16 --mode read-only -m codex -d
acpmux: usage: a mode at session start is accepted only from the local app or the unix socket, never from a remote WebSocket connection

None of these said what works instead, and nothing in --json let a caller branch on why.

What changes

The trust rules are unchanged: who may approve, and which policies and modes a remote or CLI caller may start, stay as they were. Only the way out is named, and one safe automatic step is added.

  • Every remote new refusal names its reason (error.data.reason): remote.policy_refused, remote.mode_refused, remote.cwd_missing (with cause: missing, which mkdir -p makes, or unusable), remote.cwd_outside_roots. It also says how the daemon served the caller (servedAs: peer or remote). For a new session the folder is judged before the policy and the mode, so one refusal names every field to change, and nothing is made for a start that is refused anyway (remote_guard.rs, remote_guard/peer_folder.rs).
  • The forwarding daemon adds the command that works (error.data.try, error.data.host, and ; instead: <command> at the end of the message) (server/remote_guard/hint.rs):
    • a peer that serves this machine as a remote client, or a policy it refuses: the same start on that machine itself, where its unix socket accepts it. For an ssh:// peer that is ssh -- DEST 'mkdir -p CWD && acpmux new … --policy approve-all -d', then drive it from here as HOST/NAME. For a ws(s):// peer it is the bare command, with data.runOn: HOST;
    • a peer that serves it as a peer, folder outside its roots: acpmux new --host HOST … --cwd <tryCwd>. The peer picks and checks tryCwd on its own disk: ROOT/<leaf> when nothing is there or it is a real folder, else ROOT. With no root configured, the command has no --cwd (a scratch folder);
    • --mode is dropped from every suggestion, because a remote start never takes one and neither does the host's own CLI. The session starts in the harness's asking mode;
    • a peer on an older build gives no reason, so the refusal is recognised by its message. Such a peer checks the mode before the folder, so its way out is the host's own CLI with no --cwd or --mode. That works against today's fleet before host update.
  • Automatic, without widening trust: the owner's own daemon (a proven peer, Control::Peer) may name a --cwd that doesn't exist yet inside a configured root, and it is made (0700). The make walks down from / with openat(O_DIRECTORY|O_NOFOLLOW) and mkdirat, so a link anywhere on the path, dangling or swapped in meanwhile, fails it. .. below the nearest existing folder is not followed. A Web connection never makes a folder. Only a proven peer is told the configured webRoots and tryCwd; the Web is not told this machine's folders.
  • acpmux allow's refusal says where to approve, puts acpmux deny SESSION in data.try, and notes that a session that should not ask gets its policy when it starts.
  • acpmux new --help for --host and the acpmux skill (§6) now describe the remote rules and point to error.data.try.

Tests

  • tests/remote_new_hints.rs (new, red first: red run, every test failed on base for the missing reason or hint):
    • each reason, servedAs and cause from a Web and a peer connection; only the peer gets the roots and tryCwd, and tryCwd falls back to the root when ROOT/<leaf> is a link; a folder is judged before the policy;
    • a peer's missing folder inside a root is made; .. escapes, links inside the root (dangling or to outside) and folders outside a root are refused, and nothing is made outside; nothing is made for a start the mode check refuses;
    • approve-all refused by a peer that serves us as Web: data.try is the exact on-host command, and that start on the host's unix socket succeeds as approve-all;
    • mode refused: data.try drops --mode, and that command succeeds through the peer;
    • mode plus a folder outside the roots: one data.try fixes both, and that command succeeds, with the session on the peer in that folder.
  • Unit tests: make_below refuses a base folder swapped for a link; flag spelling and shell quoting; ssh only for ssh:// peers (ssh -- DEST); each reason's suggestion, including causes and older peers; recognising an older peer's message.
  • Existing remote_guard, remote_guard_peer, remote_guard_roots, remote_guard_modes, web_mode_drift, remote_guard_refused_families, and peer_integration suites pass. fmt and clippy (-D warnings) are clean.
  • Live: ssh gcp-agent-lane-1 'mkdir -p /tmp/hbrn-x && acpmux new -m codex -n hbrn-ssh --cwd /tmp/hbrn-x --policy approve-all -d' created an approve-all session that big-red sees as gcp-agent-lane-1/hbrn-ssh (then killed).

Size: 896+23 lines, about 300 of them tests. It is one change: the owning daemon's reasons and the forwarding daemon's hint that reads them.

Review

Independent Codex review (gpt-6-astra, xhigh) in its own read-only checkout. Six rounds. BLOCKs covered: folder making through a dangling link, then through an ancestor swapped for a link; unsafe ssh destinations; ssh assumed for ws peers; hints that did not compose across refusals; a stale handshake flag; older peers. Each was fixed with a test. A last round reviewed the split that keeps remote_guard.rs under the god-file ceiling (the hint and folder helpers are now its child modules remote_guard/hint.rs and remote_guard/peer_folder.rs). SHIP at 5c3fde32017f9f5ebab3a7f8d77aa503c0d1a97a.

Stop conditions: no new timer, observer or loop. No capture: CLI and RPC error text only, nothing visible in the app.

Agent session: big-red
🤖 Generated with Claude Code