fix(tools): never let a git spawn prompt; bound git_fetch with a timeout

A remote that wanted credentials, a passphrase or a host-key confirmation
could prompt on /dev/tty inside the raw-mode TUI while a blocking
cmd.output() held a spawn_blocking thread forever, so the turn looked
frozen (a contributor to #6184-shaped reports).

- dependencies.rs: one helper, apply_git_noninteractive_env, is the single
  definition site for GIT_TERMINAL_PROMPT=0, GIT_PAGER='' and BatchMode ssh
  (skipped when the user pinned GIT_SSH_COMMAND or GIT_SSH). Git::command
  applies it, and Git::tokio_command now builds from Git::command so async
  spawns carry the same lock and prompt guards.
- runtime_api/git.rs, review_pr.rs, Git::review_command: drop their private
  copies of the env; review_pr applies the helper to its gh command too.
- git_history.rs: git_fetch runs under tokio::process with kill_on_drop and
  a 300s deadline, and reports a clear timed-out error. The "Known
  limitation" note is replaced.

Checks (run on an isolated HEAD+patch snapshot, because the shared
checkout's lib test build currently fails in other lanes' in-flight files
such as runtime_api/computer_display.rs and debug/cache.rs):
- cargo test -p codewhale-tui --lib -- git_fetch git_command
  dependencies::tests runtime_api::git review_pr tools::git_history
  tools::git:: : 89 passed, 0 failed. New tests are
  git_fetch_fails_fast_when_remote_requires_credentials (a loopback HTTP
  remote answering 401: clear error, no prompt),
  git_fetch_runner_kills_a_remote_that_never_answers (2s deadline) and
  git_commands_are_non_interactive.
- grep GIT_TERMINAL_PROMPT crates: one set site (dependencies.rs), plus
  doc comments and a test assertion.
- cargo clippy -p codewhale-tui --lib --tests: no findings in touched
  files. It reported 17 existing too_many_arguments errors in other files.
- Not run: npm test && npm run check:web (lane scope; not run here).

Refs #6184

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-09-22 13:15:11 -07:00
1 parent 3aaf473063
commit 6b75b4f564
4 files changed
+211 -15

No files matched your search

+61 -3
View File
@@ -397,6 +397,27 @@ pub trait ExternalTool {
/// Git version control.
pub struct Git;
/// Keep a git child from ever waiting on a human.
///
/// Git and ssh read credentials, passphrases and host-key confirmations from
/// `/dev/tty` directly — `stdin(null)` does not stop them — so inside the
/// raw-mode TUI or an HTTP request a prompt is an invisible, indefinite hang.
/// `GIT_TERMINAL_PROMPT=0` makes git fail instead of asking for a username or
/// password; BatchMode ssh fails instead of asking for a passphrase or an
/// unknown host key; an empty `GIT_PAGER` keeps output from ever being paged.
/// A user who pinned their own ssh transport (`GIT_SSH_COMMAND` or `GIT_SSH`)
/// keeps it untouched.
///
/// This is the single definition site; [`Git::command`] and
/// [`Git::tokio_command`] apply it to every product git spawn. Call it
/// directly only for a non-git program that may shell out to git (`gh`).
pub(crate) fn apply_git_noninteractive_env(cmd: &mut Command) {
cmd.env("GIT_TERMINAL_PROMPT", "0").env("GIT_PAGER", "");
if std::env::var_os("GIT_SSH_COMMAND").is_none() && std::env::var_os("GIT_SSH").is_none() {
cmd.env("GIT_SSH_COMMAND", "ssh -o BatchMode=yes");
}
}
impl Git {
/// Construct a read-only review command with content conversion disabled.
/// Review callers also pass `--no-ext-diff` and `--no-textconv` for diffs.
@@ -422,9 +443,7 @@ impl Git {
if cfg!(windows) { "NUL" } else { "/dev/null" },
)
.env("GIT_NO_LAZY_FETCH", "1")
.env("GIT_NO_REPLACE_OBJECTS", "1")
.env("GIT_TERMINAL_PROMPT", "0")
.env("GIT_PAGER", "");
.env("GIT_NO_REPLACE_OBJECTS", "1");
Ok(command)
};
let output = base()?
@@ -513,9 +532,16 @@ impl ExternalTool for Git {
cmd.arg(arg);
}
cmd.env("GIT_OPTIONAL_LOCKS", "0");
apply_git_noninteractive_env(&mut cmd);
Some(cmd)
}
/// Same environment as [`Git::command`]: the trait default would build a
/// bare command and silently drop the lock and prompt guards.
fn tokio_command() -> Option<tokio::process::Command> {
Self::command().map(tokio::process::Command::from)
}
fn resolve() -> Option<String> {
static CACHE: OnceLock<Option<String>> = OnceLock::new();
CACHE
@@ -933,6 +959,38 @@ mod tests {
assert_eq!(value, std::ffi::OsStr::new("0"));
}
/// No git spawn may prompt on `/dev/tty` (0.10.1 item 3): a credential,
/// passphrase or host-key prompt inside the raw-mode TUI is a silent hang.
#[test]
fn git_commands_are_non_interactive() {
if !Git::available() {
return;
}
let std_cmd = Git::command().expect("git resolves when available");
let tokio_cmd = Git::tokio_command().expect("git resolves when available");
for envs in [
std_cmd.get_envs().collect::<Vec<_>>(),
tokio_cmd.as_std().get_envs().collect::<Vec<_>>(),
] {
let get = |name: &str| {
envs.iter()
.find(|(key, _)| *key == std::ffi::OsStr::new(name))
.and_then(|(_, value)| *value)
};
assert_eq!(get("GIT_TERMINAL_PROMPT"), Some(std::ffi::OsStr::new("0")));
assert_eq!(get("GIT_PAGER"), Some(std::ffi::OsStr::new("")));
assert_eq!(get("GIT_OPTIONAL_LOCKS"), Some(std::ffi::OsStr::new("0")));
if std::env::var_os("GIT_SSH_COMMAND").is_none()
&& std::env::var_os("GIT_SSH").is_none()
{
assert_eq!(
get("GIT_SSH_COMMAND"),
Some(std::ffi::OsStr::new("ssh -o BatchMode=yes"))
);
}
}
}
/// The suppression is deliberately scoped to git. Other external tools
/// have no index to protect and must not inherit a git-specific variable.
#[test]
+3 -8
View File
@@ -91,9 +91,9 @@ async fn git_read(workspace: &FsPath, args: &[&str]) -> Result<GitRun, ApiError>
}
/// Write path for operator-driven mutations. Non-interactive by contract:
/// no terminal prompt, no pager, and BatchMode ssh (unless the user already
/// pins their own `GIT_SSH_COMMAND`) so a key prompt can never hang the
/// request. Hooks and filters run exactly as they do for the user's own
/// [`Git::tokio_command`] carries the shared no-prompt environment
/// ([`crate::dependencies::apply_git_noninteractive_env`]) so a credential or
/// key prompt can never hang the request. Hooks and filters run exactly as they do for the user's own
/// `git` — a Review-sheet commit is the user's commit.
async fn git_write(workspace: &FsPath, args: Vec<String>) -> Result<GitRun, ApiError> {
let mut command = Git::tokio_command()
@@ -102,12 +102,7 @@ async fn git_write(workspace: &FsPath, args: Vec<String>) -> Result<GitRun, ApiE
.args(&args)
.current_dir(workspace)
.stdin(Stdio::null())
.env("GIT_TERMINAL_PROMPT", "0")
.env("GIT_PAGER", "")
.kill_on_drop(true);
if std::env::var_os("GIT_SSH_COMMAND").is_none() {
command.env("GIT_SSH_COMMAND", "ssh -o BatchMode=yes");
}
finish_git(command.output(), GIT_WRITE_TIMEOUT).await
}
+145 -3
View File
@@ -414,10 +414,18 @@ impl ToolSpec for GitBlameTool {
/// or push. The execution envelope classes this as bounded fetch — shell plus
/// network authority, not write authority.
///
/// Known limitation: shares the existing git tools' no-timeout behavior; a
/// hung remote is bounded by the caller's wall clock, not the tool.
/// Never interactive and always bounded: the spawn carries the shared no-prompt
/// environment (`GIT_TERMINAL_PROMPT=0`, BatchMode ssh), so a remote that wants
/// credentials, a passphrase or a host-key confirmation fails fast instead of
/// prompting on `/dev/tty` inside the raw-mode TUI; and the child runs under
/// [`GIT_FETCH_TIMEOUT`] with `kill_on_drop`, so a remote that never answers
/// ends the call with a clear error instead of freezing the turn.
pub struct GitFetchTool;
/// Upper bound on one `git_fetch`. Generous enough for a first fetch of a
/// large repository; short enough that a silent remote cannot pass for a hang.
const GIT_FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
#[async_trait]
impl ToolSpec for GitFetchTool {
fn name(&self) -> &'static str {
@@ -478,7 +486,20 @@ impl ToolSpec for GitFetchTool {
args.extend(refspecs.clone());
let command_str = format_command(&git_ctx.working_dir, &args);
let output = run_git_command_async(git_ctx.working_dir.clone(), args).await?;
let Some(output) =
run_git_command_bounded(&git_ctx.working_dir, &args, GIT_FETCH_TIMEOUT).await?
else {
let seconds = GIT_FETCH_TIMEOUT.as_secs();
return Ok(ToolResult::error(format!(
"git fetch from remote '{remote}' timed out after {seconds}s and was stopped; \
the remote did not finish answering. No refs were changed by this call."
))
.with_metadata(json!({
"command": command_str,
"timed_out": true,
"timeout_secs": seconds,
})));
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Ok(ToolResult::error(format!(
@@ -862,6 +883,35 @@ async fn run_git_command_async(
.map_err(|e| ToolError::execution_failed(format!("git task panicked: {e}")))?
}
/// Run git under a hard deadline. `Ok(None)` means the deadline passed; the
/// child is killed when the dropped future releases it (`kill_on_drop`), so a
/// timed-out fetch never lingers holding a blocking thread.
async fn run_git_command_bounded(
working_dir: &Path,
args: &[String],
timeout: std::time::Duration,
) -> Result<Option<Output>, ToolError> {
let Some(mut cmd) = crate::dependencies::Git::tokio_command() else {
return Err(ToolError::not_available(
"git is not installed or not in PATH",
));
};
cmd.args(args)
.current_dir(working_dir)
.stdin(std::process::Stdio::null())
.kill_on_drop(true);
match tokio::time::timeout(timeout, cmd.output()).await {
Err(_) => Ok(None),
Ok(Ok(output)) => Ok(Some(output)),
Ok(Err(e)) if e.kind() == std::io::ErrorKind::NotFound => Err(ToolError::not_available(
"git is not installed or not in PATH",
)),
Ok(Err(e)) => Err(ToolError::execution_failed(format!(
"Failed to run git: {e}"
))),
}
}
fn format_command(working_dir: &Path, args: &[String]) -> String {
format!(
"git -C {} {}",
@@ -1190,6 +1240,98 @@ mod tests {
assert!(!work.path().join("file.txt").exists());
}
/// A loopback HTTP "remote" that answers every request with `response`
/// (or, when `None`, accepts and never answers). Returns its URL.
fn spawn_fake_http_remote(response: Option<&'static str>) -> String {
use std::io::{Read, Write};
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
let addr = listener.local_addr().expect("addr");
std::thread::spawn(move || {
let mut held = Vec::new();
for stream in listener.incoming().take(16) {
let Ok(mut stream) = stream else { continue };
let mut buf = [0u8; 4096];
let _ = stream.read(&mut buf);
match response {
Some(reply) => {
let _ = stream.write_all(reply.as_bytes());
}
None => held.push(stream),
}
}
});
format!("http://{addr}/repo.git")
}
fn init_repo_with_remote(root: &Path, url: &str) {
init_git_repo(root);
run_git(root, &["remote", "add", "origin", url]);
// Isolate from the developer's credential helpers and askpass so the
// only thing standing between git and a prompt is our environment.
run_git(root, &["config", "credential.helper", ""]);
run_git(root, &["config", "core.askPass", ""]);
}
#[tokio::test]
async fn git_fetch_fails_fast_when_remote_requires_credentials() {
if !git_available() {
return;
}
let url = spawn_fake_http_remote(Some(
"HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Basic realm=\"codewhale\"\r\n\
Content-Length: 0\r\nConnection: close\r\n\r\n",
));
let work = tempdir().expect("tempdir");
init_repo_with_remote(work.path(), &url);
let ctx = ToolContext::new(work.path());
let started = std::time::Instant::now();
let result = tokio::time::timeout(
std::time::Duration::from_secs(60),
GitFetchTool.execute(json!({ "remote": "origin" }), &ctx),
)
.await
.expect("git_fetch must not wait on a credential prompt")
.expect("execute");
assert!(!result.success, "{}", result.content);
assert!(
result
.content
.contains("git fetch failed for remote 'origin'"),
"{}",
result.content
);
// Git names the refusal to prompt rather than blocking on /dev/tty.
let lower = result.content.to_lowercase();
assert!(
lower.contains("terminal prompts disabled") || lower.contains("authentication"),
"{}",
result.content
);
assert!(started.elapsed() < std::time::Duration::from_secs(30));
}
#[tokio::test]
async fn git_fetch_runner_kills_a_remote_that_never_answers() {
if !git_available() {
return;
}
let url = spawn_fake_http_remote(None);
let work = tempdir().expect("tempdir");
init_repo_with_remote(work.path(), &url);
let started = std::time::Instant::now();
let outcome = run_git_command_bounded(
work.path(),
&["fetch".to_string(), "origin".to_string()],
std::time::Duration::from_secs(2),
)
.await
.expect("git spawns");
assert!(outcome.is_none(), "a silent remote must hit the deadline");
assert!(started.elapsed() < std::time::Duration::from_secs(15));
}
#[tokio::test]
async fn git_merge_tree_reports_conflicts_without_touching_tree() {
if !git_available() {
+2 -1
View File
@@ -463,12 +463,13 @@ fn run_command(workspace: &Path, program: Program, args: &[String]) -> Result<St
Program::Gh => Gh::command().context("PR review requires GitHub CLI on PATH")?,
Program::Git => Git::review_command(workspace)?,
};
// `gh` shells out to git; give it the same no-prompt environment.
crate::dependencies::apply_git_noninteractive_env(&mut command);
command
.args(args)
.current_dir(workspace)
.env("GIT_NO_REPLACE_OBJECTS", "1")
.env("GIT_NO_LAZY_FETCH", "1")
.env("GIT_TERMINAL_PROMPT", "0")
.env("GH_PROMPT_DISABLED", "1")
.stdin(Stdio::null())
.stdout(Stdio::piped())