mirror of
https://github.com/wirenboard/agent-vm.git
synced 2026-07-10 00:14:30 +00:00
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:
parent
a82552244e
commit
175578bc71
3 changed files with 162 additions and 34 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue