The plan deferred this as "the only fleet-node binary change". It is not one. `fcagent` is thread-per-connection — its own comment says so, and the live log tail has relied on exactly that for the whole length of a turn, on a second connection. So the host can drain the tap WHILE the turn's exec is in flight, from the server alone. The turn and a 20s drain loop now run concurrently. A coding phase shows its files being touched as it works rather than an hour later, all at once, and the drain is bounded by a cursor so a repeated poll returns only what is new. The cursor counts LINES, not parsed events, and that distinction is the bug this commit would otherwise have shipped. The hook appends the event and then a newline of its own, so a two-event tap is four lines; advancing by event count leaves the cursor two lines short, `tail -n +N` hands back events already recorded, and the live drain re-records everything it has already written — worse the longer the turn runs, and silent throughout. Caught while writing the test, not by it. `tap_sink` and `VmOutcome::tools` are mutually exclusive by contract: with a sink, the sink owns recording including the final batch and `tools` comes back empty. Handing the same calls back on both would double every file orb's weight with no way for the caller to tell which it was looking at. The sink is an unbounded channel to a recorder task, so the VM executor stays free of the database: it observes, phase_runner records. The task ends when the sender drops with the phase. Verified before this change: the microVM tap is real. The `microvm` scenario passed 6/6 and left ten `tool.call` rows and a `file.touch` on MICROVM.md, repo-relative, from Claude Code's own PostToolUse hook. Co-Authored-By: Claude Opus 5 <[email protected]>
332 lines
14 KiB
Rust
332 lines
14 KiB
Rust
//! What the agent did inside a microVM, taken from Claude Code's own hooks.
|
|
//!
|
|
//! The microVM tier had no action channel at all: a phase ran, a diff came
|
|
//! back, and everything between was invisible. The seam is the same one
|
|
//! [`crate::vm_stop_gate`] proved works in this image — `PostToolUse` fires
|
|
//! under `claude -p`, measured, not read off documentation.
|
|
//!
|
|
//! # The observer must not become a participant
|
|
//!
|
|
//! The hook `exit 0`s unconditionally. A `PostToolUse` hook that exits non-zero
|
|
//! feeds its stderr back to the model, so a tap with a bug would start
|
|
//! *instructing* the agent it exists to watch — and the resulting transcript
|
|
//! would look like a model that lost the plot rather than a broken hook.
|
|
//!
|
|
//! # Never inside the repository
|
|
//!
|
|
//! Everything lives under `/root`. `/mission/repo` is collected and diffed, so
|
|
//! a tap file written there would arrive in the user's delivered patch as
|
|
//! though an agent had authored it — the same rule, and the same reason, as the
|
|
//! stop gate's [`crate::vm_stop_gate::GATE_DIR`].
|
|
|
|
use serde_json::{json, Value};
|
|
|
|
/// Where the tap writes in the guest. Under `/root`, never the repo.
|
|
pub const TAP_DIR: &str = "/root/tap";
|
|
|
|
/// The file the hook appends to, one JSON object per line.
|
|
pub const TAP_FILE: &str = "/root/tap/tools.jsonl";
|
|
|
|
/// The single settings document the guest agent runs with.
|
|
///
|
|
/// One path, because there is only ever one writer — see [`guest_settings`].
|
|
pub const SETTINGS_PATH: &str = "/root/guest-settings.json";
|
|
|
|
/// Read the tap out of the guest, before collection destroys the VM.
|
|
///
|
|
/// `|| true` so a phase whose agent called no tools — or where the hook never
|
|
/// fired — reads as empty rather than as a failed probe. The difference between
|
|
/// those two is the histogram in the log, not an error here.
|
|
pub const DRAIN_PROBE: &str = "cat /root/tap/tools.jsonl 2>/dev/null || true";
|
|
|
|
/// Read the tap from line `from` onward, so a repeated drain returns only what
|
|
/// is new.
|
|
///
|
|
/// A cursor rather than a re-read: the live drain runs every few seconds
|
|
/// against a file the agent is still appending to, and re-sending the whole
|
|
/// file each pass would record every tool call once per poll — a phase would
|
|
/// finish with its early files weighted by how long it ran.
|
|
///
|
|
/// `tail -n +N` is 1-based on the FIRST line to print, so `from` is a line
|
|
/// count already consumed and the probe asks for `from + 1`.
|
|
pub fn drain_from(from: usize) -> String {
|
|
format!("tail -n +{} {TAP_FILE} 2>/dev/null || true", from + 1)
|
|
}
|
|
|
|
/// How far a drain advanced the cursor — the number of LINES it consumed.
|
|
///
|
|
/// Counts every line, including blank ones, and that is the whole point. The
|
|
/// hook appends the event and then a newline of its own, so the tap is
|
|
/// `{json}\n\n{json}\n\n…` and `parse` skips the blanks. Advancing the cursor
|
|
/// by the number of PARSED events instead would leave it short by one line per
|
|
/// event, and `tail -n +N` would hand back events already recorded — every one
|
|
/// of them written again on the next poll, with nothing anywhere reporting it.
|
|
pub fn consumed_lines(raw: &str) -> usize {
|
|
raw.lines().count()
|
|
}
|
|
|
|
/// One observed tool call.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct Observed {
|
|
pub tool: String,
|
|
/// The path the tool's **input** named, if any. From JSON, never prose.
|
|
pub path: Option<String>,
|
|
}
|
|
|
|
/// The hook script. Copies stdin verbatim to the tap file and gets out of the
|
|
/// way.
|
|
///
|
|
/// The parsing happens host-side, on purpose: a `jq` or `sed` pipeline in the
|
|
/// guest would need the tool's JSON schema baked into a shell script, inside an
|
|
/// image we do not rebuild for a parser change, with no way to tell a parse
|
|
/// failure from a quiet turn.
|
|
pub fn hook_script(dir: &str) -> String {
|
|
format!(
|
|
"#!/bin/sh\n\
|
|
# The tool tap. See cm-api/src/vm_tool_tap.rs.\n\
|
|
mkdir -p {dir} 2>/dev/null\n\
|
|
# `cat` of stdin, appended whole. One JSON object per line, because\n\
|
|
# Claude Code hands the hook one event per invocation.\n\
|
|
cat >> {dir}/tools.jsonl 2>/dev/null\n\
|
|
printf '\\n' >> {dir}/tools.jsonl 2>/dev/null\n\
|
|
# ALWAYS zero. A non-zero PostToolUse hook talks back to the model.\n\
|
|
exit 0\n"
|
|
)
|
|
}
|
|
|
|
/// The settings document for the guest, carrying **every** hook at once.
|
|
///
|
|
/// This function exists because the alternative — each feature writing its own
|
|
/// `settings.json` — is a silent clobber. The stop gate wrote the whole
|
|
/// document; a tap that did the same would erase the gate, and a coding phase
|
|
/// would then complete having written nothing, which is the exact failure the
|
|
/// gate exists to catch. One writer, one document, one test that both hooks
|
|
/// survive it.
|
|
///
|
|
/// `None` for either half means that hook is simply absent.
|
|
pub fn guest_settings(gate_dir: Option<&str>, tap_dir: Option<&str>) -> Value {
|
|
let mut hooks = serde_json::Map::new();
|
|
if let Some(dir) = gate_dir {
|
|
hooks.insert(
|
|
"Stop".into(),
|
|
json!([{ "hooks": [{ "type": "command", "command": format!("{dir}/stop-gate.sh") }] }]),
|
|
);
|
|
}
|
|
if let Some(dir) = tap_dir {
|
|
hooks.insert(
|
|
"PostToolUse".into(),
|
|
json!([{ "hooks": [{ "type": "command", "command": format!("{dir}/tap.sh") }] }]),
|
|
);
|
|
}
|
|
json!({ "hooks": Value::Object(hooks) })
|
|
}
|
|
|
|
/// One shell command that installs the tap.
|
|
///
|
|
/// Written by `printf` through an exec rather than injected with the workspace
|
|
/// tar: the tar lands in `/mission/repo`, which is exactly where this must not.
|
|
pub fn install_command(dir: &str) -> String {
|
|
format!(
|
|
"mkdir -p {dir} && rm -f {dir}/tools.jsonl \
|
|
&& printf '%s' {script} > {dir}/tap.sh && chmod +x {dir}/tap.sh",
|
|
dir = dir,
|
|
script = q(&hook_script(dir)),
|
|
)
|
|
}
|
|
|
|
/// Write the composed settings document.
|
|
pub fn settings_command(path: &str, settings: &Value) -> String {
|
|
format!("printf '%s' {} > {path}", q(&settings.to_string()))
|
|
}
|
|
|
|
/// Parse a drained tap.
|
|
///
|
|
/// Tolerant by construction: the file is appended to by a shell hook in a VM
|
|
/// that may be killed mid-write, so a truncated last line is expected and is
|
|
/// skipped rather than failing the whole drain. Losing the last tool call of a
|
|
/// phase costs one orb; losing all of them because of it would cost the tier.
|
|
pub fn parse(raw: &str) -> Vec<Observed> {
|
|
raw.lines()
|
|
.map(str::trim)
|
|
.filter(|l| !l.is_empty())
|
|
.filter_map(|line| {
|
|
let v: Value = serde_json::from_str(line).ok()?;
|
|
// Only tool events. The same hook file would carry others if the
|
|
// settings ever install one, and a `hook_event_name` we do not
|
|
// recognise must not be read as a tool named "".
|
|
let tool = v
|
|
.get("tool_name")
|
|
.or_else(|| v.get("toolName"))
|
|
.and_then(Value::as_str)?
|
|
.trim()
|
|
.to_string();
|
|
if tool.is_empty() {
|
|
return None;
|
|
}
|
|
let input = v
|
|
.get("tool_input")
|
|
.or_else(|| v.get("toolInput"))
|
|
.cloned()
|
|
.unwrap_or(Value::Null);
|
|
Some(Observed {
|
|
path: crate::mission_events::tool_path(&input),
|
|
tool,
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Single-quote for `sh`. Local copy, same rule as the stop gate's — these two
|
|
/// modules deliberately share no code, so neither can break the other.
|
|
fn q(s: &str) -> String {
|
|
format!("'{}'", s.replace('\'', r"'\''"))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// The gate and the tap must BOTH survive one settings document.
|
|
///
|
|
/// This is the whole reason `guest_settings` exists. Two writers each
|
|
/// producing a whole `settings.json` is not a merge conflict — the second
|
|
/// simply wins, no error, and the loser's hook never runs. When the loser is
|
|
/// the stop gate, a coding phase completes having written nothing: the exact
|
|
/// failure the gate was built to catch.
|
|
#[test]
|
|
fn both_hooks_survive_one_settings_document() {
|
|
let s = guest_settings(Some("/root/gate"), Some(TAP_DIR));
|
|
let hooks = s.get("hooks").expect("hooks");
|
|
assert_eq!(
|
|
hooks["Stop"][0]["hooks"][0]["command"],
|
|
json!("/root/gate/stop-gate.sh"),
|
|
"the stop gate must survive the tap being installed"
|
|
);
|
|
assert_eq!(
|
|
hooks["PostToolUse"][0]["hooks"][0]["command"],
|
|
json!("/root/tap/tap.sh")
|
|
);
|
|
}
|
|
|
|
/// Either half absent leaves the other exactly as it was.
|
|
#[test]
|
|
fn one_hook_alone_is_a_valid_document() {
|
|
let gate_only = guest_settings(Some("/root/gate"), None);
|
|
assert!(gate_only["hooks"].get("Stop").is_some());
|
|
assert!(gate_only["hooks"].get("PostToolUse").is_none());
|
|
|
|
let tap_only = guest_settings(None, Some(TAP_DIR));
|
|
assert!(tap_only["hooks"].get("Stop").is_none());
|
|
assert!(tap_only["hooks"].get("PostToolUse").is_some());
|
|
}
|
|
|
|
/// The observer must never talk back to the model.
|
|
#[test]
|
|
fn the_hook_always_exits_zero() {
|
|
let s = hook_script(TAP_DIR);
|
|
assert!(s.contains("exit 0"));
|
|
// No conditional exits at all: a `PostToolUse` hook that exits non-zero
|
|
// feeds stderr back to the agent, so the tap would become an instruction.
|
|
assert!(!s.contains("exit 1") && !s.contains("exit 2"), "{s}");
|
|
}
|
|
|
|
/// Nothing the tap writes may land in the delivered tree.
|
|
#[test]
|
|
fn the_tap_never_writes_into_the_repository() {
|
|
assert!(TAP_DIR.starts_with("/root/"));
|
|
assert!(TAP_FILE.starts_with("/root/"));
|
|
assert!(SETTINGS_PATH.starts_with("/root/"));
|
|
let cmd = install_command(TAP_DIR);
|
|
assert!(!cmd.contains("/mission/repo"), "{cmd}");
|
|
assert!(!hook_script(TAP_DIR).contains("/mission/repo"));
|
|
}
|
|
|
|
/// A real `PostToolUse` payload gives up its tool and its path — and a
|
|
/// truncated final line does not take the rest of the phase with it.
|
|
#[test]
|
|
fn a_drained_tap_parses_and_tolerates_a_torn_last_line() {
|
|
let raw = concat!(
|
|
r#"{"hook_event_name":"PostToolUse","tool_name":"Edit","#,
|
|
r#""tool_input":{"file_path":"/mission/repo/src/a.rs"}}"#,
|
|
"\n",
|
|
r#"{"hook_event_name":"PostToolUse","tool_name":"Bash","tool_input":{"command":"ls"}}"#,
|
|
"\n",
|
|
"\n",
|
|
// The VM was destroyed mid-write.
|
|
r#"{"hook_event_name":"PostToolUse","tool_name":"Wri"#,
|
|
);
|
|
assert_eq!(
|
|
parse(raw),
|
|
vec![
|
|
Observed { tool: "Edit".into(), path: Some("/mission/repo/src/a.rs".into()) },
|
|
Observed { tool: "Bash".into(), path: None },
|
|
]
|
|
);
|
|
}
|
|
|
|
/// Exactly one place in the tree writes the guest settings document.
|
|
///
|
|
/// The unit test above proves `guest_settings` composes correctly; it says
|
|
/// nothing about whether anyone bypasses it. A second `> …settings.json`
|
|
/// anywhere is the silent clobber itself, and it would pass every other
|
|
/// test in this file.
|
|
#[test]
|
|
fn nothing_else_writes_the_guest_settings() {
|
|
for (name, src) in [
|
|
("vm_stop_gate.rs", include_str!("vm_stop_gate.rs")),
|
|
("microvm_executor.rs", include_str!("microvm_executor.rs")),
|
|
] {
|
|
assert!(
|
|
!src.contains("> {d}/settings.json") && !src.contains("settings.json\","),
|
|
"{name} writes a settings document of its own; compose it through \
|
|
vm_tool_tap::guest_settings instead"
|
|
);
|
|
}
|
|
// And the one legitimate writer is this module's own helper.
|
|
let exec = include_str!("microvm_executor.rs");
|
|
assert_eq!(
|
|
exec.matches("vm_tool_tap::settings_command").count(),
|
|
1,
|
|
"the settings document must be written exactly once per turn"
|
|
);
|
|
}
|
|
|
|
/// The cursor must not re-read what it already returned.
|
|
///
|
|
/// Off by one here is not a crash, it is a double-count: `tail -n +1` and
|
|
/// `tail -n +2` both return output, and the wrong one quietly records every
|
|
/// early tool call once per poll.
|
|
#[test]
|
|
fn the_drain_cursor_asks_for_what_it_has_not_seen() {
|
|
assert!(drain_from(0).contains("tail -n +1 "));
|
|
assert!(drain_from(3).contains("tail -n +4 "));
|
|
assert!(drain_from(0).contains(TAP_FILE));
|
|
}
|
|
|
|
/// The cursor counts LINES, not events.
|
|
///
|
|
/// The hook writes the event and then a newline of its own, so a two-event
|
|
/// tap is four lines. Advancing by parsed-event count would leave the
|
|
/// cursor two lines short, `tail` would return both events again, and the
|
|
/// live drain would re-record everything it had already recorded — growing
|
|
/// worse the longer the turn ran, and silent throughout.
|
|
#[test]
|
|
fn the_cursor_counts_lines_not_events() {
|
|
let raw = concat!(
|
|
r#"{"tool_name":"Edit","tool_input":{"file_path":"a.rs"}}"#,
|
|
"\n\n",
|
|
r#"{"tool_name":"Bash","tool_input":{"command":"ls"}}"#,
|
|
"\n\n",
|
|
);
|
|
assert_eq!(parse(raw).len(), 2, "two events");
|
|
assert_eq!(consumed_lines(raw), 4, "…written across four lines");
|
|
}
|
|
|
|
/// An event that is not a tool call is not a tool named "".
|
|
#[test]
|
|
fn a_non_tool_event_is_skipped() {
|
|
assert!(parse(r#"{"hook_event_name":"SessionStart","session_id":"x"}"#).is_empty());
|
|
assert!(parse(r#"{"tool_name":" ","tool_input":{}}"#).is_empty());
|
|
}
|
|
}
|