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:
co-authored by
Claude Opus 5
parent
12147a1e01
commit
1d554396f4
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user