Files
clawmates/crates/cm-api/src/mission_workspace.rs
T
Omar SobhandClaude Opus 5 91fbd2dc88 refactor: the missions root has one definition, not five
`mission_workspace::missions_root()` is now the only place that answers "where
does mission state live". It had fragmented into five: this function, private
`env::var("CLAWMATES_MISSIONS_ROOT")` copies in security_scan, benchmark_runner
and mission_outputs, and a hardcoded `MISSIONS_HOST_ROOT` const in
mission_runtime that read no env at all.

They agree on the deployed value, so nothing has broken. The risk is entirely
in what comes next: anything that sweeps or reclaims this tree has to be
sweeping the same tree the writers use, and five definitions cannot promise
that — a reaper written against one would silently leave the others' directories
behind forever, which is how the orphans got there in the first place.

A source-walk test fails any module outside `mission_workspace` that reads the
env var itself.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-09 06:18:01 -07:00

1127 lines
47 KiB
Rust

//! 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 --filter=blob:none --single-branch` (NOT
//! `--depth 1`: a shallow clone cannot push a new branch back, and delivery
//! needs exactly that — see `clone`). A mission with a `security_scan` phase
//! gets a FULLY HYDRATED clone instead; see `wants_full_history`.
//!
//! Auth: for `git.redclaw.dev` clones we inject the ambient
//! `GITEA_TOKEN` (already provisioned in the server container's env)
//! into the clone URL as basic-auth. For any other host we fall back
//! to the ambient credential setup (SSH agent, .netrc, git helper) —
//! prod hosts run with those configured. Tokens are never logged.
use std::path::PathBuf;
use tokio::process::Command;
use uuid::Uuid;
/// The ONE definition of where mission state lives on the docker host.
///
/// There used to be five: this function, three private copies of the same
/// `env::var(...).unwrap_or(...)` in `security_scan`, `benchmark_runner` and
/// `mission_outputs`, and a hardcoded `MISSIONS_HOST_ROOT` const in
/// `mission_runtime` that read no env at all. They agree on today's
/// deployment, which is why nothing had broken — but anything that sweeps or
/// reclaims this tree has to be sure it is sweeping the same tree the writers
/// use, and five definitions cannot promise that. A GC written against one of
/// them would silently miss the others.
pub 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()))?;
}
let auth = with_ambient_auth(clone_url);
// Said once, here, where the checkout is created: every later git call uses
// a URL built the same way, so an unauthenticated forge URL is a fact worth
// one line now rather than a `/dev/tty` error later.
if let Some(why) = &auth.unauthenticated {
if auth.is_forge() {
eprintln!(
"mission_workspace: mission {mission_id} will talk to the forge \
WITHOUT credentials — {why}"
);
}
}
let auth_url = auth.url;
if path.join(".git").exists() {
// Checkouts cloned before this setting existed get it on reuse. It
// governs objects created from now on, which is what delivery needs.
share_repository_across_uids(&path);
// `ensure_checkout` runs at every phase launch, not once per mission.
// Freshening a pristine checkout is right; freshening one that already
// holds this mission's work destroys it. See `has_local_work`.
// Marker first: it is a fact we recorded, not a state we inferred.
// The tree checks stay as a second line of defence for checkouts
// created before the marker existed, and for the case where the
// marker write itself failed.
if checkout_in_use(&path) || has_local_work(&path, default_branch) {
eprintln!(
"mission_workspace: {} already holds mission work — skipping \
fetch/reset so earlier phases' output survives",
path.display()
);
} else {
fetch_and_reset(&path, default_branch, &auth_url).await?;
}
} else {
clone(&path, &auth_url, wants_full_history(pool, mission_id).await).await?;
}
Ok(Some(path))
}
/// The forge whose URLs the ambient `GITEA_TOKEN` can authenticate.
const FORGE_HOST: &str = "git.redclaw.dev";
/// A URL, and whether a credential actually reached it.
///
/// The second field is the whole point. This used to be a bare `String`: an
/// unmatched URL — an ssh remote, `http://` instead of `https://`, an explicit
/// port, a different case in the host — silently came back unauthenticated, and
/// the first symptom was git opening `/dev/tty` several layers later. Tracing
/// #55 cost hours to a failure whose cause was one unlogged early return.
pub struct Authed {
pub url: String,
/// `None` when the token was applied; otherwise WHY it was not.
pub unauthenticated: Option<String>,
/// Whether the URL names the forge our token is for. Recorded from the
/// ORIGINAL url, not re-derived from `url` — an authenticated URL carries
/// userinfo, and parsing that back out is how the answer goes wrong.
forge: bool,
}
impl Authed {
/// Is this URL on the forge our token is for? An unauthenticated URL to a
/// third-party host is normal (public repos, ssh remotes with a key); an
/// unauthenticated URL to OUR forge is a fault, and only the caller knows
/// how much it costs.
pub fn is_forge(&self) -> bool {
self.forge
}
}
/// The host component of a URL, for the scp-like and scheme forms git accepts.
///
/// Deliberately tolerant, because the point is to RECOGNISE our forge in every
/// shape it can be written, not to validate URLs: `https://`, `http://`, an
/// explicit `:port`, `user@host`, `ssh://`, and `git@host:path`.
fn host_of(url: &str) -> Option<&str> {
let rest = match url.split_once("://") {
Some((_, rest)) => rest,
// scp-like: `git@host:path/to.git`, which has no scheme.
None => url,
};
// Userinfo FIRST, then the port. The other order splits
// `oauth2:token@host` at the credential's colon and reports the username as
// the host — which is exactly how the first version of this function decided
// an authenticated forge URL was not the forge.
let authority = rest.split('/').next().filter(|s| !s.is_empty())?;
let hostport = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
Some(hostport.split(':').next().unwrap_or(hostport)).filter(|h| !h.is_empty())
}
/// Rewrite a forge URL to carry the ambient `GITEA_TOKEN` as basic-auth.
///
/// Returns the reason instead of the credential whenever it cannot: a missing
/// token, a host that is not ours, or a shape a token cannot be injected into.
/// The token is never logged — only the rewritten URL is passed to git, via
/// argv.
pub fn with_ambient_auth(url: &str) -> Authed {
auth_with_token(url, std::env::var("GITEA_TOKEN").ok().as_deref())
}
/// The testable half of [`with_ambient_auth`]. The token is a parameter because
/// a test cannot set process environment variables here — the workspace denies
/// `unsafe`, and `set_var` is racy across test threads regardless.
fn auth_with_token(url: &str, token: Option<&str>) -> Authed {
let host = host_of(url);
let forge = host.is_some_and(|h| h.eq_ignore_ascii_case(FORGE_HOST));
let unauth = |why: String| Authed {
url: url.to_string(),
unauthenticated: Some(why),
forge,
};
let host = match host {
Some(h) => h,
None => return unauth(format!("no host could be read from {url:?}")),
};
if !forge {
return unauth(format!(
"{host} is not {FORGE_HOST}, so GITEA_TOKEN does not apply — git will \
use whatever ambient credentials exist (ssh agent, .netrc, helper)"
));
}
let token = match token {
Some(t) if !t.trim().is_empty() => t,
_ => return unauth("GITEA_TOKEN is unset or empty".to_string()),
};
// Only the scheme forms can carry basic-auth. An ssh remote authenticates
// with a key, and pretending otherwise would produce a URL git rejects.
let Some((scheme, rest)) = url.split_once("://") else {
return unauth(format!(
"{url} is an ssh-style remote; a token cannot be embedded in it"
));
};
if !matches!(scheme, "http" | "https") {
return unauth(format!("scheme {scheme} cannot carry a token"));
}
// Drop any userinfo already present rather than producing `a@b@host`.
let rest = rest.split_once('@').map(|(_, r)| r).unwrap_or(rest);
Authed {
url: format!("{scheme}://oauth2:{token}@{rest}"),
unauthenticated: None,
forge,
}
}
/// Git must never wait for a human.
///
/// Without this, a URL that ended up without credentials does not fail — git
/// opens `/dev/tty` to ask for a username, and in a server container that
/// surfaces as `No such device or address`, several layers away from the
/// missing token that caused it. With it, the failure names itself:
/// `terminal prompts disabled`.
pub(crate) fn no_terminal_prompt(cmd: &mut Command) -> &mut Command {
cmd.env("GIT_TERMINAL_PROMPT", "0")
}
/// Does any phase of this mission need history it can READ, not just reference?
///
/// `--filter=blob:none` keeps every commit but fetches file contents on demand,
/// which is nearly free for a repo that gets read once — and silently useless to
/// a tool that walks history, because the agent environment has NO network route
/// to the forge. Measured: gitleaks on a 4-commit repo reported
/// "1 commits scanned" and "could not fetch <sha> from promisor remote". It was
/// not misconfigured; the blobs simply were not there and could not be got.
///
/// A security scan is the phase kind whose entire value is old content — a
/// credential committed and later deleted is exactly what it looks for, and that
/// is precisely what a lazy blob is. So those missions pay for a full clone and
/// everything else keeps the cheap one.
///
/// Best-effort: an unreadable phase list yields `false`, i.e. today's behaviour.
async fn wants_full_history(pool: &sqlx::PgPool, mission_id: Uuid) -> bool {
sqlx::query_scalar::<_, i64>(
"SELECT count(*) FROM mission_phases WHERE mission_id = $1 AND kind = 'security_scan'",
)
.bind(mission_id)
.fetch_one(pool)
.await
.map(|n| n > 0)
.unwrap_or(false)
}
/// The `git clone` flags, split out so the strategy is testable without a forge.
fn clone_args(full_history: bool) -> Vec<&'static str> {
let mut a = vec!["clone"];
if !full_history {
a.push("--filter=blob:none");
}
a.push("--single-branch");
a
}
async fn clone(path: &std::path::Path, url: &str, full_history: bool) -> Result<(), String> {
// `--filter=blob:none` rather than `--depth 1`. A shallow clone cannot
// usually push a new branch back ("shallow update not allowed"), and
// mission delivery needs exactly that. A partial clone keeps full history
// — so the base commit stays meaningful and a diff has something to be
// relative to — while fetching file contents only on demand, which is
// nearly as cheap as a shallow clone for a repo that gets read once.
if full_history {
eprintln!(
"mission_workspace: cloning {} with full history — a security_scan phase \
reads old file contents, which a partial clone cannot supply offline",
path.display()
);
}
let mut cmd = Command::new("git");
cmd.args(clone_args(full_history));
cmd.args([url, &path.display().to_string()]);
let out = no_terminal_prompt(&mut cmd)
.output()
.await
.map_err(|e| format!("spawn git clone: {e}"))?;
if !out.status.success() {
return Err(format!(
"git clone → exit {}: {}",
out.status,
// Both ends: git prints its reason LAST, and a head-only clamp keeps
// the progress noise while dropping the answer.
crate::evaluator_tools::clamp_output(&redact_token(&String::from_utf8_lossy(
&out.stderr
)))
));
}
share_repository_across_uids(path);
scrub_remote_credentials(path, url);
ignore_agent_scaffolding(path);
record_base_commit(path);
Ok(())
}
/// Record that a phase has started working in this checkout.
///
/// The explicit half of the "is this checkout in use" question. `ensure_checkout`
/// runs per phase launch and refreshes on reuse; whether that refresh is safe
/// depends on whether a phase has already run here, which is a fact about the
/// *mission* and not about the tree.
///
/// It was previously inferred from the tree — dirty status, HEAD versus the
/// remote tip — and inference is what made delivery depend on what an agent
/// happened to do. Mission `019fc444` lost work because its phase committed and
/// left a clean tree; `019fc476` lost work because the capture base had advanced
/// to match HEAD; `019fc450` survived only because a phase *failed* to commit
/// and left the tree dirty. Same code, opposite outcomes, decided by the agent.
///
/// A marker is not a heuristic. Once a phase has begun, the checkout is in use
/// until the mission ends, whatever the agent did or did not do inside it.
pub(crate) fn mark_phase_started(path: &std::path::Path) {
let marker = path.join(".git/clawmates-in-use");
if marker.exists() {
return;
}
if let Err(e) = std::fs::write(&marker, "1\n") {
eprintln!(
"mission_workspace: could not mark {} as in use ({e}) — a later phase may \
refresh the checkout and discard earlier work",
path.display()
);
}
}
/// Has a phase already started work in this checkout?
fn checkout_in_use(path: &std::path::Path) -> bool {
path.join(".git/clawmates-in-use").exists()
}
/// Has anything happened in this checkout since it was created?
///
/// `ensure_checkout` is called once per *phase launch*, not once per mission,
/// and its reuse path runs `git reset --hard origin/<branch>`. That is correct
/// for a checkout being picked up cold and destructive for one mid-mission:
/// mission `019fc444` had its phase-0 file deleted from the working tree when
/// phase 1 started, so the second phase never saw the first's output.
///
/// Delivery is what made this reachable. Before the mission branch existed,
/// agent output stayed *untracked* and `reset --hard` left it alone. Committing
/// it — the whole point of the delivery slice — makes it tracked, and tracked
/// files that are absent from `origin/<branch>` are exactly what a hard reset
/// removes. The feature that preserves work is what put it in reach of the
/// reset.
///
/// "Local work" is either a commit that is not on the fetched tip, or a dirty
/// tree. Both are checked because the two phases of the failure look different:
/// an agent that committed leaves a clean tree at a new HEAD, and one that did
/// not leaves a dirty tree at the old HEAD.
fn has_local_work(path: &std::path::Path, branch: &str) -> bool {
let git = |args: &[&str]| -> Option<String> {
let out = std::process::Command::new("git")
.arg("-C")
.arg(path)
.args(["-c", &format!("safe.directory={}", path.display())])
.args(args)
.output()
.ok()?;
out.status
.success()
.then(|| String::from_utf8_lossy(&out.stdout).trim().to_string())
};
// A dirty tree is unambiguous: someone is mid-work here.
if let Some(status) = git(&["status", "--porcelain"]) {
if !status.is_empty() {
return true;
}
}
// Otherwise compare HEAD against the *remote tip*, which is the only
// fixed point here.
//
// This deliberately does not use `.git/clawmates-base`. That marker is the
// rolling capture base and `advance_base_commit` moves it to each phase's
// committed head — so comparing HEAD against it asks "did anything happen
// since the last commit we made", which is false immediately after every
// successful delivery. Mission `019fc476` lost phase 0's file exactly that
// way: phase 0 committed, the base advanced to match HEAD, and phase 1's
// launch concluded the checkout was pristine and reset it. The preceding
// mission survived only because its phase 0 *failed* to commit and left a
// dirty tree.
//
// `origin/<branch>` does not move for the life of the mission, so "HEAD is
// not the remote tip" means a phase committed, whether one commit ago or
// five. If the remote ref cannot be resolved the answer is preserve:
// wrongly skipping a refresh costs staleness, wrongly resetting destroys a
// phase's output.
match (
git(&["rev-parse", &format!("origin/{branch}")]),
git(&["rev-parse", "HEAD"]),
) {
(Some(tip), Some(head)) => tip != head,
_ => true,
}
}
/// Let the server and the agent container both write to this checkout.
///
/// The checkout is one directory bind-mounted into two processes running as
/// different users: cm-api is uid 65532, the mission runtime container is
/// root. Git creates `.git/objects/xx/` fan-out directories on first write and
/// they inherit the writer's ownership, so whichever party commits first locks
/// the other out of that directory:
///
/// ```text
/// git add → exit 128: insufficient permission for adding an object
/// to repository database .git/objects
/// ```
///
/// The failure is intermittent, which is what makes it dangerous. Mission
/// `019fc42b` delivered cleanly because its agents committed their own work,
/// so the blobs already existed and the server's `git add` never had to write
/// one. Mission `019fc437` ran the same template, its agents left the work
/// uncommitted, and delivery lost both phases.
///
/// `core.sharedRepository` is git's own answer to a repository shared between
/// users: it makes git create objects and refs group- and world-writable. Both
/// parties read this config from the shared `.git/config`, so it governs the
/// agent's commits as much as ours.
///
/// This grants the agent no access it lacks. It is already root inside a
/// container with the entire checkout bind-mounted read-write, and could
/// rewrite any of it. The party actually gaining something is the server,
/// which is currently the one being locked out.
pub fn share_repository_across_uids(path: &std::path::Path) {
let out = std::process::Command::new("git")
.args([
"-C",
&path.display().to_string(),
"-c",
&format!("safe.directory={}", path.display()),
"config",
"core.sharedRepository",
"0777",
])
.output();
match out {
Ok(o) if o.status.success() => {}
Ok(o) => eprintln!(
"mission_workspace: could not set core.sharedRepository on {} ({}) — delivery \
may fail to commit if the agent writes git objects first",
path.display(),
String::from_utf8_lossy(&o.stderr).trim()
),
Err(e) => eprintln!(
"mission_workspace: could not set core.sharedRepository on {} ({e})",
path.display()
),
}
}
/// Remember the commit the mission started from.
///
/// Delivery needs to answer "what did this mission change", and the obvious
/// reading — working tree versus `HEAD` — is wrong the moment an agent
/// commits. `rust_sdlc` has a *committer* role, so committing is the normal
/// path, not an edge case: a mission that did its job properly would have a
/// clean tree and capture nothing at all. Observed exactly that on mission
/// 019fc372, where the agent committed `DELIVERY_PROBE.md` and the diff came
/// back empty.
///
/// Written into `.git/` so it travels with the checkout, is invisible to the
/// repository, and cannot be edited by an agent through its pinned workspace.
pub(crate) fn record_base_commit(path: &std::path::Path) {
let out = std::process::Command::new("git")
.args([
"-C",
&path.display().to_string(),
"-c",
&format!("safe.directory={}", path.display()),
"rev-parse",
"HEAD",
])
.output();
let Ok(out) = out else { return };
if !out.status.success() {
return;
}
let sha = String::from_utf8_lossy(&out.stdout).trim().to_string();
if sha.is_empty() {
return;
}
if let Err(e) = std::fs::write(path.join(".git/clawmates-base"), format!("{sha}\n")) {
eprintln!(
"mission_workspace: could not record base commit for {} ({e}) — delivery will \
fall back to diffing against HEAD and will miss committed work",
path.display()
);
}
}
/// Move the capture base forward to a commit a phase just produced.
///
/// The base is recorded once at clone time, which is right for the mission's
/// first phase and wrong for every phase after it: a later phase would diff
/// against the original clone point and claim its predecessors' commits as its
/// own work. Mission `019fc42b` showed this plainly — two coding phases, and
/// the second phase's artifact reported the *union* of both phases' files.
///
/// Advancing after each successful commit makes each artifact the incremental
/// work of one phase. The pushed branch stays cumulative, because it is built
/// from `HEAD` and therefore still carries the earlier commits.
pub(crate) fn advance_base_commit(path: &std::path::Path, sha: &str) {
let sha = sha.trim();
if sha.is_empty() {
return;
}
if let Err(e) = std::fs::write(path.join(".git/clawmates-base"), format!("{sha}\n")) {
eprintln!(
"mission_workspace: could not advance base commit for {} ({e}) — the next \
phase will re-report this phase's work as its own",
path.display()
);
}
}
/// The commit this mission's checkout started from, if it was recorded.
pub(crate) fn base_commit(path: &std::path::Path) -> Option<String> {
std::fs::read_to_string(path.join(".git/clawmates-base"))
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
/// Take the access token back out of `.git/config`.
///
/// `with_ambient_auth` embeds `GITEA_TOKEN` in the clone URL so the clone can
/// authenticate, and git then persists that URL verbatim as the `origin`
/// remote. The checkout is bind-mounted into a container the agents run in as
/// **root**, so the token sits in a file every mission agent can read, and it
/// reaches every repository that token reaches — not just this one.
///
/// Rewriting the remote to the bare URL costs one command and removes a
/// standing credential from the blast radius of any prompt injection that
/// lands in a mission. Delivery does not depend on the stored URL: it builds a
/// fresh authenticated URL at push time, which also means a rotated token
/// starts working immediately instead of after the next clone.
///
/// Best-effort and non-fatal: a checkout that keeps its token still works, and
/// failing the mission over it would trade a real capability for a marginal
/// improvement in a situation we have already logged.
pub(crate) fn scrub_remote_credentials(path: &std::path::Path, original_url: &str) {
if !original_url.contains('@') && !original_url.contains("oauth2:") {
// Nothing was injected (SSH remote, or no token configured).
return;
}
let bare = strip_credentials(original_url);
let out = std::process::Command::new("git")
.args([
"-C",
&path.display().to_string(),
"remote",
"set-url",
"origin",
&bare,
])
.output();
match out {
Ok(o) if o.status.success() => {}
Ok(o) => eprintln!(
"mission_workspace: could not scrub credentials from {} — the access token \
remains readable in .git/config: {}",
path.display(),
redact_token(&String::from_utf8_lossy(&o.stderr))
.chars()
.take(200)
.collect::<String>()
),
Err(e) => eprintln!(
"mission_workspace: could not scrub credentials from {} ({e}) — the access \
token remains readable in .git/config",
path.display()
),
}
}
/// `https://user:secret@host/path` → `https://host/path`.
fn strip_credentials(url: &str) -> String {
let Some((scheme, rest)) = url.split_once("://") else {
return url.to_string();
};
match rest.split_once('@') {
// Only the *authority* may carry credentials; an `@` later in the path
// is an ordinary character and must not be treated as a separator.
Some((userinfo, host_and_path)) if !userinfo.contains('/') => {
format!("{scheme}://{host_and_path}")
}
_ => url.to_string(),
}
}
/// Files the agent runtime writes into its own workspace, which is pinned to
/// the repository root (`MissionRuntimeProvisioner::pin_agent_workspaces`).
///
/// They are the agent's identity scaffolding, not the user's code — `SOUL.md`
/// opens "Who You Are / You're not a chatbot." Observed on mission 019fc058,
/// where all seven appeared as untracked files in a freshly cloned repo.
///
/// `pub(crate)` because a repo-less mission needs the same list and cannot use
/// the same mechanism: `ignore_agent_scaffolding` writes `.git/info/exclude`,
/// and a mission with no repository has no `.git`. `mission_outputs` filters on
/// this list directly — one list, two consumers, so the next file the runtime
/// starts seeding is excluded from both at once.
pub(crate) const AGENT_SCAFFOLDING: &[&str] = &[
"AGENTS.md",
"HEARTBEAT.md",
"IDENTITY.md",
"MEMORY.md",
"SOUL.md",
"TOOLS.md",
"USER.md",
];
/// Keep the agent's own scaffolding out of the user's repository.
///
/// Two things went wrong without this. Every mission's tree was permanently
/// dirty, so a `done_when` written about a clean tree could never pass. And
/// once mission delivery starts committing, `git add -A` would have put the
/// agent's `SOUL.md` and `MEMORY.md` into someone's repository and pushed
/// them.
///
/// Written to `.git/info/exclude` rather than `.gitignore`: the exclude file
/// is local to this checkout and never itself appears as a change, so the
/// repository the user gets back is untouched. Crucially it only suppresses
/// *untracked* files — a repo that genuinely tracks its own `AGENTS.md` still
/// reports modifications to it, which is the behaviour we want.
///
/// Best-effort: a checkout that cannot be annotated is noisier, not broken.
fn ignore_agent_scaffolding(path: &std::path::Path) {
let exclude = path.join(".git/info/exclude");
let mut body = std::fs::read_to_string(&exclude).unwrap_or_default();
if body.contains("clawmates: agent scaffolding") {
return;
}
body.push_str("\n# clawmates: agent scaffolding — written by the runtime into its\n");
body.push_str("# pinned workspace, never part of the repository.\n");
for name in AGENT_SCAFFOLDING {
body.push_str(&format!("/{name}\n"));
}
if let Some(dir) = exclude.parent() {
let _ = std::fs::create_dir_all(dir);
}
if let Err(e) = std::fs::write(&exclude, body) {
eprintln!(
"mission_workspace: could not write {} ({e}) — agent scaffolding will show as \
untracked in this checkout",
exclude.display()
);
}
}
pub(crate) fn redact_token(s: &str) -> String {
// Strip any "oauth2:<token>@" segment that git may echo back on
// failures. Belt-and-braces: also nuke any raw token env value.
let mut out = s.to_string();
if let Some(pos) = out.find("oauth2:") {
if let Some(at) = out[pos..].find('@') {
out.replace_range(pos..pos + at, "oauth2:***");
}
}
if let Ok(t) = std::env::var("GITEA_TOKEN") {
if !t.is_empty() {
out = out.replace(&t, "***");
}
}
out
}
async fn fetch_and_reset(
path: &std::path::Path,
branch: &str,
auth_url: &str,
) -> Result<(), String> {
// A checkout cloned before delivery existed is shallow, and a shallow repo
// cannot push a new branch. Deepen it once, here, rather than discovering
// the problem at push time when there is work on the line. `--unshallow`
// errors on a repo that is already complete, so it is only attempted when
// the marker file is present.
if path.join(".git/shallow").exists() {
let mut cmd = Command::new("git");
cmd.args([
"-C",
&path.display().to_string(),
"fetch",
"--unshallow",
auth_url,
]);
let deepen = no_terminal_prompt(&mut cmd).output().await;
match deepen {
Ok(o) if o.status.success() => {}
Ok(o) => eprintln!(
"mission_workspace: could not deepen shallow checkout at {} — a delivery \
push may be rejected: {}",
path.display(),
redact_token(&String::from_utf8_lossy(&o.stderr))
.chars()
.take(200)
.collect::<String>()
),
Err(e) => eprintln!(
"mission_workspace: could not deepen shallow checkout at {} ({e})",
path.display()
),
}
}
// Fetch from an explicitly authenticated URL rather than the stored
// remote. `scrub_remote_credentials` strips the token out of
// `.git/config` — the checkout is readable by agents running as root —
// so `git fetch origin` has no credentials and fails with
// "could not read Username". Building the URL here also means a rotated
// token takes effect immediately instead of at the next clone.
let mut cmd = Command::new("git");
cmd.args(["-C", &path.display().to_string(), "fetch", auth_url, branch]);
let fetch = no_terminal_prompt(&mut cmd)
.output()
.await
.map_err(|e| format!("spawn git fetch: {e}"))?;
if !fetch.status.success() {
return Err(format!(
"git fetch origin {branch} → exit {}: {}",
fetch.status,
crate::evaluator_tools::clamp_output(&redact_token(&String::from_utf8_lossy(
&fetch.stderr
)))
));
}
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,
redact_token(&String::from_utf8_lossy(&reset.stderr))
.chars()
.take(400)
.collect::<String>()
));
}
// HEAD just moved to the freshly fetched tip; that is this run's starting
// point, so the recorded base moves with it.
record_base_commit(path);
Ok(())
}
#[cfg(test)]
mod tests {
/// The clone strategy is a fact worth pinning: `--depth 1` breaks delivery
/// (a shallow clone cannot push a new branch — "shallow update not
/// allowed"), and `--filter=blob:none` breaks history-reading tools offline.
/// Both failure modes are real and were both hit.
#[test]
fn the_clone_strategy_is_partial_by_default_and_never_shallow() {
let partial = clone_args(false);
assert!(partial.contains(&"--filter=blob:none"), "{partial:?}");
assert!(!partial.iter().any(|a| a.starts_with("--depth")), "{partial:?}");
// A security_scan mission must NOT get the lazy-blob filter: its scanner
// walks old file contents and cannot reach the forge to fetch them.
let full = clone_args(true);
assert!(!full.contains(&"--filter=blob:none"), "{full:?}");
assert!(!full.iter().any(|a| a.starts_with("--depth")), "{full:?}");
// Both keep --single-branch: the mission only ever works one branch.
for args in [partial, full] {
assert!(args.contains(&"--single-branch"), "{args:?}");
}
}
use super::*;
const TOK: Option<&str> = Some("secret123");
/// The forge in every shape a remote can be written. Each of these used to
/// fall out of the `strip_prefix("https://git.redclaw.dev/")` match and come
/// back unauthenticated with no log line — the fail-open found while tracing
/// #55.
#[test]
fn the_forge_is_recognised_however_the_url_is_written() {
for url in [
"https://git.redclaw.dev/o/r.git",
"http://git.redclaw.dev/o/r.git",
"https://GIT.RedClaw.dev/o/r.git",
"https://git.redclaw.dev:3000/o/r.git",
"https://oauth2:[email protected]/o/r.git",
] {
let a = auth_with_token(url, TOK);
assert!(a.is_forge(), "{url} was not recognised as the forge");
assert!(
a.unauthenticated.is_none(),
"{url} → {:?}",
a.unauthenticated
);
assert!(a.url.contains("oauth2:secret123@"), "{}", a.url);
// And exactly one set of credentials, not `old@` left behind.
assert_eq!(a.url.matches('@').count(), 1, "{}", a.url);
}
// The port and the scheme survive the rewrite — changing either would
// point the push somewhere the operator did not configure.
assert!(auth_with_token("https://git.redclaw.dev:3000/o/r.git", TOK)
.url
.contains("@git.redclaw.dev:3000/o/r.git"));
assert!(auth_with_token("http://git.redclaw.dev/o/r.git", TOK)
.url
.starts_with("http://oauth2:"));
}
/// Every path that cannot authenticate must SAY so. "Unauthenticated and
/// silent" is the shape that cost hours: the first symptom was git opening
/// /dev/tty, several layers from the cause.
#[test]
fn an_unauthenticated_url_carries_its_reason() {
let cases = [
(auth_with_token("https://git.redclaw.dev/o/r.git", None), true),
(auth_with_token("https://git.redclaw.dev/o/r.git", Some(" ")), true),
(auth_with_token("[email protected]:o/r.git", TOK), true),
(auth_with_token("ssh://[email protected]/o/r.git", TOK), true),
(auth_with_token("https://github.com/o/r.git", TOK), false),
];
for (a, is_forge) in cases {
let why = a.unauthenticated.as_deref().unwrap_or("");
assert!(!why.is_empty(), "{} came back with no reason", a.url);
assert_eq!(a.is_forge(), is_forge, "{}", a.url);
// And the URL is handed back untouched, so a caller that proceeds
// anyway (ssh keys, .netrc) still works.
assert!(!a.url.contains("secret123"), "{}", a.url);
}
}
/// A token must never be embedded in a URL for someone else's host.
#[test]
fn the_token_never_leaves_the_forge() {
for url in [
"https://github.com/o/r.git",
"https://git.redclaw.dev.evil.example/o/r.git",
"https://evil.example/git.redclaw.dev/r.git",
] {
let a = auth_with_token(url, TOK);
assert!(!a.url.contains("secret123"), "{url} → {}", a.url);
assert!(!a.is_forge(), "{url}");
}
}
/// The exclude must be idempotent — `ensure_checkout` re-runs on every
/// phase, and appending the same block each time would grow the file
/// without bound.
#[test]
fn scaffolding_exclusion_is_written_once() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".git/info")).unwrap();
ignore_agent_scaffolding(dir.path());
let first = std::fs::read_to_string(dir.path().join(".git/info/exclude")).unwrap();
assert!(
first.contains("/SOUL.md"),
"the agent's identity file is excluded"
);
assert!(first.contains("/MEMORY.md"));
ignore_agent_scaffolding(dir.path());
let second = std::fs::read_to_string(dir.path().join(".git/info/exclude")).unwrap();
assert_eq!(first, second, "re-running must not append a second block");
}
#[test]
fn credentials_are_stripped_from_a_remote_url() {
assert_eq!(
strip_credentials("https://oauth2:[email protected]/o/r.git"),
"https://git.redclaw.dev/o/r.git"
);
// No credentials: unchanged.
assert_eq!(
strip_credentials("https://git.redclaw.dev/o/r.git"),
"https://git.redclaw.dev/o/r.git"
);
// SSH form has no `://` authority to rewrite.
assert_eq!(
strip_credentials("[email protected]:o/r.git"),
"[email protected]:o/r.git"
);
// An `@` inside the path is not a credential separator.
assert_eq!(
strip_credentials("https://host/scope/@org/pkg.git"),
"https://host/scope/@org/pkg.git"
);
}
/// An existing exclude file belongs to the repository; keep it.
#[test]
fn an_existing_exclude_is_preserved() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".git/info")).unwrap();
std::fs::write(dir.path().join(".git/info/exclude"), "/local-scratch\n").unwrap();
ignore_agent_scaffolding(dir.path());
let body = std::fs::read_to_string(dir.path().join(".git/info/exclude")).unwrap();
assert!(
body.contains("/local-scratch"),
"pre-existing rules survive"
);
assert!(body.contains("/AGENTS.md"));
}
/// Seed a checkout that has an `origin`, like a real clone does. Without
/// one `origin/<branch>` does not resolve and `has_local_work` takes its
/// preserve-by-default path, which would make the pristine case untestable.
fn seed(dir: &std::path::Path, remote: &std::path::Path) {
std::process::Command::new("git")
.args(["init", "--quiet", "--bare"])
.arg(remote)
.output()
.unwrap();
let g = |args: &[&str]| {
std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.unwrap();
};
g(&["init", "--quiet"]);
g(&["config", "user.email", "[email protected]"]);
g(&["config", "user.name", "T"]);
g(&["checkout", "-q", "-B", "main"]);
std::fs::write(dir.join("README.md"), "# base\n").unwrap();
g(&["add", "."]);
g(&["commit", "--quiet", "-m", "base"]);
g(&["remote", "add", "origin", &remote.display().to_string()]);
g(&["push", "--quiet", "origin", "main"]);
g(&["fetch", "--quiet", "origin", "main"]);
record_base_commit(dir);
}
/// A checkout mid-mission must not be mistaken for a cold one.
///
/// `ensure_checkout` runs per phase launch and resets on the reuse path.
/// Mission `019fc444` lost phase 0's committed file that way. The fix then
/// failed again on mission `019fc476` for a different reason, which the
/// last case here pins down.
#[test]
fn local_work_is_recognized_before_a_checkout_is_reset() {
let tmp = tempfile::tempdir().unwrap();
let repo = &tmp.path().join("repo");
std::fs::create_dir_all(repo).unwrap();
seed(repo, &tmp.path().join("remote.git"));
let repo = repo.as_path();
assert!(
!has_local_work(repo, "main"),
"a freshly cloned checkout has no work and may be refreshed"
);
// An agent that wrote files and did not commit: dirty tree, HEAD put.
std::fs::write(repo.join("ALPHA.md"), "ALPHA\n").unwrap();
assert!(has_local_work(repo, "main"), "uncommitted agent output is work");
// An agent (or delivery) that committed: clean tree, HEAD moved. This
// is the shape that was destroyed on 019fc444, because a hard reset
// leaves untracked files alone but removes tracked ones.
git_in(repo, &["add", "ALPHA.md"]);
git_in(repo, &["commit", "--quiet", "-m", "phase 0"]);
let status = std::process::Command::new("git")
.arg("-C")
.arg(repo)
.args(["status", "--porcelain"])
.output()
.unwrap();
assert!(
String::from_utf8_lossy(&status.stdout).trim().is_empty(),
"the commit left a clean tree — the case a dirty-tree check misses"
);
assert!(
has_local_work(repo, "main"),
"committed phase output must not be reset away"
);
// The regression from 019fc476. Delivery advances the capture base to
// the commit it just made, so any check comparing HEAD against that
// base reports "nothing happened" the instant a phase succeeds — and
// the next phase resets the work away. Advancing it here is what makes
// this a real reproduction rather than a restatement of the case above.
let head = std::process::Command::new("git")
.arg("-C")
.arg(repo)
.args(["rev-parse", "HEAD"])
.output()
.unwrap();
let head = String::from_utf8_lossy(&head.stdout).trim().to_string();
advance_base_commit(repo, &head);
assert_eq!(
base_commit(repo).as_deref(),
Some(head.as_str()),
"the base now equals HEAD, which is the trap"
);
assert!(
has_local_work(repo, "main"),
"a phase that committed successfully must still count as work \
after the capture base advances to match its commit"
);
}
fn git_in(dir: &std::path::Path, args: &[&str]) {
std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.unwrap();
}
/// A checkout in use must be recognised regardless of what the agent did.
///
/// This is the Seam-1 property. The tree-state heuristics were each correct
/// in isolation and each blind to a different case: `019fc444` committed
/// and left a clean tree, `019fc476` had its base advanced to match HEAD,
/// `019fc450` survived only because a phase FAILED to commit. Whether the
/// work survived was decided by the agent, not by us.
///
/// The marker is set when a phase launches, before the agent does anything,
/// so every one of those states answers the same way.
#[test]
fn an_in_use_checkout_is_recognized_whatever_the_agent_did() {
let tmp = tempfile::tempdir().unwrap();
let repo = &tmp.path().join("repo");
std::fs::create_dir_all(repo).unwrap();
seed(repo, &tmp.path().join("remote.git"));
let repo = repo.as_path();
assert!(!checkout_in_use(repo), "a fresh clone is not in use");
mark_phase_started(repo);
assert!(checkout_in_use(repo), "a launched phase marks the checkout");
// The three production states, all of which must now answer the same.
// (a) agent wrote nothing at all — the case every tree heuristic misses.
assert!(checkout_in_use(repo), "clean tree at the base commit");
// (b) agent committed, leaving a clean tree at a moved HEAD.
std::fs::write(repo.join("WORK.md"), "work\n").unwrap();
git_in(repo, &["add", "WORK.md"]);
git_in(repo, &["commit", "--quiet", "-m", "phase work"]);
let head = std::process::Command::new("git")
.arg("-C")
.arg(repo)
.args(["rev-parse", "HEAD"])
.output()
.unwrap();
let head = String::from_utf8_lossy(&head.stdout).trim().to_string();
assert!(checkout_in_use(repo));
// (c) capture advanced the base to match HEAD — the collision that
// defeated the HEAD-versus-base check on 019fc476.
advance_base_commit(repo, &head);
assert!(
checkout_in_use(repo),
"an advanced base must not make an in-use checkout look pristine"
);
// Marking twice is safe; phases launch repeatedly across a mission.
mark_phase_started(repo);
assert!(checkout_in_use(repo));
}
/// Nobody re-derives the missions root.
///
/// It had fragmented into five definitions — this function, three private
/// `env::var("CLAWMATES_MISSIONS_ROOT")` copies, and a hardcoded const
/// that read no env at all. They agreed on the deployed value, so nothing
/// ever broke; the risk is entirely in what comes next. Anything that
/// sweeps, reclaims or reaps this tree has to be sweeping the same tree the
/// writers use, and five definitions cannot promise that.
#[test]
fn the_missions_root_has_exactly_one_definition() {
fn walk(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
for entry in std::fs::read_dir(dir).expect("readable source dir") {
let path = entry.expect("readable entry").path();
if path.is_dir() {
walk(&path, out);
} else if path.extension().is_some_and(|e| e == "rs") {
out.push(path);
}
}
}
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut files = Vec::new();
walk(&root, &mut files);
for path in files {
if path.ends_with("mission_workspace.rs") {
continue;
}
let src = std::fs::read_to_string(&path).expect("readable source");
assert!(
!src.contains("var(\"CLAWMATES_MISSIONS_ROOT\")"),
"{} reads CLAWMATES_MISSIONS_ROOT itself — call \
`mission_workspace::missions_root()` so a reaper and a writer \
cannot disagree about which tree they are looking at",
path.display()
);
}
}
}