acpmux: a refused remote new says why and names the command that works
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
newrefusal names its reason (error.data.reason):remote.policy_refused,remote.mode_refused,remote.cwd_missing(withcause:missing, whichmkdir -pmakes, orunusable),remote.cwd_outside_roots. It also says how the daemon served the caller (servedAs:peerorremote). 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 isssh -- DEST 'mkdir -p CWD && acpmux new … --policy approve-all -d', then drive it from here asHOST/NAME. For aws(s)://peer it is the bare command, withdata.runOn: HOST; - a peer that serves it as a peer, folder outside its roots:
acpmux new --host HOST … --cwd <tryCwd>. The peer picks and checkstryCwdon its own disk:ROOT/<leaf>when nothing is there or it is a real folder, elseROOT. With no root configured, the command has no--cwd(a scratch folder); --modeis 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--cwdor--mode. That works against today's fleet beforehost update.
- 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
- Automatic, without widening trust: the owner's own daemon (a proven peer,
Control::Peer) may name a--cwdthat doesn't exist yet inside a configured root, and it is made (0700). The make walks down from/withopenat(O_DIRECTORY|O_NOFOLLOW)andmkdirat, 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 configuredwebRootsandtryCwd; the Web is not told this machine's folders. acpmux allow's refusal says where to approve, putsacpmux deny SESSIONindata.try, and notes that a session that should not ask gets its policy when it starts.acpmux new --helpfor--hostand the acpmux skill (§6) now describe the remote rules and point toerror.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,
servedAsandcausefrom a Web and a peer connection; only the peer gets the roots andtryCwd, andtryCwdfalls back to the root whenROOT/<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.tryis the exact on-host command, and that start on the host's unix socket succeeds as approve-all; - mode refused:
data.trydrops--mode, and that command succeeds through the peer; - mode plus a folder outside the roots: one
data.tryfixes both, and that command succeeds, with the session on the peer in that folder.
- each reason,
- Unit tests:
make_belowrefuses a base folder swapped for a link; flag spelling and shell quoting; ssh only forssh://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, andpeer_integrationsuites 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 asgcp-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