claude-vm-shim: intercept remote-control cloud sessions too

`claude remote-control` does NOT use the `--bg-spare` path we built around.
For each cloud-opened session it spawns:

  claude --print --sdk-url https://api.anthropic.com/v1/code/sessions/cse_… \
         --session-id cse_… --input-format stream-json --output-format \
         stream-json --replay-user-messages

A completely different protocol — JSON-stream over stdio, no UDS handshake.
Our original argv[1]=="--bg-spare" rule didn't catch it.

Shim: add a second match for argv[1]=="--print" && any(arg=="--sdk-url").
Specific enough that it won't fire on ordinary `claude --print "hi"`.

Dispatcher: add `run_vm_cloud_session()` — much simpler than the bg-spare
path. No UDS bind, no TCP listener, no claim-frame translation. Just spawn
`agent-vm claude --no-git -- <forwarded args>` with stdio inherited and
propagate the in-VM claude's exit code back to the rc parent. The
stream-json protocol flows end-to-end through inherited stdin/stdout/stderr.

install.sh: also use `-f` (not `-x`) for the .so check; copy the wrapper
script into /opt/claude-vm-shim/bin so the install is self-contained.

Verified end-to-end: `claude-shimmed remote-control --spawn=same-dir`
attaches a cloud session inside a fresh agent-vm; the rc TUI shows the
in-VM-generated session name; claude.ai/code/session_… shows "Attached".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Evgeny Boger 2026-05-27 21:14:28 +00:00
parent a82552244e
commit 175578bc71
3 changed files with 162 additions and 34 deletions

View file

