cmux-tui: agent-transfer-v1 forwards chat moves to acpmux with the daemon's own guard (cx-rppd.1)
Summary
Move to Cloud (cmuxterm-hq#1874 slice 1, bead cx-rppd.1) needs to move an agent chat from the Mac's acpmux to a Cloud VM's acpmux and back. acpmux's acpmux.transfer.v1 calls (#19359) only work on acpmux's local socket, and an unguarded receive would let the moved chat pick its own folder, preset and policy. This PR adds daemon verbs that forward those calls to the daemon's own acpmux, with a receive guard the daemon builds itself. The guarded receive in acpmux is the parallel PR "1a" (_acpmux/transfer_receive {manifest, guard} answering guarded:true); these tests use a fake acpmux, so they don't depend on 1a landing.
New capability agent-transfer-v1, advertised when the daemon has an acpmux socket path and knows its host (the binary sets the host next to the fs-v1 Cloud host test in link::start_link_entry). Spec: cmux-tui/spec/commands.md, "Agent chat transfer".
Target verbs (the chat arrives here):
agent-transfer-receive {manifest, cwd}sends_acpmux/transfer_receive {manifest, guard}. On a Cloud host the guard is{control:"peer", roots:[canonical $HOME/work]}. On any other host (a Mac taking a chat back) it's{control:"web", roots:[cwd]}, andcwdmust canonicalize to an existing folder strictly under$HOMEthat isn't$HOMEor inside a dot folder of$HOME. On every hostcwdmust canonicalize under the roots before anything is forwarded. If acpmux doesn't answerguarded:true, the receive is refused (agent_transfer.unguarded). The daemon keeps the canonical folder, and the move and chat ids from the manifest, perreceive_id. It keeps at most 16 receives, and that cap is checked before acpmux makes a receive, so a refused receive leaves nothing behind in acpmux.- Machine kind (hq-84). Receive and finish read
agent-session-start's host gate (AgentStartHostSource) again on every call. They refuse on a team VM withagent_session.team_vm_blocked, and on a Cloud VM of unknown kind (or a daemon never told its kind) withagent_session.host_unverified, the same codes start uses. This matters because a peer-control chat keeps its permission policy and mode and runs its queue at once. On a shared work user, other members could read its history. So peer-control receives are limited to Macs, SSH hosts and personal (owner) Cloud VMs. agent-transfer-write {receive_id, path, offset, data}sends one page of a receive this daemon guarded.agent-transfer-finish {receive_id, name?}sends_acpmux/transfer_finishwith the receive's own folder ascwd. A finish that fails for good drops the receive: anything but a timeout, an unreachable or busy acpmux, ortransfer.incomplete.agent-transfer-receive-abort {receive_id}drops the receive and forwards acpmux's target-side_acpmux/transfer_forget {transferId, sessionId}(#19411). The ids come from the receive's own manifest, never from the frame. acpmux then deletes the receive's files and refuses that move afterwards. The answer is{dropped, forgotten, landed}. acpmux's forget is not exposed as a daemon verb.
Source verbs (the chat leaves here):
agent-transfer-begin {surface, interrupt?},agent-transfer-commit {surface, transfer_id, to}andagent-transfer-abort {surface, transfer_id}take the session id only from the store tab record ofsurface, the same wayagent-session-attachdoes.- Begin serves scrubbed copies of two bundle files, and the manifest lists their scrubbed sizes:
manifest.json, without the absolutesourcepaths of native files.session.json, without the source machine's launch settings (sessionEnv,harnessArgv,harnessRoots,preset,tags). Itscwdstays, because the target's import needs a folder and finish replaces it.- These two names are compared case-insensitively.
- If either file can't be scrubbed, the move is aborted, so the chat doesn't stay frozen.
- The events logs still carry local paths from the source machine. That's accepted, because the guarded finish never runs them.
agent-transfer-read {transfer_id, path, offset, limit?}serves only paths the begin manifest of a move begun through this daemon lists, exactly as listed. So on a case-insensitive disk,Session.jsoncan't reach the unscrubbed file.- Abort forgets the move even when acpmux refuses the abort.
A frame is refused before anything reaches acpmux if it carries guard, roots, control, env, mcpServers, sessionId/session_id, any of the relay gate's COMMAND_PARAMS, or any unknown param or verb. Pages are capped at 4 MiB of decoded data. Calls run on worker threads, at most 8 at once. A move entry is remembered for 24 h. agent-transfer-* client lines use the bulk mux lane in cmux-remote.
Receive ids are not tied to the connection that made them: another trusted local connection of the same user can write, finish or abort a receive, for example after the pump reconnects. That grants nothing new. The folder comes from the daemon's own record of the receive, never from the frame, and acpmux checks it again against the guard it stored with the receive.
Small supporting changes:
agent_session_linkitems are nowpub(crate), so this module can reuseAcpmuxLink.agent_session_startexposes its host gate asMux::agent_host_gate.- The attach tests' fake acpmux now answers
_acpmux/transfer_*. cloud_fssplits its Cloud host test out ofinstall_if_cloud_host.server.rsstays within its god-file baseline after the base merge: the module ispub, and its route chains onto the attach route.
Relay and authorization analysis
Callers. Only a trusted same-uid Unix client that isn't a remote client can use these verbs. This is the same check as agent-session-attach: is_unix && !is_remote_client. On the legacy Cloud link, the app reaches the VM daemon exactly this way (cmux-remote mux-control opens a plain Unix connection to the daemon socket). So this PR adds no entry to the remote relay allowlist (remote_relay/gate.rs ALLOWED_COMMANDS). A cmux link client gets remote_denied. WebSocket clients, stamped clients and unregistered ids all get one generic agent_transfer.not_trusted answer before their frame is parsed.
Local command or content execution. Finish imports a chat whose queued prompts then run through an agent that's already configured on this machine. The guard narrows this:
- No preset, env, argv, MCP servers or tags. acpmux clears them under the guard (1a), and this daemon refuses frames that try to pass them and scrubs them out of
session.json. - The folder must be under roots the daemon chose from its own host.
- Under web control (toward a Mac), the policy, rules and modes must ask, and acpmux refuses the import otherwise (1a).
- Under peer control (toward a VM), the chat keeps its policy and mode. That's why receive is limited by the machine kind gate to personal Cloud VMs: team VMs and VMs of unknown kind are refused.
No verb takes a command. Command-bearing params are refused on every verb.
Access to unowned objects.
- A receive creates a new chat. acpmux refuses an id that's already present, unless that copy moved away from this machine earlier.
- Write, finish and receive-abort only work on receive ids this daemon created and guarded. The forget that receive-abort sends names only that receive's own move.
- The source verbs are scoped by
surfaceto agent tabs of this store that have a bound session. An unknown surface, a non-agent surface and an unbound tab all get the sameagent_transfer.unknown_tabanswer. - Commit and abort send the tab's session together with the transfer id, and acpmux checks the move.
- Read only accepts moves begun through this daemon, and only paths their manifest lists.
Local state exposure. Begin and read hand out a whole chat (log, draft, native store copy), but only for a tab-bound chat and only to a trusted local client. That client can already attach to the chat and read its full history. The absolute native source paths and the session.json launch settings are removed. session.json keeps its cwd, and the events logs keep source paths; the guarded finish runs neither.
Policy tests (in agent_transfer_tests.rs):
the_remote_relay_denies_every_agent_transfer_verbandevery_verb_is_refused_on_every_untrusted_connectiona_client_guard_roots_control_env_session_or_command_param_is_refusedreceive_abort_has_acpmux_forget_that_move_only(acpmux's forget isn't a verb, and no frame can name a move)a_folder_outside_the_roots_is_refused_before_acpmuxa_receive_is_refused_on_a_team_vm_and_on_a_cloud_vm_of_unknown_kind(and a personal Cloud VM is allowed),a_daemon_that_was_never_told_its_host_kind_refuses_a_receiveandfinish_reads_the_host_kind_againan_unmapped_or_unbound_surface_gets_the_unknown_answer,read_takes_only_bundle_paths_of_a_move_this_daemon_beganandread_serves_only_paths_the_begin_manifest_listswrite_and_finish_take_only_a_receive_this_daemon_guardedandan_acpmux_that_did_not_apply_the_guard_is_refused- Slot handling:
abandoned_receives_free_their_slots_once_aborted,a_finish_that_cannot_succeed_drops_its_receive_and_a_retryable_one_keeps_itandabort_forgets_the_move_even_when_acpmux_refuses_it - Scrubbing:
begin_strips_absolute_source_paths_from_the_bundle_manifestandbegin_scrubs_launch_settings_from_session_json_and_keeps_its_folder - Allow cases: the Cloud and Mac receive tests,
write_and_finish_forward_the_receive_and_finish_uses_its_folder,source_verbs_take_the_chat_from_the_surface_tabandpages_over_four_mebibytes_are_refused
Testing
All runs are on the cmux build fleet (scripts/ci/cmux-tui-rust-check.sh, macOS), using --arg test --arg agent_transfer unless noted.
First pass:
- Red at
55b5789ea34c(tests plus a stub module), step80524648138dbb4da1249383: 15 failed, 1 passed. - Green at
dce31080889c, step5b6ef14cee157bdb6ba98b11: 16 passed.
Review round (team VM gate, slots, scrubbing, listed paths):
- Red at
99d77219e810, stepafbd25c9987c4d707789fefd: 11 failed, 13 passed. Example: the team VM receive answeredok:truewithreceive_id rcv_1. - Green at
4b47dd1247bc, step8eff23528e100fe738f51919: 24 passed.agent_session_attach(step9028e5010696e416d6ce860d) passed 22, and clippy and fmt both passed.
After merging feat-cmux-next at 886962c91a79 (with #19411, transfer_forget):
- Red at
d9f6e113e27a(test that receive-abort forwardstransfer_forget), stepf9406c1f54cc3fadee85b612: 1 failed (receive_abort_has_acpmux_forget_that_move_only, thelandedfield), 24 passed. - Green at
b2a910c0cb22, stepfb2753ff3bd24068f9c69e7a: 25 passed, plusmux_lanes::tests::agent_transfer_pages_use_the_bulk_lane. agent_session_attachatb2a910c0cb22, step8242277c7593dd0fd112953e: 22 passed.- Clippy (step
c3a7456aeb84f30fe96b4487) and fmt (step94e2d8ad0bb59c022b629cf5) both passed.
Local checks:
cmux-tui/scripts/check-spec-inventory.py: ok.scripts/cmux-next/check-no-godfiles.sh --only rust --base 886962c91a79: ok.scripts/cmux-next/crash_ratchet.py: no Rust crate gained a hit. It reports Swiftasync_closure_default_arggains in CmuxNextAgentPane and CmuxNextApp, but this branch changes no Swift files.
Not verified here:
- No Testbox run of
cargo check --target x86_64-pc-windows-gnu. The new code iscfg(unix), and I'm relying on this PR's Windows lane. - No live end-to-end move. That needs 1a (guarded acpmux) and 1c (the app pump). Until 1a lands, a real acpmux answers a receive without
guarded:true, so this daemon refuses it (fail closed).
Changelog
none
Proof
None needed: daemon protocol only, with no UI change.
🤖 Generated with Claude Code
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.
Summary by cubic
Adds agent-transfer-v1, daemon verbs that forward acpmux chat moves with a guard the daemon builds itself, letting a trusted local client move an agent chat between a Mac and a Cloud VM. The guard stops the moved chat from picking its own folder, preset, or policy, and unguarded acpmux answers are refused.
Guard
- Cloud hosts receive with
peercontrol rooted at$HOME/work; other hosts withwebcontrol at acwdthat must be an existing folder strictly under$HOME, outside dot folders. - Receives are refused at receive and again at finish on team VMs and on Cloud VMs of unknown kind, following
agent-session-start's gate; a peer-control chat keeps its policy and mode. - Frames carrying a client guard, roots, control, env, MCP, session, or command params are refused before reaching acpmux, and only trusted local clients reach these verbs.
Verbs
agent-transfer-receive/write/finishforward_acpmux/transfer_*, and only the receive's own folder is usable;begin/commit/abort/readtake the session from the surface's tab record, and begin strips absolute source paths and launch settings from the bundle manifest.agent-transfer-receive-abortforwards acpmux's target-side_acpmux/transfer_forgetwith the move's ids from the receive's own manifest, so acpmux drops the receive's files at once; acpmux's forget is not a daemon verb.- Receive slots cannot leak: the cap is checked before acpmux makes a receive, a failed or abandoned receive releases its slot, and abort forgets the move even when acpmux refuses.
- Pages are capped at 4 MiB and use the bulk mux lane so they never queue ahead of PTY input.