返回 CodeWhale
dependencies.rs
根目录 / crates / tui / src / dependencies.rs
1 //! External-binary dependency resolution for tools that shell out to
2 //! locally-installed programs (Python for `code_execution` / RLM REPL,
3 //! `pdftotext` for PDF reading in `read_file`, future tools as added).
4 //!
5 //! Before v0.8.31, tools that called external binaries hardcoded the
6 //! command name and failed at execution time when the binary wasn't on
7 //! `PATH`. The most-cited example was `code_execution`, which spawned
8 //! `python3` directly — Windows users (where the launcher is `py` or
9 //! `python`, not `python3`) saw `Failed to execute tool: program not
10 //! found` with no upstream hint of what was wrong.
11 //!
12 //! This module centralises the probe-then-decide pattern. The supported
13 //! callers today are:
14 //!
15 //! - Tool catalog construction (`core::engine::tool_catalog`): for
16 //! tools that should be advertised to the model only when the
17 //! required runtime is present.
18 //! - Doctor command (`run_doctor` in `main.rs`): for surfacing the
19 //! resolved state to the user so missing dependencies aren't an
20 //! invisible failure.
21 //! - Long-lived REPL runtime (`repl::runtime`): for RLM and inline `repl`
22 //! blocks that need to spawn Python on every supported platform.
23 //!
24 //! Results are cached for the process lifetime via [`std::sync::OnceLock`]
25 //! — probing a binary involves a `Command::output` per candidate and
26 //! we'd rather not pay that on every model turn.
27
28 use std::path::{Path, PathBuf};
29 use std::process::Command;
30 use std::sync::OnceLock;
31
32 /// Candidate executable names for the Python interpreter, in the
33 /// order we try them. On Windows the launcher convention is `py -3`,
34 /// so we add it as a third option; the resolver splits on whitespace
35 /// at execution time so `py -3 /tmp/code.py` runs correctly.
36 ///
37 /// Order matters: `python3` first because it's the unambiguous v3
38 /// binary on Unix and rules out Python 2 leftovers. `python` second
39 /// covers Windows installations that drop the version suffix and
40 /// modern macOS where Homebrew installs both. `py -3` last as a
41 /// Windows-launcher fallback.
42 pub const PYTHON_CANDIDATES: &[&str] = &["python3", "python", "py -3"];
43
44 /// Probe a single executable. Returns `true` when the candidate
45 /// responds to `--version` with a successful exit. Splits on
46 /// whitespace so `"py -3"` works as a candidate.
47 ///
48 /// We deliberately use `--version` rather than `which` so the probe
49 /// is portable across Unix, Windows (no `which` by default), and
50 /// containers. The downside is that we spawn a subprocess per
51 /// candidate; the resolver caches the result so this only fires
52 /// once per process.
53 #[must_use]
54 pub fn probe_executable(spec: &str) -> bool {
55 probe_executable_with_flag(spec, "--version")
56 }
57
58 /// Probe a single executable using an explicit version/help flag.
59 ///
60 /// Most tools report their presence via `--version`, but some do not:
61 /// Poppler's `pdftotext` treats `--version` as an input *filename* and
62 /// exits non-zero ("I/O Error: Couldn't open file '--version'"), so the
63 /// default probe reports it missing even when it is installed (#1667).
64 /// Such tools pass their own flag (e.g. `-v`) here.
65 #[must_use]
66 pub fn probe_executable_with_flag(spec: &str, version_flag: &str) -> bool {
67 let mut parts = spec.split_whitespace();
68 let Some(program) = parts.next() else {
69 return false;
70 };
71 let mut cmd = version_probe_command(program);
72 cmd.args(parts).arg(version_flag);
73 matches!(probe_output(&mut cmd, false, VERSION_PROBE_TIMEOUT), Ok(output) if output.status.success())
74 }
75
76 /// Probe a single executable and capture its version banner in one spawn.
77 ///
78 /// Same contract as [`probe_executable`] (success = exit 0), but returns the
79 /// trimmed stdout so callers that want the banner don't need a second process
80 /// launch. Returns `None` when the probe fails or stdout is not valid UTF-8.
81 pub fn probe_executable_capturing(spec: &str, version_flag: &str) -> Option<String> {
82 let mut parts = spec.split_whitespace();
83 let program = parts.next()?;
84 let mut cmd = version_probe_command(program);
85 cmd.args(parts).arg(version_flag);
86 let output = probe_output(&mut cmd, true, VERSION_PROBE_TIMEOUT).ok()?;
87 if !output.status.success() {
88 return None;
89 }
90 String::from_utf8(output.stdout)
91 .ok()
92 .map(|s| s.trim().to_string())
93 .filter(|s| !s.is_empty())
94 }
95
96 const VERSION_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
97 const VERSION_PROBE_MAX_OUTPUT: u64 = 16 * 1024;
98
99 fn version_probe_command(program: impl AsRef<std::ffi::OsStr>) -> Command {
100 let mut cmd = Command::new(program);
101 crate::utils::suppress_console_window(&mut cmd);
102 crate::child_env::apply_to_command(&mut cmd, std::iter::empty::<(&str, &str)>());
103 // A presence probe needs no bootstrap code or import paths from the
104 // parent. The general child allowlist retains these for normal SDK tools.
105 for key in ["NODE_OPTIONS", "NODE_PATH", "PYTHONPATH", "RUSTC_WRAPPER"] {
106 cmd.env_remove(key);
107 }
108 cmd
109 }
110
111 /// Version/help probes never inherit stdin, retain unbounded banners, or leave
112 /// a normal descendant alive after their deadline. No runtime is constructed.
113 fn probe_output(
114 command: &mut Command,
115 capture: bool,
116 timeout: std::time::Duration,
117 ) -> std::io::Result<std::process::Output> {
118 use std::io::Read;
119 use std::process::Stdio;
120 use wait_timeout::ChildExt;
121 command
122 .stdin(Stdio::null())
123 .stderr(Stdio::null())
124 .stdout(if capture {
125 Stdio::piped()
126 } else {
127 Stdio::null()
128 });
129 let (mut child, tree) = crate::process_tree::spawn_contained_std(command)?;
130 let mut tree = Some(tree);
131 let result = (|| {
132 let reader = if capture {
133 let pipe = child
134 .stdout
135 .take()
136 .ok_or_else(|| std::io::Error::other("version probe stdout missing"))?;
137 let (tx, rx) = std::sync::mpsc::sync_channel(1);
138 std::thread::Builder::new()
139 .name("version-probe-output".into())
140 .spawn(move || {
141 let mut bytes = Vec::new();
142 let result = pipe
143 .take(VERSION_PROBE_MAX_OUTPUT + 1)
144 .read_to_end(&mut bytes)
145 .and_then(|_| {
146 if bytes.len() as u64 > VERSION_PROBE_MAX_OUTPUT {
147 Err(std::io::Error::new(
148 std::io::ErrorKind::InvalidData,
149 "version probe output exceeded limit",
150 ))
151 } else {
152 Ok(bytes)
153 }
154 });
155 let _ = tx.send(result);
156 })?;
157 Some(rx)
158 } else {
159 None
160 };
161 let status = child.wait_timeout(timeout)?.ok_or_else(|| {
162 std::io::Error::new(
163 std::io::ErrorKind::TimedOut,
164 "version probe did not finish before its deadline",
165 )
166 })?;
167 // A successful parent may leave an inherited pipe open in a child.
168 drop(tree.take());
169 let stdout = match reader {
170 Some(reader) => reader
171 .recv_timeout(std::time::Duration::from_millis(250))
172 .map_err(|_| {
173 std::io::Error::new(
174 std::io::ErrorKind::TimedOut,
175 "version probe pipe did not close",
176 )
177 })??,
178 None => Vec::new(),
179 };
180 Ok(std::process::Output {
181 status,
182 stdout,
183 stderr: Vec::new(),
184 })
185 })();
186 if result.is_err() {
187 drop(tree.take());
188 let _ = child.kill();
189 let _ = child.wait_timeout(std::time::Duration::from_millis(250));
190 }
191 result
192 }
193
194 fn executable_path_candidates(program: &str) -> Vec<PathBuf> {
195 let program_path = Path::new(program);
196 if program_path.components().count() > 1 {
197 return vec![program_path.to_path_buf()];
198 }
199
200 let Some(path) = std::env::var_os("PATH") else {
201 return vec![PathBuf::from(program)];
202 };
203
204 let mut candidates = Vec::new();
205 for dir in std::env::split_paths(&path) {
206 let bare = dir.join(program);
207 candidates.push(bare.clone());
208
209 #[cfg(windows)]
210 if Path::new(program).extension().is_none() {
211 let pathext =
212 std::env::var_os("PATHEXT").unwrap_or_else(|| ".COM;.EXE;.BAT;.CMD".into());
213 for ext in pathext.to_string_lossy().split(';') {
214 if ext.is_empty() {
215 continue;
216 }
217 candidates.push(bare.with_extension(ext.trim_start_matches('.')));
218 }
219 }
220 }
221
222 candidates
223 }
224
225 fn resolve_executable_path(spec: &str, version_flag: &str) -> Option<String> {
226 let mut parts = spec.split_whitespace();
227 let program = parts.next()?;
228 let args: Vec<&str> = parts.collect();
229
230 for candidate in executable_path_candidates(program) {
231 if !candidate.is_file() {
232 continue;
233 }
234
235 let mut cmd = version_probe_command(&candidate);
236 cmd.args(&args).arg(version_flag);
237
238 if matches!(probe_output(&mut cmd, false, VERSION_PROBE_TIMEOUT), Ok(output) if output.status.success())
239 {
240 return Some(candidate.to_string_lossy().into_owned());
241 }
242 }
243
244 None
245 }
246
247 /// Resolve the Python interpreter once per process. Returns the
248 /// candidate spec (e.g. `"python3"` or `"py -3"`) that succeeded,
249 /// or `None` when every candidate failed.
250 ///
251 /// Callers that need to spawn the interpreter should split this
252 /// string on whitespace — see [`split_interpreter_spec`].
253 pub fn resolve_python_interpreter() -> Option<String> {
254 static CACHE: OnceLock<Option<String>> = OnceLock::new();
255 CACHE
256 .get_or_init(|| {
257 for candidate in PYTHON_CANDIDATES {
258 if probe_executable(candidate) {
259 tracing::info!(
260 target: "tool_dependencies",
261 candidate = candidate,
262 "Resolved Python interpreter",
263 );
264 return Some((*candidate).to_string());
265 }
266 }
267 tracing::warn!(
268 target: "tool_dependencies",
269 tried = ?PYTHON_CANDIDATES,
270 "No Python interpreter found",
271 );
272 None
273 })
274 .clone()
275 }
276
277 /// Resolve `pdftotext` (from Poppler) once per process. Used by
278 /// file and web PDF paths for truthful availability diagnostics. Unlike
279 /// the Python case, `read_file` itself still works for text files
280 /// when `pdftotext` is missing — this resolver exists so the doctor
281 /// command can surface the miss before a PDF read returns its typed
282 /// `binary_unavailable` result.
283 pub fn resolve_pdftotext() -> Option<String> {
284 static CACHE: OnceLock<Option<String>> = OnceLock::new();
285 CACHE
286 .get_or_init(|| {
287 // Poppler's `pdftotext` rejects `--version` (it is parsed as an
288 // input filename and exits non-zero), so probe with `-v`, which
289 // prints the version banner and exits 0 (#1667).
290 if probe_executable_with_flag("pdftotext", "-v") {
291 Some("pdftotext".to_string())
292 } else {
293 None
294 }
295 })
296 .clone()
297 }
298
299 /// Resolve `tesseract` (OCR engine) once per process. Used by the
300 /// `image_ocr` tool on platforms that do not have a native OCR backend.
301 /// Tesseract is the de-facto open-source OCR engine and ships as a single
302 /// binary on every platform we support, so the candidate list is just
303 /// `tesseract`.
304 pub fn resolve_tesseract() -> Option<String> {
305 static CACHE: OnceLock<Option<String>> = OnceLock::new();
306 CACHE
307 .get_or_init(|| {
308 if probe_executable("tesseract") {
309 tracing::info!(
310 target: "tool_dependencies",
311 "Resolved tesseract binary for image_ocr",
312 );
313 Some("tesseract".to_string())
314 } else {
315 tracing::warn!(
316 target: "tool_dependencies",
317 "tesseract binary not found; image_ocr will rely on native OCR if available",
318 );
319 None
320 }
321 })
322 .clone()
323 }
324
325 /// Resolve `pandoc` (universal document converter) once per
326 /// process. Used by the `pandoc_convert` tool to decide whether
327 /// to register itself with the model. Pandoc is a single-binary
328 /// install, so the candidate list is just `pandoc` — no platform
329 /// fallback path.
330 pub fn resolve_pandoc() -> Option<String> {
331 static CACHE: OnceLock<Option<String>> = OnceLock::new();
332 CACHE
333 .get_or_init(|| {
334 if let Some(path) = resolve_executable_path("pandoc", "--version") {
335 tracing::info!(
336 target: "tool_dependencies",
337 "Resolved pandoc binary for pandoc_convert",
338 );
339 Some(path)
340 } else {
341 tracing::warn!(
342 target: "tool_dependencies",
343 "pandoc binary not found; pandoc_convert tool will not be registered",
344 );
345 None
346 }
347 })
348 .clone()
349 }
350
351 /// Whether an optional tool whose backend lives on this host (an interpreter,
352 /// a converter, an OCR engine) is available: `probe` decides, except that a
353 /// conformance replay answers with the recorded host's set so goldens do not
354 /// depend on what the machine running them has installed (test builds only).
355 pub(crate) fn host_tool_available(tool: &str, probe: impl FnOnce() -> bool) -> bool {
356 #[cfg(all(test, unix))]
357 if let Some(available) = RECORDED_HOST_TOOLS.with(|cell| {
358 cell.borrow()
359 .as_ref()
360 .map(|tools| tools.iter().any(|name| name == tool))
361 }) {
362 return available;
363 }
364 // Only a conformance replay reads the name.
365 #[cfg(not(all(test, unix)))]
366 let _ = tool;
367 probe()
368 }
369
370 #[cfg(all(test, unix))]
371 thread_local! {
372 static RECORDED_HOST_TOOLS: std::cell::RefCell<Option<Vec<String>>> =
373 const { std::cell::RefCell::new(None) };
374 }
375
376 /// Pin [`host_tool_available`] on this thread to a recorded host's tools until
377 /// the guard drops.
378 #[cfg(all(test, unix))]
379 pub(crate) fn pin_recorded_host_tools(tools: Vec<String>) -> RecordedHostToolsGuard {
380 RECORDED_HOST_TOOLS.with(|cell| *cell.borrow_mut() = Some(tools));
381 RecordedHostToolsGuard
382 }
383
384 #[cfg(all(test, unix))]
385 pub(crate) struct RecordedHostToolsGuard;
386
387 #[cfg(all(test, unix))]
388 impl Drop for RecordedHostToolsGuard {
389 fn drop(&mut self) {
390 RECORDED_HOST_TOOLS.with(|cell| *cell.borrow_mut() = None);
391 }
392 }
393
394 /// Resolve the Node.js runtime once per process. Used by the
395 /// `js_execution` tool to decide whether to advertise itself in
396 /// the catalog. Unlike Python, the executable name `node` is the
397 /// same across every platform we ship to — there's no `node3` or
398 /// `node.exe` variant to fall through to — so this is a single
399 /// probe rather than a candidate ladder.
400 pub fn resolve_node() -> Option<String> {
401 static CACHE: OnceLock<Option<String>> = OnceLock::new();
402 CACHE
403 .get_or_init(|| {
404 if probe_executable("node") {
405 tracing::info!(
406 target: "tool_dependencies",
407 "Resolved Node.js runtime for js_execution",
408 );
409 Some("node".to_string())
410 } else {
411 tracing::warn!(
412 target: "tool_dependencies",
413 "Node.js runtime not found; js_execution tool will not be advertised",
414 );
415 None
416 }
417 })
418 .clone()
419 }
420
421 /// A Node.js runtime chosen by *running* each candidate, plus every candidate
422 /// rejected on the way and why (for `/plugin` and doctor diagnostics).
423 #[derive(Debug, Clone, PartialEq, Eq, Default)]
424 pub struct NodeResolution {
425 pub selected: Option<(PathBuf, (u32, u32, u32))>,
426 pub rejected: Vec<(PathBuf, String)>,
427 }
428
429 impl NodeResolution {
430 /// One-line human summary of why no `kind` candidate was selected.
431 #[must_use]
432 pub fn describe_rejections(&self, kind: HostRuntimeKind) -> String {
433 if self.rejected.is_empty() {
434 return format!("no `{}` found {}", kind.name(), kind.search_scope());
435 }
436 self.rejected
437 .iter()
438 .map(|(path, reason)| format!("{}: {reason}", path.display()))
439 .collect::<Vec<_>>()
440 .join("; ")
441 }
442 }
443
444 /// `major.minor.patch` at the start of `text`, ignoring any pre-release or
445 /// build suffix (`1.4.2-canary.3+abc`).
446 fn parse_version_triple(text: &str) -> Option<(u32, u32, u32)> {
447 let mut parts = text.split(['.', '-', '+']);
448 let major = parts.next()?.parse().ok()?;
449 let minor = parts.next()?.parse().ok()?;
450 let patch = parts.next()?.parse().ok()?;
451 Some((major, minor, patch))
452 }
453
454 /// Parse `node --version` output (`v22.20.0`).
455 #[must_use]
456 pub fn parse_node_version(banner: &str) -> Option<(u32, u32, u32)> {
457 parse_version_triple(banner.trim().strip_prefix('v')?)
458 }
459
460 /// Whether `version` satisfies the extension host floor `^22.19 || >=24`
461 /// (the DSH `engines` range: odd-numbered 23 is not an LTS line).
462 #[must_use]
463 pub fn node_version_supported_for_extension_host(version: (u32, u32, u32)) -> bool {
464 let (major, minor, _) = version;
465 (major == 22 && minor >= 19) || major >= 24
466 }
467
468 /// Parse `bun --version` output (`1.4.0`, or `1.4.0-canary.1+abc`).
469 #[must_use]
470 pub fn parse_bun_version(banner: &str) -> Option<(u32, u32, u32)> {
471 parse_version_triple(banner.trim())
472 }
473
474 /// The oldest Bun the extension host accepts. The `Bun.plugin` module shim,
475 /// `--no-install`, `--no-env-file`, the macOS jetsam memory limit and the
476 /// native-code lockdown were measured against Bun 1.4.0 on macOS 26.1 arm64
477 /// only. CI's JS host-suite leg installs Bun 1.4.0 on `ubuntu-latest`; the
478 /// Rust host integration tests do not run on Bun in CI, and no Windows Bun
479 /// run is recorded. A newer Bun on which a lock no longer holds fails the
480 /// host's start (`extension-host/src/runtime.ts`).
481 pub const BUN_MIN_VERSION_FOR_EXTENSION_HOST: (u32, u32, u32) = (1, 4, 0);
482
483 /// Node flags that switch off builtins able to load native code in-process:
484 /// `node:sqlite` (SQLite extensions are `dlopen`ed even under `--no-addons`)
485 /// and `node:ffi` (on by default where it exists: Node 26.10 has it, 22.20 and
486 /// 24.19 reject the flag). Each is passed only when the chosen Node accepts
487 /// it; the host refuses to start if either builtin is still available.
488 pub const NODE_NATIVE_CODE_FLAGS: &[&str] = &["--no-experimental-sqlite", "--no-experimental-ffi"];
489
490 /// A JavaScript runtime that can run the extension host.
491 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
492 pub enum HostRuntimeKind {
493 Bun,
494 Node,
495 }
496
497 impl HostRuntimeKind {
498 /// The name the host reports in `host/hello` (`bun` / `node`).
499 #[must_use]
500 pub fn name(self) -> &'static str {
501 match self {
502 Self::Bun => "bun",
503 Self::Node => "node",
504 }
505 }
506
507 fn program(self) -> &'static str {
508 match (self, cfg!(windows)) {
509 (Self::Bun, false) => "bun",
510 (Self::Bun, true) => "bun.exe",
511 (Self::Node, false) => "node",
512 (Self::Node, true) => "node.exe",
513 }
514 }
515
516 fn floor(self) -> String {
517 match self {
518 Self::Bun => {
519 let (major, minor, patch) = BUN_MIN_VERSION_FOR_EXTENSION_HOST;
520 format!(">={major}.{minor}.{patch}")
521 }
522 // `node_version_supported_for_extension_host`.
523 Self::Node => "^22.19 || >=24".to_string(),
524 }
525 }
526
527 /// Where a search for this runtime looks ([`runtime_candidates`]).
528 fn search_scope(self) -> &'static str {
529 match self {
530 Self::Bun => "on PATH or in $BUN_INSTALL/bin (default ~/.bun/bin)",
531 Self::Node => "on PATH",
532 }
533 }
534
535 fn parse(self, banner: &str) -> Option<(u32, u32, u32)> {
536 match self {
537 Self::Bun => parse_bun_version(banner),
538 Self::Node => parse_node_version(banner),
539 }
540 }
541
542 fn supported(self, version: (u32, u32, u32)) -> bool {
543 match self {
544 Self::Bun => version >= BUN_MIN_VERSION_FOR_EXTENSION_HOST,
545 Self::Node => node_version_supported_for_extension_host(version),
546 }
547 }
548 }
549
550 /// The runtime chosen for the extension host.
551 #[derive(Debug, Clone, PartialEq, Eq)]
552 pub struct HostRuntime {
553 pub kind: HostRuntimeKind,
554 pub path: PathBuf,
555 pub version: (u32, u32, u32),
556 /// Node: the [`NODE_NATIVE_CODE_FLAGS`] this Node accepts. Empty for Bun.
557 pub native_code_flags: Vec<&'static str>,
558 /// The canonical host entry is embedded in this Bun executable.
559 pub compiled: bool,
560 }
561
562 impl HostRuntime {
563 #[must_use]
564 pub fn version_string(&self) -> String {
565 let (major, minor, patch) = self.version;
566 format!("{major}.{minor}.{patch}")
567 }
568
569 /// Whether a version the running host reported (`process.versions.bun`
570 /// or `process.versions.node`: no `v`, maybe a pre-release suffix) is the
571 /// version this runtime's probe saw.
572 #[must_use]
573 pub fn reports_version(&self, reported: &str) -> bool {
574 parse_version_triple(reported.trim().trim_start_matches('v')) == Some(self.version)
575 }
576 }
577
578 /// The outcome of `[extension_host] runtime` selection, with every rejected
579 /// candidate (for `/plugin` and doctor). `bun` / `node` are `None` when that
580 /// runtime was not probed.
581 #[derive(Debug, Clone, PartialEq, Eq)]
582 pub struct HostRuntimeResolution {
583 pub choice: crate::config::ExtensionHostRuntime,
584 pub selected: Option<HostRuntime>,
585 pub bun: Option<NodeResolution>,
586 pub node: Option<NodeResolution>,
587 }
588
589 impl HostRuntimeResolution {
590 /// One line: what runs the host and why, including why Bun was passed
591 /// over when `auto` fell back to Node, and every candidate of the chosen
592 /// runtime that was skipped or rejected on the way.
593 #[must_use]
594 pub fn summary(&self) -> String {
595 let choice = self.choice.as_str();
596 let Some(runtime) = &self.selected else {
597 return self.failure();
598 };
599 let mut line = format!(
600 "{} {} at {} (runtime = \"{choice}\")",
601 runtime.kind.name(),
602 runtime.version_string(),
603 runtime.path.display()
604 );
605 if runtime.compiled {
606 line.push_str("; compiled canonical host (runtime embedded)");
607 }
608 if runtime.kind == HostRuntimeKind::Node
609 && let Some(bun) = &self.bun
610 {
611 line.push_str(&format!(
612 "; Bun {} not used: {}",
613 HostRuntimeKind::Bun.floor(),
614 bun.describe_rejections(HostRuntimeKind::Bun)
615 ));
616 }
617 let probed = match runtime.kind {
618 HostRuntimeKind::Bun => self.bun.as_ref(),
619 HostRuntimeKind::Node => self.node.as_ref(),
620 };
621 if let Some(probed) = probed
622 && !probed.rejected.is_empty()
623 {
624 line.push_str(&format!(
625 "; passed over: {}",
626 probed.describe_rejections(runtime.kind)
627 ));
628 }
629 line
630 }
631
632 /// Why no runtime was selected.
633 #[must_use]
634 pub fn failure(&self) -> String {
635 let mut reasons = Vec::new();
636 if let Some(bun) = &self.bun {
637 reasons.push(format!(
638 "bun: {}",
639 bun.describe_rejections(HostRuntimeKind::Bun)
640 ));
641 }
642 if let Some(node) = &self.node {
643 reasons.push(format!(
644 "node: {}",
645 node.describe_rejections(HostRuntimeKind::Node)
646 ));
647 }
648 let (bun, node) = (HostRuntimeKind::Bun.floor(), HostRuntimeKind::Node.floor());
649 let wanted = match self.choice {
650 crate::config::ExtensionHostRuntime::Auto => {
651 format!("Bun {bun} or Node.js {node} (set `[extension_host] bun` or `node`)")
652 }
653 crate::config::ExtensionHostRuntime::Bun => {
654 format!("Bun {bun} (`runtime = \"bun\"`; set `[extension_host] bun`)")
655 }
656 crate::config::ExtensionHostRuntime::Node => {
657 format!("Node.js {node} (set `[extension_host] node`)")
658 }
659 };
660 format!("the extension host needs {wanted}; {}", reasons.join("; "))
661 }
662 }
663
664 /// A probe of a runtime binary: no inherited environment (no credentials, no
665 /// `NODE_OPTIONS` preloads), since it runs unsandboxed; Windows needs
666 /// `SystemRoot` to load system DLLs.
667 fn probe_command(path: &Path) -> Command {
668 let mut cmd = Command::new(path);
669 crate::utils::suppress_console_window(&mut cmd);
670 cmd.env_clear();
671 #[cfg(windows)]
672 if let Some(root) = std::env::var_os("SystemRoot") {
673 cmd.env("SystemRoot", root);
674 }
675 cmd.stdin(std::process::Stdio::null())
676 .stderr(std::process::Stdio::null());
677 cmd
678 }
679
680 fn probe_runtime_version(kind: HostRuntimeKind, path: &Path) -> Result<(u32, u32, u32), String> {
681 // Only absolute candidates are run: a relative `PATH` entry resolves
682 // against the current (workspace) directory, where a repository could
683 // plant a `node`.
684 if !path.is_absolute() {
685 return Err("not an absolute path; skipped".to_string());
686 }
687 let mut cmd = probe_command(path);
688 let compiled = kind == HostRuntimeKind::Bun && is_compiled_host_path(path);
689 cmd.arg(if compiled {
690 "--codewhale-host-info"
691 } else {
692 "--version"
693 });
694 let output = probe_output(&mut cmd, true, VERSION_PROBE_TIMEOUT)
695 .map_err(|error| format!("does not start ({error})"))?;
696 if !output.status.success() {
697 return Err(format!("does not run (exit {})", output.status));
698 }
699 if compiled {
700 return parse_compiled_host_info(&output.stdout, crate::extension_host::bundle_sha256());
701 }
702 let banner = String::from_utf8_lossy(&output.stdout);
703 kind.parse(&banner)
704 .ok_or_else(|| format!("unrecognized version banner `{}`", banner.trim()))
705 }
706
707 /// A packaged host is adjacent to the running Engine, not searched in a
708 /// repository or downloaded at launch. Explicit runtime overrides remain sole
709 /// candidates; `runtime = "node"` never probes this optional Bun image.
710 fn compiled_host_candidate() -> Option<PathBuf> {
711 let executable = std::env::current_exe().ok()?;
712 let candidate = executable.parent()?.join(if cfg!(windows) {
713 "codewhale-extension-host.exe"
714 } else {
715 "codewhale-extension-host"
716 });
717 candidate.is_file().then_some(candidate)
718 }
719
720 fn is_compiled_host_path(path: &Path) -> bool {
721 path.file_name().is_some_and(|name| {
722 name == "codewhale-extension-host" || name == "codewhale-extension-host.exe"
723 })
724 }
725
726 fn parse_compiled_host_info(
727 bytes: &[u8],
728 expected_source: &str,
729 ) -> Result<(u32, u32, u32), String> {
730 #[derive(serde::Deserialize)]
731 #[serde(deny_unknown_fields)]
732 struct Info {
733 kind: String,
734 runtime: String,
735 platform: String,
736 arch: String,
737 version: String,
738 bundle_sha256: String,
739 }
740 if bytes.len() > 4096 {
741 return Err("compiled host identity exceeds 4096 bytes".to_string());
742 }
743 let info: Info = serde_json::from_slice(bytes)
744 .map_err(|error| format!("invalid compiled host identity ({error})"))?;
745 if info.kind != "codewhale-extension-host" || info.runtime != "bun" {
746 return Err("executable is not a compiled Bun extension host".to_string());
747 }
748 let expected_platform = match std::env::consts::OS {
749 "macos" => "darwin",
750 "windows" => "win32",
751 os => os,
752 };
753 let expected_arch = match std::env::consts::ARCH {
754 "aarch64" => "arm64",
755 "x86_64" => "x64",
756 arch => arch,
757 };
758 if info.platform != expected_platform || info.arch != expected_arch {
759 return Err("compiled host executed target differs from this Engine".to_string());
760 }
761 if info.bundle_sha256 != expected_source {
762 return Err("compiled host source digest differs from this Engine; reinstall matching release assets or choose a system runtime".to_string());
763 }
764 parse_bun_version(&info.version).ok_or_else(|| "invalid compiled Bun version".to_string())
765 }
766
767 /// Every `program` on `PATH`; for Bun also its default install location
768 /// (`$BUN_INSTALL/bin`, else `~/.bun/bin`), which the Bun installer adds to
769 /// shell profiles but a GUI-launched process may not see.
770 fn runtime_candidates(kind: HostRuntimeKind) -> Vec<PathBuf> {
771 let mut candidates: Vec<PathBuf> = executable_path_candidates(kind.program())
772 .into_iter()
773 .filter(|candidate| candidate.is_file())
774 .collect();
775 if kind == HostRuntimeKind::Bun {
776 let install = std::env::var_os("BUN_INSTALL")
777 .map(PathBuf::from)
778 .or_else(|| codewhale_paths::user_home().map(|home| home.join(".bun")));
779 if let Some(bin) = install.map(|root| root.join("bin").join(kind.program()))
780 && bin.is_file()
781 {
782 candidates.push(bin);
783 }
784 }
785 candidates
786 }
787
788 /// Why a runtime found by *searching* must not be run: it sits in a
789 /// `node_modules` tree (a package's bin shim, such as
790 /// `node_modules/.bin/bun`), or inside the current working directory (usually
791 /// the workspace), where a repository could plant it. Either would run
792 /// unsandboxed as a version probe and then host plugin code. A configured
793 /// override is never checked: naming a path is the opt-in.
794 ///
795 /// Known limits: a working directory that contains the user's home (a
796 /// launch from `~`, or from `/` as GUI apps are) is not treated as a
797 /// workspace, because every user-level install lives under it, so a runtime
798 /// planted directly there is not caught. The `node_modules` test reads the
799 /// `PATH` spelling, not the resolved target, so a global npm install reached
800 /// through a symlink outside `node_modules` still counts as trusted.
801 fn untrusted_location(
802 kind: HostRuntimeKind,
803 candidate: &Path,
804 cwd: Option<&Path>,
805 home: Option<&Path>,
806 ) -> Option<String> {
807 let opt_in = format!("set `[extension_host] {}` to use it anyway", kind.name());
808 if candidate
809 .components()
810 .any(|part| part.as_os_str().eq_ignore_ascii_case("node_modules"))
811 {
812 return Some(format!(
813 "inside a `node_modules` directory; skipped ({opt_in})"
814 ));
815 }
816 let cwd = cwd?;
817 let contains_home = |home: &Path| {
818 home.starts_with(cwd) || std::fs::canonicalize(home).is_ok_and(|home| home.starts_with(cwd))
819 };
820 if cwd.parent().is_none() || home.is_some_and(contains_home) {
821 return None;
822 }
823 // `current_dir` is the resolved path; a `PATH` entry may be spelled
824 // through a symlink (`/tmp` on macOS), so compare its resolved directory.
825 let inside = candidate.starts_with(cwd)
826 || candidate
827 .parent()
828 .and_then(|dir| std::fs::canonicalize(dir).ok())
829 .is_some_and(|dir| dir.starts_with(cwd));
830 inside.then(|| {
831 format!(
832 "inside the working directory {}; skipped ({opt_in})",
833 cwd.display()
834 )
835 })
836 }
837
838 /// Resolve one runtime. A configured override is the only candidate: one
839 /// that does not run or is below the floor fails resolution for this
840 /// runtime with its reason, rather than falling through to a search.
841 /// Otherwise the search candidates, minus those in an [`untrusted_location`],
842 /// which are recorded as rejected without being run. Blocking.
843 fn resolve_runtime(kind: HostRuntimeKind, override_path: Option<&Path>) -> NodeResolution {
844 if let Some(path) = override_path {
845 return select_runtime(kind, vec![path.to_path_buf()]);
846 }
847 let cwd = std::env::current_dir().ok();
848 let home = codewhale_paths::user_home();
849 let mut skipped = Vec::new();
850 let compiled = (kind == HostRuntimeKind::Bun)
851 .then(compiled_host_candidate)
852 .flatten();
853 let candidates = compiled
854 .iter()
855 .cloned()
856 .chain(runtime_candidates(kind))
857 .filter(|candidate| {
858 // An image shipped beside the Engine has its install authority.
859 // It is still probed for the exact embedded source identity.
860 if compiled.as_ref() == Some(candidate) {
861 return true;
862 }
863 match untrusted_location(kind, candidate, cwd.as_deref(), home.as_deref()) {
864 Some(reason) => {
865 skipped.push((candidate.clone(), reason));
866 false
867 }
868 None => true,
869 }
870 })
871 .collect();
872 let mut resolution = select_runtime(kind, candidates);
873 skipped.append(&mut resolution.rejected);
874 resolution.rejected = skipped;
875 resolution
876 }
877
878 /// Choose the extension host's runtime for `[extension_host] runtime`:
879 /// `bun` and `node` try only that runtime (an explicit choice never falls
880 /// back); `auto` prefers a supported Bun and otherwise uses Node, recording
881 /// why Bun was passed over. Blocking: call from `spawn_blocking`.
882 #[must_use]
883 pub fn resolve_extension_host_runtime(
884 choice: crate::config::ExtensionHostRuntime,
885 node_override: Option<&Path>,
886 bun_override: Option<&Path>,
887 ) -> HostRuntimeResolution {
888 select_host_runtime(
889 choice,
890 || resolve_runtime(HostRuntimeKind::Bun, bun_override),
891 || resolve_runtime(HostRuntimeKind::Node, node_override),
892 )
893 }
894
895 fn select_host_runtime(
896 choice: crate::config::ExtensionHostRuntime,
897 probe_bun: impl FnOnce() -> NodeResolution,
898 probe_node: impl FnOnce() -> NodeResolution,
899 ) -> HostRuntimeResolution {
900 use crate::config::ExtensionHostRuntime as Choice;
901 let mut resolution = HostRuntimeResolution {
902 choice,
903 selected: None,
904 bun: None,
905 node: None,
906 };
907 let pick = |kind: HostRuntimeKind, probe: &NodeResolution| {
908 probe.selected.clone().map(|(path, version)| HostRuntime {
909 native_code_flags: match kind {
910 HostRuntimeKind::Bun => Vec::new(),
911 HostRuntimeKind::Node => accepted_flags(&path, NODE_NATIVE_CODE_FLAGS),
912 },
913 compiled: kind == HostRuntimeKind::Bun && is_compiled_host_path(&path),
914 kind,
915 path,
916 version,
917 })
918 };
919 if matches!(choice, Choice::Auto | Choice::Bun) {
920 let bun = probe_bun();
921 resolution.selected = pick(HostRuntimeKind::Bun, &bun);
922 resolution.bun = Some(bun);
923 }
924 if resolution.selected.is_none() && matches!(choice, Choice::Auto | Choice::Node) {
925 let node = probe_node();
926 resolution.selected = pick(HostRuntimeKind::Node, &node);
927 resolution.node = Some(node);
928 }
929 resolution
930 }
931
932 /// The `flags` a runtime starts with (`<runtime> <flag> --version` exits 0).
933 /// Blocking: one short probe per flag.
934 fn accepted_flags(path: &Path, flags: &[&'static str]) -> Vec<&'static str> {
935 flags
936 .iter()
937 .copied()
938 .filter(|flag| {
939 let mut cmd = probe_command(path);
940 cmd.args([*flag, "--version"])
941 .stdout(std::process::Stdio::null());
942 cmd.status().is_ok_and(|status| status.success())
943 })
944 .collect()
945 }
946
947 fn select_runtime(kind: HostRuntimeKind, candidates: Vec<PathBuf>) -> NodeResolution {
948 let mut seen = std::collections::HashSet::new();
949 let mut resolution = NodeResolution::default();
950 for candidate in candidates {
951 // Deduplicate by spelling only: resolving symlinks here would be a
952 // blocking call per candidate for a cosmetic gain.
953 if !seen.insert(candidate.clone()) {
954 continue;
955 }
956 match probe_runtime_version(kind, &candidate) {
957 Ok(version) if kind.supported(version) => {
958 resolution.selected = Some((candidate, version));
959 break;
960 }
961 Ok((major, minor, patch)) => resolution.rejected.push((
962 candidate,
963 format!(
964 "{major}.{minor}.{patch} is below the {} floor",
965 kind.floor()
966 ),
967 )),
968 Err(reason) => resolution.rejected.push((candidate, reason)),
969 }
970 }
971 resolution
972 }
973
974 // ---------------------------------------------------------------------------
975 // ExternalTool trait — unified subprocess interface
976 // ---------------------------------------------------------------------------
977
978 /// A tool that DeepSeek-TUI shells out to. Instead of scattering
979 /// `Command::new("git")` / `Command::new("gh")` across the codebase,
980 /// each external dependency implements this trait once in this module.
981 /// Callers ask the tool for a pre-populated [`Command`] and chain their
982 /// own args, working directory, and spawn method.
983 ///
984 /// # Example
985 ///
986 /// ```ignore
987 /// let output = Git::command()
988 /// .expect("git not found")
989 /// .args(["diff", "--stat"])
990 /// .current_dir(&workspace)
991 /// .output()?;
992 /// ```
993 pub trait ExternalTool {
994 /// Candidate binary names, tried in order until one responds to
995 /// `--version`. For single-binary tools (git, gh, node) this is a
996 /// one-element slice.
997 fn candidates() -> &'static [&'static str];
998
999 /// Resolve the best candidate once per process (cached). Returns
1000 /// the spec string (e.g. `"python3"` or `"py -3"`).
1001 fn resolve() -> Option<String>;
1002
1003 /// Quick availability check — true when the tool was found on PATH.
1004 fn available() -> bool {
1005 Self::resolve().is_some()
1006 }
1007
1008 /// Build a `std::process::Command` pre-populated with the resolved
1009 /// binary (and any fixed arguments from a multi-word candidate like
1010 /// `"py -3"`). Returns `None` when the tool isn't installed.
1011 ///
1012 /// Callers should chain `.args(...)`, `.current_dir(...)`, and then
1013 /// call `.output()`, `.status()`, or `.spawn()`.
1014 fn command() -> Option<Command> {
1015 Some(command_for_spec(&Self::resolve()?))
1016 }
1017
1018 /// The error a caller sees when the tool is not installed. It names the
1019 /// binary the user would install (`git`, `python3`), never the Rust type
1020 /// path (`codewhale_tui::dependencies::Git`).
1021 fn not_found_error() -> std::io::Error {
1022 let name = Self::candidates().first().copied().unwrap_or("tool");
1023 std::io::Error::new(
1024 std::io::ErrorKind::NotFound,
1025 format!("{name} not found on PATH"),
1026 )
1027 }
1028
1029 /// Convenience: run the tool with arguments in a working directory
1030 /// and return the captured output.
1031 fn output(args: &[&str], cwd: &std::path::Path) -> std::io::Result<std::process::Output> {
1032 let mut cmd = Self::command().ok_or_else(Self::not_found_error)?;
1033 cmd.args(args).current_dir(cwd).output()
1034 }
1035
1036 /// Convenience: run the tool with arguments and return only the
1037 /// exit status (discards stdout/stderr).
1038 #[cfg_attr(not(test), expect(dead_code))]
1039 fn status(args: &[&str], cwd: &std::path::Path) -> std::io::Result<std::process::ExitStatus> {
1040 let mut cmd = Self::command().ok_or_else(Self::not_found_error)?;
1041 cmd.args(args).current_dir(cwd).status()
1042 }
1043
1044 /// Build a `tokio::process::Command` pre-populated with the resolved
1045 /// binary (and any fixed arguments from a multi-word candidate like
1046 /// `"py -3"`). Returns `None` when the tool isn't installed.
1047 ///
1048 /// Async callers (`code_execution`, `js_execution`) use this instead
1049 /// of [`ExternalTool::command`] so they can `.await` the child.
1050 fn tokio_command() -> Option<tokio::process::Command> {
1051 let spec = Self::resolve()?;
1052 let (program, fixed_args) = split_interpreter_spec(&spec);
1053 let mut cmd = tokio::process::Command::new(&program);
1054 crate::utils::suppress_tokio_console_window(&mut cmd);
1055 for arg in &fixed_args {
1056 cmd.arg(arg);
1057 }
1058 Some(cmd)
1059 }
1060 }
1061
1062 /// Build a `std::process::Command` for an interpreter spec such as `"py -3"`.
1063 fn command_for_spec(spec: &str) -> Command {
1064 let (program, fixed_args) = split_interpreter_spec(spec);
1065 let mut cmd = Command::new(&program);
1066 crate::utils::suppress_console_window(&mut cmd);
1067 for arg in &fixed_args {
1068 cmd.arg(arg);
1069 }
1070 cmd
1071 }
1072
1073 /// [`command_for_spec`] started from the sanitized child environment. Used by
1074 /// the runtimes whose every caller runs model-authored code (Python, Node),
1075 /// so no constructor for them hands out the parent's credentials.
1076 fn scrubbed_command_for_spec(spec: &str) -> Command {
1077 let mut cmd = command_for_spec(spec);
1078 crate::child_env::apply_to_command(&mut cmd, std::iter::empty::<(&str, &str)>());
1079 cmd
1080 }
1081
1082 // ---------------------------------------------------------------------------
1083 // Concrete tool implementations
1084 // ---------------------------------------------------------------------------
1085
1086 /// Git version control.
1087 pub struct Git;
1088
1089 /// Keep a git child from ever waiting on a human.
1090 ///
1091 /// Git and ssh read credentials, passphrases and host-key confirmations from
1092 /// `/dev/tty` directly — `stdin(null)` does not stop them — so inside the
1093 /// raw-mode TUI or an HTTP request a prompt is an invisible, indefinite hang.
1094 /// `GIT_TERMINAL_PROMPT=0` makes git fail instead of asking for a username or
1095 /// password; BatchMode ssh fails instead of asking for a passphrase or an
1096 /// unknown host key; an empty `GIT_PAGER` keeps output from ever being paged.
1097 /// A user who pinned their own ssh transport (`GIT_SSH_COMMAND` or `GIT_SSH`)
1098 /// keeps it untouched.
1099 ///
1100 /// This is the single definition site; [`Git::command`] and
1101 /// [`Git::tokio_command`] apply it to every product git spawn. Call it
1102 /// directly only for a non-git program that may shell out to git (`gh`).
1103 pub(crate) fn apply_git_noninteractive_env(cmd: &mut Command) {
1104 cmd.env("GIT_TERMINAL_PROMPT", "0").env("GIT_PAGER", "");
1105 if std::env::var_os("GIT_SSH_COMMAND").is_none() && std::env::var_os("GIT_SSH").is_none() {
1106 cmd.env("GIT_SSH_COMMAND", "ssh -o BatchMode=yes");
1107 }
1108 }
1109
1110 impl Git {
1111 /// Flags every `diff`, `show` or patch `log` that collects repository
1112 /// content passes. `--no-ext-diff`/`--no-textconv` skip diff drivers; a
1113 /// dirty check or `diff.submodule=diff` spawns a child git inside each
1114 /// submodule that inherits neither flag, so submodules compare by commit
1115 /// only. A read that touches the working tree also runs the superproject's
1116 /// clean filters, which no flag disables: build it from
1117 /// [`Self::review_command`] too.
1118 pub(crate) const REVIEW_DIFF_ARGS: [&'static str; 4] = [
1119 "--no-ext-diff",
1120 "--no-textconv",
1121 "--submodule=short",
1122 "--ignore-submodules=dirty",
1123 ];
1124
1125 /// Construct a read-only review command with content conversion disabled.
1126 /// Review callers also pass [`Self::REVIEW_DIFF_ARGS`] for diffs.
1127 /// Configured filters otherwise execute even when those flags are present.
1128 pub(crate) fn review_command(workspace: &Path) -> anyhow::Result<Command> {
1129 let overrides = Self::review_filter_overrides(workspace)?;
1130 let mut command = Self::review_base(workspace)?;
1131 let mut count = 2;
1132 for (key, value) in overrides {
1133 // A subsection may contain '='; `-c key=value` would then
1134 // override a different key. Separate env fields preserve it.
1135 command.env(format!("GIT_CONFIG_KEY_{count}"), key);
1136 command.env(format!("GIT_CONFIG_VALUE_{count}"), value);
1137 count += 1;
1138 }
1139 command.env("GIT_CONFIG_COUNT", count.to_string());
1140 Ok(command)
1141 }
1142
1143 /// Git with fsmonitor, hooks, lazy fetch and replace objects disabled,
1144 /// running in `workspace`. [`Self::review_command`] adds filter overrides.
1145 pub(crate) fn review_base(workspace: &Path) -> anyhow::Result<Command> {
1146 use anyhow::Context;
1147
1148 let mut command = Self::command().context("git not found on PATH")?;
1149
1150 // Runtime config pairs are required to disable filter names containing
1151 // '=' without changing which key Git sees. Older Git ignores them.
1152 // Probe once for this process's cached executable, before any read.
1153 // A random value prevents repository config from spoofing support.
1154 static REVIEW_CONFIG_SUPPORTED: OnceLock<bool> = OnceLock::new();
1155 if !*REVIEW_CONFIG_SUPPORTED.get_or_init(|| {
1156 let Some(mut probe) = Self::command() else {
1157 return false;
1158 };
1159 let value = uuid::Uuid::new_v4().to_string();
1160 probe
1161 .current_dir(workspace)
1162 .stdin(std::process::Stdio::null())
1163 .env_remove("GIT_CONFIG")
1164 .env_remove("GIT_CONFIG_PARAMETERS")
1165 .env("GIT_CONFIG_COUNT", "1")
1166 .env("GIT_CONFIG_KEY_0", "codewhale.reviewConfigCapability")
1167 .env("GIT_CONFIG_VALUE_0", &value)
1168 .args(["config", "--get", "codewhale.reviewConfigCapability"]);
1169 matches!(probe.output(), Ok(output)
1170 if output.status.success() && output.stdout == format!("{value}\n").as_bytes())
1171 }) {
1172 anyhow::bail!(
1173 "Cannot safely inspect Git review configuration: Git runtime configuration overrides are unavailable; upgrade Git (2.31 or newer)"
1174 );
1175 }
1176
1177 command
1178 .current_dir(workspace)
1179 .stdin(std::process::Stdio::null())
1180 // GIT_CONFIG redirects only `git config`, not `git diff`.
1181 // Both phases must observe the same effective repository config.
1182 .env_remove("GIT_CONFIG")
1183 .env_remove("GIT_CONFIG_PARAMETERS")
1184 .env("GIT_CONFIG_COUNT", "2")
1185 .env("GIT_CONFIG_KEY_0", "core.fsmonitor")
1186 .env("GIT_CONFIG_VALUE_0", "false")
1187 .env("GIT_CONFIG_KEY_1", "core.hooksPath")
1188 .env(
1189 "GIT_CONFIG_VALUE_1",
1190 if cfg!(windows) { "NUL" } else { "/dev/null" },
1191 )
1192 .env("GIT_NO_LAZY_FETCH", "1")
1193 .env("GIT_NO_REPLACE_OBJECTS", "1");
1194 Ok(command)
1195 }
1196
1197 /// Config overrides (`key`, `value`) that neutralize every clean/process
1198 /// filter driver configured for the repository at `workspace`. Callers
1199 /// apply them through `GIT_CONFIG_KEY_n`/`GIT_CONFIG_VALUE_n`.
1200 pub(crate) fn review_filter_overrides(
1201 workspace: &Path,
1202 ) -> anyhow::Result<Vec<(String, &'static str)>> {
1203 use anyhow::{Context, bail};
1204
1205 let output = Self::review_base(workspace)?
1206 .args([
1207 "config",
1208 "--null",
1209 "--name-only",
1210 "--get-regexp",
1211 r"^filter\..*\.(clean|process|required)$",
1212 ])
1213 .output()
1214 .context("Failed to inspect Git review filters")?;
1215 let no_filters =
1216 output.status.code() == Some(1) && output.stdout.is_empty() && output.stderr.is_empty();
1217 if (!output.status.success() && !no_filters)
1218 || (!output.stdout.is_empty() && !output.stdout.ends_with(&[0]))
1219 {
1220 bail!("Cannot safely inspect Git review configuration");
1221 }
1222 let mut filters = std::collections::BTreeSet::new();
1223 for key in output
1224 .stdout
1225 .split(|byte| *byte == 0)
1226 .filter(|key| !key.is_empty())
1227 {
1228 let key =
1229 std::str::from_utf8(key).context("Git review filter name is not valid UTF-8")?;
1230 let (driver, _) = key
1231 .rsplit_once('.')
1232 .context("Invalid Git review filter key")?;
1233 filters.insert(driver.to_string());
1234 }
1235 Ok(filters
1236 .into_iter()
1237 .flat_map(|driver| {
1238 [("clean", ""), ("process", ""), ("required", "false")]
1239 .map(|(suffix, value)| (format!("{driver}.{suffix}"), value))
1240 })
1241 .collect())
1242 }
1243 }
1244
1245 impl ExternalTool for Git {
1246 fn candidates() -> &'static [&'static str] {
1247 &["git"]
1248 }
1249
1250 /// Every `Git` invocation in the product is issued against a repository
1251 /// the user also works in by hand. `git status` and `git diff`
1252 /// opportunistically refresh the index, and that refresh takes
1253 /// `.git/index.lock` — which is why a user's own `git commit` could fail
1254 /// with "Unable to create '.../.git/index.lock': File exists" while
1255 /// codewhale was merely idling in the same repo (#5617, reported by
1256 /// @LmeSzinc).
1257 ///
1258 /// `GIT_OPTIONAL_LOCKS=0` suppresses only *optional* lock-taking, so
1259 /// reads stop touching the index while genuine writes (`add`, `commit`,
1260 /// `stash`, `update-ref`) are unaffected — including the snapshot
1261 /// side-repo runner, which writes to its own git dir. `git diff --quiet`
1262 /// exit-code semantics are preserved, which `snapshot::repo` relies on
1263 /// for `/undo` cursoring.
1264 ///
1265 /// Set here rather than on the `ExternalTool::command` default so it does
1266 /// not leak onto `Gh`, `Cargo`, `Node`, `Python`, or `RustC`. Prefer the
1267 /// environment variable over the `--no-optional-locks` flag: the flag is
1268 /// top-level (it must precede the subcommand, awkward for the several
1269 /// call sites that build argument vectors), it would change the
1270 /// agent-visible command string rendered by `tools::git::format_command`,
1271 /// and an unknown flag hard-fails on old git while an unknown environment
1272 /// variable is silently ignored.
1273 ///
1274 /// The child also starts from the sanitized environment (see
1275 /// [`crate::child_env::apply_to_git_command`]): workspace config such as
1276 /// `core.fsmonitor` or a clean filter makes even a read like `git status`
1277 /// run a program the workspace chose, so no git child gets the parent's
1278 /// credentials. The guards below are applied after the scrub.
1279 fn command() -> Option<Command> {
1280 let mut cmd = Command::new(Self::resolve()?);
1281 crate::utils::suppress_console_window(&mut cmd);
1282 crate::child_env::apply_to_git_command(&mut cmd);
1283 cmd.env("GIT_OPTIONAL_LOCKS", "0");
1284 apply_git_noninteractive_env(&mut cmd);
1285 Some(cmd)
1286 }
1287
1288 /// Same environment as [`Git::command`]: the trait default would build a
1289 /// bare command and silently drop the lock and prompt guards.
1290 fn tokio_command() -> Option<tokio::process::Command> {
1291 Self::command().map(tokio::process::Command::from)
1292 }
1293
1294 fn resolve() -> Option<String> {
1295 static CACHE: OnceLock<Option<String>> = OnceLock::new();
1296 CACHE
1297 .get_or_init(|| {
1298 // The review capability cache must follow the executable
1299 // checked here even if a later child changes PATH or cwd.
1300 let path = resolve_executable_path(Self::candidates().first()?, "--version")?;
1301 let path = std::path::absolute(path).ok()?;
1302 tracing::info!(target: "tool_dependencies", "Resolved git binary");
1303 Some(path.to_string_lossy().into_owned())
1304 })
1305 .clone()
1306 }
1307 }
1308
1309 /// GitHub CLI.
1310 pub struct Gh;
1311
1312 impl ExternalTool for Gh {
1313 fn candidates() -> &'static [&'static str] {
1314 &["gh"]
1315 }
1316
1317 fn resolve() -> Option<String> {
1318 static CACHE: OnceLock<Option<String>> = OnceLock::new();
1319 CACHE
1320 .get_or_init(|| {
1321 for candidate in Self::candidates() {
1322 if probe_executable(candidate) {
1323 tracing::info!(target: "tool_dependencies", "Resolved gh binary");
1324 return Some((*candidate).to_string());
1325 }
1326 }
1327 None
1328 })
1329 .clone()
1330 }
1331 }
1332
1333 /// Rust compiler — used for version reporting in diagnostics.
1334 pub struct RustC;
1335
1336 impl ExternalTool for RustC {
1337 fn candidates() -> &'static [&'static str] {
1338 &["rustc"]
1339 }
1340
1341 fn resolve() -> Option<String> {
1342 static CACHE: OnceLock<Option<String>> = OnceLock::new();
1343 CACHE
1344 .get_or_init(|| {
1345 // Probe with capture so the `--version` banner observed during
1346 // resolution is reused by [`rustc_version_banner`] instead of
1347 // paying a second rustc process launch (each launch loads
1348 // libLLVM, which dominated diagnostic-command init profiles).
1349 for candidate in Self::candidates() {
1350 if let Some(banner) = probe_executable_capturing(candidate, "--version") {
1351 tracing::info!(target: "tool_dependencies", "Resolved rustc binary");
1352 let _ = RUSTC_VERSION_BANNER.set(Some(banner));
1353 return Some((*candidate).to_string());
1354 }
1355 }
1356 None
1357 })
1358 .clone()
1359 }
1360 }
1361
1362 /// Captured `--version` banner from the [`RustC`] resolution probe.
1363 ///
1364 /// `None` until `RustC::resolve()`/`available()`/`command()` first runs, or
1365 /// when rustc is absent/failing. Reading this after an `available()` check
1366 /// yields the same string the tool would print, without a second process.
1367 static RUSTC_VERSION_BANNER: OnceLock<Option<String>> = OnceLock::new();
1368
1369 /// The rustc `--version` banner, if rustc resolved successfully.
1370 ///
1371 /// Populated as a side effect of resolving [`RustC`]; this reads no fresh
1372 /// process state. Callers wanting the value should touch `RustC::available()`
1373 /// first (as the diagnostics path does).
1374 #[must_use]
1375 pub fn rustc_version_banner() -> Option<String> {
1376 RUSTC_VERSION_BANNER.get().cloned().flatten()
1377 }
1378
1379 /// Rust build tool — used by the `run_tests` tool.
1380 pub struct Cargo;
1381
1382 impl ExternalTool for Cargo {
1383 fn candidates() -> &'static [&'static str] {
1384 &["cargo"]
1385 }
1386
1387 fn resolve() -> Option<String> {
1388 static CACHE: OnceLock<Option<String>> = OnceLock::new();
1389 CACHE
1390 .get_or_init(|| {
1391 for candidate in Self::candidates() {
1392 if probe_executable(candidate) {
1393 tracing::info!(target: "tool_dependencies", "Resolved cargo binary");
1394 return Some((*candidate).to_string());
1395 }
1396 }
1397 None
1398 })
1399 .clone()
1400 }
1401 }
1402
1403 /// Python interpreter — used by `code_execution` tool and RLM REPL.
1404 /// Delegates to the existing [`resolve_python_interpreter`] so the
1405 /// multi-candidate ladder (`python3` → `python` → `py -3`) is
1406 /// shared with legacy callers until they migrate to the trait.
1407 pub struct Python;
1408
1409 impl ExternalTool for Python {
1410 fn candidates() -> &'static [&'static str] {
1411 PYTHON_CANDIDATES
1412 }
1413
1414 /// Every Python caller runs model-authored code (`code_execution`, the
1415 /// RLM REPL), so both constructors (and the `output`/`status` helpers
1416 /// built on them) start the child from the sanitized environment instead
1417 /// of inheriting provider credentials and other parent secrets. Callers
1418 /// that need extra variables re-apply them through
1419 /// [`crate::child_env::apply_to_tokio_command`] with explicit overrides.
1420 fn command() -> Option<Command> {
1421 Some(scrubbed_command_for_spec(&Self::resolve()?))
1422 }
1423
1424 fn tokio_command() -> Option<tokio::process::Command> {
1425 Self::command().map(tokio::process::Command::from)
1426 }
1427
1428 fn resolve() -> Option<String> {
1429 resolve_python_interpreter()
1430 }
1431 }
1432
1433 /// Node.js runtime — used by the `js_execution` tool.
1434 /// The binary name `node` is the same on every platform we support,
1435 /// so this is a single probe rather than a candidate ladder.
1436 pub struct Node;
1437
1438 impl ExternalTool for Node {
1439 fn candidates() -> &'static [&'static str] {
1440 &["node"]
1441 }
1442
1443 /// Node runs model-authored code (`js_execution`); like [`Python`], every
1444 /// constructor starts from the sanitized environment.
1445 fn command() -> Option<Command> {
1446 Some(scrubbed_command_for_spec(&Self::resolve()?))
1447 }
1448
1449 fn tokio_command() -> Option<tokio::process::Command> {
1450 Self::command().map(tokio::process::Command::from)
1451 }
1452
1453 fn resolve() -> Option<String> {
1454 resolve_node()
1455 }
1456 }
1457
1458 // ---------------------------------------------------------------------------
1459 // Legacy interpreter helpers (kept for existing callers until migrated)
1460 // ---------------------------------------------------------------------------
1461
1462 /// Split an interpreter spec like `"py -3"` into the program name
1463 /// and any initial arguments. Returns `("py", vec!["-3"])` for the
1464 /// example; returns `("python3", vec![])` for a bare name.
1465 ///
1466 /// Callers spawn `Command::new(program).args(args).arg(script_path)`.
1467 #[must_use]
1468 pub fn split_interpreter_spec(spec: &str) -> (String, Vec<String>) {
1469 let mut parts = spec.split_whitespace();
1470 let program = parts.next().unwrap_or("").to_string();
1471 let args = parts.map(str::to_string).collect();
1472 (program, args)
1473 }
1474
1475 #[cfg(test)]
1476 mod tests {
1477 use super::*;
1478
1479 #[test]
1480 fn compiled_host_identity_requires_exact_engine_source_and_real_bun_version() {
1481 let digest = "a".repeat(64);
1482 let info = serde_json::json!({
1483 "kind": "codewhale-extension-host", "runtime": "bun", "version": "1.4.0", "bundle_sha256": digest,
1484 "platform": match std::env::consts::OS { "macos" => "darwin", "windows" => "win32", os => os },
1485 "arch": match std::env::consts::ARCH { "aarch64" => "arm64", "x86_64" => "x64", arch => arch },
1486 });
1487 assert_eq!(
1488 parse_compiled_host_info(&serde_json::to_vec(&info).unwrap(), &digest),
1489 Ok((1, 4, 0))
1490 );
1491 let mut changed = info.clone();
1492 changed["bundle_sha256"] = "b".repeat(64).into();
1493 assert!(
1494 parse_compiled_host_info(&serde_json::to_vec(&changed).unwrap(), &digest)
1495 .unwrap_err()
1496 .contains("differs from this Engine")
1497 );
1498 for (key, value) in [
1499 ("runtime", "node"),
1500 ("kind", "bun"),
1501 ("version", "not-a-version"),
1502 ("platform", "incorrect-platform"),
1503 ("arch", "incorrect-arch"),
1504 ] {
1505 let mut invalid = info.clone();
1506 invalid[key] = value.into();
1507 assert!(
1508 parse_compiled_host_info(&serde_json::to_vec(&invalid).unwrap(), &digest).is_err()
1509 );
1510 }
1511 assert!(
1512 parse_compiled_host_info(&vec![b' '; 4097], &digest)
1513 .unwrap_err()
1514 .contains("4096")
1515 );
1516 assert!(parse_compiled_host_info(b"{}", &digest).is_err());
1517 }
1518
1519 #[cfg(unix)]
1520 #[test]
1521 fn version_probes_scrub_credentials_preloads_and_close_stdin() {
1522 let _home = crate::test_support::SealedHome::new();
1523 let _secret =
1524 crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY", "synthetic-probe-secret");
1525 let _preload = crate::test_support::EnvVarGuard::set("NODE_OPTIONS", "synthetic-preload");
1526 let mut command = version_probe_command("/bin/sh");
1527 command.args(["-c", "[ -z \"${DEEPSEEK_API_KEY+x}\" ] && [ -z \"${NODE_OPTIONS+x}\" ] && ! read ignored && printf clean"]);
1528 let output = probe_output(&mut command, true, VERSION_PROBE_TIMEOUT).unwrap();
1529 assert!(output.status.success());
1530 assert_eq!(output.stdout, b"clean");
1531 }
1532
1533 #[cfg(unix)]
1534 #[test]
1535 fn version_probe_timeout_kills_ordinary_descendants() {
1536 let root = tempfile::tempdir().unwrap();
1537 let pid_file = root.path().join("descendant.pid");
1538 let mut command = version_probe_command("/bin/sh");
1539 command
1540 .args(["-c", "sleep 30 & echo $! > \"$1\"; wait", "probe"])
1541 .arg(&pid_file);
1542 let started = std::time::Instant::now();
1543 let error =
1544 probe_output(&mut command, false, std::time::Duration::from_millis(150)).unwrap_err();
1545 assert_eq!(error.kind(), std::io::ErrorKind::TimedOut);
1546 assert!(started.elapsed() < std::time::Duration::from_secs(2));
1547 let pid = crate::process_tree::read_pid_file(&pid_file, std::time::Duration::from_secs(1));
1548 assert!(crate::process_tree::wait_for_pid_exit(
1549 pid,
1550 std::time::Duration::from_secs(2)
1551 ));
1552 }
1553
1554 #[cfg(unix)]
1555 #[test]
1556 fn version_probe_reaps_successful_parents_inherited_pipe_child() {
1557 let root = tempfile::tempdir().unwrap();
1558 let pid_file = root.path().join("pipe-child.pid");
1559 let mut command = version_probe_command("/bin/sh");
1560 command
1561 .args(["-c", "sleep 30 & echo $! > \"$1\"; printf version", "probe"])
1562 .arg(&pid_file);
1563 let output = probe_output(&mut command, true, VERSION_PROBE_TIMEOUT).unwrap();
1564 assert!(output.status.success());
1565 assert_eq!(output.stdout, b"version");
1566 let pid = crate::process_tree::read_pid_file(&pid_file, std::time::Duration::from_secs(1));
1567 assert!(crate::process_tree::wait_for_pid_exit(
1568 pid,
1569 std::time::Duration::from_secs(2)
1570 ));
1571 }
1572
1573 #[cfg(unix)]
1574 #[test]
1575 fn version_probe_refuses_oversized_banner() {
1576 let mut command = version_probe_command("/bin/sh");
1577 command.args(["-c", "head -c 20000 /dev/zero"]);
1578 assert_eq!(
1579 probe_output(&mut command, true, VERSION_PROBE_TIMEOUT)
1580 .unwrap_err()
1581 .kind(),
1582 std::io::ErrorKind::InvalidData
1583 );
1584 }
1585
1586 #[test]
1587 fn node_version_banner_parses_and_floor_matches_dsh_engines() {
1588 assert_eq!(parse_node_version("v22.20.0\n"), Some((22, 20, 0)));
1589 assert_eq!(parse_node_version("v24.1.0-nightly"), Some((24, 1, 0)));
1590 assert_eq!(parse_node_version("22.20.0"), None);
1591 assert!(node_version_supported_for_extension_host((22, 19, 0)));
1592 assert!(!node_version_supported_for_extension_host((22, 18, 9)));
1593 assert!(!node_version_supported_for_extension_host((23, 11, 0)));
1594 assert!(!node_version_supported_for_extension_host((20, 19, 0)));
1595 assert!(node_version_supported_for_extension_host((24, 0, 0)));
1596 }
1597
1598 #[cfg(unix)]
1599 #[test]
1600 fn node_ladder_skips_broken_and_old_candidates() {
1601 use std::os::unix::fs::PermissionsExt;
1602 let dir = tempfile::tempdir().unwrap();
1603 let script = |name: &str, body: &str| {
1604 let path = dir.path().join(name);
1605 std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
1606 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
1607 path
1608 };
1609 let broken = script(
1610 "broken-node",
1611 "echo 'dyld: Library not loaded' >&2; exit 134",
1612 );
1613 let old = script("old-node", "echo v20.11.1");
1614 let good = script("good-node", "echo v22.20.0");
1615 let later = script("later-node", "echo v24.0.0");
1616 let relative = PathBuf::from("node_modules/.bin/node");
1617 let resolution = select_runtime(
1618 HostRuntimeKind::Node,
1619 vec![
1620 relative.clone(),
1621 broken.clone(),
1622 old.clone(),
1623 good.clone(),
1624 later,
1625 ],
1626 );
1627 assert_eq!(resolution.selected, Some((good, (22, 20, 0))));
1628 assert_eq!(resolution.rejected.len(), 3);
1629 assert_eq!(resolution.rejected[0].0, relative);
1630 assert!(resolution.rejected[0].1.contains("not an absolute path"));
1631 assert_eq!(resolution.rejected[1].0, broken);
1632 assert!(resolution.rejected[1].1.contains("does not run"));
1633 assert_eq!(resolution.rejected[2].0, old);
1634 assert!(resolution.rejected[2].1.contains("below"));
1635 }
1636
1637 #[test]
1638 fn bun_version_banner_parses_and_floor_is_the_validated_release() {
1639 assert_eq!(parse_bun_version("1.4.0\n"), Some((1, 4, 0)));
1640 assert_eq!(parse_bun_version("1.4.2-canary.3+abc"), Some((1, 4, 2)));
1641 assert_eq!(parse_bun_version("v1.4.0"), None);
1642 assert!(HostRuntimeKind::Bun.supported((1, 4, 0)));
1643 assert!(HostRuntimeKind::Bun.supported((2, 0, 0)));
1644 assert!(!HostRuntimeKind::Bun.supported((1, 3, 14)));
1645 }
1646
1647 #[cfg(unix)]
1648 #[test]
1649 fn host_runtime_auto_prefers_bun_and_explicit_choices_never_fall_back() {
1650 use crate::config::ExtensionHostRuntime as Choice;
1651 use std::os::unix::fs::PermissionsExt;
1652 let dir = tempfile::tempdir().unwrap();
1653 let script = |name: &str, body: &str| {
1654 let path = dir.path().join(name);
1655 std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
1656 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
1657 path
1658 };
1659 let bun = script("bun", "echo 1.4.0");
1660 let old_bun = script("old-bun", "echo 1.3.14");
1661 // A Node without `node:ffi`: it rejects `--no-experimental-ffi` the
1662 // way Node 22 and 24 do.
1663 let node = script(
1664 "node",
1665 "case \"$1\" in --no-experimental-ffi) echo 'bad option' >&2; exit 9;; esac\necho v24.1.0",
1666 );
1667
1668 let probe = |kind, candidates: &[&PathBuf]| {
1669 select_runtime(
1670 kind,
1671 candidates.iter().map(|path| (*path).clone()).collect(),
1672 )
1673 };
1674
1675 // auto: a supported Bun wins, and Node is never probed. The Bun
1676 // passed over on the way is in the summary.
1677 let auto = select_host_runtime(
1678 Choice::Auto,
1679 || probe(HostRuntimeKind::Bun, &[&old_bun, &bun]),
1680 || panic!("node must not be probed when Bun is usable"),
1681 );
1682 let selected = auto.selected.clone().unwrap();
1683 assert_eq!(selected.kind, HostRuntimeKind::Bun);
1684 assert_eq!(selected.path, bun);
1685 assert_eq!(selected.version_string(), "1.4.0");
1686 assert!(selected.native_code_flags.is_empty());
1687 let summary = auto.summary();
1688 assert!(summary.starts_with("bun 1.4.0 at "), "{summary}");
1689 assert!(summary.contains("(runtime = \"auto\")"), "{summary}");
1690 assert!(
1691 summary.contains("passed over: ") && summary.contains("1.3.14 is below"),
1692 "{summary}"
1693 );
1694
1695 // auto without a supported Bun: Node, and the summary says why.
1696 let fallback = select_host_runtime(
1697 Choice::Auto,
1698 || probe(HostRuntimeKind::Bun, &[&old_bun]),
1699 || probe(HostRuntimeKind::Node, &[&node]),
1700 );
1701 assert_eq!(
1702 fallback.selected.as_ref().unwrap().kind,
1703 HostRuntimeKind::Node
1704 );
1705 let summary = fallback.summary();
1706 assert!(summary.starts_with("node 24.1.0 at "), "{summary}");
1707 assert!(summary.contains("Bun >=1.4.0 not used"), "{summary}");
1708 assert!(
1709 summary.contains("1.3.14 is below the >=1.4.0 floor"),
1710 "{summary}"
1711 );
1712 // Nothing found: the message names every place that was searched.
1713 let none_found = select_host_runtime(Choice::Auto, NodeResolution::default, || {
1714 probe(HostRuntimeKind::Node, &[&node])
1715 });
1716 assert!(
1717 none_found
1718 .summary()
1719 .contains("no `bun` found on PATH or in $BUN_INSTALL/bin (default ~/.bun/bin)"),
1720 "{}",
1721 none_found.summary()
1722 );
1723
1724 // runtime = "bun": no Node fallback, even when Node works.
1725 let bun_only = select_host_runtime(
1726 Choice::Bun,
1727 || probe(HostRuntimeKind::Bun, &[&old_bun]),
1728 || panic!("runtime = \"bun\" must not probe node"),
1729 );
1730 assert!(bun_only.selected.is_none());
1731 assert!(
1732 bun_only.failure().contains("needs Bun >=1.4.0"),
1733 "{}",
1734 bun_only.failure()
1735 );
1736
1737 // runtime = "node": Bun is never probed.
1738 let node_only = select_host_runtime(
1739 Choice::Node,
1740 || panic!("runtime = \"node\" must not probe bun"),
1741 || probe(HostRuntimeKind::Node, &[&node]),
1742 );
1743 let node_runtime = node_only.selected.unwrap();
1744 assert_eq!(node_runtime.kind, HostRuntimeKind::Node);
1745 // Only the flags this Node starts with are passed.
1746 assert_eq!(
1747 node_runtime.native_code_flags,
1748 vec!["--no-experimental-sqlite"]
1749 );
1750 assert!(node_only.bun.is_none());
1751 }
1752
1753 /// A runtime found by searching that a repository could have planted is
1754 /// never run; a configured override is exempt from that check but is
1755 /// final: when it fails, resolution fails with its reason.
1756 #[cfg(unix)]
1757 #[test]
1758 fn untrusted_search_candidates_are_skipped_and_a_failing_override_is_final() {
1759 use crate::config::ExtensionHostRuntime as Choice;
1760 use std::os::unix::fs::PermissionsExt;
1761 let dir = tempfile::tempdir().unwrap();
1762 let script = |name: &str, body: &str| {
1763 let path = dir.path().join(name);
1764 std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
1765 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
1766 path
1767 };
1768 let kind = HostRuntimeKind::Bun;
1769 let home = dir.path().join("home");
1770 let workspace = dir.path().join("workspace");
1771 std::fs::create_dir_all(workspace.join("bin")).unwrap();
1772 // `current_dir` is resolved; the temp dir may be spelled through a
1773 // symlink (`/var` → `/private/var` on macOS).
1774 let cwd = std::fs::canonicalize(&workspace).unwrap();
1775
1776 let shim = Path::new("/opt/tools/node_modules/.bin/bun");
1777 let reason = untrusted_location(kind, shim, None, None).unwrap();
1778 assert!(reason.contains("`node_modules`"), "{reason}");
1779 let reason = untrusted_location(
1780 kind,
1781 &workspace.join("bin/bun"),
1782 Some(cwd.as_path()),
1783 Some(home.as_path()),
1784 )
1785 .unwrap();
1786 assert!(reason.contains("inside the working directory"), "{reason}");
1787 assert!(reason.contains("`[extension_host] bun`"), "{reason}");
1788 let link = dir.path().join("link");
1789 std::os::unix::fs::symlink(workspace.join("bin"), &link).unwrap();
1790 assert!(
1791 untrusted_location(
1792 kind,
1793 &link.join("bun"),
1794 Some(cwd.as_path()),
1795 Some(home.as_path())
1796 )
1797 .is_some()
1798 );
1799 assert_eq!(
1800 untrusted_location(
1801 kind,
1802 &dir.path().join("elsewhere/bun"),
1803 Some(cwd.as_path()),
1804 Some(home.as_path())
1805 ),
1806 None
1807 );
1808 // A working directory that contains the home is not a workspace.
1809 let user_bun = home.join(".bun/bin/bun");
1810 assert_eq!(
1811 untrusted_location(kind, &user_bun, Some(home.as_path()), Some(home.as_path())),
1812 None
1813 );
1814 assert_eq!(
1815 untrusted_location(kind, &user_bun, Some(Path::new("/")), Some(home.as_path())),
1816 None
1817 );
1818
1819 // A failing override never falls through to a search.
1820 let broken = script("broken-bun", "exit 3");
1821 let bun_only = resolve_extension_host_runtime(Choice::Bun, None, Some(broken.as_path()));
1822 assert!(bun_only.selected.is_none());
1823 let failure = bun_only.failure();
1824 assert!(
1825 failure.contains(&format!("{}: does not run", broken.display())),
1826 "{failure}"
1827 );
1828 assert_eq!(bun_only.bun.as_ref().unwrap().rejected.len(), 1);
1829 // Under `auto` the Node that runs instead says why the Bun did not.
1830 let node = script("node", "echo v24.1.0");
1831 let auto = resolve_extension_host_runtime(
1832 Choice::Auto,
1833 Some(node.as_path()),
1834 Some(broken.as_path()),
1835 );
1836 let summary = auto.summary();
1837 assert!(summary.starts_with("node 24.1.0 at "), "{summary}");
1838 assert!(
1839 summary.contains(&format!(
1840 "Bun >=1.4.0 not used: {}: does not run",
1841 broken.display()
1842 )),
1843 "{summary}"
1844 );
1845 }
1846
1847 #[test]
1848 fn probe_executable_returns_false_for_unknown_binary() {
1849 // Pick a name we're confident isn't on any developer's PATH.
1850 // If this ever starts failing locally, rename it.
1851 assert!(!probe_executable("codewhale-tui-imaginary-binary-xyz123"));
1852 }
1853
1854 #[test]
1855 fn probe_executable_handles_multi_word_specs() {
1856 // `py -3` should split correctly. The probe will fail on
1857 // most non-Windows machines (no `py` launcher), which is
1858 // fine — we're checking that the *split* doesn't crash.
1859 let _ = probe_executable("py -3");
1860 }
1861
1862 #[test]
1863 fn probe_executable_with_flag_returns_false_for_unknown_binary() {
1864 assert!(!probe_executable_with_flag(
1865 "codewhale-tui-imaginary-binary-xyz123",
1866 "-v"
1867 ));
1868 }
1869
1870 #[test]
1871 fn probe_executable_delegates_to_double_dash_version() {
1872 // `probe_executable` must remain exactly
1873 // `probe_executable_with_flag(.., "--version")`.
1874 let spec = "codewhale-tui-imaginary-binary-xyz123";
1875 assert_eq!(
1876 probe_executable(spec),
1877 probe_executable_with_flag(spec, "--version")
1878 );
1879 }
1880
1881 #[test]
1882 fn pdftotext_resolver_detects_installed_poppler_via_dash_v() {
1883 // Regression for #1667: Poppler's `pdftotext` rejects `--version`
1884 // (it is parsed as an input filename and exits non-zero), so the
1885 // generic `--version` probe reports it missing even when installed.
1886 // The resolver must probe with `-v`. Gated on pdftotext actually
1887 // being installed so CI without Poppler stays green.
1888 if probe_executable_with_flag("pdftotext", "-v") {
1889 assert!(
1890 resolve_pdftotext().is_some(),
1891 "an installed pdftotext must be detected via -v (#1667)"
1892 );
1893 }
1894 }
1895
1896 #[test]
1897 fn split_interpreter_spec_strips_args() {
1898 assert_eq!(
1899 split_interpreter_spec("python3"),
1900 ("python3".to_string(), Vec::<String>::new())
1901 );
1902 assert_eq!(
1903 split_interpreter_spec("py -3"),
1904 ("py".to_string(), vec!["-3".to_string()])
1905 );
1906 assert_eq!(
1907 split_interpreter_spec(" python3 "),
1908 ("python3".to_string(), Vec::<String>::new()),
1909 "leading/trailing whitespace must be tolerated"
1910 );
1911 }
1912
1913 #[test]
1914 fn split_interpreter_spec_handles_empty_string() {
1915 assert_eq!(
1916 split_interpreter_spec(""),
1917 (String::new(), Vec::<String>::new())
1918 );
1919 }
1920
1921 #[test]
1922 fn python_resolver_is_cached_across_calls() {
1923 // Whatever the first call returns, subsequent calls return
1924 // the same value (cached). If this test ever flakes, the
1925 // OnceLock semantics changed and we need to rethink the
1926 // resolver.
1927 let first = resolve_python_interpreter();
1928 let second = resolve_python_interpreter();
1929 assert_eq!(first, second);
1930 }
1931
1932 #[test]
1933 fn python_resolver_returns_some_on_developer_machines() {
1934 // CI hosts have Python; developer machines have Python.
1935 // The one environment where this returns None is bare-bones
1936 // Windows / minimal CI containers — fine, those just don't
1937 // get code_execution registered, which is the whole point.
1938 // We don't assert Some() because we don't want this test
1939 // to fail in those environments. Instead we just confirm
1940 // the resolver doesn't panic and returns a stable value.
1941 let resolved = resolve_python_interpreter();
1942 if let Some(name) = resolved {
1943 assert!(
1944 !name.is_empty(),
1945 "resolved interpreter name must be non-empty"
1946 );
1947 // The resolved name must be one of our candidates.
1948 assert!(
1949 PYTHON_CANDIDATES.contains(&name.as_str()),
1950 "resolved {name:?} is not in PYTHON_CANDIDATES {PYTHON_CANDIDATES:?}"
1951 );
1952 }
1953 }
1954
1955 // ===================================================================
1956 // ExternalTool trait tests
1957 // ===================================================================
1958
1959 #[test]
1960 fn python_candidates_matches_const() {
1961 assert_eq!(Python::candidates(), PYTHON_CANDIDATES);
1962 }
1963
1964 #[test]
1965 fn node_candidates_is_node_only() {
1966 assert_eq!(Node::candidates(), &["node"]);
1967 }
1968
1969 #[test]
1970 fn git_candidates_is_git_only() {
1971 assert_eq!(Git::candidates(), &["git"]);
1972 }
1973
1974 #[test]
1975 fn gh_candidates_is_gh_only() {
1976 assert_eq!(Gh::candidates(), &["gh"]);
1977 }
1978
1979 #[test]
1980 fn rustc_candidates_is_rustc_only() {
1981 assert_eq!(RustC::candidates(), &["rustc"]);
1982 }
1983
1984 #[test]
1985 fn missing_tool_error_names_the_binary_not_the_rust_type() {
1986 struct Missing;
1987 impl ExternalTool for Missing {
1988 fn candidates() -> &'static [&'static str] {
1989 &["codewhale-imaginary-tool", "fallback-name"]
1990 }
1991 fn resolve() -> Option<String> {
1992 None
1993 }
1994 }
1995
1996 let error = Missing::output(&["--version"], std::path::Path::new("."))
1997 .expect_err("an unresolvable tool must not spawn");
1998 assert_eq!(error.kind(), std::io::ErrorKind::NotFound);
1999 assert_eq!(
2000 error.to_string(),
2001 "codewhale-imaginary-tool not found on PATH"
2002 );
2003 assert!(!error.to_string().contains("::"), "{error}");
2004 assert_eq!(Git::not_found_error().to_string(), "git not found on PATH");
2005 }
2006
2007 #[test]
2008 fn cargo_candidates_is_cargo_only() {
2009 assert_eq!(Cargo::candidates(), &["cargo"]);
2010 }
2011
2012 #[test]
2013 fn concrete_resolvers_do_not_cross_contaminate_when_available() {
2014 let values = [
2015 Git::resolve().map(|v| ("git", v)),
2016 Gh::resolve().map(|v| ("gh", v)),
2017 RustC::resolve().map(|v| ("rustc", v)),
2018 Cargo::resolve().map(|v| ("cargo", v)),
2019 Node::resolve().map(|v| ("node", v)),
2020 ];
2021 let resolved: Vec<(&str, String)> = values.into_iter().flatten().collect();
2022
2023 for i in 0..resolved.len() {
2024 for j in (i + 1)..resolved.len() {
2025 assert_ne!(
2026 resolved[i].1, resolved[j].1,
2027 "{} and {} unexpectedly resolved to the same binary",
2028 resolved[i].0, resolved[j].0
2029 );
2030 }
2031 }
2032 }
2033
2034 #[test]
2035 fn git_resolve_is_cached() {
2036 let first = Git::resolve();
2037 let second = Git::resolve();
2038 assert_eq!(first, second);
2039 }
2040
2041 #[test]
2042 fn gh_resolve_is_cached() {
2043 let first = Gh::resolve();
2044 let second = Gh::resolve();
2045 assert_eq!(first, second);
2046 }
2047
2048 #[test]
2049 fn python_trait_resolve_is_cached() {
2050 let first = Python::resolve();
2051 let second = Python::resolve();
2052 assert_eq!(first, second);
2053 }
2054
2055 #[test]
2056 fn node_resolve_is_cached() {
2057 let first = Node::resolve();
2058 let second = Node::resolve();
2059 assert_eq!(first, second);
2060 }
2061
2062 #[test]
2063 fn rustc_resolve_is_cached() {
2064 let first = RustC::resolve();
2065 let second = RustC::resolve();
2066 assert_eq!(first, second);
2067 }
2068
2069 #[test]
2070 fn cargo_resolve_is_cached() {
2071 let first = Cargo::resolve();
2072 let second = Cargo::resolve();
2073 assert_eq!(first, second);
2074 }
2075
2076 #[test]
2077 fn git_available_matches_resolve() {
2078 assert_eq!(Git::available(), Git::resolve().is_some());
2079 }
2080
2081 #[test]
2082 fn python_available_matches_resolve() {
2083 assert_eq!(Python::available(), Python::resolve().is_some());
2084 }
2085
2086 #[test]
2087 fn node_available_matches_resolve() {
2088 assert_eq!(Node::available(), Node::resolve().is_some());
2089 }
2090
2091 #[test]
2092 fn rustc_available_matches_resolve() {
2093 assert_eq!(RustC::available(), RustC::resolve().is_some());
2094 }
2095
2096 #[test]
2097 fn cargo_available_matches_resolve() {
2098 assert_eq!(Cargo::available(), Cargo::resolve().is_some());
2099 }
2100
2101 #[test]
2102 fn git_command_returns_some_when_available() {
2103 if Git::available() {
2104 assert!(Git::command().is_some());
2105 }
2106 }
2107
2108 /// Every git command we build must be lock-free (#5617). Without this,
2109 /// a read-only probe can take `.git/index.lock` in the user's own
2110 /// repository and break a `git commit` they run by hand.
2111 #[test]
2112 fn git_command_never_takes_optional_locks() {
2113 if !Git::available() {
2114 return;
2115 }
2116 let cmd = Git::command().expect("git resolves when available");
2117 let value = cmd
2118 .get_envs()
2119 .find(|(key, _)| *key == std::ffi::OsStr::new("GIT_OPTIONAL_LOCKS"))
2120 .and_then(|(_, value)| value)
2121 .expect("GIT_OPTIONAL_LOCKS must be set on every git command");
2122 assert_eq!(value, std::ffi::OsStr::new("0"));
2123 }
2124
2125 /// No git spawn may prompt on `/dev/tty` (0.10.1 item 3): a credential,
2126 /// passphrase or host-key prompt inside the raw-mode TUI is a silent hang.
2127 #[test]
2128 fn git_commands_are_non_interactive() {
2129 if !Git::available() {
2130 return;
2131 }
2132 let std_cmd = Git::command().expect("git resolves when available");
2133 let tokio_cmd = Git::tokio_command().expect("git resolves when available");
2134 for envs in [
2135 std_cmd.get_envs().collect::<Vec<_>>(),
2136 tokio_cmd.as_std().get_envs().collect::<Vec<_>>(),
2137 ] {
2138 let get = |name: &str| {
2139 envs.iter()
2140 .find(|(key, _)| *key == std::ffi::OsStr::new(name))
2141 .and_then(|(_, value)| *value)
2142 };
2143 assert_eq!(get("GIT_TERMINAL_PROMPT"), Some(std::ffi::OsStr::new("0")));
2144 assert_eq!(get("GIT_PAGER"), Some(std::ffi::OsStr::new("")));
2145 assert_eq!(get("GIT_OPTIONAL_LOCKS"), Some(std::ffi::OsStr::new("0")));
2146 if std::env::var_os("GIT_SSH_COMMAND").is_none()
2147 && std::env::var_os("GIT_SSH").is_none()
2148 {
2149 assert_eq!(
2150 get("GIT_SSH_COMMAND"),
2151 Some(std::ffi::OsStr::new("ssh -o BatchMode=yes"))
2152 );
2153 }
2154 }
2155 }
2156
2157 /// The suppression is deliberately scoped to git. Other external tools
2158 /// have no index to protect and must not inherit a git-specific variable.
2159 #[test]
2160 fn optional_lock_suppression_does_not_leak_to_other_tools() {
2161 for cmd in [Gh::command(), Cargo::command(), Node::command()]
2162 .into_iter()
2163 .flatten()
2164 {
2165 assert!(
2166 !cmd.get_envs()
2167 .any(|(key, _)| key == std::ffi::OsStr::new("GIT_OPTIONAL_LOCKS")),
2168 "only Git may set GIT_OPTIONAL_LOCKS"
2169 );
2170 }
2171 }
2172
2173 /// The Python and Node constructors run model-authored code, so the
2174 /// command they build must not carry the parent's environment. This
2175 /// runs on every unix runner, with or without Python or Node installed.
2176 #[cfg(unix)]
2177 #[test]
2178 fn runtime_commands_do_not_inherit_parent_secret_env() {
2179 let _env_lock = crate::test_support::lock_test_env();
2180 let _secret = crate::test_support::EnvVarGuard::set(
2181 "CODEWHALE_TEST_RUNTIME_SECRET",
2182 "runtime-secret-value",
2183 );
2184 let output = scrubbed_command_for_spec("env").output().expect("env runs");
2185 let stdout = String::from_utf8_lossy(&output.stdout);
2186 assert!(output.status.success(), "{stdout}");
2187 assert!(!stdout.contains("runtime-secret-value"), "{stdout}");
2188 assert!(stdout.contains("PATH="), "{stdout}");
2189
2190 // Both constructors of each runtime are built on the scrubbed spec,
2191 // which sets the sanitized environment explicitly.
2192 let has_explicit_path = |cmd: &Command| {
2193 cmd.get_envs()
2194 .any(|(key, value)| key == std::ffi::OsStr::new("PATH") && value.is_some())
2195 };
2196 for cmd in [Python::command(), Node::command()].into_iter().flatten() {
2197 assert!(has_explicit_path(&cmd), "{cmd:?}");
2198 }
2199 for cmd in [Python::tokio_command(), Node::tokio_command()]
2200 .into_iter()
2201 .flatten()
2202 {
2203 assert!(has_explicit_path(cmd.as_std()), "{cmd:?}");
2204 }
2205 }
2206
2207 /// Workspace git config can make even `git status` run a program
2208 /// (`core.fsmonitor`); that program must not see the parent's secrets.
2209 #[cfg(unix)]
2210 #[test]
2211 fn git_command_does_not_inherit_parent_secret_env() {
2212 use std::os::unix::fs::PermissionsExt;
2213 if !Git::available() {
2214 return;
2215 }
2216 let _env_lock = crate::test_support::lock_test_env();
2217 let _secret =
2218 crate::test_support::EnvVarGuard::set("CODEWHALE_TEST_GIT_SECRET", "git-secret-value");
2219 let repo = tempfile::tempdir().expect("repo");
2220 let hooks = tempfile::tempdir().expect("hooks");
2221 let marker = hooks.path().join("seen");
2222 let hook = hooks.path().join("fsmonitor.sh");
2223 std::fs::write(
2224 &hook,
2225 format!(
2226 "#!/bin/sh\nprintf 'leak=%s\\n' \"${{CODEWHALE_TEST_GIT_SECRET-unset}}\" >> '{}'\nexit 1\n",
2227 marker.display()
2228 ),
2229 )
2230 .expect("write hook");
2231 std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).expect("chmod");
2232 let run = |args: &[&str]| {
2233 let status = Git::status(args, repo.path()).expect("git spawns");
2234 assert!(status.success(), "git {args:?}");
2235 };
2236 run(&["init", "-q"]);
2237 run(&["config", "user.email", "test@example.com"]);
2238 run(&["config", "user.name", "Test User"]);
2239 std::fs::write(repo.path().join("file.txt"), "hello\n").expect("write");
2240 run(&["add", "."]);
2241 run(&["commit", "-q", "-m", "init"]);
2242 run(&["config", "core.fsmonitor", &hook.to_string_lossy()]);
2243
2244 let output = Git::output(&["status", "--porcelain"], repo.path()).expect("status");
2245 assert!(output.status.success());
2246 let seen = std::fs::read_to_string(&marker).expect("fsmonitor hook ran");
2247 assert!(seen.contains("leak=unset"), "{seen}");
2248 assert!(!seen.contains("git-secret-value"), "{seen}");
2249 }
2250
2251 #[test]
2252 fn python_command_returns_some_when_available() {
2253 if Python::available() {
2254 assert!(Python::command().is_some());
2255 }
2256 }
2257
2258 #[test]
2259 fn python_tokio_command_returns_some_when_available() {
2260 if Python::available() {
2261 assert!(Python::tokio_command().is_some());
2262 }
2263 }
2264
2265 #[test]
2266 fn node_tokio_command_returns_some_when_available() {
2267 if Node::available() {
2268 assert!(Node::tokio_command().is_some());
2269 }
2270 }
2271
2272 #[test]
2273 fn git_review_accepts_supported_runtime_config() {
2274 let dir = tempfile::tempdir().unwrap();
2275 let output = Git::review_command(dir.path())
2276 .expect("native Git supports runtime overrides")
2277 .args(["config", "--get", "core.fsmonitor"])
2278 .output()
2279 .unwrap();
2280 assert!(output.status.success());
2281 assert_eq!(output.stdout, b"false\n");
2282 }
2283
2284 /// The child has a fresh executable/capability cache. Its wrapper ignores
2285 /// runtime config as older Git would; it is not an old-Git installation.
2286 #[cfg(unix)]
2287 #[test]
2288 fn git_review_refuses_unsupported_runtime_config() {
2289 use std::os::unix::fs::PermissionsExt;
2290 const CHILD: &str = "CODEWHALE_TEST_UNSUPPORTED_GIT_REVIEW";
2291 if let Some(repo) = std::env::var_os(CHILD) {
2292 let repo = PathBuf::from(repo);
2293 if std::env::var_os("CODEWHALE_TEST_MISSING_GIT_REVIEW").is_some() {
2294 let error = Git::review_command(&repo).expect_err("Git is absent");
2295 assert_eq!(error.to_string(), "git not found on PATH");
2296 return;
2297 }
2298 match Git::review_command(&repo) {
2299 Err(error) => assert!(
2300 error
2301 .to_string()
2302 .contains("runtime configuration overrides are unavailable"),
2303 "{error:#}"
2304 ),
2305 Ok(mut command) => {
2306 let output = command.args(["status", "--porcelain"]).output().unwrap();
2307 let witness = std::fs::read_to_string(repo.join("helper-seen"))
2308 .unwrap_or_else(|_| "no helper witness".into());
2309 panic!(
2310 "unsupported Git reached repository read: status={:?}; {witness}",
2311 output.status
2312 );
2313 }
2314 }
2315 assert!(!repo.join("helper-seen").exists());
2316 return;
2317 }
2318 let _lock = crate::test_support::lock_test_env();
2319 let real_git = resolve_executable_path("git", "--version").expect("absolute native Git");
2320 let repo = tempfile::tempdir().unwrap();
2321 let wrapper_dir = tempfile::tempdir().unwrap();
2322 let helper = repo.path().join("fsmonitor.sh");
2323 let marker = repo.path().join("helper-seen");
2324 std::fs::write(
2325 &helper,
2326 format!(
2327 "#!/bin/sh\nprintf 'unsupported-runtime-config-helper\\n' >> '{}'\nexit 1\n",
2328 marker.display()
2329 ),
2330 )
2331 .unwrap();
2332 std::fs::set_permissions(&helper, std::fs::Permissions::from_mode(0o755)).unwrap();
2333 for args in [
2334 vec!["init", "-q"],
2335 vec!["config", "core.fsmonitor", helper.to_str().unwrap()],
2336 // A static capability marker would falsely accept this repository.
2337 vec!["config", "codewhale.reviewConfigCapability", "supported"],
2338 ] {
2339 let output = Git::output(&args, repo.path()).unwrap();
2340 assert!(output.status.success(), "{output:?}");
2341 }
2342 let wrapper = wrapper_dir.path().join("git");
2343 let quoted_git = real_git.replace('\'', "'\\''");
2344 std::fs::write(
2345 &wrapper,
2346 format!("#!/bin/sh\nunset GIT_CONFIG_COUNT\nexec '{quoted_git}' \"$@\"\n"),
2347 )
2348 .unwrap();
2349 std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
2350 let output = Command::new(std::env::current_exe().unwrap())
2351 .args([
2352 "--exact",
2353 "dependencies::tests::git_review_refuses_unsupported_runtime_config",
2354 "--nocapture",
2355 ])
2356 .env(CHILD, repo.path())
2357 .env("PATH", wrapper_dir.path())
2358 .output()
2359 .unwrap();
2360 assert!(
2361 output.status.success(),
2362 "emulated unsupported Git refusal failed:\n{}\n{}",
2363 String::from_utf8_lossy(&output.stdout),
2364 String::from_utf8_lossy(&output.stderr)
2365 );
2366 assert!(!marker.exists());
2367 let missing_path = tempfile::tempdir().unwrap();
2368 let missing = Command::new(std::env::current_exe().unwrap())
2369 .args([
2370 "--exact",
2371 "dependencies::tests::git_review_refuses_unsupported_runtime_config",
2372 "--nocapture",
2373 ])
2374 .env(CHILD, repo.path())
2375 .env("CODEWHALE_TEST_MISSING_GIT_REVIEW", "1")
2376 .env("PATH", missing_path.path())
2377 .output()
2378 .unwrap();
2379 assert!(
2380 missing.status.success(),
2381 "missing Git diagnostic failed: {}\n{}",
2382 String::from_utf8_lossy(&missing.stdout),
2383 String::from_utf8_lossy(&missing.stderr)
2384 );
2385 }
2386
2387 #[cfg(unix)]
2388 #[test]
2389 fn git_review_keeps_checked_executable_after_path_changes() {
2390 use std::os::unix::fs::PermissionsExt;
2391 const CHILD: &str = "CODEWHALE_TEST_GIT_REVIEW_PATH_CHANGE";
2392 if let Some(repo) = std::env::var_os(CHILD) {
2393 let _lock = crate::test_support::lock_test_env();
2394 let repo = PathBuf::from(repo);
2395 let initial = Git::review_command(&repo)
2396 .unwrap()
2397 .args(["config", "--get", "core.fsmonitor"])
2398 .output()
2399 .unwrap();
2400 assert!(initial.status.success());
2401 assert_eq!(initial.stdout, b"false\n");
2402 let changed = std::env::var_os("CODEWHALE_TEST_CHANGED_GIT_PATH").unwrap();
2403 let _path = crate::test_support::EnvVarGuard::set("PATH", changed);
2404 let output = Git::review_command(&repo)
2405 .unwrap()
2406 .args(["status", "--porcelain"])
2407 .output()
2408 .unwrap();
2409 assert!(output.status.success(), "{output:?}");
2410 let marker = repo.join("path-change-helper-seen");
2411 assert!(
2412 !marker.exists(),
2413 "cached review switched to unchecked Git: {}",
2414 std::fs::read_to_string(marker).unwrap_or_default()
2415 );
2416 return;
2417 }
2418 let _lock = crate::test_support::lock_test_env();
2419 let real_git = resolve_executable_path("git", "--version").expect("absolute native Git");
2420 let quoted_git = real_git.replace('\'', "'\\''");
2421 let repo = tempfile::tempdir().unwrap();
2422 let original_path = tempfile::tempdir().unwrap();
2423 let supported_git_dir = original_path.path().join("Git path with spaces");
2424 std::fs::create_dir(&supported_git_dir).unwrap();
2425 let changed_path = tempfile::tempdir().unwrap();
2426 for (dir, prefix) in [
2427 (supported_git_dir.as_path(), ""),
2428 (changed_path.path(), "unset GIT_CONFIG_COUNT\n"),
2429 ] {
2430 let script = dir.join("git");
2431 std::fs::write(
2432 &script,
2433 format!("#!/bin/sh\n{prefix}exec '{quoted_git}' \"$@\"\n"),
2434 )
2435 .unwrap();
2436 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
2437 }
2438 let helper = repo.path().join("fsmonitor.sh");
2439 std::fs::write(
2440 &helper,
2441 format!(
2442 "#!/bin/sh\nprintf 'path-change-fixture-helper\\n' >> '{}'\nexit 1\n",
2443 repo.path().join("path-change-helper-seen").display()
2444 ),
2445 )
2446 .unwrap();
2447 std::fs::set_permissions(&helper, std::fs::Permissions::from_mode(0o755)).unwrap();
2448 for args in [
2449 vec!["init", "-q"],
2450 vec!["config", "core.fsmonitor", helper.to_str().unwrap()],
2451 ] {
2452 assert!(Git::output(&args, repo.path()).unwrap().status.success());
2453 }
2454 let output = Command::new(std::env::current_exe().unwrap())
2455 .args([
2456 "--exact",
2457 "dependencies::tests::git_review_keeps_checked_executable_after_path_changes",
2458 "--nocapture",
2459 ])
2460 .env(CHILD, repo.path())
2461 .env("CODEWHALE_TEST_CHANGED_GIT_PATH", changed_path.path())
2462 .env("PATH", &supported_git_dir)
2463 .output()
2464 .unwrap();
2465 assert!(
2466 output.status.success(),
2467 "native PATH-switch review fixture failed:\n{}\n{}",
2468 String::from_utf8_lossy(&output.stdout),
2469 String::from_utf8_lossy(&output.stderr)
2470 );
2471 assert!(!repo.path().join("path-change-helper-seen").exists());
2472 }
2473
2474 #[test]
2475 fn git_output_version_succeeds() {
2476 // Only run when git is actually installed.
2477 if !Git::available() {
2478 return;
2479 }
2480 let tmp = std::env::temp_dir();
2481 let out = Git::output(&["--version"], &tmp);
2482 assert!(
2483 out.is_ok(),
2484 "git --version must succeed when git is available"
2485 );
2486 let out = out.unwrap();
2487 assert!(out.status.success(), "git --version must exit 0");
2488 let stdout = String::from_utf8_lossy(&out.stdout);
2489 assert!(
2490 stdout.contains("git version"),
2491 "git --version stdout must contain 'git version', got: {}",
2492 stdout.trim()
2493 );
2494 }
2495
2496 #[test]
2497 fn python_output_version_succeeds() {
2498 if !Python::available() {
2499 return;
2500 }
2501 let tmp = std::env::temp_dir();
2502 let out = Python::output(&["--version"], &tmp);
2503 assert!(out.is_ok(), "python --version must spawn");
2504 let out = out.unwrap();
2505 // Python --version writes to stdout on 3.x, so just check
2506 // that it succeeded (exit 0).
2507 assert!(out.status.success(), "python --version must exit 0");
2508 }
2509
2510 #[test]
2511 fn node_output_version_succeeds() {
2512 if !Node::available() {
2513 return;
2514 }
2515 let tmp = std::env::temp_dir();
2516 let out = Node::output(&["--version"], &tmp);
2517 assert!(out.is_ok(), "node --version must spawn");
2518 let out = out.unwrap();
2519 assert!(out.status.success(), "node --version must exit 0");
2520 }
2521
2522 #[test]
2523 fn cargo_output_version_succeeds() {
2524 if !Cargo::available() {
2525 return;
2526 }
2527 let tmp = std::env::temp_dir();
2528 let out = Cargo::output(&["--version"], &tmp);
2529 assert!(out.is_ok(), "cargo --version must spawn");
2530 let out = out.unwrap();
2531 assert!(out.status.success(), "cargo --version must exit 0");
2532 }
2533
2534 #[test]
2535 fn external_tool_output_respects_cwd() {
2536 // Verify that `output()` runs in the requested directory.
2537 if !Git::available() {
2538 return;
2539 }
2540 let tmp = std::env::temp_dir();
2541 let out = Git::output(&["rev-parse", "--show-toplevel"], &tmp);
2542 assert!(out.is_ok(), "git rev-parse must spawn");
2543 let out = out.unwrap();
2544 // rev-parse --show-toplevel in a non-git dir should fail
2545 // because temp_dir is not a git repo. That's expected.
2546 // The key assertion: the command executed without IO errors.
2547 // We don't assert success because temp_dir might or might not
2548 // be inside a git worktree.
2549 let _ = out; // just checking it didn't panic/IO-error
2550 }
2551 }
2552
2552 lines RUST