mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-11 01:51:34 -10:00
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:
1 parent
3aaf473063
commit
6b75b4f564
4 files changed
+211
-15
No files matched your search
@@ -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]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in new issue
Block a user