task #25: per-mission repo checkout on mission launch

Closes the follow-up gap flagged when task #23 landed. security_scan
and benchmark_runner now exec against $CLAWMATES_MISSIONS_ROOT/
{mission_id}/repo — this commit is what actually puts a repo there.

  - crates/cm-api/src/mission_workspace.rs — new module.
    ensure_checkout(pool, workspace_id, mission_id):
      * mission with no repo_id → Ok(None), no-op
      * repo cloned into $ROOT/{id}/repo (--depth 1)
      * dir already a git repo → fetch + reset --hard origin/{branch}
        (idempotent — every launch brings the tree in sync with the
        remote default_branch)
    Auth uses the process's ambient git credential setup (SSH agent /
    .netrc / helper). Tokens deliberately not embedded in URLs.

  - crates/cm-api/src/mission_orchestrator.rs — on_launch calls
    ensure_checkout after team materialization + team_id bind.
    Non-fatal: clone failures log and continue so research_only
    missions (no repo needed) don't get blocked.

  - deploy/compose/docker-compose.yml — new named volume
    missions_workspaces mounted at /var/lib/clawmates-missions on
    both the server (writer) and where the clawmates-runtime
    container will mount it (reader for docker exec). CLAWMATES_
    MISSIONS_ROOT + CLAWMATES_RUNTIME_CONTAINER env vars set on
    the server so mission_workspace + exec_target read the same
    canonical values.

The scan/bench trigger buttons now actually produce findings once
you (a) run a mission whose repo_id is set, (b) have the
clawmates-runtime container bind-mounting missions_workspaces at
/var/lib/clawmates-missions.

