task #23: retire per-team ZeroClaw container coords (Option A)

Missions never populated teams.zeroclaw_container /
teams.zeroclaw_gateway_url — those were research/loops-era columns
for long-lived per-team containers. Every mission-materialized team
runs inside the SHARED runtime as claws-as-agents provisioned via
RuntimeProvisioner. Reading zeroclaw_container on a mission row
always came up NULL, making security_scan + benchmark_runner
silently fail with "mission has no team container yet."

Changes:
  - migrations/0054_drop_teams_zeroclaw_columns.sql — DROP both
    columns.
  - cm-db/src/repo/teams.rs — delete dead helpers
    team_container_coords + set_team_container_coords.
  - cm-api/src/security_scan.rs — replace team_container_for_mission
    with exec_target(pool, mission_id): container from env
    CLAWMATES_RUNTIME_CONTAINER (default clawmates-runtime); workdir
    from env CLAWMATES_MISSIONS_ROOT + /{mission_id}/repo
    (same convention pdf_renderer uses); precondition that mission
    must have repo_id bound.
  - cm-api/src/benchmark_runner.rs — same shape.

Follow-up (not in this commit): mission_orchestrator + compose stack
still need to wire a per-mission repo checkout under
CLAWMATES_MISSIONS_ROOT before scan/bench actually produce findings.
Columns cleanup here removes the misleading silent-fail; the
missing-checkout gap is now surfaced with a clear error.

Verified: SQLX_OFFLINE=true cargo check --workspace + cargo test
-p cm-api --test mission_orchestrator both green.

