fix(delivery): four failures from the #55 trace — auth, prompts, truncation, retry

All four were surfaced while tracing #55 and left open. Each one on its own is
small; together they are why a two-line git rejection took hours to read.

**1. `with_ambient_auth` failed open.** It matched one literal prefix,
`https://git.redclaw.dev/`, and returned the URL unchanged for everything else
with no log line. An `http://` remote, an explicit port, a different case in the
host, an ssh remote, a URL that already carried userinfo — all came back
unauthenticated and looked identical to success. It now returns `Authed`, which
carries the URL AND why no credential reached it, and recognises the forge in
every shape a remote can be written (host parsed with userinfo stripped BEFORE
the port, or `oauth2:token@host` reports its username as the host — the first
version of this function did exactly that and failed its own test).

**2. Nothing set `GIT_TERMINAL_PROMPT=0`.** So a credential-less URL did not
fail — git opened `/dev/tty`, and in a server container that surfaces as
`No such device or address`, several layers from the missing token. Now set on
every git invocation that can reach the network. And `push_url_for` refuses
outright when the URL is on OUR forge and unauthenticated: that push cannot
succeed, and letting it proceed only buys a symptom that looks like something
else.

**3. The truncation fix went to the wrong path.** e31688b clamped the caller,
but a rejected push comes back as `Ok(Publish { error })` — the string was
already cut to 300 head chars inside `git()`, so the reject reason had been
dropped before the both-ends clamp ever saw it. Clamped where the output is
produced, and redacted there too.

**4. #55: a mission that re-clones can never push.** The branch name is
deterministic per (mission, phase, iteration), so a checkout rebuilt after a
retry, a container teardown or disk loss produces divergent history against its
own branch, and git rejects it — leaving the work on a local branch in a
directory the sweeper deletes. Reachable in normal operation, not just by
deleting a checkout by hand.

The escape is a NEW ref, not `--force`: forcing would overwrite whatever the
earlier attempt pushed, which may be the only copy of that work, to make this
attempt look tidy. The retry lands on `<branch>-<sha8>` — deterministic,
self-describing in a branch list, and collision-free since divergent history is
by definition a different sha. "Never force" stays a rule.

NEGATIVE CONTROL, run rather than assumed: with the rescue arm disabled,
`diverged_history_lands_on_a_new_branch_instead_of_being_lost` FAILS with git's
real `! [rejected] ... (fetch first)` — which also demonstrates fix 3, since that
whole message now survives to the assertion. The test asserts the earlier
attempt's ref is byte-identical afterwards.

Also measured, not read off docs: which hooks fire under `claude -p` (2.1.222,
via `--settings`). SessionStart, UserPromptSubmit, PreToolUse, PostToolUse,
SubagentStop and Stop fire; TaskCreated, TaskCompleted, TeammateIdle, SessionEnd,
Notification and PreCompact do not. So the agent-teams hooks Slice 3 deferred are
inert on our path by construction, and `Stop` is the seam that could move
`done_when` into the agent's own loop.

507 tests pass, clippy clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-06 13:15:15 -07:00
co-authored by Claude Opus 5
parent 12147a1e01
commit 1d554396f4
4 changed files with 472 additions and 56 deletions
+225 -41
View File
@@ -65,7 +65,19 @@ pub async fn ensure_checkout(
.map_err(|e| format!("mkdir {}: {e}", parent.display()))?;
}
let auth_url = with_ambient_auth(clone_url);
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.
@@ -92,21 +104,120 @@ pub async fn ensure_checkout(
Ok(Some(path))
}
/// If the URL points at git.redclaw.dev AND GITEA_TOKEN is set in the
/// environment, rewrite it to include the token as basic-auth. Returns
/// the URL unchanged otherwise. The token is never logged (we only
/// pass the rewritten URL into `git clone` via argv).
pub(crate) fn with_ambient_auth(url: &str) -> String {
let Ok(token) = std::env::var("GITEA_TOKEN") else {
return url.to_string();
/// 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(crate) 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,
};
if token.is_empty() {
return url.to_string();
// 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(crate) 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)"
));
}
if let Some(rest) = url.strip_prefix("https://git.redclaw.dev/") {
return format!("https://oauth2:{token}@git.redclaw.dev/{rest}");
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"));
}
url.to_string()
// 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")
}
async fn clone(path: &std::path::Path, url: &str) -> Result<(), String> {
@@ -116,14 +227,15 @@ async fn clone(path: &std::path::Path, url: &str) -> Result<(), String> {
// — 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.
let out = Command::new("git")
.args([
"clone",
"--filter=blob:none",
"--single-branch",
url,
&path.display().to_string(),
])
let mut cmd = Command::new("git");
cmd.args([
"clone",
"--filter=blob:none",
"--single-branch",
url,
&path.display().to_string(),
]);
let out = no_terminal_prompt(&mut cmd)
.output()
.await
.map_err(|e| format!("spawn git clone: {e}"))?;
@@ -131,10 +243,11 @@ async fn clone(path: &std::path::Path, url: &str) -> Result<(), String> {
return Err(format!(
"git clone → exit {}: {}",
out.status,
redact_token(&String::from_utf8_lossy(&out.stderr))
.chars()
.take(400)
.collect::<String>()
// 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);
@@ -523,16 +636,15 @@ async fn fetch_and_reset(
// 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 deepen = Command::new("git")
.args([
"-C",
&path.display().to_string(),
"fetch",
"--unshallow",
auth_url,
])
.output()
.await;
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!(
@@ -556,8 +668,9 @@ async fn fetch_and_reset(
// 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 fetch = Command::new("git")
.args(["-C", &path.display().to_string(), "fetch", auth_url, branch])
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}"))?;
@@ -565,10 +678,9 @@ async fn fetch_and_reset(
return Err(format!(
"git fetch origin {branch} → exit {}: {}",
fetch.status,
redact_token(&String::from_utf8_lossy(&fetch.stderr))
.chars()
.take(400)
.collect::<String>()
crate::evaluator_tools::clamp_output(&redact_token(&String::from_utf8_lossy(
&fetch.stderr
)))
));
}
let reset = Command::new("git")
@@ -602,6 +714,78 @@ async fn fetch_and_reset(
mod tests {
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.