Skip to content

acpmux: guarded transfer receive for Move to Cloud - #19448

Merged
teamleaderleo merged 12 commits into
feat-cmux-nextfrom
continuity-guarded-receive
Oct 11, 2026
Merged

teamleaderleo merged 12 commits into
feat-cmux-nextfrom
continuity-guarded-receive

Conversation

@teamleaderleo

@teamleaderleo teamleaderleo commented Oct 11, 2026 •

Copy link
Copy Markdown
Collaborator

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 $HOME itself, can't sit inside a hidden folder of $HOME, and can't hold ~/.claude, ~/.codex or acpmux's home. Unknown keys, other control values, empty roots and a non-object guard are refused (transfer.guard).
  • The guard is stored in the receive's guard.json. transfer_write and transfer_finish read it from there, so a later call can't drop it. The receive answer adds guarded: true.

What a guarded move enforces

At receive:

  • Only these bundle paths are accepted: session.json, manifest.json, events/*.ndjson, native/claude-project/.claude/projects/*/*.jsonl and native/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).
  • Terminal agents are refused (transfer.harness).
  • For web, the families the Web never drives (codex, opencode) are refused (transfer.family).

At finish, hub/transfer_guard.rs guard_session rewrites session.json before the import:

  • Folder: 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).
  • Family: recomputed from this daemon's profile. A family claimed in the bundle is ignored.
  • Policy: a chat with no policy gets ask, under either control.
  • Asking checks, web only: the policy must be ask or deny-all and 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.rs session_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.
  • peer keeps 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.
  • Cleared fields: preset, session_env, harness_roots, harness_argv and tags are cleared, and remote_origin is set. As a result the agent starts with no preset args, no cmux MCP servers and, for claude-stdio, inside the remote-chain sandbox.
  • Kept only when valid: model_request stays only if the profile declares that model, and claude_profile only if it's a configured profile.
  • Identity: agent_session_id must match [A-Za-z0-9_-]{8,128} (transfer.native). session.json must name the chat the manifest named (transfer.schema), and from is capped at 4 KiB (transfer.from).

During the import:

  • The queued, queue_updated and turn_started records are rewritten to the guard's control, and web also sets trustGate. So the moved queue is dispatched under the guard's rules, also after a restart (restore_queue reads these records back). A transfer_guarded {control, roots} record is appended.
  • Agent store files are restored only for the exact session id: <sid>.jsonl for Claude, rollout-*-<sid>.jsonl for Codex, and only for the session's own harness label. Any other file refuses the finish.
  • A guarded chat keeps its agent_session_id only 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.
  • Each file is written to a part file beside its target and then linked into place (never over an existing file) or renamed over the moved-away copy's. A failed restore removes the part file and the files it created, and fails the finish (transfer.native) with nothing imported.
  • A moved-away closed copy here is replaced only when its folder is under a root. Otherwise the finish fails with transfer.exists.

Agent store modes: stripped, not refused

A resumed agent can take a permission mode back from its own store:

  • Current Claude Code restores the last permissionMode a transcript records when you resume interactively. claude-agent-acp and -p resumes may not, but that depends on the version.
  • Codex's rollout turn_context records carry the turn's approval_policy and sandbox_policy. Codex doesn't reliably restore them on resume today.

