//! 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/` //! 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, 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, /// 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 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/`. 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/` 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 { 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/` 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 { 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::() ), 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:@" 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::() ), 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::() )); } // 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:old@git.redclaw.dev/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("git@git.redclaw.dev:o/r.git", TOK), true), (auth_with_token("ssh://git@git.redclaw.dev/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:secret123@git.redclaw.dev/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("git@github.com:o/r.git"), "git@github.com: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/` 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", "t@clawmates.local"]); 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) { 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() ); } } }