The container drain revisits finished phases for 30 minutes; the other drains are idempotent because they truncate, and the taint file deliberately is not. The first live mission (01a0cb7e) recorded the same event four times. Record only when no event for the phase already carries at least as many hosts. Live result otherwise as designed: curl https://example.com tainted iana.org (the page's link), not example.com (the agent's own target), and grep -rn curl added nothing. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
552 lines
24 KiB
Rust
552 lines
24 KiB
Rust
//! Gate and observe the tools a **container-tier** mission agent runs.
|
|
//!
|
|
//! The container tier is the one that actually runs missions in production,
|
|
//! and until now it had neither. Both gaps have the same cause: `claude_cli`
|
|
//! runs claude as a subprocess, claude runs its tools inside that subprocess,
|
|
//! and so those calls never pass through ZeroClaw's executor — which is the
|
|
//! only thing that emits `TurnEvent::ToolCall`, and therefore the only thing
|
|
//! the gateway turns into a frame ClawMates can see. Recovering the calls from
|
|
//! the CLI's own `stream-json` output does not help either: the transport was
|
|
//! never the problem, and a mission proved it by producing zero `tool.call`
|
|
//! events with the parser working perfectly.
|
|
//!
|
|
//! Hooks are the way in, and they are already proven. Claude Code reads
|
|
//! `hooks.PreToolUse` / `PostToolUse` from the document passed to `--settings`
|
|
//! and honours them under `-p` — measured against the real binary, where a
|
|
//! `PreToolUse` hook blocked a `Bash` call, recorded the payload, and got its
|
|
//! refusal reason back to the model.
|
|
//!
|
|
//! So this module writes the same hook scripts the microVM tier already uses
|
|
//! into the mission's container, and the provider is pointed at the settings
|
|
//! document. One mechanism, two tiers.
|
|
//!
|
|
//! # Everything here degrades to "no hooks", never to a failed mission
|
|
//!
|
|
//! A phase that runs unobserved still delivers. A phase that fails to start
|
|
//! because telemetry could not be installed delivers nothing, which is a worse
|
|
//! trade — the same stance `microvm_executor` takes for the same reason.
|
|
|
|
use bollard::Docker;
|
|
use std::time::Duration;
|
|
|
|
/// Where the hooks live inside the mission container.
|
|
///
|
|
/// Under `/root`, never under `/mission/repo`: anything written into the
|
|
/// checkout would show up in the diff the mission delivers.
|
|
pub const HOOK_DIR: &str = "/root/toolhooks";
|
|
/// The settings document `claude -p --settings` is pointed at.
|
|
pub const SETTINGS_PATH: &str = "/root/toolhooks/settings.json";
|
|
/// Where the `PostToolUse` tap appends, inside the container.
|
|
pub const TAP_DIR: &str = "/root/toolhooks/tap";
|
|
|
|
pub const INSTALL_TIMEOUT: Duration = Duration::from_secs(30);
|
|
|
|
/// Install the pre-execution gate and the tool tap into a mission container.
|
|
///
|
|
/// Returns the settings path on success. `None` means the container runs
|
|
/// without hooks — logged, never fatal.
|
|
pub async fn install(docker: &Docker, container: &str) -> Option<String> {
|
|
install_with(docker, container, None).await
|
|
}
|
|
|
|
/// As [`install`], carrying a phase's task policy into the gate.
|
|
pub async fn install_with(
|
|
docker: &Docker,
|
|
container: &str,
|
|
task: Option<&crate::vm_tool_gate::TaskPolicy>,
|
|
) -> Option<String> {
|
|
let script = build_install_script(task);
|
|
let argv = vec!["sh".to_string(), "-lc".to_string(), script];
|
|
match crate::container_exec::exec_as_root(docker, container, None, &argv, INSTALL_TIMEOUT).await
|
|
{
|
|
Ok(out) if out.exit_code == Some(0) => Some(SETTINGS_PATH.to_string()),
|
|
other => {
|
|
eprintln!(
|
|
"container_tool_hooks: could not install hooks in {container} ({other:?}) — \
|
|
this mission's tool calls will run unchecked and unrecorded"
|
|
);
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One shell script that lays down both hooks and the settings document.
|
|
///
|
|
/// Composed here rather than by each hook module writing its own file: two
|
|
/// writers of one `settings.json` is a silent clobber, and the microVM tier
|
|
/// already learned that the expensive way.
|
|
fn build_install_script(task: Option<&crate::vm_tool_gate::TaskPolicy>) -> String {
|
|
let settings = crate::vm_tool_tap::guest_settings(
|
|
None,
|
|
Some(TAP_DIR),
|
|
Some(HOOK_DIR),
|
|
);
|
|
format!(
|
|
"set -e\n\
|
|
mkdir -p {hooks} {tap}\n\
|
|
cat > {hooks}/tool-gate.sh <<'CM_GATE_EOF'\n{gate}\nCM_GATE_EOF\n\
|
|
chmod +x {hooks}/tool-gate.sh\n\
|
|
cat > {tap}/tap.sh <<'CM_TAP_EOF'\n{tap_script}\nCM_TAP_EOF\n\
|
|
chmod +x {tap}/tap.sh\n\
|
|
cat > {settings_path} <<'CM_SETTINGS_EOF'\n{settings}\nCM_SETTINGS_EOF\n",
|
|
hooks = HOOK_DIR,
|
|
tap = TAP_DIR,
|
|
gate = crate::vm_tool_gate::hook_script_with(HOOK_DIR, task),
|
|
tap_script = crate::vm_tool_tap::hook_script(TAP_DIR),
|
|
settings_path = SETTINGS_PATH,
|
|
settings = settings,
|
|
)
|
|
}
|
|
|
|
/// The MCP configuration `claude -p --mcp-config` is pointed at.
|
|
///
|
|
/// Under `/root` with the hooks, never under `/mission/repo`: it carries a
|
|
/// bearer token, and anything written into the checkout arrives in the diff the
|
|
/// mission delivers.
|
|
pub const MCP_CONFIG_PATH: &str = "/root/toolhooks/clawmates-mcp.json";
|
|
|
|
/// Where the mission container reaches this server.
|
|
///
|
|
/// Mission containers join `clawmates_core`, the same network the API is on, so
|
|
/// the API is reachable by container name. The name differs between
|
|
/// deployments (`clawmates-server-1` locally, `clawmates_server_1` on gw-04),
|
|
/// so the default is derived from **our own** hostname — docker's embedded DNS
|
|
/// resolves a container id on a user-defined network, which makes this
|
|
/// self-configuring rather than a constant that is right in one place.
|
|
/// Measured from a sibling container: both the id and the name return 200.
|
|
pub fn api_origin() -> Option<String> {
|
|
if let Ok(v) = std::env::var("CLAWMATES_API_ORIGIN") {
|
|
if !v.trim().is_empty() {
|
|
return Some(v.trim().trim_end_matches('/').to_string());
|
|
}
|
|
}
|
|
let host = std::env::var("HOSTNAME").ok()?;
|
|
let host = host.trim();
|
|
if host.is_empty() {
|
|
return None;
|
|
}
|
|
Some(format!("http://{host}:8080"))
|
|
}
|
|
|
|
/// The `--mcp-config` document: one HTTP server, carrying its own credential.
|
|
///
|
|
/// The token is a `skills:read` session and nothing else. It is written into a
|
|
/// file the agent can read — it runs `Bash` — so the only thing keeping this
|
|
/// safe is that the credential authenticates to exactly one route. See
|
|
/// `cm_auth::authenticate_scoped`.
|
|
pub fn mcp_document(origin: &str, token: &str) -> serde_json::Value {
|
|
serde_json::json!({
|
|
"mcpServers": {
|
|
"clawmates_skills": {
|
|
"type": "http",
|
|
"url": format!("{origin}/mcp/skills"),
|
|
"headers": { "Authorization": format!("Bearer {token}") }
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// NOTE on `--allowedTools`. The provider passes it only when the config sets
|
|
// `tools`, and the seed already does — without it `claude -p` stops mid-turn to
|
|
// ask for write permission. Whether the MCP tools ALSO need naming there is not
|
|
// documented anywhere we control, and the daemon exposes no config read to
|
|
// merge into that list safely: overwriting it would take `Write` and `Bash`
|
|
// away from every mission agent, and that failure would look like agents that
|
|
// stopped working rather than a config that was replaced.
|
|
//
|
|
// So it is left alone and the question is answered by running a mission with
|
|
// the door installed. Guessing here is how the last three defects in this file
|
|
// were introduced.
|
|
|
|
/// Write the MCP configuration into a mission container.
|
|
///
|
|
/// Returns the path on success. `None` means the mission runs without a door —
|
|
/// logged, never fatal, exactly like the hooks above. A phase that cannot
|
|
/// retrieve a skill still delivers; a phase that fails to start because a
|
|
/// config write failed delivers nothing.
|
|
pub async fn install_door(docker: &Docker, container: &str, doc: &serde_json::Value) -> Option<String> {
|
|
// `printf %s` with the JSON single-quoted, not a heredoc: the document is
|
|
// one line and contains no newline to terminate on.
|
|
let script = format!(
|
|
"mkdir -p {HOOK_DIR} && printf '%s' {} > {MCP_CONFIG_PATH} && chmod 600 {MCP_CONFIG_PATH}",
|
|
crate::vm_tool_tap::shell_quote(&doc.to_string()),
|
|
);
|
|
let argv = vec!["sh".to_string(), "-lc".to_string(), script];
|
|
match crate::container_exec::exec_as_root(docker, container, None, &argv, INSTALL_TIMEOUT).await
|
|
{
|
|
Ok(out) if out.exit_code == Some(0) => Some(MCP_CONFIG_PATH.to_string()),
|
|
other => {
|
|
eprintln!(
|
|
"container_tool_hooks: could not write the MCP config in {container} ({other:?}) — this mission runs without the skills door"
|
|
);
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Event kinds under which the gate's own state lands in the mission record.
|
|
///
|
|
/// Recorded, not only logged, so "was this mission gated?" is answerable from
|
|
/// the mission afterwards. Stderr is where the answer used to go, which is the
|
|
/// same place as nowhere once the container that printed it is gone.
|
|
pub const GATE_INSTALLED: &str = "gate.installed";
|
|
pub const GATE_ABSENT: &str = "gate.absent";
|
|
/// The gate ran but could not parse its input and allowed everything. See
|
|
/// [`crate::vm_tool_gate::INERT_FILE`] — this is the reader that marker was
|
|
/// missing in production; until now only a unit test looked for it.
|
|
pub const GATE_INERT: &str = "gate.inert";
|
|
/// One call the gate refused. `detail` is the hook event with `rule` set
|
|
/// beside it — see [`crate::vm_tool_gate::denial_detail`]. Both tiers.
|
|
pub const GATE_DENIED: &str = "gate.denied";
|
|
/// One call a task policy would have refused while it was in shadow. Same
|
|
/// detail shape as [`GATE_DENIED`]; the difference is that it RAN.
|
|
pub const GATE_WOULD_DENY: &str = "gate.would_deny";
|
|
|
|
/// Write the install outcome into the mission record.
|
|
pub async fn record_install(
|
|
pool: &sqlx::PgPool,
|
|
mission_id: uuid::Uuid,
|
|
phase_id: Option<uuid::Uuid>,
|
|
hooks: Option<&str>,
|
|
) {
|
|
let mut e = match hooks {
|
|
Some(path) => crate::mission_events::MissionEvent::new(mission_id, GATE_INSTALLED)
|
|
.target(path)
|
|
.detail(serde_json::json!({ "settings": path, "tap": tap_file() })),
|
|
None => crate::mission_events::MissionEvent::new(mission_id, GATE_ABSENT).detail(
|
|
serde_json::json!({
|
|
"why": "container_tool_hooks::install failed — this mission's tool \
|
|
calls run unchecked and unrecorded"
|
|
}),
|
|
),
|
|
};
|
|
if let Some(p) = phase_id {
|
|
e = e.phase(p);
|
|
}
|
|
crate::mission_events::record(pool, e).await;
|
|
}
|
|
|
|
/// The inert marker's path inside the container.
|
|
pub fn inert_file() -> String {
|
|
format!("{HOOK_DIR}/{}", crate::vm_tool_gate::INERT_FILE)
|
|
}
|
|
|
|
/// Did the gate go inert since the last drain? Reads the marker and clears
|
|
/// it, so each occurrence is reported once.
|
|
///
|
|
/// `Some(text)` is the marker's contents — every line the gate appended while
|
|
/// it could not parse. `None` is "the marker is not there", which is the
|
|
/// normal case and also, by construction, the only case that means the gate
|
|
/// was actually checking.
|
|
pub async fn drain_inert(docker: &Docker, container: &str) -> Option<String> {
|
|
let file = inert_file();
|
|
let script = format!("cat {file} 2>/dev/null && rm -f {file} 2>/dev/null; true");
|
|
let argv = vec!["sh".to_string(), "-lc".to_string(), script];
|
|
match crate::container_exec::exec_as_root(docker, container, None, &argv, INSTALL_TIMEOUT).await
|
|
{
|
|
Ok(out) if !out.stdout.trim().is_empty() => Some(out.stdout.trim().to_string()),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// The gate's denial record inside the mission container.
|
|
pub fn denied_file() -> String {
|
|
format!("{HOOK_DIR}/{}", crate::vm_tool_gate::DENIED_FILE)
|
|
}
|
|
|
|
/// Every call the gate refused since the last drain, one JSON line each
|
|
/// (`vm_tool_gate::denial_detail` reads them). Read-then-truncate, like
|
|
/// [`drain`], for the same reason: no cursor to keep, and the phase has
|
|
/// finished so nothing is appending.
|
|
pub async fn drain_denied(docker: &Docker, container: &str) -> Vec<String> {
|
|
let file = denied_file();
|
|
let script = format!("cat {file} 2>/dev/null || true; : > {file} 2>/dev/null || true");
|
|
let argv = vec!["sh".to_string(), "-lc".to_string(), script];
|
|
match crate::container_exec::exec_as_root(docker, container, None, &argv, INSTALL_TIMEOUT).await
|
|
{
|
|
Ok(out) => out
|
|
.stdout
|
|
.lines()
|
|
.map(str::trim)
|
|
.filter(|l| !l.is_empty())
|
|
.map(str::to_string)
|
|
.collect(),
|
|
Err(_) => Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// The gate's shadow record: calls a policy WOULD have refused, had it been
|
|
/// enforcing. Drained exactly like [`drain_denied`] and recorded as
|
|
/// [`GATE_WOULD_DENY`], because a shadow mode whose output nobody reads is
|
|
/// an off switch with extra steps.
|
|
pub async fn drain_would_deny(docker: &Docker, container: &str) -> Vec<String> {
|
|
let file = format!("{HOOK_DIR}/{}", crate::vm_tool_gate::WOULD_DENY_FILE);
|
|
let script = format!("cat {file} 2>/dev/null || true; : > {file} 2>/dev/null || true");
|
|
let argv = vec!["sh".to_string(), "-lc".to_string(), script];
|
|
match crate::container_exec::exec_as_root(docker, container, None, &argv, INSTALL_TIMEOUT).await
|
|
{
|
|
Ok(out) => out
|
|
.stdout
|
|
.lines()
|
|
.map(str::trim)
|
|
.filter(|l| !l.is_empty())
|
|
.map(str::to_string)
|
|
.collect(),
|
|
Err(_) => Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// Hosts named in fetched content, as the tap recorded them. One event per
|
|
/// finished phase, carrying the whole list: stage 1 of argument provenance
|
|
/// is observation only, and this is what gets inspected before any rule is
|
|
/// built on it.
|
|
pub const TAINT_HOSTS: &str = "taint.hosts";
|
|
|
|
/// Read the tap's taint file. NOT cleared, unlike every drain above: it is
|
|
/// the state a future `untrusted-target` rule consults for the rest of the
|
|
/// mission, so each phase's event is the set known when that phase ended.
|
|
pub async fn drain_taint(docker: &Docker, container: &str) -> Vec<String> {
|
|
let argv = vec![
|
|
"sh".to_string(),
|
|
"-lc".to_string(),
|
|
crate::vm_tool_tap::taint_probe(TAP_DIR),
|
|
];
|
|
match crate::container_exec::exec_as_root(docker, container, None, &argv, INSTALL_TIMEOUT).await
|
|
{
|
|
Ok(out) => crate::vm_tool_tap::parse_taint(&out.stdout),
|
|
Err(_) => Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// The detail of a [`TAINT_HOSTS`] event.
|
|
pub fn taint_detail(hosts: &[String], tier: &str) -> serde_json::Value {
|
|
serde_json::json!({
|
|
"hosts": hosts,
|
|
"count": hosts.len(),
|
|
"capped": hosts.len() >= crate::vm_tool_tap::MAX_TAINT_HOSTS,
|
|
"tier": tier,
|
|
})
|
|
}
|
|
|
|
/// The tap file inside the mission container.
|
|
pub fn tap_file() -> String {
|
|
format!("{TAP_DIR}/tools.jsonl")
|
|
}
|
|
|
|
/// Read everything the tap recorded, then clear it.
|
|
///
|
|
/// Read-then-truncate rather than a cursor, because this tier has no
|
|
/// long-lived loop to hold one: the microVM path drains inside the turn it is
|
|
/// watching, while a container turn is driven asynchronously by
|
|
/// `topology_worker`. Truncation makes the drain idempotent — a second pass
|
|
/// reads an empty file and records nothing — without a column to store a
|
|
/// cursor in.
|
|
///
|
|
/// Called only for phases that have FINISHED, so the agent is no longer
|
|
/// appending and the read/truncate gap cannot lose an event.
|
|
pub async fn drain(docker: &Docker, container: &str) -> Vec<crate::vm_tool_tap::Observed> {
|
|
let file = tap_file();
|
|
// `cat` then truncate in one exec: two round-trips would widen the window
|
|
// between them for no benefit.
|
|
let script = format!("cat {file} 2>/dev/null || true; : > {file} 2>/dev/null || true");
|
|
let argv = vec!["sh".to_string(), "-lc".to_string(), script];
|
|
match crate::container_exec::exec_as_root(docker, container, None, &argv, INSTALL_TIMEOUT).await
|
|
{
|
|
Ok(out) => crate::vm_tool_tap::parse(&out.stdout),
|
|
Err(e) => {
|
|
// A reaped container is the normal end state, not a fault.
|
|
eprintln!("container_tool_hooks: no tap drained from {container}: {e}");
|
|
Vec::new()
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// Every command the settings document names must be a file the installer
|
|
/// actually writes.
|
|
///
|
|
/// This caught a real one: the document pointed PostToolUse at
|
|
/// `{TAP_DIR}/tap.sh` while the installer wrote `{HOOK_DIR}/tap.sh`, so
|
|
/// the hook referenced a file that did not exist. Claude Code does not
|
|
/// complain about a missing hook command — it simply records nothing, and
|
|
/// a mission ran with the tap installed, pointed at nothing, and silent.
|
|
///
|
|
/// Asserting that the script "mentions tap.sh" did not catch it. The paths
|
|
/// have to be compared.
|
|
#[test]
|
|
fn every_hook_command_is_a_file_the_installer_writes() {
|
|
let settings = crate::vm_tool_tap::guest_settings(None, Some(TAP_DIR), Some(HOOK_DIR));
|
|
let script = build_install_script(None);
|
|
|
|
let hooks = settings["hooks"].as_object().expect("hooks");
|
|
assert!(!hooks.is_empty(), "no hooks at all");
|
|
for (event, entries) in hooks {
|
|
let cmd = entries[0]["hooks"][0]["command"]
|
|
.as_str()
|
|
.unwrap_or_else(|| panic!("{event} has no command"));
|
|
assert!(
|
|
script.contains(&format!("cat > {cmd} <<")),
|
|
"{event} points at {cmd}, which the installer never writes — \
|
|
the hook is registered and inert"
|
|
);
|
|
assert!(
|
|
script.contains(&format!("chmod +x {cmd}")),
|
|
"{event} points at {cmd}, which is never made executable"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_script_writes_both_hooks_and_the_settings_document() {
|
|
let s = build_install_script(None);
|
|
assert!(s.contains("tool-gate.sh"), "the pre-execution gate is missing");
|
|
assert!(s.contains("tap.sh"), "the tool tap is missing");
|
|
assert!(s.contains(SETTINGS_PATH), "the settings document is missing");
|
|
// Both hooks in ONE document — the whole reason this is composed here.
|
|
assert!(s.contains("PreToolUse"));
|
|
assert!(s.contains("PostToolUse"));
|
|
}
|
|
|
|
/// Nothing may be written into the mission checkout.
|
|
///
|
|
/// A file left under `/mission/repo` shows up in the diff the mission
|
|
/// delivers, so hook plumbing would arrive as part of the agent's work.
|
|
#[test]
|
|
fn nothing_is_written_into_the_checkout() {
|
|
assert!(HOOK_DIR.starts_with("/root/"));
|
|
assert!(SETTINGS_PATH.starts_with("/root/"));
|
|
assert!(TAP_DIR.starts_with("/root/"));
|
|
assert!(!build_install_script(None).contains("/mission/repo"));
|
|
}
|
|
|
|
/// The two halves must stay together.
|
|
///
|
|
/// Writing the hooks without pointing the provider at them leaves a gate
|
|
/// that is installed and inert — indistinguishable from a gate that found
|
|
/// nothing, which is this codebase's signature failure. Pointing the
|
|
/// provider at a document nobody wrote makes claude fail to start.
|
|
#[test]
|
|
fn the_installer_and_the_provider_prop_agree() {
|
|
let orchestrator = include_str!("mission_orchestrator.rs");
|
|
assert!(
|
|
orchestrator.contains("set_claude_cli_settings")
|
|
&& orchestrator.contains("container_tool_hooks::SETTINGS_PATH"),
|
|
"the hooks are installed but nothing points claude at them"
|
|
);
|
|
let runtime = include_str!("mission_runtime.rs");
|
|
assert!(
|
|
runtime.contains("container_tool_hooks::install"),
|
|
"the provider is pointed at a settings document nobody writes"
|
|
);
|
|
// Both container paths — created AND reused. A hook that exists only
|
|
// on first creation disappears after a server redeploy.
|
|
assert_eq!(
|
|
runtime.matches("container_tool_hooks::install").count(),
|
|
2,
|
|
"install must run on the reuse path too"
|
|
);
|
|
}
|
|
|
|
/// The drain must clear what it read.
|
|
///
|
|
/// Truncation IS the idempotency here — there is no cursor column and no
|
|
/// marker row. A drain that reads without clearing would re-record every
|
|
/// tool call on every tick, and a phase's early files would end up weighted
|
|
/// by how long the sweep ran.
|
|
#[test]
|
|
fn the_drain_reads_then_clears() {
|
|
let file = tap_file();
|
|
assert!(file.starts_with(TAP_DIR), "the tap must live under {TAP_DIR}");
|
|
// The script is built inline in `drain`; assert on the shape it must
|
|
// have, since getting this wrong duplicates every event silently.
|
|
let script = format!("cat {file} 2>/dev/null || true; : > {file} 2>/dev/null || true");
|
|
assert!(script.contains(&format!("cat {file}")), "must read");
|
|
assert!(script.contains(&format!(": > {file}")), "must clear");
|
|
}
|
|
|
|
/// The sweep has to exist, or the hooks write a file nobody reads.
|
|
#[test]
|
|
fn something_actually_collects_the_tap() {
|
|
let runner = include_str!("phase_runner.rs");
|
|
assert!(
|
|
runner.contains("container_tool_hooks::drain"),
|
|
"the tap is written and never collected — the same shape as a gate \
|
|
that is installed and inert"
|
|
);
|
|
assert!(
|
|
runner.contains("drain_finished_container_phases(pool).await?"),
|
|
"the drain exists but the tick does not call it"
|
|
);
|
|
}
|
|
|
|
/// The taint file is never cleared, so its record needs its own
|
|
/// once-per-phase guard. Measured without one: the first live mission
|
|
/// recorded the same `taint.hosts` event four times, and the sweep that
|
|
/// revisits a finished phase for 30 minutes would have kept going.
|
|
#[test]
|
|
fn the_taint_record_is_written_once_per_phase() {
|
|
let runner = include_str!("phase_runner.rs");
|
|
let body = runner
|
|
.split("drain_taint(&docker, &container).await")
|
|
.nth(1)
|
|
.expect("the sweep drains the taint file");
|
|
let guard = body.find("SELECT EXISTS").expect("no once-per-phase guard");
|
|
let record = body.find("TAINT_HOSTS,\n").unwrap_or(usize::MAX).min(
|
|
body.find("MissionEvent::new").expect("the record"),
|
|
);
|
|
assert!(guard < record, "the guard must run before the record is written");
|
|
}
|
|
|
|
/// The drain must use the connector that honours DOCKER_HOST.
|
|
///
|
|
/// The server reaches Docker through a socket proxy, so
|
|
/// `connect_with_local_defaults` fails there — and it failed SILENTLY,
|
|
/// which meant the sweep did nothing while the tap filled up and every
|
|
/// other link in the chain looked correct. Cost a full diagnostic cycle.
|
|
#[test]
|
|
fn the_sweep_connects_the_way_the_rest_of_the_server_does() {
|
|
let runner = include_str!("phase_runner.rs");
|
|
let body = runner
|
|
.split("async fn drain_finished_container_phases(")
|
|
.nth(1)
|
|
.and_then(|s| s.split("\nasync fn ").next())
|
|
.expect("sweep body");
|
|
assert!(
|
|
body.contains("container_exec::connect()"),
|
|
"the sweep must use the DOCKER_HOST-aware connector"
|
|
);
|
|
assert!(
|
|
// The CALL, not the word: the comment above it names the
|
|
// connector it is warning against.
|
|
!body.contains("connect_with_local_defaults()"),
|
|
"the local-socket connector fails behind the socket proxy"
|
|
);
|
|
}
|
|
|
|
/// The generated installer must be valid shell — a here-doc or quoting slip
|
|
/// makes it fail in the container, where the only symptom is a mission that
|
|
/// silently runs unhooked.
|
|
#[test]
|
|
fn the_install_script_is_valid_shell() {
|
|
if std::process::Command::new("bash").arg("-c").arg("true").status().is_err() {
|
|
return;
|
|
}
|
|
let tmp = std::env::temp_dir().join(format!("cm-install-{}.sh", std::process::id()));
|
|
std::fs::write(&tmp, build_install_script(None)).unwrap();
|
|
let out = std::process::Command::new("bash")
|
|
.arg("-n")
|
|
.arg(&tmp)
|
|
.output()
|
|
.expect("bash -n");
|
|
let _ = std::fs::remove_file(&tmp);
|
|
assert!(
|
|
out.status.success(),
|
|
"installer will not parse: {}",
|
|
String::from_utf8_lossy(&out.stderr)
|
|
);
|
|
}
|
|
}
|