diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 02d301c29..c526534b2 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -151,6 +151,7 @@ Everything an operator chooses lives in `deploy/.env`, read by | `ENGINE_AGENT_OPENCODE_CONFIG` | no | the image's policy | The opencode half of the driven-session policy: an opencode config with a `permission` block, given to every engine-launched opencode session as `OPENCODE_CONFIG_CONTENT` (the user's own config is not edited). Allow `gh pr merge` here where agents may merge their own PRs. `off` turns it off ([`ENGINE.md`, "Permission posture"](ENGINE.md#permission-posture)). | | `ENGINE_AGENT_CODEX_POLICY` | no | the image file, else `/etc/codex/requirements.toml` | The Codex half. Codex itself reads `/etc/codex/requirements.toml` and no other path; the engine checks that file, and a named one too, is readable and wires the gitguard hook (`[[hooks.SessionStart]]` + `[[hooks.PreToolUse]]` running `/usr/local/bin/klaudia hook gitguard` with `failClosed = true`, plus the `[features] hooks = true` pin that stops a session switching hooks off) before a Codex launch. `/etc/codex/requirements.toml` and its directory must also be root-owned and not group- or world-writable, and `/usr/local/bin/klaudia` a root-owned regular file the same way; the hook always runs that image build, never a runtime `VOGT_KLAUDIA_VERSION` copy, so a gitguard fix ships only with an image rebuild (WI-1146). This is an accident guard, not a sandbox: the session user's passwordless sudo can defeat it. Empty checks `/etc/codex/requirements.toml`; the image's reference copy at `/usr/local/share/vogt/driven-session-codex-requirements.toml` without it is a refusal. `off` is the only way a deployment that has the file skips the check. A path that is missing, empty, unreadable or without that hook refuses the Codex launch (400) and does not fall through to bypass; Claude and opencode are not blocked. The shipped file wires the hook for the Codex fork (WI-1356) ([`ENGINE.md`, "Permission posture"](ENGINE.md#permission-posture)). | | `ENGINE_SESSION_RSS_WARN` | no | off | Flag any session whose process tree's resident memory reaches this (`8GiB`). Usage is shown either way; this only marks the heavy ones. | +| `ENGINE_WORKTREE_GITHUB_TOKEN` | no | unset | GitHub token the engine uses when `session.start` fetches the worktree base from a GitHub `origin` (WI-1666). Set it to the same token sessions receive as `GH_TOKEN` (the manifest entry named by `ENGINE_AGENT_AUTH_GH_TOKEN_FROM`). A file is not used: the engine reads the variable and passes it to `GIT_ASKPASS` for that one fetch, and redacts it from errors. Unset, an `https://github.com` origin refuses the worktree start instead of failing as `Repository not found`. SSH GitHub origins and non-GitHub origins do not need it and keep the engine user's own git credentials. | | `ENGINE_WORKTREE_CLEAN_ON_HIBERNATE` | no | off | When a session is hibernated or stopped and its directory is a linked git worktree, remove regenerable build output there (`target` beside a `Cargo.toml`, `node_modules`, `.venv`). A repo's main checkout is never touched, and a tracked file is never deleted ([`ENGINE.md`, "Worktree build output"](ENGINE.md#worktree-build-output)). | | `ENGINE_DISK_WARN_PERCENT` | no | `85` | Flag the workspace volume on `sweep`, `status` and `engine.status` once this percent of it is used. A whole percent from 1 to 100; anything else fails the boot. | | `ENGINE_ECONOMICS_TICK_SECS` | no | `15` | How often the engine reads each live session's transcript to meter its turns, context and compactions, reported on the session summary and as histograms on `GET /metrics` ([`ENGINE.md`, "Session economics"](ENGINE.md#session-economics)). `0` disables it. | diff --git a/docs/ENGINE.md b/docs/ENGINE.md index f651bcc2a..f8c6a5ea2 100644 --- a/docs/ENGINE.md +++ b/docs/ENGINE.md @@ -847,8 +847,17 @@ than these routes. fails, or a base the remote does not have, refuses the start (`400`) rather than falling back to the local ref. The fetch moves only that one remote-tracking ref, never a local branch. The fetch runs as the engine - process, before the session process exists, so a private origin is reached - with the engine user's credentials rather than the session account's. Every other refusal (codex policy, agent launch) is checked before the + process, before the session process exists (WI-1666). An `https://github.com` + origin (host exactly `github.com`) is fetched with + `ENGINE_WORKTREE_GITHUB_TOKEN`, falling back to `GH_TOKEN` on the engine + process — the same token agent-auth aliases to `GH_TOKEN` inside a session. + The value is given to git only through `GIT_ASKPASS` (`vogt-git-askpass`, + reading `ENGINE_WORKTREE_FETCH_TOKEN` for that one command) and is never put + on an argument list or into an error. An https GitHub origin with neither + variable set refuses the start (`400`) and names + `ENGINE_WORKTREE_GITHUB_TOKEN`. SSH (`git@github.com:...`), a host that only + contains the substring `github.com`, and every non-GitHub origin keep the + engine user's own git credentials and are not given the token. Every other refusal (codex policy, agent launch) is checked before the cut, and a start that fails after it (prompt file, PTY spawn) removes the worktree and its branch again, so a refused start leaves neither behind. Absent means the session opens in `cwd`, byte-identical to a request from diff --git a/engine/deploy/git-askpass.sh b/engine/deploy/git-askpass.sh index de502a37c..f68c5e7f9 100755 --- a/engine/deploy/git-askpass.sh +++ b/engine/deploy/git-askpass.sh @@ -1,23 +1,42 @@ -#!/usr/bin/env bash -set -euo pipefail - -prompt="${1:-}" +#!/bin/sh +set -eu +# Host is the exact hostname, not a substring. +# github.com.example and notgithub.com do not receive the GitHub token. +prompt=${1:-} case "$prompt" in - *Username*github.com*) - printf '%s\n' 'x-access-token' - ;; - *Username*) - printf '%s\n' 'git' - ;; - *Password*github.com*) - [[ -n "${GH_TOKEN:-}" ]] || exit 1 + *://*) host=${prompt##*://} ;; + *) host=$prompt ;; +esac +host=${host%%/*} +host=${host##*@} +host=${host%%:*} +host=${host%\'} +host=${host#\'} +case "$prompt" in + *[Uu]sername*) + if [ "$host" = "github.com" ]; then + printf '%s\n' x-access-token + else + printf '%s\n' git + fi + ;; + *[Pp]assword*) + if [ "$host" = "github.com" ]; then + if [ -n "${ENGINE_WORKTREE_FETCH_TOKEN:-}" ]; then + printf '%s\n' "$ENGINE_WORKTREE_FETCH_TOKEN" + elif [ -n "${GH_TOKEN:-}" ]; then printf '%s\n' "$GH_TOKEN" - ;; - *Password*) - [[ -n "${GIT_AUTH_TOKEN:-}" ]] || exit 1 - printf '%s\n' "$GIT_AUTH_TOKEN" - ;; - *) + else + exit 1 + fi + else + if [ -z "${GIT_AUTH_TOKEN:-}" ]; then exit 1 - ;; + fi + printf '%s\n' "$GIT_AUTH_TOKEN" + fi + ;; + *) + exit 1 + ;; esac diff --git a/engine/server/src/git.rs b/engine/server/src/git.rs index 9c00d0181..439034502 100644 --- a/engine/server/src/git.rs +++ b/engine/server/src/git.rs @@ -290,9 +290,18 @@ pub fn remove_worktree(checkout: &Path, path: &Path, branch: &str) { /// fetch moves only the one remote-tracking ref, never a local branch. /// /// The fetch runs as the engine process. `create_worktree` is called before -/// the session process exists, so a private origin is reached with the -/// engine user's credentials, not the session account's (WI-1630 review, -/// finding 3). +/// the session process exists, so a private origin is reached with a GitHub +/// token the engine holds, not a token the session process would later +/// receive (WI-1666). That token is the same one agent-auth aliases to +/// `GH_TOKEN` for sessions: `ENGINE_WORKTREE_GITHUB_TOKEN`, or `GH_TOKEN` +/// when the engine process itself already has it. It is handed to git only +/// through `GIT_ASKPASS` for this one fetch, never written to a ref, a +/// remote URL, argv, or a log line. +/// +/// A GitHub `origin` with no such token refuses before git runs, naming the +/// missing credential. A non-GitHub origin (a local bare, a forge that does +/// not speak this token) is fetched with the engine user's own git +/// credentials, as before. fn fetch_base_ref(checkout: &Path, base_ref: &str) -> Result { // One prefix only. `origin/origin/main` is a branch literally named that, // which the remote will not have, and the fetch then refuses. @@ -312,10 +321,27 @@ fn fetch_base_ref(checkout: &Path, base_ref: &str) -> Result { // branch updates that local branch, not `refs/remotes/origin/`, // and the local branch is exactly the stale ref this must not follow. let spec = format!("+refs/heads/{source}:refs/remotes/origin/{source}"); - git_output(checkout, &["fetch", "--quiet", "origin", &spec]).map_err(|error| { - ApiError::BadRequest(format!( - "could not fetch base ref {base_ref:?} from origin: {error}" - )) + let github_https = origin_is_github_https(checkout); + let token = worktree_github_token(); + if github_https && token.is_none() { + return Err(ApiError::BadRequest( + "could not fetch base ref from origin: this origin is https GitHub and the engine has no GitHub token for the worktree fetch (set ENGINE_WORKTREE_GITHUB_TOKEN to the same token sessions get as GH_TOKEN, or export GH_TOKEN on the engine process)".into(), + )); + } + // Only an https GitHub origin uses the session token. A local path, another + // forge, or git@github.com SSH keeps the engine user's own credentials. + let fetch_token = token.as_deref().filter(|_| github_https); + git_fetch(checkout, &spec, fetch_token).map_err(|error| { + let detail = redact_token(error.to_string(), fetch_token); + if github_https { + ApiError::BadRequest(format!( + "could not fetch base ref {base_ref:?} from origin using the engine GitHub token (ENGINE_WORKTREE_GITHUB_TOKEN, or GH_TOKEN on the engine process). The token may not be able to read this private repository: {detail}" + )) + } else { + ApiError::BadRequest(format!( + "could not fetch base ref {base_ref:?} from origin: {detail}" + )) + } })?; if git_output(checkout, &["rev-parse", "--verify", "--quiet", &remote_ref]).is_err() { return Err(ApiError::BadRequest(format!( @@ -331,14 +357,63 @@ fn fetch_base_ref(checkout: &Path, base_ref: &str) -> Result { /// the worktree has to exist before the session's cwd is resolved, so this /// cannot wait on the async runtime. fn git_output(repo: &Path, args: &[&str]) -> Result { - let output = std::process::Command::new("git") + git_command(repo, args, None) +} + +/// `git fetch` of one refspec. When `token` is set it is the only credential +/// git may use for this command: askpass answers GitHub with +/// `x-access-token` / the token, and the checkout's credential helper is +/// blanked so it cannot supply a different one. `insteadOf` rewrites in the +/// repo config still apply. The token is an environment value, never an +/// argument. +fn git_fetch(repo: &Path, spec: &str, token: Option<&str>) -> Result { + git_command(repo, &["fetch", "--quiet", "origin", spec], token) +} + +fn git_command(repo: &Path, args: &[&str], token: Option<&str>) -> Result { + let mut command = std::process::Command::new("git"); + command .args(args) .current_dir(repo) .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_TERMINAL_PROMPT", "0"); + if let Some(token) = token { + // This fetch's only credential. Askpass answers GitHub with the + // token; inherited token variables are removed so a different + // GH_TOKEN in the engine environment cannot win, and the checkout's + // credential helper is blanked via the environment config (which git + // applies on top of the repo config). The token is not an argument. + // `env_clear` is required: `Command` keeps an inherited GIT_ASKPASS + // even after `env_remove` + `env`, and git then uses that one. + let mut kept: Vec<(String, String)> = std::env::vars() + .filter(|(key, _)| { + !matches!( + key.as_str(), + "GH_TOKEN" + | "GITHUB_TOKEN" + | "GIT_AUTH_TOKEN" + | "GIT_ASKPASS" + | "ENGINE_WORKTREE_FETCH_TOKEN" + ) + }) + .collect(); + kept.push(("GIT_CONFIG_NOSYSTEM".into(), "1".into())); + kept.push(("GIT_TERMINAL_PROMPT".into(), "0".into())); + kept.push(("GIT_ASKPASS".into(), worktree_askpass_path())); + kept.push(("GIT_ASKPASS_REQUIRE".into(), "force".into())); + kept.push(("GIT_CONFIG_COUNT".into(), "1".into())); + kept.push(("GIT_CONFIG_KEY_0".into(), "credential.helper".into())); + kept.push(("GIT_CONFIG_VALUE_0".into(), String::new())); + kept.push(("ENGINE_WORKTREE_FETCH_TOKEN".into(), token.to_string())); + command.env_clear(); + command.envs(kept); + } + let output = command .output() .map_err(|e| ApiError::Internal(format!("spawn git: {e}")))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + let stderr = redact_token(stderr, token); return Err(ApiError::Internal(format!( "git {}: {}", args.join(" "), @@ -348,6 +423,71 @@ fn git_output(repo: &Path, args: &[&str]) -> Result { Ok(String::from_utf8_lossy(&output.stdout).into_owned()) } +fn origin_is_github_https(repo: &Path) -> bool { + let Ok(url) = git_output(repo, &["remote", "get-url", "origin"]) else { + return false; + }; + origin_url_is_github_https(url.trim()) +} + +fn origin_url_is_github_https(url: &str) -> bool { + let url = url.trim(); + let rest = url + .strip_prefix("https://") + .or_else(|| url.strip_prefix("HTTPS://")) + .or_else(|| url.strip_prefix("http://")) + .or_else(|| url.strip_prefix("HTTP://")); + let Some(rest) = rest else { + return false; + }; + let host = rest + .rsplit_once('@') + .map(|(_, hostpath)| hostpath) + .unwrap_or(rest) + .split('/') + .next() + .unwrap_or("") + .split(':') + .next() + .unwrap_or(""); + host.eq_ignore_ascii_case("github.com") +} + +/// The GitHub token the worktree fetch may use. `ENGINE_WORKTREE_GITHUB_TOKEN` +/// is the deployment's explicit copy of the token sessions receive as +/// `GH_TOKEN`. `GH_TOKEN` on the engine process is the same name, used when +/// the engine was started with it directly. Empty values are absent. +/// The value is never logged. +fn worktree_github_token() -> Option { + for key in ["ENGINE_WORKTREE_GITHUB_TOKEN", "GH_TOKEN"] { + if let Ok(value) = std::env::var(key) { + let value = value.trim().to_string(); + if !value.is_empty() { + return Some(value); + } + } + } + None +} + +fn worktree_askpass_path() -> String { + std::env::var("ENGINE_WORKTREE_ASKPASS") + .ok() + .filter(|path| !path.trim().is_empty()) + .unwrap_or_else(|| "/usr/local/bin/vogt-git-askpass".to_string()) +} + +/// Drop a token that git echoed back (a URL, an askpass trace) before the +/// text can reach an error or a log line. +fn redact_token(text: String, token: Option<&str>) -> String { + match token { + Some(token) if !token.is_empty() && text.contains(token) => { + text.replace(token, "[redacted]") + } + _ => text, + } +} + fn clean_repo_rel(path: &str) -> Result { let path = path.trim(); if path.is_empty() { @@ -1308,6 +1448,72 @@ pub async fn operate( mod tests { use super::*; + /// Process environment is process-global. Worktree-token tests take this + /// lock so they do not clobber each other or a caller's token. + struct EnvLock; + static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + impl EnvLock { + fn acquire() -> std::sync::MutexGuard<'static, ()> { + ENV_LOCK.lock().unwrap_or_else(|poison| poison.into_inner()) + } + } + + /// Saves and restores the token variables a test touches. + struct TokenEnv { + saved: Vec<(&'static str, Option)>, + } + + impl TokenEnv { + fn apply(pairs: &[(&'static str, Option<&str>)]) -> Self { + let saved = pairs + .iter() + .map(|(key, _)| (*key, std::env::var(key).ok())) + .collect(); + for (key, value) in pairs { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + Self { saved } + } + + fn set(pairs: &[(&'static str, &str)]) -> Self { + let owned: Vec<(&'static str, Option<&str>)> = pairs + .iter() + .map(|(key, value)| (*key, Some(*value))) + .collect(); + Self::apply(&owned) + } + + fn clear() -> Self { + Self::apply(&[ + ("ENGINE_WORKTREE_GITHUB_TOKEN", None), + ("GH_TOKEN", None), + ("GITHUB_TOKEN", None), + ("GIT_AUTH_TOKEN", None), + ("ENGINE_WORKTREE_ASKPASS", None), + ]) + } + } + + impl Drop for TokenEnv { + fn drop(&mut self) { + for (key, value) in &self.saved { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + } + } + + fn repo_askpass() -> std::path::PathBuf { + let mut path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + path.push("../deploy/git-askpass.sh"); + path + } + #[test] fn classify_basics() { assert_eq!(classify_status("?", "?"), StatusKind::Untracked); @@ -1586,6 +1792,7 @@ mod tests { #[test] fn a_worktree_is_cut_from_the_base_and_leaves_the_dirty_checkout_alone() { + let _guard = EnvLock::acquire(); let tmp = tempfile::tempdir().unwrap(); let repo = dirty_checkout(&tmp); let before = std::fs::read_to_string(repo.join("f.txt")).unwrap(); @@ -1606,6 +1813,7 @@ mod tests { #[test] fn a_worktree_is_refused_for_a_bad_name_an_unknown_base_or_a_taken_path() { + let _guard = EnvLock::acquire(); let tmp = tempfile::tempdir().unwrap(); let repo = dirty_checkout(&tmp); @@ -1645,6 +1853,7 @@ mod tests { #[test] fn a_worktree_can_be_cut_from_a_worktree() { + let _guard = EnvLock::acquire(); let tmp = tempfile::tempdir().unwrap(); let repo = dirty_checkout(&tmp); let first = create_worktree(&repo, "first", "topic").unwrap(); @@ -1661,6 +1870,7 @@ mod tests { #[test] fn a_fetch_failure_refuses_the_worktree_instead_of_cutting_from_the_stale_local_ref() { + let _guard = EnvLock::acquire(); let tmp = tempfile::tempdir().unwrap(); let repo = dirty_checkout(&tmp); let local = git_output(&repo, &["rev-parse", "topic"]).unwrap(); @@ -1680,8 +1890,313 @@ mod tests { assert_eq!(git_output(&repo, &["rev-parse", "topic"]).unwrap(), local); } + #[test] + fn github_https_is_the_host_not_a_substring() { + assert!(origin_url_is_github_https( + "https://github.com/TheDancingDeveloper-org/example.git" + )); + assert!(origin_url_is_github_https( + "https://x-access-token:secret@github.com/org/example.git" + )); + assert!(!origin_url_is_github_https( + "https://github.com.example.net/org/example.git" + )); + assert!(!origin_url_is_github_https( + "https://notgithub.com/org/example.git" + )); + assert!(!origin_url_is_github_https( + "git@github.com:TheDancingDeveloper-org/example.git" + )); + assert!(!origin_url_is_github_https("/tmp/origin.git")); + } + + #[test] + fn a_github_origin_without_a_token_names_the_missing_credential() { + let tmp = tempfile::tempdir().unwrap(); + let repo = dirty_checkout(&tmp); + git( + &repo, + &[ + "remote", + "set-url", + "origin", + "https://github.com/TheDancingDeveloper-org/example.git", + ], + ); + let _guard = EnvLock::acquire(); + let _env = TokenEnv::clear(); + + let failed = create_worktree(&repo, "wi-1666-worker", "topic").unwrap_err(); + + let message = failed.to_string(); + assert!(message.contains("no GitHub token"), "{message}"); + assert!( + message.contains("ENGINE_WORKTREE_GITHUB_TOKEN"), + "{message}" + ); + assert!( + !message.contains(" "), + "no collapsed-line padding: {message}" + ); + assert!(!tmp.path().join("wi-1666-worker").exists()); + } + + #[test] + fn a_github_fetch_uses_askpass_and_strips_other_tokens() { + let tmp = tempfile::tempdir().unwrap(); + let repo = dirty_checkout(&tmp); + let origin = tmp.path().join("origin.git"); + let trace = tmp.path().join("fetch-env"); + let real = "/usr/bin/git"; + let wrapper = tmp.path().join("git"); + std::fs::write( + &wrapper, + format!( + "#!/bin/sh\n\ +real='{real}'\n\ +origin='{origin}'\n\ +trace='{trace}'\n\ +if [ \"$1\" = fetch ]; then\n\ + {{\n\ + echo \"askpass=${{GIT_ASKPASS:-}}\"\n\ + echo \"gh=${{GH_TOKEN-unset}}\"\n\ + echo \"ght=${{GITHUB_TOKEN-unset}}\"\n\ + echo \"gat=${{GIT_AUTH_TOKEN-unset}}\"\n\ + echo \"fetch=${{ENGINE_WORKTREE_FETCH_TOKEN:-}}\"\n\ + echo \"helper=$(\"$real\" -C \"$PWD\" config --get credential.helper || true)\"\n\ + }} > \"$trace\"\n\ + token=$(\"$GIT_ASKPASS\" \"Password for 'https://github.com':\") || exit 7\n\ + echo \"printed=$token\" >> \"$trace\"\n\ + [ \"$token\" = worktree-token-secret ] || exit 8\n\ + exit 9\n\ +fi\n\ +exec \"$real\" \"$@\"\n", + real = real, + origin = origin.display(), + trace = trace.display(), + ), + ) + .unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&wrapper).unwrap().permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&wrapper, perms).unwrap(); + } + git( + &repo, + &[ + "remote", + "set-url", + "origin", + "https://github.com/TheDancingDeveloper-org/example.git", + ], + ); + git( + &repo, + &[ + "config", + "credential.helper", + "!echo password=helper-token-must-not-win", + ], + ); + + let _guard = EnvLock::acquire(); + let askpass = repo_askpass().canonicalize().expect("askpass"); + let askpass_s = askpass.display().to_string(); + let _env = TokenEnv::set(&[ + ("ENGINE_WORKTREE_GITHUB_TOKEN", "worktree-token-secret"), + ("GH_TOKEN", "session-token-should-not-be-used"), + ("GITHUB_TOKEN", "github-token-should-not-be-used"), + ("GIT_AUTH_TOKEN", "git-auth-should-not-be-used"), + ("ENGINE_WORKTREE_ASKPASS", &askpass_s), + ]); + let original = std::env::var("PATH").unwrap_or_default(); + std::env::set_var("PATH", format!("{}:{original}", tmp.path().display())); + + let failed = create_worktree(&repo, "wi-1666-wired", "topic"); + std::env::set_var("PATH", &original); + let recorded = std::fs::read_to_string(&trace).unwrap_or_default(); + let message = failed + .expect_err("the wrapper stops after recording the fetch environment") + .to_string(); + assert!(message.contains("could not fetch"), "{message}\n{recorded}"); + assert!( + !message.contains("worktree-token-secret"), + "token leaked into the error: {message}" + ); + let askpass = recorded + .lines() + .find_map(|line| line.strip_prefix("askpass=")) + .unwrap_or(""); + let expected = repo_askpass().canonicalize().unwrap().display().to_string(); + assert_eq!( + askpass, expected, + "GIT_ASKPASS must be the shipped askpass: {recorded}" + ); + assert!( + recorded.contains("gh=unset\n") || recorded.contains("gh=\n"), + "{recorded}" + ); + assert!(recorded.contains("ght=unset"), "{recorded}"); + assert!(recorded.contains("gat=unset"), "{recorded}"); + assert!( + recorded.contains("printed=worktree-token-secret"), + "{recorded}" + ); + assert!( + recorded.lines().any(|line| line == "helper="), + "credential.helper must be blanked: {recorded}" + ); + } + + #[test] + fn a_non_github_origin_keeps_its_own_credentials_when_gh_token_is_set() { + let tmp = tempfile::tempdir().unwrap(); + let repo = dirty_checkout(&tmp); + let trace = tmp.path().join("fetch-env"); + let real = "/usr/bin/git"; + let wrapper = tmp.path().join("git"); + std::fs::write( + &wrapper, + format!( + "#!/bin/sh\n\ +real='{real}'\n\ +trace='{trace}'\n\ +if [ \"$1\" = fetch ]; then\n\ + {{\n\ + echo \"askpass=${{GIT_ASKPASS-unset}}\"\n\ + echo \"fetch=${{ENGINE_WORKTREE_FETCH_TOKEN-unset}}\"\n\ + echo \"gh=${{GH_TOKEN-unset}}\"\n\ + echo \"helper=$(\"$real\" -C \"$PWD\" config --get credential.helper || true)\"\n\ + }} > \"$trace\"\n\ +fi\n\ +exec \"$real\" \"$@\"\n", + real = real, + trace = trace.display(), + ), + ) + .unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&wrapper).unwrap().permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&wrapper, perms).unwrap(); + } + + let _guard = EnvLock::acquire(); + let inherited_askpass = std::env::var("GIT_ASKPASS").ok(); + std::env::remove_var("GIT_ASKPASS"); + let _env = TokenEnv::set(&[("GH_TOKEN", "session-token-should-not-be-used")]); + let original = std::env::var("PATH").unwrap_or_default(); + std::env::set_var("PATH", format!("{}:{original}", tmp.path().display())); + + let worktree = create_worktree(&repo, "wi-1666-local", "topic"); + std::env::set_var("PATH", &original); + match inherited_askpass { + Some(value) => std::env::set_var("GIT_ASKPASS", value), + None => std::env::remove_var("GIT_ASKPASS"), + } + let recorded = std::fs::read_to_string(&trace).unwrap_or_default(); + let worktree = worktree.unwrap_or_else(|error| { + panic!("local fetch failed: {error}\ntrace:\n{recorded}"); + }); + assert_eq!( + std::fs::read_to_string(worktree.join("f.txt")).unwrap(), + "remote\n" + ); + assert!( + recorded.contains("askpass=unset"), + "a non-GitHub fetch must not set GIT_ASKPASS: {recorded}" + ); + assert!( + recorded.contains("fetch=unset"), + "a non-GitHub fetch must not receive the token: {recorded}" + ); + assert!( + recorded.contains("session-token-should-not-be-used"), + "the engine user's GH_TOKEN stays for a non-GitHub origin: {recorded}" + ); + } + + #[test] + fn an_ssh_github_origin_does_not_require_the_token() { + let tmp = tempfile::tempdir().unwrap(); + let repo = dirty_checkout(&tmp); + git( + &repo, + &[ + "remote", + "set-url", + "origin", + "git@github.invalid:TheDancingDeveloper-org/example.git", + ], + ); + let _guard = EnvLock::acquire(); + let _env = TokenEnv::clear(); + + let failed = create_worktree(&repo, "wi-1666-ssh", "topic").unwrap_err(); + let message = failed.to_string(); + assert!( + !message.contains("no GitHub token"), + "ssh must not demand the https token: {message}" + ); + assert!(message.contains("could not fetch"), "{message}"); + } + + #[test] + fn the_shipped_askpass_prints_the_worktree_token_for_github_only() { + let askpass = repo_askpass(); + let run = |prompt: &str, extra: &[(&str, &str)]| -> String { + let mut cmd = std::process::Command::new(&askpass); + cmd.env_clear(); + cmd.env("PATH", "/usr/bin:/bin"); + cmd.arg(prompt) + .env("ENGINE_WORKTREE_FETCH_TOKEN", "worktree-token-secret") + .env("GH_TOKEN", "session-token-should-not-be-used") + .env("GIT_AUTH_TOKEN", "git-auth-token"); + for (k, v) in extra { + cmd.env(k, v); + } + let out = cmd.output().expect("askpass"); + format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ) + }; + let password = run("Password for 'https://github.com':", &[]); + assert_eq!( + password.trim(), + "worktree-token-secret", + "askpass did not return the worktree token" + ); + let user = run("Username for 'https://github.com':", &[]); + assert_eq!(user.trim(), "x-access-token"); + let lookalike = run("Password for 'https://github.com.evil.test':", &[]); + assert_eq!(lookalike.trim(), "git-auth-token"); + let not_github = run("Password for 'https://notgithub.com':", &[]); + assert_eq!(not_github.trim(), "git-auth-token"); + } + + #[test] + fn github_https_matches_the_host_exactly() { + // Host matching is exact. A checkout whose origin merely contains the + // substring github.com is not given the token and is not refused for + // lacking one. + assert!(!origin_url_is_github_https( + "https://github.com.example.net/org/repo.git" + )); + assert!(origin_url_is_github_https( + "https://user:token@github.com/org/repo.git" + )); + } + #[test] fn a_slashed_base_is_cut_from_origin_bare_or_origin_prefixed() { + let _guard = EnvLock::acquire(); let tmp = tempfile::tempdir().unwrap(); let repo = dirty_checkout(&tmp); let local_before = git_output(&repo, &["rev-parse", "lines/codex-next"]).unwrap();