Skip to main content

oneharness_core/io/
runner.rs

1//! Spawning harness subprocesses with timeouts and bounded parallelism.
2//!
3//! This layer only spawns and captures — it does no parsing, so the spawn path
4//! and the extraction path stay independently testable. stdout and stderr are
5//! drained on their own threads so a chatty harness can never deadlock the wait.
6
7use std::path::PathBuf;
8use std::process::{Command, Stdio};
9use std::sync::atomic::{AtomicUsize, Ordering};
10use std::sync::Mutex;
11use std::time::{Duration, Instant};
12
13use wait_timeout::ChildExt;
14
15use crate::domain::report::{Capture, Status};
16
17/// A fully-specified subprocess to run.
18#[derive(Clone)]
19pub struct Job {
20    pub argv: Vec<String>,
21    pub cwd: Option<PathBuf>,
22    pub env: Vec<(String, String)>,
23    pub timeout: Duration,
24}
25
26/// One job's final result plus how many times it was invoked. `attempts` is 1
27/// for a plain run, and more when a retry policy re-ran it (structured output).
28pub struct Outcome {
29    pub capture: Capture,
30    pub attempts: u32,
31}
32
33/// Run `jobs` concurrently, at most `max_parallel` at a time, preserving order.
34pub fn run_jobs(jobs: &[Job], max_parallel: usize) -> Vec<Capture> {
35    run_jobs_with(jobs, max_parallel, |_, _, _| None)
36        .into_iter()
37        .map(|o| o.capture)
38        .collect()
39}
40
41/// Like [`run_jobs`], but after each run consults `retry` to decide whether to
42/// re-run the same job with a new argv. `retry(job_index, attempt, &capture)`
43/// returns `Some(next_argv)` to run again (e.g. structured-output validation
44/// failed, so re-prompt) or `None` to stop. `attempt` is the number of runs
45/// completed so far (1 after the first), and the policy is responsible for its
46/// own bound — the loop runs until it returns `None`. Only the argv changes
47/// across attempts; cwd/env/timeout are reused. Returns each job's final capture
48/// and total attempt count, in input order.
49///
50/// `retry` is `Sync` because it is shared across worker threads; the domain
51/// validation it performs is pure, keeping this layer free of parsing logic.
52pub fn run_jobs_with<F>(jobs: &[Job], max_parallel: usize, retry: F) -> Vec<Outcome>
53where
54    F: Fn(usize, u32, &Capture) -> Option<Vec<String>> + Sync,
55{
56    let n = jobs.len();
57    if n == 0 {
58        return Vec::new();
59    }
60    let workers = max_parallel.clamp(1, n);
61    let next = AtomicUsize::new(0);
62    let slots: Vec<Mutex<Option<Outcome>>> = (0..n).map(|_| Mutex::new(None)).collect();
63    let retry = &retry;
64
65    std::thread::scope(|scope| {
66        for _ in 0..workers {
67            scope.spawn(|| loop {
68                let i = next.fetch_add(1, Ordering::SeqCst);
69                if i >= n {
70                    break;
71                }
72                let outcome = run_job_with_retry(&jobs[i], i, retry);
73                *slots[i].lock().expect("slot mutex poisoned") = Some(outcome);
74            });
75        }
76    });
77
78    slots
79        .into_iter()
80        .map(|m| {
81            m.into_inner()
82                .expect("slot mutex poisoned")
83                .expect("slot unfilled")
84        })
85        .collect()
86}
87
88/// Run one job, then loop while `retry` asks for another attempt with a new argv.
89fn run_job_with_retry<F>(job: &Job, index: usize, retry: &F) -> Outcome
90where
91    F: Fn(usize, u32, &Capture) -> Option<Vec<String>>,
92{
93    let mut capture = run_job(job);
94    let mut attempts = 1u32;
95    while let Some(next_argv) = retry(index, attempts, &capture) {
96        let next = Job {
97            argv: next_argv,
98            cwd: job.cwd.clone(),
99            env: job.env.clone(),
100            timeout: job.timeout,
101        };
102        capture = run_job(&next);
103        attempts += 1;
104    }
105    Outcome { capture, attempts }
106}
107
108/// Resolve a program name to the binary actually spawned.
109///
110/// On Windows this is load-bearing: `CreateProcess` only auto-appends `.exe`, so
111/// a bare name like `claude` never finds the `claude.cmd` shim that npm (and many
112/// other installers) drop on `PATH`. Resolving via `which` is PATHEXT-aware, so it
113/// locates the `.cmd`/`.bat` shim — and `std::process::Command`, given a path that
114/// ends in `.cmd`/`.bat`, runs it through the command interpreter with the safe
115/// argument escaping added in Rust 1.77 (CVE-2024-24576). Detection already uses
116/// `which`, so a harness can be reported `available` yet still fail to spawn from a
117/// bare name; this closes that gap. If resolution fails, fall back to the original
118/// name so the spawn error stays accurate.
119///
120/// On non-Windows the name is returned unchanged: PATH lookup already handles
121/// extensionless binaries, and we must not alter the established spawn behavior.
122fn resolve_program(program: &str) -> std::ffi::OsString {
123    #[cfg(windows)]
124    {
125        if let Ok(path) = which::which(program) {
126            return path.into_os_string();
127        }
128    }
129    program.into()
130}
131
132/// Decide the program and argument list to actually spawn for `argv`.
133///
134/// Normally this is just the [`resolve_program`]'d binary plus `argv[1..]`. On
135/// Windows there is one rewrite: since Rust 1.77, `Command` runs a `.cmd`/`.bat`
136/// through cmd.exe but refuses (with `InvalidInput`) any argument it cannot
137/// escape for cmd.exe — a newline is the trigger. npm installs every JS harness
138/// as a `claude.cmd` shim, so a multi-line argument (a rendered `--system`)
139/// fails to spawn. When that exact situation is detected — a resolved `.cmd`/
140/// `.bat` *and* a multi-line argument — parse the npm shim and invoke its real
141/// target directly: a node interpreter plus script, or — as for claude-code,
142/// whose bin is `bin/claude.exe` — the wrapped executable itself. That target is
143/// a real `.exe`, so std's ordinary argument encoding carries the newline
144/// through (only a `.cmd`/`.bat` goes through cmd.exe). Anything else
145/// (single-line args, an unparseable shim, a non-shim `.cmd`) falls through
146/// unchanged, so the established spawn path — and its error reporting — is byte
147/// for byte what it was. The function is platform-shaped on purpose: the rewrite
148/// only exists on Windows, and the pure shim parsing it relies on is tested on
149/// every platform in `domain::shim`.
150fn spawn_target(argv: &[String]) -> (std::ffi::OsString, Vec<String>) {
151    let resolved = resolve_program(&argv[0]);
152    let rest = argv[1..].to_vec();
153    #[cfg(windows)]
154    {
155        if let Some(plan) = windows_shim_plan(std::path::Path::new(&resolved), &rest) {
156            return plan;
157        }
158    }
159    (resolved, rest)
160}
161
162/// The Windows shim rewrite for [`spawn_target`]: `Some((interpreter, args))`
163/// when `resolved` is a `.cmd`/`.bat`, some argument is multi-line, and the file
164/// parses as an npm shim; `None` (fall through to the `.cmd`) otherwise.
165#[cfg(windows)]
166fn windows_shim_plan(
167    resolved: &std::path::Path,
168    args: &[String],
169) -> Option<(std::ffi::OsString, Vec<String>)> {
170    // Only act when std would actually refuse the spawn: a multi-line argument.
171    if !args.iter().any(|a| a.contains('\n') || a.contains('\r')) {
172        return None;
173    }
174    let ext = resolved.extension()?.to_str()?.to_ascii_lowercase();
175    if ext != "cmd" && ext != "bat" {
176        return None;
177    }
178    let contents = std::fs::read_to_string(resolved).ok()?;
179    let dir = resolved.parent()?.to_str()?;
180    let target = crate::domain::shim::parse_cmd_shim(&contents, dir)?;
181    let mut full = target.prefix_args;
182    full.extend_from_slice(args);
183    Some((resolve_program(&target.interpreter), full))
184}
185
186/// Run a single job, returning its raw capture. Never panics on harness behavior.
187pub fn run_job(job: &Job) -> Capture {
188    let start = Instant::now();
189    let (program, args) = spawn_target(&job.argv);
190    let mut command = Command::new(program);
191    command
192        .args(&args)
193        .stdin(Stdio::null())
194        .stdout(Stdio::piped())
195        .stderr(Stdio::piped());
196    if let Some(cwd) = &job.cwd {
197        command.current_dir(cwd);
198        // Mirror a shell `cd`: keep $PWD consistent with the working directory.
199        // `current_dir` only chdir()s the child; the inherited $PWD stays stale.
200        // Some tools (e.g. Bun-based CLIs like OpenCode) trust $PWD over getcwd()
201        // to locate the project, so a stale $PWD points them at the wrong dir.
202        // Use the logical path (no symlink resolution), like `cd` does.
203        let pwd = if cwd.is_absolute() {
204            cwd.clone()
205        } else {
206            std::env::current_dir()
207                .map(|base| base.join(cwd))
208                .unwrap_or_else(|_| cwd.clone())
209        };
210        command.env("PWD", pwd);
211    }
212    // Explicit --env entries win over the derived PWD above.
213    for (key, value) in &job.env {
214        command.env(key, value);
215    }
216
217    let mut child = match command.spawn() {
218        Ok(child) => child,
219        Err(err) => {
220            return Capture {
221                status: Status::SpawnError,
222                exit_code: None,
223                duration_ms: Some(start.elapsed().as_millis()),
224                stdout: String::new(),
225                stderr: String::new(),
226                error: Some(format!(
227                    "failed to spawn `{}`: {err}. Suggestion: check the binary exists and is executable (try `oneharness detect`)",
228                    job.argv[0]
229                )),
230            };
231        }
232    };
233
234    // Drain both pipes on their own threads so wait never blocks on a full buffer.
235    let mut out = child.stdout.take().expect("piped stdout");
236    let mut err = child.stderr.take().expect("piped stderr");
237    let out_reader = std::thread::spawn(move || read_all(&mut out));
238    let err_reader = std::thread::spawn(move || read_all(&mut err));
239
240    let (status, exit_code, timed_out) = match child.wait_timeout(job.timeout) {
241        Ok(Some(exit)) => {
242            let code = exit.code();
243            let status = if code == Some(0) {
244                Status::Ok
245            } else {
246                Status::Nonzero
247            };
248            (status, code, false)
249        }
250        Ok(None) => {
251            let _ = child.kill();
252            let _ = child.wait();
253            (Status::Timeout, None, true)
254        }
255        Err(_) => {
256            let _ = child.kill();
257            let _ = child.wait();
258            (Status::SpawnError, None, false)
259        }
260    };
261
262    let stdout = out_reader.join().unwrap_or_default();
263    let stderr = err_reader.join().unwrap_or_default();
264    let duration_ms = Some(start.elapsed().as_millis());
265
266    let error = if timed_out {
267        Some(format!(
268            "`{}` exceeded the {}s timeout and was killed. Suggestion: raise --timeout or simplify the prompt",
269            job.argv[0],
270            job.timeout.as_secs()
271        ))
272    } else if status == Status::SpawnError {
273        Some(format!("`{}` could not be waited on", job.argv[0]))
274    } else {
275        None
276    };
277
278    Capture {
279        status,
280        exit_code,
281        duration_ms,
282        stdout,
283        stderr,
284        error,
285    }
286}
287
288fn read_all<R: std::io::Read>(reader: &mut R) -> String {
289    let mut buf = Vec::new();
290    let _ = std::io::Read::read_to_end(reader, &mut buf);
291    String::from_utf8_lossy(&buf).into_owned()
292}
293
294/// What a streaming line callback asks the run to do next.
295#[derive(Debug, Clone, Copy, PartialEq, Eq)]
296pub enum StreamStep {
297    /// Keep reading the harness's output.
298    Continue,
299    /// Stop now and tear down the child — the consumer has gone away (e.g. a
300    /// broken stdout pipe: it short-circuited on an observed action).
301    Stop,
302}
303
304/// Run one job, invoking `on_line` for each complete line of stdout **as it
305/// arrives**, and return the same [`Capture`] the batch path would (accumulated
306/// stdout/stderr, status, timing) so the caller can still emit a final envelope.
307///
308/// This is the streaming counterpart to [`run_job`]: it exists so a consumer can
309/// observe a harness's normalized events incrementally and short-circuit the
310/// moment it sees a disallowed action — instead of paying for a whole turn before
311/// judging it. The parsing stays out of this layer: `on_line` (a pure
312/// domain-driven closure in the command layer) decides what to emit and returns
313/// [`StreamStep::Stop`] to end early (the command layer returns `Stop` when its
314/// write to the consumer fails, i.e. the consumer closed the stream). On `Stop`
315/// or timeout the child is killed; on normal EOF its exit is awaited. Never
316/// panics on harness behavior — same contract as [`run_job`].
317pub fn run_job_streaming<F>(job: &Job, mut on_line: F) -> Capture
318where
319    F: FnMut(&str) -> StreamStep,
320{
321    use std::io::BufRead;
322    use std::sync::mpsc;
323
324    let start = Instant::now();
325    let (program, args) = spawn_target(&job.argv);
326    let mut command = Command::new(program);
327    command
328        .args(&args)
329        .stdin(Stdio::null())
330        .stdout(Stdio::piped())
331        .stderr(Stdio::piped());
332    if let Some(cwd) = &job.cwd {
333        command.current_dir(cwd);
334        let pwd = if cwd.is_absolute() {
335            cwd.clone()
336        } else {
337            std::env::current_dir()
338                .map(|base| base.join(cwd))
339                .unwrap_or_else(|_| cwd.clone())
340        };
341        command.env("PWD", pwd);
342    }
343    for (key, value) in &job.env {
344        command.env(key, value);
345    }
346
347    let mut child = match command.spawn() {
348        Ok(child) => child,
349        Err(err) => {
350            return Capture {
351                status: Status::SpawnError,
352                exit_code: None,
353                duration_ms: Some(start.elapsed().as_millis()),
354                stdout: String::new(),
355                stderr: String::new(),
356                error: Some(format!(
357                    "failed to spawn `{}`: {err}. Suggestion: check the binary exists and is executable (try `oneharness detect`)",
358                    job.argv[0]
359                )),
360            };
361        }
362    };
363
364    let out = child.stdout.take().expect("piped stdout");
365    let mut err = child.stderr.take().expect("piped stderr");
366    let err_reader = std::thread::spawn(move || read_all(&mut err));
367
368    // A reader thread turns blocking line reads into channel messages, so the
369    // main loop can honor the wall-clock timeout (recv_timeout) between lines
370    // without non-blocking I/O. Each message is one line *with* its terminator
371    // preserved, so the accumulated stdout is byte-faithful to the batch path.
372    let (tx, rx) = mpsc::channel::<String>();
373    let out_reader = std::thread::spawn(move || {
374        let mut reader = std::io::BufReader::new(out);
375        loop {
376            let mut line = String::new();
377            match reader.read_line(&mut line) {
378                Ok(0) => break, // EOF
379                Ok(_) => {
380                    if tx.send(line).is_err() {
381                        break; // main loop gone
382                    }
383                }
384                Err(_) => break,
385            }
386        }
387    });
388
389    let deadline = start + job.timeout;
390    let mut stdout = String::new();
391    let mut stopped = false;
392    let mut timed_out = false;
393    loop {
394        let now = Instant::now();
395        if now >= deadline {
396            timed_out = true;
397            break;
398        }
399        match rx.recv_timeout(deadline - now) {
400            Ok(line) => {
401                stdout.push_str(&line);
402                // Feed the callback the line without its trailing newline(s).
403                if on_line(line.trim_end_matches(['\n', '\r'])) == StreamStep::Stop {
404                    stopped = true;
405                    break;
406                }
407            }
408            Err(mpsc::RecvTimeoutError::Timeout) => {
409                timed_out = true;
410                break;
411            }
412            Err(mpsc::RecvTimeoutError::Disconnected) => break, // EOF
413        }
414    }
415
416    let (status, exit_code) = if stopped || timed_out {
417        let _ = child.kill();
418        let _ = child.wait();
419        if timed_out {
420            (Status::Timeout, None)
421        } else {
422            // Consumer-driven stop: not a harness failure. Report Ok so the
423            // (best-effort) final envelope isn't misread as an error; the
424            // consumer already has what it needed.
425            (Status::Ok, None)
426        }
427    } else {
428        match child.wait() {
429            Ok(exit) => {
430                let code = exit.code();
431                let status = if code == Some(0) {
432                    Status::Ok
433                } else {
434                    Status::Nonzero
435                };
436                (status, code)
437            }
438            Err(_) => (Status::SpawnError, None),
439        }
440    };
441
442    // Drain any remaining buffered lines the reader already had, so the final
443    // envelope's stdout is complete even if we broke out on stop/timeout.
444    let _ = out_reader.join();
445    while let Ok(line) = rx.try_recv() {
446        stdout.push_str(&line);
447    }
448    let stderr = err_reader.join().unwrap_or_default();
449
450    let error = if timed_out {
451        Some(format!(
452            "`{}` exceeded the {}s timeout and was killed. Suggestion: raise --timeout or simplify the prompt",
453            job.argv[0],
454            job.timeout.as_secs()
455        ))
456    } else {
457        None
458    };
459
460    Capture {
461        status,
462        exit_code,
463        duration_ms: Some(start.elapsed().as_millis()),
464        stdout,
465        stderr,
466        error,
467    }
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473
474    fn job(argv: &[&str]) -> Job {
475        Job {
476            argv: argv.iter().map(|s| s.to_string()).collect(),
477            cwd: None,
478            env: Vec::new(),
479            timeout: Duration::from_secs(5),
480        }
481    }
482
483    #[test]
484    fn empty_jobs_returns_empty_without_spawning_workers() {
485        // The no-work fast path: no jobs means no captures and no threads.
486        assert!(run_jobs(&[], 4).is_empty());
487    }
488
489    #[test]
490    fn spawn_error_is_data_not_a_panic() {
491        // A binary that cannot be spawned must surface as a `SpawnError` capture
492        // with a helpful message — never a crash. Run it through `run_jobs` so the
493        // worker-pool path that fills the result slot is exercised too.
494        let jobs = [job(&["/no/such/oneharness-binary-xyz", "arg"])];
495        let captures = run_jobs(&jobs, 1);
496        assert_eq!(captures.len(), 1);
497        let cap = &captures[0];
498        assert_eq!(cap.status, Status::SpawnError);
499        assert!(cap.exit_code.is_none());
500        assert!(cap.stdout.is_empty());
501        assert!(cap.duration_ms.is_some());
502        let msg = cap.error.as_deref().unwrap_or_default();
503        assert!(msg.contains("failed to spawn"), "{msg}");
504        assert!(msg.contains("oneharness-binary-xyz"), "{msg}");
505    }
506
507    #[test]
508    fn run_jobs_with_retries_until_the_policy_stops() {
509        // The structured-output loop in disguise: re-run with a fresh argv until
510        // the policy returns None. Uses unspawnable binaries so it stays portable
511        // and process-free, asserting on the attempt count and that the *final*
512        // capture came from the last retry's argv (its error names that binary).
513        let jobs = [job(&["/no/such/first"])];
514        let outcomes = run_jobs_with(&jobs, 1, |i, attempt, cap| {
515            assert_eq!(i, 0);
516            assert_eq!(cap.status, Status::SpawnError);
517            (attempt < 3).then(|| vec![format!("/no/such/retry-{attempt}")])
518        });
519        assert_eq!(outcomes.len(), 1);
520        assert_eq!(outcomes[0].attempts, 3);
521        let err = outcomes[0].capture.error.as_deref().unwrap_or_default();
522        assert!(
523            err.contains("retry-2"),
524            "final capture should be last retry: {err}"
525        );
526    }
527
528    #[test]
529    fn run_jobs_with_a_no_op_policy_runs_once() {
530        let jobs = [job(&["/no/such/binary"])];
531        let outcomes = run_jobs_with(&jobs, 1, |_, _, _| None);
532        assert_eq!(outcomes[0].attempts, 1);
533        // Empty input is still the no-work fast path.
534        assert!(run_jobs_with(&[], 4, |_, _, _| None).is_empty());
535    }
536
537    /// A portable job that prints three lines then exits 0 (`sh -c printf` on
538    /// Unix, `cmd /c echo` on Windows), for exercising the streaming reader.
539    fn three_line_job() -> Job {
540        #[cfg(not(windows))]
541        let argv = vec![
542            "sh".to_string(),
543            "-c".to_string(),
544            "printf 'a\\nb\\nc\\n'".to_string(),
545        ];
546        #[cfg(windows)]
547        let argv = vec![
548            "cmd".to_string(),
549            "/c".to_string(),
550            "echo a& echo b& echo c".to_string(),
551        ];
552        Job {
553            argv,
554            cwd: None,
555            env: Vec::new(),
556            timeout: Duration::from_secs(10),
557        }
558    }
559
560    #[test]
561    fn streaming_delivers_each_line_and_accumulates_stdout() {
562        // The happy path: every line reaches the callback in order, and the final
563        // capture's stdout is the byte-faithful accumulation, status Ok.
564        let mut lines = Vec::new();
565        let cap = run_job_streaming(&three_line_job(), |line| {
566            lines.push(line.to_string());
567            StreamStep::Continue
568        });
569        assert_eq!(cap.status, Status::Ok);
570        assert_eq!(cap.exit_code, Some(0));
571        assert_eq!(lines.len(), 3, "got {lines:?}");
572        // Trim to tolerate any shell quirks; the content is a/b/c in order.
573        let trimmed: Vec<_> = lines.iter().map(|l| l.trim()).collect();
574        assert_eq!(trimmed, vec!["a", "b", "c"]);
575        for token in ["a", "b", "c"] {
576            assert!(cap.stdout.contains(token), "stdout: {:?}", cap.stdout);
577        }
578    }
579
580    #[test]
581    fn streaming_stops_and_tears_down_on_callback_stop() {
582        // Returning Stop after the first line ends the run immediately (the child
583        // is killed); the consumer-driven stop is reported as Ok, not a failure.
584        let mut count = 0u32;
585        let cap = run_job_streaming(&three_line_job(), |_| {
586            count += 1;
587            StreamStep::Stop
588        });
589        assert_eq!(count, 1, "should stop after the first line");
590        assert_eq!(cap.status, Status::Ok);
591    }
592
593    #[test]
594    fn streaming_spawn_error_is_data_not_a_panic() {
595        // A missing binary surfaces as a SpawnError capture, same as run_job.
596        let job = job(&["/no/such/oneharness-stream-binary"]);
597        let cap = run_job_streaming(&job, |_| StreamStep::Continue);
598        assert_eq!(cap.status, Status::SpawnError);
599        assert!(cap.error.as_deref().unwrap_or_default().contains("spawn"));
600    }
601
602    #[test]
603    fn resolve_program_falls_back_to_the_name_when_unresolvable() {
604        // A name that PATH lookup cannot resolve must come back unchanged on every
605        // platform, so the spawn attempt — and its error message — stay accurate.
606        let name = "oneharness-no-such-binary-zzz";
607        assert_eq!(resolve_program(name), std::ffi::OsString::from(name));
608    }
609
610    #[cfg(windows)]
611    #[test]
612    fn windows_shim_plan_rewrites_a_cmd_only_for_multiline_args() {
613        use std::io::Write;
614
615        // A minimal npm-style `.cmd` shim written to a real temp file, so the
616        // plan reads it the way `run_job` would.
617        let dir = std::env::temp_dir().join(format!("oh-shim-{}", std::process::id()));
618        std::fs::create_dir_all(&dir).unwrap();
619        let cmd_path = dir.join("claude.cmd");
620        let mut f = std::fs::File::create(&cmd_path).unwrap();
621        write!(
622            f,
623            "SET \"_prog=node\"\r\n\"%_prog%\" \"%dp0%\\cli.js\" %*\r\n"
624        )
625        .unwrap();
626        drop(f);
627
628        let dir_str = dir.to_str().unwrap();
629        let script = format!("{dir_str}\\cli.js");
630
631        // A multi-line argument triggers the rewrite: node + script + the args.
632        let multiline = vec!["-p".to_string(), "a\nb\nc".to_string()];
633        let (prog, args) = windows_shim_plan(&cmd_path, &multiline).expect("multiline → rewrite");
634        // `node` resolves to a real path ending in node.exe.
635        assert!(
636            std::path::Path::new(&prog)
637                .to_string_lossy()
638                .to_ascii_lowercase()
639                .ends_with("node.exe"),
640            "interpreter should resolve to node.exe, got {prog:?}"
641        );
642        assert_eq!(args, vec![script, "-p".to_string(), "a\nb\nc".to_string()]);
643
644        // A single-line argument must NOT be rewritten — the `.cmd` spawns fine.
645        let single = vec!["-p".to_string(), "hello".to_string()];
646        assert!(windows_shim_plan(&cmd_path, &single).is_none());
647
648        std::fs::remove_dir_all(&dir).ok();
649    }
650
651    #[cfg(windows)]
652    #[test]
653    fn windows_shim_plan_ignores_non_batch_programs() {
654        // A real `.exe` (or anything not `.cmd`/`.bat`) is never rewritten, even
655        // with a multi-line argument — std spawns it directly without cmd.exe.
656        let exe = resolve_program("where");
657        let multiline = vec!["x\ny".to_string()];
658        assert!(windows_shim_plan(std::path::Path::new(&exe), &multiline).is_none());
659    }
660
661    #[cfg(windows)]
662    #[test]
663    fn resolve_program_finds_a_cmd_shim_on_windows() {
664        // `where` is a stock Windows console command living at a real path; the
665        // bare name must resolve to an absolute, existing file (the gap that left
666        // npm `.cmd` shims unspawnable from a bare name).
667        let resolved = resolve_program("where");
668        let path = std::path::Path::new(&resolved);
669        assert!(
670            path.is_absolute(),
671            "expected an absolute path, got {resolved:?}"
672        );
673        assert!(path.exists(), "resolved path should exist: {resolved:?}");
674    }
675}