`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]>
1127 lines
47 KiB
Rust
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()
|
|
);
|
|
}
|
|
}
|
|
}
|