From 175578bc71424397edd3104f1471c19f84e3fe3d Mon Sep 17 00:00:00 2001 From: Evgeny Boger Date: Wed, 27 May 2026 21:14:28 +0000 Subject: [PATCH] claude-vm-shim: intercept remote-control cloud sessions too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 -- ` 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 --- claude-vm-shim/dispatcher/src/main.rs | 166 +++++++++++++++++++++----- claude-vm-shim/install.sh | 10 +- claude-vm-shim/shim/src/lib.rs | 20 +++- 3 files changed, 162 insertions(+), 34 deletions(-) diff --git a/claude-vm-shim/dispatcher/src/main.rs b/claude-vm-shim/dispatcher/src/main.rs index d013fa5..317a136 100644 --- a/claude-vm-shim/dispatcher/src/main.rs +++ b/claude-vm-shim/dispatcher/src/main.rs @@ -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 ` → claim-sock UDS handshake (claude --bg path) + // * `--print --sdk-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 = 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 = 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 --session-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 { + 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 -- `. 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::>() + )); + + 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, diff --git a/claude-vm-shim/install.sh b/claude-vm-shim/install.sh index ec6fc6d..dac2249 100755 --- a/claude-vm-shim/install.sh +++ b/claude-vm-shim/install.sh @@ -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" < "$target_root/bin/claude-shimmed" < Option<(CString, Vec)> { - // 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 ` (the `claude --bg` machinery), OR + // * `claude --print --sdk-url --session-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 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,