Closes task #23.
This commit is contained in:
Omar Sobh
2026-07-19 23:18:28 -07:00
parent 74ee990d7e
commit 854a617777
4 changed files with 108 additions and 98 deletions
+36 -20
View File
@@ -153,9 +153,9 @@ pub async fn run(
other => other, other => other,
}; };
let container = team_container_for_mission(pool, mission_id).await?; let (container, workdir) = exec_target(pool, mission_id).await?;
let cmd = harness.command(); let cmd = harness.command();
let raw = docker_exec(&container, &cmd) let raw = docker_exec(&container, &workdir, &cmd)
.await .await
.map_err(|e| format!("exec {cmd:?}: {e}"))?; .map_err(|e| format!("exec {cmd:?}: {e}"))?;
let metrics = parse_output(&raw, &harness); let metrics = parse_output(&raw, &harness);
@@ -229,23 +229,34 @@ async fn phase_config(pool: &PgPool, phase_id: Uuid) -> Result<Value, String> {
.unwrap_or_else(|| json!({}))) .unwrap_or_else(|| json!({})))
} }
async fn team_container_for_mission(pool: &PgPool, mission_id: Uuid) -> Result<String, String> { /// Post-task-#23: shared runtime container + per-mission working dir.
let row = sqlx::query( /// See security_scan::exec_target for the same convention.
"SELECT t.zeroclaw_container async fn exec_target(
FROM missions m pool: &PgPool,
JOIN teams t ON t.id = m.team_id mission_id: Uuid,
WHERE m.id = $1", ) -> Result<(String, std::path::PathBuf), String> {
let repo_id: Option<Uuid> = sqlx::query_scalar(
"SELECT repo_id FROM missions WHERE id = $1",
) )
.bind(mission_id) .bind(mission_id)
.fetch_optional(pool) .fetch_optional(pool)
.await .await
.map_err(|e| format!("resolve container: {e}"))?; .map_err(|e| format!("resolve mission repo: {e}"))?
row.and_then(|r| { .flatten();
r.try_get::<Option<String>, _>("zeroclaw_container") if repo_id.is_none() {
.ok() return Err(
.flatten() "mission has no repo bound — benchmark requires a repository under mission.repo_id"
}) .into(),
.ok_or_else(|| "mission has no team_id / team container".to_string()) );
}
let container = std::env::var("CLAWMATES_RUNTIME_CONTAINER")
.unwrap_or_else(|_| "clawmates-runtime".to_string());
let root = std::env::var("CLAWMATES_MISSIONS_ROOT")
.unwrap_or_else(|_| "/var/lib/clawmates-missions".to_string());
let workdir = std::path::PathBuf::from(root)
.join(mission_id.to_string())
.join("repo");
Ok((container, workdir))
} }
async fn load_before(pool: &PgPool, phase_id: Uuid) -> Result<Option<Value>, String> { async fn load_before(pool: &PgPool, phase_id: Uuid) -> Result<Option<Value>, String> {
@@ -269,8 +280,8 @@ async fn load_before(pool: &PgPool, phase_id: Uuid) -> Result<Option<Value>, Str
/// harness — Cargo.toml → CargoBench, package.json with vitest → /// harness — Cargo.toml → CargoBench, package.json with vitest →
/// VitestBench, pyproject with pytest-benchmark → PytestBench. /// VitestBench, pyproject with pytest-benchmark → PytestBench.
async fn auto_detect(pool: &PgPool, mission_id: Uuid) -> Result<Harness, String> { async fn auto_detect(pool: &PgPool, mission_id: Uuid) -> Result<Harness, String> {
let container = team_container_for_mission(pool, mission_id).await?; let (container, workdir) = exec_target(pool, mission_id).await?;
let listing = docker_exec(&container, &["ls".into(), "/workspace/repo".into()]) let listing = docker_exec(&container, &workdir, &["ls".into()])
.await .await
.unwrap_or_default(); .unwrap_or_default();
if listing.contains("Cargo.toml") { if listing.contains("Cargo.toml") {
@@ -287,12 +298,17 @@ async fn auto_detect(pool: &PgPool, mission_id: Uuid) -> Result<Harness, String>
}) })
} }
/// Fire-and-forget `docker exec` against the mission's team container. /// Fire-and-forget `docker exec` against the shared runtime container
async fn docker_exec(container: &str, cmd: &[String]) -> Result<String, String> { /// at the mission's working dir.
async fn docker_exec(
container: &str,
workdir: &std::path::Path,
cmd: &[String],
) -> Result<String, String> {
let mut args = vec![ let mut args = vec![
"exec".to_string(), "exec".to_string(),
"-w".into(), "-w".into(),
"/workspace/repo".into(), workdir.display().to_string(),
container.to_string(), container.to_string(),
]; ];
args.extend(cmd.iter().cloned()); args.extend(cmd.iter().cloned());
+47 -26
View File
@@ -61,14 +61,14 @@ pub async fn run(pool: &PgPool, mission_id: Uuid, phase_id: Uuid) -> Result<usiz
] ]
}); });
let container = team_container_for_mission(pool, mission_id).await?; let (container, workdir) = exec_target(pool, mission_id).await?;
let mut all_findings: Vec<Finding> = Vec::new(); let mut all_findings: Vec<Finding> = Vec::new();
for tool in &tools { for tool in &tools {
let findings = match tool.as_str() { let findings = match tool.as_str() {
"cargo_audit" => run_cargo_audit(&container).await, "cargo_audit" => run_cargo_audit(&container, &workdir).await,
"gitleaks" => run_gitleaks(&container).await, "gitleaks" => run_gitleaks(&container, &workdir).await,
"trivy_fs" => run_trivy_fs(&container).await, "trivy_fs" => run_trivy_fs(&container, &workdir).await,
"semgrep" => run_semgrep(&container).await, "semgrep" => run_semgrep(&container, &workdir).await,
other => { other => {
eprintln!("security_scan: unknown tool `{other}` — skipped"); eprintln!("security_scan: unknown tool `{other}` — skipped");
Ok(Vec::new()) Ok(Vec::new())
@@ -109,9 +109,10 @@ pub async fn run(pool: &PgPool, mission_id: Uuid, phase_id: Uuid) -> Result<usiz
// ── Per-tool runners ──────────────────────────────────────────── // ── Per-tool runners ────────────────────────────────────────────
async fn run_cargo_audit(container: &str) -> Result<Vec<Finding>, String> { async fn run_cargo_audit(container: &str, workdir: &std::path::Path) -> Result<Vec<Finding>, String> {
let out = docker_exec_json( let out = docker_exec_json(
container, container,
workdir,
&[ &[
"sh".into(), "sh".into(),
"-c".into(), "-c".into(),
@@ -149,9 +150,10 @@ async fn run_cargo_audit(container: &str) -> Result<Vec<Finding>, String> {
Ok(findings) Ok(findings)
} }
async fn run_gitleaks(container: &str) -> Result<Vec<Finding>, String> { async fn run_gitleaks(container: &str, workdir: &std::path::Path) -> Result<Vec<Finding>, String> {
let out = docker_exec_raw( let out = docker_exec_raw(
container, container,
workdir,
&[ &[
"sh".into(), "sh".into(),
"-c".into(), "-c".into(),
@@ -183,9 +185,10 @@ async fn run_gitleaks(container: &str) -> Result<Vec<Finding>, String> {
Ok(findings) Ok(findings)
} }
async fn run_trivy_fs(container: &str) -> Result<Vec<Finding>, String> { async fn run_trivy_fs(container: &str, workdir: &std::path::Path) -> Result<Vec<Finding>, String> {
let out = docker_exec_json( let out = docker_exec_json(
container, container,
workdir,
&[ &[
"sh".into(), "sh".into(),
"-c".into(), "-c".into(),
@@ -225,9 +228,10 @@ async fn run_trivy_fs(container: &str) -> Result<Vec<Finding>, String> {
Ok(findings) Ok(findings)
} }
async fn run_semgrep(container: &str) -> Result<Vec<Finding>, String> { async fn run_semgrep(container: &str, workdir: &std::path::Path) -> Result<Vec<Finding>, String> {
let out = docker_exec_json( let out = docker_exec_json(
container, container,
workdir,
&[ &[
"sh".into(), "sh".into(),
"-c".into(), "-c".into(),
@@ -279,30 +283,47 @@ async fn load_phase_config(pool: &PgPool, phase_id: Uuid) -> Result<Value, Strin
.unwrap_or_else(|| json!({}))) .unwrap_or_else(|| json!({})))
} }
async fn team_container_for_mission(pool: &PgPool, mission_id: Uuid) -> Result<String, String> { /// Post-task-#23: resolve the (container, working_dir) pair to exec
let row = sqlx::query( /// scans in. Missions run inside the SHARED runtime container
"SELECT t.zeroclaw_container /// (`CLAWMATES_RUNTIME_CONTAINER`, default `clawmates-runtime`) with
FROM missions m /// the working dir mounted at `$CLAWMATES_MISSIONS_ROOT/{mission_id}/repo`
JOIN teams t ON t.id = m.team_id /// on the host and the same path inside the runtime.
WHERE m.id = $1", ///
/// A mission MUST have a `repo_id` bound for scans to run — the
/// scanners need a source tree. Returning a clear error surfaces
/// that gap instead of silently reporting zero findings.
async fn exec_target(pool: &PgPool, mission_id: Uuid) -> Result<(String, PathBuf), String> {
let repo_id: Option<Uuid> = sqlx::query_scalar(
"SELECT repo_id FROM missions WHERE id = $1",
) )
.bind(mission_id) .bind(mission_id)
.fetch_optional(pool) .fetch_optional(pool)
.await .await
.map_err(|e| format!("resolve container: {e}"))?; .map_err(|e| format!("resolve mission repo: {e}"))?
row.and_then(|r| { .flatten();
r.try_get::<Option<String>, _>("zeroclaw_container") if repo_id.is_none() {
.ok() return Err(
.flatten() "mission has no repo bound — security scan requires a repository under mission.repo_id"
}) .into(),
.ok_or_else(|| "mission has no team container yet".to_string()) );
}
let container = std::env::var("CLAWMATES_RUNTIME_CONTAINER")
.unwrap_or_else(|_| "clawmates-runtime".to_string());
let root = std::env::var("CLAWMATES_MISSIONS_ROOT")
.unwrap_or_else(|_| "/var/lib/clawmates-missions".to_string());
let workdir = PathBuf::from(root).join(mission_id.to_string()).join("repo");
Ok((container, workdir))
} }
async fn docker_exec_raw(container: &str, cmd: &[String]) -> Result<String, String> { async fn docker_exec_raw(
container: &str,
workdir: &std::path::Path,
cmd: &[String],
) -> Result<String, String> {
let mut args = vec![ let mut args = vec![
"exec".to_string(), "exec".to_string(),
"-w".into(), "-w".into(),
"/workspace/repo".into(), workdir.display().to_string(),
container.to_string(), container.to_string(),
]; ];
args.extend(cmd.iter().cloned()); args.extend(cmd.iter().cloned());
@@ -314,8 +335,8 @@ async fn docker_exec_raw(container: &str, cmd: &[String]) -> Result<String, Stri
Ok(String::from_utf8_lossy(&out.stdout).into_owned()) Ok(String::from_utf8_lossy(&out.stdout).into_owned())
} }
async fn docker_exec_json(container: &str, cmd: &[String]) -> Result<Value, String> { async fn docker_exec_json(container: &str, workdir: &std::path::Path, cmd: &[String]) -> Result<Value, String> {
let raw = docker_exec_raw(container, cmd).await?; let raw = docker_exec_raw(container, workdir, cmd).await?;
let trimmed = raw.trim(); let trimmed = raw.trim();
if trimmed.is_empty() { if trimmed.is_empty() {
return Ok(json!({})); return Ok(json!({}));
-52
View File
@@ -318,55 +318,3 @@ pub async fn set_team_runtime_config(
Ok(()) Ok(())
} }
/// 0046: read the team's per-container coordinates. Both fields NULL
/// means the team has never spawned; the runtime provisions on first
/// iteration.
pub async fn team_container_coords(
pool: &PgPool,
id: Uuid,
workspace_id: WorkspaceId,
) -> Result<Option<(Option<String>, Option<String>)>, DbError> {
use sqlx::Row;
let row: Option<sqlx::postgres::PgRow> = sqlx::query(
"SELECT zeroclaw_container, zeroclaw_gateway_url FROM teams
WHERE id = $1 AND workspace_id = $2",
)
.bind(id)
.bind(workspace_id.as_uuid())
.fetch_optional(pool)
.await?;
Ok(row.map(|r| {
(
r.try_get::<Option<String>, _>("zeroclaw_container")
.ok()
.flatten(),
r.try_get::<Option<String>, _>("zeroclaw_gateway_url")
.ok()
.flatten(),
)
}))
}
/// 0046: set (or clear) the team's per-container coordinates once
/// spawn_team lands them.
pub async fn set_team_container_coords(
pool: &PgPool,
id: Uuid,
workspace_id: WorkspaceId,
container: Option<&str>,
gateway_url: Option<&str>,
) -> Result<(), DbError> {
sqlx::query(
"UPDATE teams
SET zeroclaw_container = $3,
zeroclaw_gateway_url = $4
WHERE id = $1 AND workspace_id = $2",
)
.bind(id)
.bind(workspace_id.as_uuid())
.bind(container)
.bind(gateway_url)
.execute(pool)
.await?;
Ok(())
}
@@ -0,0 +1,25 @@
-- Task #23: retire per-team ZeroClaw container coords.
--
-- The zeroclaw_container + zeroclaw_gateway_url columns on teams were
-- a research/loops-era artifact: each team owned its own long-lived
-- ZeroClaw daemon. Missions replaced that model — every mission-
-- materialized team is a workspace-scoped ephemeral roster of claws
-- provisioned as agents inside the SHARED runtime (see
-- mission_orchestrator + runtime_provision::RuntimeProvisioner).
--
-- Missions never populated these columns; only the legacy team-wizard
-- did (via set_team_runtime_config, now pruned). Keeping them made
-- security_scan + benchmark_runner silently fail on mission runs
-- because they read a NULL container name.
--
-- The mission-era exec target is (shared runtime container) at
-- ($CLAWMATES_MISSIONS_ROOT/$mission_id/repo). See security_scan.rs
-- + benchmark_runner.rs for the resolver.
BEGIN;
ALTER TABLE teams
DROP COLUMN IF EXISTS zeroclaw_container,
DROP COLUMN IF EXISTS zeroclaw_gateway_url;
COMMIT;