Repository navigation
acpmux: guarded transfer receive for Move to Cloud - #19448
Conversation
Behavior tests at the RPC boundary for `_acpmux/transfer_receive` with a
`guard: {control, roots}`: the moved queue runs under the guard's control
(also after a restart), the folder must be under a root, policy, rules and
mode must ask, preset, session env, store roots and cmux tools are dropped,
the agent store comes back only for its exact session id without its mode,
the guard is sticky and local-socket only, and a moved-away copy outside
the roots is never replaced. The fake agent takes FAKE_SESSION_PREFIX for
real-length session ids.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`_acpmux/transfer_receive {manifest, guard: {control, roots}}` takes a chat
from a daemon this one does not take as its own user. The guard is checked
(existing folders strictly under $HOME, not hidden, not holding an agent
store or acpmux's home) and stored with the receive, so write and finish
are held to it.
A guarded receive takes only the bundle's own files and at most 8 agent
store files, never a terminal agent, and for `web` never a refused family.
A guarded finish rewrites session.json before the import: the folder must
canonicalize under a root, the family comes from this daemon's profile,
policy (none is ask), rules, mode and options must ask (refused, not
downgraded), preset, session env, store roots, argv and tags are cleared,
remote_origin is set, and the agent session id must be plain. Queued,
queue_updated and turn_started records carry the guard's control (`web`
also gates on folder trust) and a transfer_guarded record is logged. The
agent store comes back only for the exact session id, never over a file,
without the records that set a permission mode (Claude permissionMode,
Codex turn_context). A moved-away copy outside the roots is never
replaced. Unguarded moves are unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d for Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configuration
You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…king checks Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The to-cloud direction (peer) moves the person's own chat to their own machine, and a peer's prompts have no mode rule at dispatch: only a web guard requires an asking policy, rules, mode and options now. Every other check stays for both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…web chat must have a mode Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A guarded finish keeps the agent session id only with its own agent file from the bundle. A file already here refuses the move (transfer.session), unless it is the session of the chat's own moved-away copy, which the file replaces. Files go in through a part file beside the target (linked, or renamed over the moved-away copy's), and a failed restore fails the import instead of answering nativeRestored 0. Under web, a chat of a family with asking modes must have a recorded mode: dispatch takes a mode-less chat's Web prompt on its policy alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ty-guarded-receive # Conflicts: # cmux-tui/crates/acpmux/tests/fake_agent.py
…ty-guarded-receive # Conflicts: # cmux-tui/crates/acpmux/src/hub/transfer_in.rs
Bugbot is paused — on-demand spend limit reachedBugbot uses usage-based billing for this team and has hit its on-demand spend limit. A team admin can raise the spend limit in the Cursor dashboard, or wait for the next billing cycle to continue. |
Summary
Move to Cloud needs to hand a chat to a daemon on another machine without that machine's side getting the local user's powers. Today
acpmux.transfer.v1(#19359) trusts the bundle: the moved queue runs with the control each prompt was recorded under, and the chat keeps its preset, session env, policy, mode and folder. This PR adds a guarded receive to acpmux (cmuxterm-hq#1874 slice 1, PR 1a). Nothing calls it yet; the daemon verbs (1b) and the app pump (1c) come next. Unguarded moves are unchanged.API
_acpmux/transfer_receive {manifest, guard: {control: "peer" | "web", roots: [abs, ...]}}. It's still served only on the local Unix socket (every WebSocket origin gets "Method not found").roots: each one must canonicalize to an existing folder strictly under$HOME. A root can't be$HOMEitself, can't sit inside a hidden folder of$HOME, and can't hold~/.claude,~/.codexor acpmux's home. Unknown keys, othercontrolvalues, empty roots and a non-object guard are refused (transfer.guard).guard.json.transfer_writeandtransfer_finishread it from there, so a later call can't drop it. The receive answer addsguarded: true.What a guarded move enforces
At receive:
session.json,manifest.json,events/*.ndjson,native/claude-project/.claude/projects/*/*.jsonlandnative/codex-rollout/.codex/sessions/**.jsonl. Anything else is refused (transfer.path), and at most 8 agent store files totalling at most 1 GiB are allowed (transfer.native).transfer.harness).web, the families the Web never drives (codex, opencode) are refused (transfer.family).At finish,
hub/transfer_guard.rsguard_sessionrewritessession.jsonbefore the import:cwd(the param, else the chat's own) must canonicalize to a folder under a root. It's written back canonical, so a symlink that points out of the roots is refused (transfer.cwd).familyclaimed in the bundle is ignored.ask, under either control.webonly: the policy must beaskordeny-alland the rules must not auto-approve (transfer.policy). The mode, and every config option that isn't a model or effort choice, must be listed as asking for the chat's family in this daemon's table, and the family must be one the Web drives (transfer.mode). A chat with no recorded mode is refused too when its family has asking modes: dispatch (remote_floor.rssession_asks_now) takes a mode-less chat's Web prompt on its policy alone, so the guard can't rely on it. A family with no asking modes may arrive mode-less, held to its asking policy. Anything else is refused, not downgraded.peerkeeps its policy, rules and mode. Peer is the to-cloud direction: the source is the user's own Mac, the target is their own sandbox VM, and a peer's prompts have no mode rule at dispatch. Every other check in this list applies under both controls.preset,session_env,harness_roots,harness_argvandtagsare cleared, andremote_originis set. As a result the agent starts with no preset args, no cmux MCP servers and, for claude-stdio, inside the remote-chain sandbox.model_requeststays only if the profile declares that model, andclaude_profileonly if it's a configured profile.agent_session_idmust match[A-Za-z0-9_-]{8,128}(transfer.native).session.jsonmust name the chat the manifest named (transfer.schema), andfromis capped at 4 KiB (transfer.from).During the import:
queued,queue_updatedandturn_startedrecords are rewritten to the guard's control, andwebalso setstrustGate. So the moved queue is dispatched under the guard's rules, also after a restart (restore_queuereads these records back). Atransfer_guarded {control, roots}record is appended.<sid>.jsonlfor Claude,rollout-*-<sid>.jsonlfor Codex, and only for the session's own harness label. Any other file refuses the finish.agent_session_idonly with its own file from the bundle; with none, the id is cleared and its agent starts fresh with its acpmux history. So a remote chat can never resume an agent session that already lives on this machine. A target file that already exists refuses the finish (transfer.session), unless it belongs to this chat's own moved-away copy (same chat id and session id, folder under a root), whose file is replaced.transfer.native) with nothing imported.transfer.exists.Agent store modes: stripped, not refused
A resumed agent can take a permission mode back from its own store:
permissionModea transcript records when you resume interactively. claude-agent-acp and-presumes may not, but that depends on the version.turn_contextrecords carry the turn'sapproval_policyandsandbox_policy. Codex doesn't reliably restore them on resume today.The guarded restore strips these instead of refusing the move:
permissionModefield;typeorsubtypeisturn_contextor a permission-mode record;The history the agent resumes stays intact, and the mode it runs in comes from acpmux.
Security reasoning
The caller (the 1b daemon verbs) builds the guard itself. This PR makes sure the bundle's contents can't widen it:
web, the policy, rules and mode ask, checked on this daemon's own profile and table (underpeerthe chat keeps the user's own settings, as a peer prompt would);localcontrol from the source.The guard is sticky, so a later finish without it is still held to it. Nothing becomes reachable from a WebSocket origin.
One limit, as designed: under
web(bringing a chat back to the Mac), a chat whose mode isn't in the asking table is refused, not downgraded. Codex chats can't be brought back guarded until a Codex asking mode exists (follow-up); they are refused at receive (transfer.family). Underpeerthey move.Testing
The new behavior tests are in
cmux-tui/crates/acpmux/tests/hub_integration/transfer_guard.rs. Each one moves a chat between two in-process daemons over the RPC boundary:webafter the person sets approve-all, also after a restart;ask; underweb, approve-all, approve-edits and auto-approve rules are refused, and underpeerthe same chats move with their policy and rules;weband kept underpeer;peerand its held prompt runs with peer control; underwebit's refused at receive (transfer.family), and a web-driven family with a non-asking mode or approve-all policy is refused at finish (transfer.mode/transfer.policy);mcpServersare empty;web, a mode-less chat of a family with asking modes is refused, and one of a family without them is taken;fromcap, session id mismatch, and local socket only;The fake agent takes
FAKE_SESSION_PREFIXso its session ids are as long as a real agent's.All runs are on the fleet with
scripts/ci/cmux-tui-rust-check.shon macOS:test transfer_guardon9a363c10a625(plus the compile fixaa4bf9fae679, stepf7bb2f433fb8cbd25bd51fe1): 8 failed, 1 passed (the unguarded control). The first assertion that fails isa guarded receive says so: {"receiveId":...,"sessionId":...}(left: Null, right: true): the receive ignored the guard. The restart test failed ona guarded chat with no policy asks(policy: null).test transfer_guardon9e07b175d562(step16e841ea5f592b083c6ce5e8): 8 passed, 1 failed (a_guard_is_checked_kept_by_the_receive_and_taken_only_on_the_local_socket: the finish imported the other chat instead of refusing withtransfer.schema).test transfer_guardond0884ee09619(step907cf307fcb0efd9685e40a5): 7 passed, 3 failed. Each failure is a peer move refused withtransfer.policy: policy approve-all does not ask before it actsortransfer.mode: mode default does not ask before it acts for a fake chat here.test transfer_guardona68ab1359955(stepf4210ba425834908099434a5): 8 passed, 3 failed. The existing-file finish succeeded instead oftransfer.session, the move back kept the stalebefore the movetranscript, and a mode-less web chat was accepted.test transfer_guardon5cc055f3d6ed(step11c8460707ea6797a7df0982): 11 passed, 0 failed.test transfer::(the existing transfer tests) on5cc055f3d6ed(step78102829618f8bb1839fa233): 2 passed, 0 failed.clippy(stepa18a756697a8c9b3af3fcd99) andfmt(step30df961e2bd66cffbf9509f8) on5cc055f3d6ed: both pass.scripts/cmux-next/crash_ratchet.pyreports no acpmux hits. Its only findings are SwiftCmuxNextControlones, and it reports the same findings on the base tree.Not run:
cargo check --target x86_64-pc-windows-gnu, because no Testbox was available. This PR relies on its Windows build lane for that. The full acpmux suite didn't run on the fleet; only the two filters above did.Changelog
none (there's no user entry point yet; the daemon verbs and the app pump follow)
🤖 Generated with Claude Code