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:
@@ -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());
|
||||||
|
|||||||
@@ -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!({}));
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user