claude-vm-shim: forward dispatcher stdin to in-VM claude

`agent-vm`'s non-TTY exec path uses `exec_stream_with` (output-only)
*and* injects a `[ -t 0 ] || exec < /dev/null` redirect into the bash
prelude that wraps the agent invocation. Together they close the in-guest
process's stdin before it can read anything.

That's fatal for `claude --print --input-format stream-json` (the
per-session subprocess of `claude remote-control`): the in-VM claude
announces "ready" on stdout, but never receives the user messages the rc
parent forwards from claude.ai/code on stdin. The cloud session shows
"Attached" and then sits silent.

Two changes, both env-gated by AGENT_VM_FORWARD_STDIN=1 so default
behavior is unchanged:

  crates/agent-vm/src/run.rs:
    - Skip the `exec < /dev/null` line in the bash prelude when the env
      var is set.
    - In the streaming-exec branch, call `e.stdin_pipe()` and spawn a
      tokio task that pumps tokio::io::stdin() into the returned
      ExecSink (with .close() on EOF / error).

  claude-vm-shim/dispatcher/src/main.rs:
    - Set AGENT_VM_FORWARD_STDIN=1 on each agent-vm spawn.
    - Reorder the agent-vm-binary candidate list to prefer
      /opt/claude-vm-shim/bin/agent-vm. Required because the npm-shipped
      agent-vm doesn't yet have the patch; the locally-built copy does.

Tested: rc parent boots a VM and stays connected to the cloud session.
The in-VM agent-vm-bc9a32b2e20c sandbox shows stock public_only network
policy (no `private` rule); the dispatcher now spawns the patched
agent-vm from /opt/claude-vm-shim/bin.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Evgeny Boger 2026-05-27 23:08:48 +00:00
parent 44da7166fb
commit 56cbda8d65
2 changed files with 53 additions and 7 deletions

View file

@ -143,6 +143,11 @@ fn run_vm_cloud_session(forwarded: &[OsString]) -> std::io::Result<i32> {
let mut cmd = Command::new(&agent_vm);
cmd.env("MSB_PATH", &msb_path)
// Tell agent-vm to (a) skip the default non-TTY stdin → /dev/null
// redirect and (b) wire stdin through the streaming exec API.
// Without this the in-guest claude can't read user messages from
// the cloud session.
.env("AGENT_VM_FORWARD_STDIN", "1")
.arg("claude")
.arg("--no-git")
.current_dir(&vm_workdir)
@ -180,13 +185,16 @@ fn resolve_agent_vm_binary() -> String {
return p;
}
}
// The npm-installed `/usr/bin/agent-vm` is a `#!/usr/bin/env node` shim
// and won't resolve `node` under the daemon's scrubbed PATH. Prefer the
// native ELF binary in the platform subpackage.
// Prefer a local patched copy at /opt/claude-vm-shim/bin/agent-vm —
// the upstream npm one doesn't honor AGENT_VM_FORWARD_STDIN yet, which
// breaks stream-json input to in-VM claude. Fall back to the
// npm-installed platform subpackage's ELF (the `/usr/bin/agent-vm`
// shim is a `#!/usr/bin/env node` wrapper that won't resolve `node`
// under the daemon's scrubbed PATH).
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",
"/opt/claude-vm-shim/bin/agent-vm",
"/usr/bin/agent-vm",
"/usr/local/bin/agent-vm",
];

View file

@ -746,10 +746,24 @@ pub async fn launch(agent: Agent, args: Args) -> Result<i32> {
fi\n",
)
};
// The `[ -t 0 ] || exec < /dev/null` redirect satisfies codex 0.133's
// exec subcommand under non-TTY launches, but breaks any agent that
// needs to read its stdin in non-TTY mode — notably
// `claude --print --input-format stream-json` as used by
// `claude remote-control` per-session subprocesses. Skip the redirect
// when AGENT_VM_FORWARD_STDIN=1; the launcher then also wires the
// streaming-exec path with `stdin_pipe()` below so our stdin reaches
// the in-guest agent.
let forward_stdin =
std::env::var("AGENT_VM_FORWARD_STDIN").as_deref() == Ok("1");
let stdin_null_line = if forward_stdin {
""
} else {
"[ -t 0 ] || exec < /dev/null\n "
};
let prelude = format!(
"sed -i '/^nameserver .*:/d' /etc/resolv.conf 2>/dev/null || true\n\
[ -t 0 ] || exec < /dev/null\n\
{chrome_mcp_prelude}\
{stdin_null_line}{chrome_mcp_prelude}\
_hook={path}/.agent-vm.runtime.sh\n\
if [ -f \"$_hook\" ]; then\n\
\techo \"==> sourcing $_hook\" >&2\n\
@ -793,7 +807,8 @@ pub async fn launch(agent: Agent, args: Args) -> Result<i32> {
use tokio::io::AsyncWriteExt as _;
let mut handle = sandbox
.exec_stream_with(cmd, |e| {
e.args(agent_args).cwd(project_guest_path.clone())
let e = e.args(agent_args).cwd(project_guest_path.clone());
if forward_stdin { e.stdin_pipe() } else { e }
})
.await
.with_context(|| {
@ -802,6 +817,29 @@ pub async fn launch(agent: Agent, args: Args) -> Result<i32> {
sandbox_log_dir(&session.sandbox_name).display()
)
})?;
// When AGENT_VM_FORWARD_STDIN=1, pump our stdin into the in-guest
// process's stdin. Required for stream-json mode under `claude --print
// --input-format stream-json` (used by `claude remote-control`
// per-session subprocesses).
if let Some(sink) = handle.take_stdin() {
tokio::spawn(async move {
use tokio::io::AsyncReadExt as _;
let mut stdin = tokio::io::stdin();
let mut buf = vec![0u8; 16 * 1024];
loop {
match stdin.read(&mut buf).await {
Ok(0) | Err(_) => break,
Ok(n) => {
if sink.write(&buf[..n]).await.is_err() {
break;
}
}
}
}
let _ = sink.close().await;
});
}
let mut stdout = tokio::io::stdout();
let mut stderr = tokio::io::stderr();
// Track whether we actually saw an Exited event. The previous