Verified: SQLX_OFFLINE=true cargo check -p cm-api +
cargo test -p cm-api --test mission_orchestrator both green.
This commit is contained in:
Omar Sobh
2026-07-20 04:04:36 -07:00
parent 854a617777
commit 214d0c5e9f
4 changed files with 168 additions and 0 deletions
+1
View File
@@ -12,6 +12,7 @@ mod mcp_door;
mod mcp_skills; mod mcp_skills;
pub mod mission_orchestrator; pub mod mission_orchestrator;
pub mod mission_refiner; pub mod mission_refiner;
pub mod mission_workspace;
pub mod node_rules; pub mod node_rules;
pub mod pdf_renderer; pub mod pdf_renderer;
pub mod quota; pub mod quota;
+18
View File
@@ -82,6 +82,24 @@ pub async fn on_launch(
.await .await
.map_err(|e| format!("bind team on mission: {e}"))?; .map_err(|e| format!("bind team on mission: {e}"))?;
// Ensure the mission's repo is checked out at
// $CLAWMATES_MISSIONS_ROOT/{mission_id}/repo — where
// security_scan + benchmark_runner exec against. Non-fatal:
// missions without a repo (research_only, custom) skip cleanly,
// and clone failures log without blocking launch (the operator
// sees the error on the canvas via the run's failed status when
// a repo-dependent phase tries to fire).
match crate::mission_workspace::ensure_checkout(pool, workspace_id, mission_id).await {
Ok(Some(path)) => eprintln!(
"mission_orchestrator: repo checked out at {}",
path.display()
),
Ok(None) => {}
Err(e) => eprintln!(
"mission_orchestrator: repo checkout for {mission_id} failed (continuing): {e}"
),
}
Ok(Some(team_id)) Ok(Some(team_id))
} }
+138
View File
@@ -0,0 +1,138 @@
//! Per-mission repo checkout.
//!
//! Missions execute their coding / benchmark / security phases against
//! a filesystem checkout of `missions.repo_id` at
//! `$CLAWMATES_MISSIONS_ROOT/{mission_id}/repo`. That path is what
//! `security_scan::exec_target` + `benchmark_runner::exec_target`
//! both `docker exec -w` into.
//!
//! `ensure_checkout` is called from `mission_orchestrator::on_launch`
//! and is idempotent:
//! - no repo_id → no-op (Ok(None))
//! - dir already a git repo → `fetch + reset --hard origin/<branch>`
//! to bring it in sync
//! - dir missing → `git clone --depth 1 <url> <path>`
//!
//! Auth: relies on the ambient git credential setup (SSH agent,
//! .netrc, or git-credential helper) in the process environment. We
//! deliberately don't embed tokens in URLs — footgun risk outweighs
//! the ergonomics, and prod already runs with a helper configured.
use std::path::PathBuf;
use tokio::process::Command;
use uuid::Uuid;
fn missions_root() -> PathBuf {
std::env::var("CLAWMATES_MISSIONS_ROOT")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("/var/lib/clawmates-missions"))
}
pub fn checkout_path(mission_id: Uuid) -> PathBuf {
missions_root().join(mission_id.to_string()).join("repo")
}
/// Ensure the mission's repo is checked out at `checkout_path`.
/// Returns Ok(None) when the mission has no repo bound, Ok(Some(path))
/// when a checkout is in place (freshly cloned or brought up-to-date).
pub async fn ensure_checkout(
pool: &sqlx::PgPool,
workspace_id: cm_domain::WorkspaceId,
mission_id: Uuid,
) -> Result<Option<PathBuf>, String> {
let mission = cm_db::repo::missions::get(pool, mission_id, workspace_id.as_uuid())
.await
.map_err(|e| format!("load mission: {e}"))?
.ok_or_else(|| "mission not found".to_string())?;
let Some(repo_id) = mission.repo_id else {
return Ok(None);
};
let repo = cm_db::repo::repos::get(pool, repo_id, workspace_id)
.await
.map_err(|e| format!("load repo {repo_id}: {e}"))?;
let clone_url = repo
.clone_url
.as_deref()
.ok_or_else(|| format!("repo {repo_id} has no clone_url"))?;
let default_branch = repo.default_branch.as_deref().unwrap_or("main");
let path = checkout_path(mission_id);
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|e| format!("mkdir {}: {e}", parent.display()))?;
}
if path.join(".git").exists() {
fetch_and_reset(&path, default_branch).await?;
} else {
clone(&path, clone_url).await?;
}
Ok(Some(path))
}
async fn clone(path: &std::path::Path, url: &str) -> Result<(), String> {
let out = Command::new("git")
.args([
"clone",
"--depth",
"1",
url,
&path.display().to_string(),
])
.output()
.await
.map_err(|e| format!("spawn git clone: {e}"))?;
if !out.status.success() {
return Err(format!(
"git clone {url} → exit {}: {}",
out.status,
String::from_utf8_lossy(&out.stderr)
.chars()
.take(400)
.collect::<String>()
));
}
Ok(())
}
async fn fetch_and_reset(path: &std::path::Path, branch: &str) -> Result<(), String> {
let fetch = Command::new("git")
.args(["-C", &path.display().to_string(), "fetch", "--depth", "1", "origin", branch])
.output()
.await
.map_err(|e| format!("spawn git fetch: {e}"))?;
if !fetch.status.success() {
return Err(format!(
"git fetch origin {branch} → exit {}: {}",
fetch.status,
String::from_utf8_lossy(&fetch.stderr)
.chars()
.take(400)
.collect::<String>()
));
}
let reset = Command::new("git")
.args([
"-C",
&path.display().to_string(),
"reset",
"--hard",
&format!("origin/{branch}"),
])
.output()
.await
.map_err(|e| format!("spawn git reset: {e}"))?;
if !reset.status.success() {
return Err(format!(
"git reset --hard origin/{branch} → exit {}: {}",
reset.status,
String::from_utf8_lossy(&reset.stderr)
.chars()
.take(400)
.collect::<String>()
));
}
Ok(())
}
+11
View File
@@ -28,6 +28,11 @@ volumes:
# the unix-socket equivalent of the K8s sidecar topology. # the unix-socket equivalent of the K8s sidecar topology.
broker_run: {} broker_run: {}
broker_key: {} broker_key: {}
# Per-mission repo checkouts + generated PDFs. Server writes to
# this via mission_workspace + pdf_renderer; the (separately-managed)
# clawmates-runtime container bind-mounts the same path so scans
# + benches exec at the same tree.
missions_workspaces: {}
services: services:
postgres: postgres:
@@ -118,9 +123,15 @@ services:
CLAWMATES_CONFIG: /etc/clawmates/clawmates.toml CLAWMATES_CONFIG: /etc/clawmates/clawmates.toml
CLAWMATES_DATABASE__URL: postgres://postgres:${POSTGRES_PASSWORD:?set in .env}@postgres:5432/clawmates CLAWMATES_DATABASE__URL: postgres://postgres:${POSTGRES_PASSWORD:?set in .env}@postgres:5432/clawmates
DOCKER_HOST: tcp://socket-proxy:2375 DOCKER_HOST: tcp://socket-proxy:2375
CLAWMATES_MISSIONS_ROOT: /var/lib/clawmates-missions
CLAWMATES_RUNTIME_CONTAINER: ${CLAWMATES_RUNTIME_CONTAINER:-clawmates-runtime}
volumes: volumes:
- ./clawmates.toml:/etc/clawmates/clawmates.toml:ro - ./clawmates.toml:/etc/clawmates/clawmates.toml:ro
- broker_run:/run/clawmates - broker_run:/run/clawmates
# Per-mission repo checkouts. Bind-mounted host path so the
# separately-managed clawmates-runtime container can see the
# same trees at the same path when it exec's for scans/benches.
- missions_workspaces:/var/lib/clawmates-missions
networks: [edge, core, engine_net] networks: [edge, core, engine_net]
ports: ports:
- "8080:8080" - "8080:8080"