@ -105,40 +105,64 @@ fn main() {
passthrough_local_claude(original_claude, forwarded);
}
// Expect argv[2] == "--bg-spare", argv[3] == claim sock path.
if forwarded.first().map(|s| s.as_os_str()) != Some(OsStr::new("--bg-spare")) {
debug_log("argv[2] is not --bg-spare; passing through");
passthrough_local_claude(original_claude, forwarded);
}
let claim_sock_host = match forwarded.get(1) {
Some(s) => PathBuf::from(s),
None => {
debug_log("missing claim sock path; passing through");
passthrough_local_claude(original_claude, forwarded);
// Two modes:
// * `--bg-spare <sock>` → claim-sock UDS handshake (claude --bg path)
// * `--print --sdk-url <url> …` → JSON-stream over stdio (remote-control)
let mode_arg = forwarded.first().map(|s| s.as_os_str());
if mode_arg == Some(OsStr::new("--bg-spare")) {
let claim_sock_host = match forwarded.get(1) {
Some(s) => PathBuf::from(s),
None => {
debug_log("missing claim sock path; passing through");
passthrough_local_claude(original_claude, forwarded);
}
};
let extra_args: Vec<OsString> = forwarded.iter().skip(2).cloned().collect();
if !extra_args.is_empty() {
debug_log(&format!(
"warn: {} extra args after claim sock (unused in VM mode): {:?}",
extra_args.len(),
extra_args
));
}
match run_vm_session(&claim_sock_host) {
Ok(()) => {
debug_log("VM session finished cleanly");
std::process::exit(0);
}
Err(e) => {
debug_log(&format!(
"VM session failed: {e}; falling back to passthrough"
));
eprintln!("claude-vm-dispatcher: {e}");
passthrough_local_claude(original_claude, forwarded);
}
}
};
let extra_args: Vec<OsString> = forwarded.iter().skip(2).cloned().collect();
if !extra_args.is_empty() {
debug_log(&format!(
"warn: {} extra args after claim sock (unused in VM mode): {:?}",
extra_args.len(),
extra_args
));
}
// Run the VM session.
match run_vm_session(&claim_sock_host) {
Ok(()) => {
debug_log("VM session finished cleanly");
std::process::exit(0);
}
Err(e) => {
debug_log(&format!("VM session failed: {e}; falling back to passthrough"));
eprintln!("claude-vm-dispatcher: {e}");
passthrough_local_claude(original_claude, forwarded);
if mode_arg == Some(OsStr::new("--print"))
&& forwarded.iter().any(|s| s == "--sdk-url")
{
// Cloud session opened via `claude remote-control`. No UDS — just
// pipe stdio through agent-vm into an in-VM claude that speaks the
// same `--print --sdk-url ...` protocol.
match run_vm_cloud_session(original_claude.clone(), &forwarded) {
Ok(code) => std::process::exit(code),
Err(e) => {
debug_log(&format!(
"cloud session failed: {e}; falling back to passthrough"
));
eprintln!("claude-vm-dispatcher: {e}");
passthrough_local_claude(original_claude, forwarded);
}
}
}
debug_log(&format!(
"no known intercept mode (argv[2]={:?}); passing through",
mode_arg.map(|s| s.to_string_lossy().into_owned())
));
passthrough_local_claude(original_claude, forwarded);
}
fn run_vm_session(claim_sock_host: &std::path::Path) -> std::io::Result<()> {
@ -343,6 +367,90 @@ fn run_vm_session(claim_sock_host: &std::path::Path) -> std::io::Result<()> {
Ok(())
}
/// Cloud-session mode (`claude remote-control` dispatch).
///
/// The host's `remote-control` parent already spawned us with the cloud
/// session arguments — `--print --sdk-url <cse url> --session-id <id>
/// --input-format stream-json --output-format stream-json [...]`. Our job
/// is to run the *same* `claude` command inside a fresh agent-vm, piping
/// our inherited stdio through to it.
///
/// Returns the in-VM claude's exit code so the rc parent sees a faithful
/// result.
fn run_vm_cloud_session(
original_claude: PathBuf,
forwarded: &[OsString],
) -> std::io::Result<i32> {
debug_log(&format!(
"cloud session begin: {} forwarded args",
forwarded.len()
));
// Per-dispatcher VM workdir so each session gets its own state dir.
let workdir_root = std::env::var("CLAUDE_VM_SHIM_WORKDIR_ROOT")
.unwrap_or_else(|_| "/tmp/claude-vm-shim-work".to_string());
let vm_workdir = format!("{workdir_root}/{}", std::process::id());
let _ = std::fs::create_dir_all(&vm_workdir);
let agent_vm = match std::env::var("CLAUDE_VM_SHIM_AGENT_VM_BIN") {
Ok(p) if !p.is_empty() => p,
_ => {
let candidates = [
"/opt/claude-vm-shim/bin/agent-vm",
"/usr/lib/node_modules/@wirenboard/agent-vm/node_modules/@wirenboard/agent-vm-linux-x64/bin/agent-vm",
"/usr/local/lib/node_modules/@wirenboard/agent-vm/node_modules/@wirenboard/agent-vm-linux-x64/bin/agent-vm",
"/usr/bin/agent-vm",
"/usr/local/bin/agent-vm",
];
candidates
.iter()
.find(|p| std::path::Path::new(p).is_file())
.map(|p| p.to_string())
.unwrap_or_else(|| "agent-vm".to_string())
}
};
let msb_path = std::env::var("CLAUDE_VM_SHIM_MSB_PATH").unwrap_or_else(|_| {
"/usr/lib/node_modules/@wirenboard/agent-vm/node_modules/@wirenboard/agent-vm-linux-x64/bin/msb".to_string()
});
// Forward the verbatim claude argv to `agent-vm claude -- <args>`. Drop
// argv[0]; agent-vm prepends `claude` itself.
let mut cmd = Command::new(&agent_vm);
cmd.env("AGENT_VM_ALLOW_LOCAL_EGRESS", "1")
.env("MSB_PATH", &msb_path)
.arg("claude")
.arg("--no-git")
.current_dir(&vm_workdir)
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.arg("--");
for a in forwarded {
cmd.arg(a);
}
debug_log(&format!(
"spawning agent-vm claude --no-git -- {:?} (cwd={vm_workdir})",
forwarded
.iter()
.map(|a| a.to_string_lossy().into_owned())
.collect::<Vec<_>>()
));
let _ = original_claude; // (currently unused; reserved for path translation)
let mut child = cmd
.spawn()
.map_err(|e| std::io::Error::other(format!("spawn agent-vm: {e}")))?;
debug_log(&format!("agent-vm child pid={}", child.id()));
let status = child
.wait()
.map_err(|e| std::io::Error::other(format!("wait for agent-vm: {e}")))?;
let code = status.code().unwrap_or(1);
debug_log(&format!("cloud session exit: {status:?} → code={code}"));
Ok(code)
}
fn accept_uds_with_timeout(
listener: &UnixListener,
timeout: Duration,

View file

@ -19,8 +19,11 @@ bindir="${HOME}/.local/bin"
shim="${here}/target/release/libclaude_vm_shim.so"
dispatcher="${here}/target/release/claude-vm-dispatcher"
bridge="${here}/target/release/claude-vm-bridge"
if [ ! -x "$shim" ] || [ ! -x "$dispatcher" ] || [ ! -x "$bridge" ]; then
# .so doesn't need the exec bit; bins do. Use -f for the lib and -x for bins.
if [ ! -f "$shim" ] || [ ! -x "$dispatcher" ] || [ ! -x "$bridge" ]; then
echo "build artifacts missing — run \`cargo build --release\` from ${here} first" >&2
echo "found:" >&2
ls -la "$shim" "$dispatcher" "$bridge" 2>&1 | sed 's/^/ /' >&2
exit 1
fi
@ -41,6 +44,9 @@ mkdir -p "$target_root/bin" "$target_root/lib" "$bindir"
install -m 0755 "$shim" "$target_root/lib/libclaude_vm_shim.so"
install -m 0755 "$dispatcher" "$target_root/bin/claude-vm-dispatcher"
install -m 0755 "$bridge" "$target_root/bin/claude-vm-bridge"
# Copy the wrapper into the install prefix so we don't depend on source-tree
# permissions (overlay FS sometimes strips the exec bit).
install -m 0755 "${here}/wrapper/claude-wrapper.sh" "$target_root/bin/claude-wrapper.sh"
# Render the wrapper with bound paths so users can run it without setting envs.
cat > "$target_root/bin/claude-shimmed" <<EOF
@ -48,7 +54,7 @@ cat > "$target_root/bin/claude-shimmed" <<EOF
export CLAUDE_VM_SHIM_REAL_CLAUDE="${real_claude}"
export CLAUDE_VM_SHIM_SO="${target_root}/lib/libclaude_vm_shim.so"
export CLAUDE_VM_SHIM_DISPATCHER="${target_root}/bin/claude-vm-dispatcher"
exec "${here}/wrapper/claude-wrapper.sh" "\$@"
exec "${target_root}/bin/claude-wrapper.sh" "\$@"
EOF
chmod 0755 "$target_root/bin/claude-shimmed"

View file

@ -106,12 +106,21 @@ impl CArgv {
/// Returns Some((dispatcher_path, new_argv)) if this exec call should be
/// redirected through the dispatcher. The new_argv is: [dispatcher, original_path, original_argv[1..]].
fn maybe_redirect(path_c: &CStr, argv: &[CString]) -> Option<(CString, Vec<CString>)> {
// Match: argv[1] == "--bg-spare".
if argv.len() < 2 {
return None;
}
let arg1 = argv[1].to_string_lossy();
if arg1 != "--bg-spare" {
// Match either:
// * `claude --bg-spare <sock>` (the `claude --bg` machinery), OR
// * `claude --print --sdk-url <url> --session-id <id> …` (cloud sessions
// opened via `claude remote-control`).
//
// The two paths use very different protocols (UDS handshake vs. JSON-stream
// over stdio) but both need to land inside a fresh agent-vm.
let is_bg_spare = arg1 == "--bg-spare";
let is_cloud_session = arg1 == "--print" && argv.iter().any(|a| a.to_bytes() == b"--sdk-url");
if !is_bg_spare && !is_cloud_session {
return None;
}
@ -119,10 +128,15 @@ fn maybe_redirect(path_c: &CStr, argv: &[CString]) -> Option<(CString, Vec<CStri
let dispatcher = match std::env::var("CLAUDE_VM_SHIM_DISPATCHER") {
Ok(p) if !p.is_empty() => p,
_ => {
debug_log("CLAUDE_VM_SHIM_DISPATCHER not set; passing through --bg-spare");
debug_log("CLAUDE_VM_SHIM_DISPATCHER not set; passing through");
return None;
}
};
debug_log(&format!(
"intercept: {} (path={:?})",
if is_bg_spare { "--bg-spare" } else { "--print --sdk-url" },
path_c.to_string_lossy()
));
let dispatcher_c = match CString::new(dispatcher.clone()) {
Ok(c) => c,
Err(_) => return None,