The guarded restore strips these instead of refusing the move:

  • it removes every top-level permissionMode field;
  • it drops records whose type or subtype is turn_context or a permission-mode record;
  • it drops lines that aren't JSON objects.

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:

  • the folder is canonical and under a root;
  • under web, the policy, rules and mode ask, checked on this daemon's own profile and table (under peer the chat keeps the user's own settings, as a peer prompt would);
  • every field that changes what spawns, or what the agent can reach, is cleared or validated;
  • agent store files can't land outside the session's own file;
  • queued prompts can't keep a local control 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). Under peer they 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:

  • queued prompts under the guard's control, refused at dispatch under web after the person sets approve-all, also after a restart;
  • the unguarded move still runs the same prompt;
  • a folder outside the roots, missing, relative or behind a symlink is refused, the source stays frozen, and an abort gives it back;
  • no policy becomes ask; under web, approve-all, approve-edits and auto-approve rules are refused, and under peer the same chats move with their policy and rules;
  • a claimed family is ignored under both controls; a non-asking mode or option is refused under web and kept under peer;
  • a Codex-family chat with a non-asking mode and approve-all policy moves under peer and its held prompt runs with peer control; under web it'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);
  • the agent's spawn env lacks the preset and session env keys, and its mcpServers are empty;
  • agent store restore: exact id only, mode lines stripped; an agent session already here refused with its file kept; no file shipped clears the id; an unwritable folder fails the finish with no part file and nothing imported; a move back replaces its own moved-away copy's file; bad id, wrong label, stray paths and a ninth file refused;
  • under web, a mode-less chat of a family with asking modes is refused, and one of a family without them is taken;
  • guard validation, stickiness, the from cap, session id mismatch, and local socket only;
  • no replacing a moved-away copy outside the roots.

The fake agent takes FAKE_SESSION_PREFIX so its session ids are as long as a real agent's.

All runs are on the fleet with scripts/ci/cmux-tui-rust-check.sh on macOS:

  • Red: test transfer_guard on 9a363c10a625 (plus the compile fix aa4bf9fae679, step f7bb2f433fb8cbd25bd51fe1): 8 failed, 1 passed (the unguarded control). The first assertion that fails is a guarded receive says so: {"receiveId":...,"sessionId":...} (left: Null, right: true): the receive ignored the guard. The restart test failed on a guarded chat with no policy asks (policy: null).
  • Red for the id check added after a self-review: test transfer_guard on 9e07b175d562 (step 16e841ea5f592b083c6ce5e8): 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 with transfer.schema).
  • Red for the peer/web split: test transfer_guard on d0884ee09619 (step 907cf307fcb0efd9685e40a5): 7 passed, 3 failed. Each failure is a peer move refused with transfer.policy: policy approve-all does not ask before it acts or transfer.mode: mode default does not ask before it acts for a fake chat here.
  • Red for the agent session rule, atomic restore and mode-less web chats: test transfer_guard on a68ab1359955 (step f4210ba425834908099434a5): 8 passed, 3 failed. The existing-file finish succeeded instead of transfer.session, the move back kept the stale before the move transcript, and a mode-less web chat was accepted.
  • Green: test transfer_guard on 5cc055f3d6ed (step 11c8460707ea6797a7df0982): 11 passed, 0 failed.
  • Regression: test transfer:: (the existing transfer tests) on 5cc055f3d6ed (step 78102829618f8bb1839fa233): 2 passed, 0 failed.
  • Lint: clippy (step a18a756697a8c9b3af3fcd99) and fmt (step 30df961e2bd66cffbf9509f8) on 5cc055f3d6ed: both pass. scripts/cmux-next/crash_ratchet.py reports no acpmux hits. Its only findings are Swift CmuxNextControl ones, 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

teamleaderleo and others added 6 commits October 11, 2026 07:47
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>
@coderabbitai

coderabbitai Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: Repository: manaflow-ai/cmux/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: 086317f0-b20a-4232-97bf-3cb3abc25761

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

teamleaderleo and others added 6 commits October 11, 2026 08:35
…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
@cursor

cursor Bot commented Oct 11, 2026

Copy link
Copy Markdown

Bugbot is paused — on-demand spend limit reached

Bugbot 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.

@teamleaderleo
teamleaderleo merged commit 7213adf into feat-cmux-next Oct 11, 2026
82 checks passed
@teamleaderleo
teamleaderleo deleted the continuity-guarded-receive branch October 11, 2026 16:56

This branch was previously deployed

1 inactive deployment
artifacts — dbcbb8f9 Deployed Oct 11, 2026 by teamleaderleo via publish tree-addressed cmux-tui #5560
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant