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
+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(())
}