Skip to main content

remem/eval/coding_bench/
runner.rs

1use std::fs;
2use std::io::Write;
3use std::path::{Path, PathBuf};
4use std::time::{Instant, SystemTime, UNIX_EPOCH};
5
6use anyhow::{bail, Context, Result};
7use sha2::{Digest, Sha256};
8
9use super::condition::apply_condition;
10use super::dry_run::{effective_matrix, write_dry_run_json};
11use super::failure::{classify_failure_reason, output_indicates_compile_failure, FailureEvidence};
12use super::fixture::{load_fixture, selected_conditions, selected_tasks, validate_relative_path};
13use super::isolation::{prepare_codex_isolation, runner_isolation_violation};
14use super::process::{command_output, ensure_success, CommandOutcome};
15use super::run_plan::randomized_run_plan;
16use super::score::{
17    build_memory_attribution, parse_changed_paths, parse_codex_jsonl_usage, patch_pattern_failures,
18    summarize_runs, unauthorized_paths, update_memory_attribution_outcome,
19};
20use super::types::{
21    BenchCondition, BenchTokenUsage, CodingBenchFixture, CodingBenchOptions, CodingBenchReport,
22    CodingBenchTask, CommandReport, ConditionReport, RunArtifacts, RunReport, RunnerReport,
23};
24
25pub fn dry_run_plan(options: &CodingBenchOptions) -> Result<String> {
26    super::live_approval::validate_local_planning(options)?;
27    let fixture = load_fixture(&options.fixture_path)?;
28    let conditions = selected_conditions(options)?;
29    let tasks = selected_tasks(&fixture, options)?;
30    let total = conditions.len() * tasks.len() * options.runs_per_condition;
31    if !options.json_out.trim().is_empty() {
32        write_dry_run_json(options, &conditions, &tasks, total)?;
33    }
34    let mut output = String::new();
35    output.push_str("coding benchmark dry run\n");
36    output.push_str(&format!("fixture: {}\n", options.fixture_path));
37    output.push_str(&format!(
38        "runs_per_condition: {}\n",
39        options.runs_per_condition
40    ));
41    output.push_str(&format!("task_set: {}\n", options.task_set));
42    output.push_str(&format!("matrix: {}\n", effective_matrix(options)));
43    output.push_str(&format!(
44        "runner: {} model: {}\n",
45        options.runner, options.model
46    ));
47    output.push_str(&format!("planned_runs: {total}\n"));
48    for condition in &conditions {
49        for task in &tasks {
50            output.push_str(&format!(
51                "- {} {} x{}\n",
52                condition.as_str(),
53                task.id,
54                options.runs_per_condition
55            ));
56        }
57    }
58    Ok(output)
59}
60
61pub async fn run_coding_bench(options: &CodingBenchOptions) -> Result<CodingBenchReport> {
62    super::live_approval::enforce_execution_gate(options)?;
63    if options.runs_per_condition == 0 {
64        bail!("--runs-per-condition must be greater than zero");
65    }
66    let fixture = load_fixture(&options.fixture_path)?;
67    let conditions = selected_conditions(options)?;
68    let tasks = selected_tasks(&fixture, options)?;
69    ensure_selected_conditions_are_executable(&conditions)?;
70    super::preflight::validate_condition_inputs(options, &conditions, &tasks)?;
71    let run_plan = randomized_run_plan(&conditions, tasks.len(), options.runs_per_condition)?;
72    let fixture_sha256 = file_sha256(&options.fixture_path)?;
73    let generated_at_epoch = current_epoch();
74    let artifact_root = report_artifact_root(&options.json_out, generated_at_epoch)?;
75    let runner_version = runner_version(options);
76    let mut grouped_runs = conditions
77        .iter()
78        .map(|condition| (*condition, Vec::new()))
79        .collect::<Vec<_>>();
80
81    for entry in run_plan {
82        let task = tasks
83            .get(entry.task_index)
84            .context("coding benchmark run plan referenced missing task")?;
85        let run = run_one(
86            options,
87            &fixture,
88            entry.condition,
89            task,
90            entry.run_index,
91            &artifact_root,
92        )
93        .await?;
94        super::audit_contract::validate_run_context_audit(&run)?;
95        eprintln!(
96            "[coding-bench] {} {} run {}: resolved={} tokens={}",
97            entry.condition.as_str(),
98            task.id,
99            entry.run_index,
100            run.resolved,
101            run.usage.total_tokens
102        );
103        if let Some((_, runs)) = grouped_runs
104            .iter_mut()
105            .find(|(condition, _)| *condition == entry.condition)
106        {
107            runs.push(run);
108        } else {
109            bail!(
110                "coding benchmark run plan referenced unselected condition {}",
111                entry.condition.as_str()
112            );
113        }
114    }
115
116    let mut condition_reports = Vec::new();
117    for (condition, runs) in grouped_runs {
118        let summary = summarize_runs(&runs);
119        condition_reports.push(ConditionReport {
120            name: condition,
121            summary,
122            runs,
123        });
124    }
125
126    Ok(CodingBenchReport {
127        schema_version: 1,
128        generated_at_epoch,
129        fixture_path: options.fixture_path.clone(),
130        fixture_sha256,
131        remem_rev: current_git_rev(Path::new(".")).unwrap_or_else(|| "unknown".to_string()),
132        source_dirty: current_git_dirty(Path::new(".")),
133        command: report_command(options),
134        artifact_policy: "raw_artifacts_local_ignored".to_string(),
135        runner: RunnerReport {
136            provider: options
137                .provider
138                .clone()
139                .unwrap_or_else(|| options.runner.clone()),
140            model: options.model.clone(),
141            runner: options.runner.clone(),
142            version: runner_version,
143        },
144        runs_per_condition: options.runs_per_condition,
145        ignore_budget: options.ignore_budget,
146        conditions: condition_reports,
147    })
148}
149
150async fn run_one(
151    options: &CodingBenchOptions,
152    fixture: &CodingBenchFixture,
153    condition: BenchCondition,
154    task: &CodingBenchTask,
155    run_index: usize,
156    artifact_root: &Path,
157) -> Result<RunReport> {
158    let start = Instant::now();
159    let run_root = unique_temp_dir(condition, &task.id, run_index);
160    let repo_dir = run_root.join("repo");
161    let data_dir = run_root.join("remem-data");
162    let artifact_dir =
163        artifact_root.join(format!("{}-{}-{}", condition.as_str(), task.id, run_index));
164    fs::create_dir_all(&artifact_dir)
165        .with_context(|| format!("create artifact dir {}", artifact_dir.display()))?;
166    prepare_repo(fixture, &repo_dir)?;
167    let condition_input_root = match condition {
168        BenchCondition::CuratedFileBudgeted => options.curator_root.as_deref(),
169        BenchCondition::RememE2e => options.memory_config.as_deref(),
170        _ => None,
171    };
172    let setup = apply_condition(
173        condition,
174        fixture,
175        task,
176        &repo_dir,
177        &data_dir,
178        condition_input_root.map(Path::new),
179    )
180    .await?;
181    commit_condition_inputs(&repo_dir)?;
182    let prompt = build_prompt(task, setup.prompt_note.as_deref());
183
184    let runner_outcome = invoke_agent(
185        options,
186        &repo_dir,
187        &run_root,
188        &setup.env,
189        &prompt,
190        task.timeout_ms,
191    )
192    .context("invoke coding-agent runner")?;
193    let runner_stdout = write_artifact(&artifact_dir, "runner.stdout", &runner_outcome.stdout)?;
194    let runner_stderr = write_artifact(&artifact_dir, "runner.stderr", &runner_outcome.stderr)?;
195    let (usage, turns) = if options.runner == "codex" {
196        parse_codex_jsonl_usage(&runner_outcome.stdout)
197    } else {
198        (BenchTokenUsage::default(), None)
199    };
200
201    let status = command_output("git", ["status", "--porcelain"], &repo_dir, &[], 30_000)?;
202    let changed_paths = parse_changed_paths(&status.stdout);
203    let unauthorized =
204        unauthorized_paths(&changed_paths, &task.allowed_paths, &task.forbidden_paths);
205    let diff = command_output("git", ["diff", "--binary"], &repo_dir, &[], 30_000)?;
206    let final_diff = write_artifact(&artifact_dir, "final.diff", &diff.stdout)?;
207    write_hidden_files(task, &repo_dir)?;
208
209    let mut score_commands = Vec::new();
210    let patch_pattern_failures = patch_pattern_failures(
211        &diff.stdout,
212        &task.score.required_patch_patterns,
213        &task.score.forbidden_patch_patterns,
214    );
215    let mut score_failed = !patch_pattern_failures.is_empty();
216    let forbidden_patch_failed = patch_pattern_failures
217        .iter()
218        .any(|failure| failure.starts_with("forbidden patch pattern"));
219    let mut compile_failed = false;
220    for (index, command) in task.score.commands.iter().enumerate() {
221        let (program, args) = command
222            .split_first()
223            .context("score command unexpectedly empty after validation")?;
224        let outcome = command_output(
225            program,
226            args.iter().map(String::as_str),
227            &repo_dir,
228            &[],
229            120_000,
230        )
231        .with_context(|| format!("run score command {:?}", command))?;
232        if outcome.exit_code != Some(0) || outcome.timed_out {
233            score_failed = true;
234            compile_failed |= output_indicates_compile_failure(&outcome.stdout, &outcome.stderr);
235        }
236        let stdout_artifact = write_artifact(
237            &artifact_dir,
238            &format!("score-{index}.stdout"),
239            &outcome.stdout,
240        )?;
241        let stderr_artifact = write_artifact(
242            &artifact_dir,
243            &format!("score-{index}.stderr"),
244            &outcome.stderr,
245        )?;
246        score_commands.push(CommandReport {
247            command: command.clone(),
248            exit_code: outcome.exit_code,
249            timed_out: outcome.timed_out,
250            stdout_artifact,
251            stderr_artifact,
252        });
253    }
254
255    let mut memory_contract = condition
256        .uses_remem_attribution()
257        .then(|| build_memory_attribution(&setup.memory_attribution, &runner_outcome.stdout));
258    let final_head_sha = current_git_rev(&repo_dir);
259    let failure_reason = classify_failure_reason(FailureEvidence {
260        score_failed,
261        compile_failed,
262        forbidden_patch_failed,
263        unauthorized_paths: &unauthorized,
264        memory_contract: memory_contract.as_ref(),
265        memory_input: &setup.memory_attribution,
266        runner_isolation_violation: runner_isolation_violation(
267            &runner_outcome.stdout,
268            &runner_outcome.stderr,
269        )
270        .as_deref(),
271        runner_timed_out: runner_outcome.timed_out,
272        runner_exit_code: runner_outcome.exit_code,
273        runner_stdout: &runner_outcome.stdout,
274        runner_stderr: &runner_outcome.stderr,
275    });
276    let resolved = failure_reason.is_none();
277    if let Some(attribution) = &mut memory_contract {
278        update_memory_attribution_outcome(attribution, resolved, failure_reason);
279    }
280    if !options.keep_workdirs {
281        let _ = fs::remove_dir_all(&run_root);
282    }
283
284    Ok(RunReport {
285        condition,
286        task_id: task.id.clone(),
287        run_index,
288        resolved,
289        failure_reason,
290        usage,
291        turns,
292        wall_time_ms: start.elapsed().as_millis(),
293        final_head_sha,
294        changed_paths,
295        unauthorized_path_changes: unauthorized,
296        runner_exit_code: runner_outcome.exit_code,
297        runner_timed_out: runner_outcome.timed_out,
298        runtime_contract_failure: setup.context_audit_status
299            == super::RememContextAuditStatus::ContractFailure,
300        runtime_contract_failure_reason: setup.context_audit_failure_reason.clone(),
301        context_audit_status: setup.context_audit_status,
302        context_audit_failure_reason: setup.context_audit_failure_reason,
303        remem_context_audit: setup.remem_context_audit,
304        curator_log: setup.curator_log,
305        e2e_pipeline: setup.e2e_pipeline,
306        score_commands,
307        memory_contract,
308        artifacts: RunArtifacts {
309            runner_stdout,
310            runner_stderr,
311            final_diff,
312        },
313        workdir: options
314            .keep_workdirs
315            .then(|| run_root.to_string_lossy().to_string()),
316    })
317}
318
319fn prepare_repo(fixture: &CodingBenchFixture, repo_dir: &Path) -> Result<()> {
320    fs::create_dir_all(repo_dir)
321        .with_context(|| format!("create repo dir {}", repo_dir.display()))?;
322    for (path, content) in &fixture.repo.files {
323        write_relative_file(repo_dir, path, content)?;
324    }
325    let init = command_output("git", ["init", "-b", "main"], repo_dir, &[], 30_000)?;
326    if init.exit_code != Some(0) {
327        let fallback = command_output("git", ["init"], repo_dir, &[], 30_000)?;
328        ensure_success("git init", &fallback)?;
329        ensure_success(
330            "git checkout -b main",
331            &command_output("git", ["checkout", "-b", "main"], repo_dir, &[], 30_000)?,
332        )?;
333    }
334    ensure_success(
335        "git config user.email",
336        &command_output(
337            "git",
338            ["config", "user.email", "coding-bench@example.invalid"],
339            repo_dir,
340            &[],
341            30_000,
342        )?,
343    )?;
344    ensure_success(
345        "git config user.name",
346        &command_output(
347            "git",
348            ["config", "user.name", "remem coding bench"],
349            repo_dir,
350            &[],
351            30_000,
352        )?,
353    )?;
354    ensure_success(
355        "git add",
356        &command_output("git", ["add", "."], repo_dir, &[], 30_000)?,
357    )?;
358    ensure_success(
359        "git commit",
360        &command_output(
361            "git",
362            ["commit", "-m", "initial fixture"],
363            repo_dir,
364            &[],
365            30_000,
366        )?,
367    )?;
368    Ok(())
369}
370
371fn commit_condition_inputs(repo_dir: &Path) -> Result<()> {
372    let status = command_output("git", ["status", "--porcelain"], repo_dir, &[], 30_000)?;
373    if status.stdout.trim().is_empty() {
374        return Ok(());
375    }
376    ensure_success(
377        "git add condition inputs",
378        &command_output("git", ["add", "."], repo_dir, &[], 30_000)?,
379    )?;
380    ensure_success(
381        "git commit condition inputs",
382        &command_output(
383            "git",
384            ["commit", "-m", "condition inputs"],
385            repo_dir,
386            &[],
387            30_000,
388        )?,
389    )?;
390    Ok(())
391}
392
393fn invoke_agent(
394    options: &CodingBenchOptions,
395    repo_dir: &Path,
396    run_root: &Path,
397    env: &[(String, String)],
398    prompt: &str,
399    timeout_ms: u64,
400) -> Result<CommandOutcome> {
401    match options.runner.as_str() {
402        "codex" => {
403            let isolation = prepare_codex_isolation(run_root, &options.codex_bin)?;
404            let mut runner_env = env.to_vec();
405            runner_env.extend(isolation.env.clone());
406            let args = build_codex_exec_args(options, repo_dir, prompt);
407            let mut wrapped_args = isolation.args_prefix.clone();
408            wrapped_args.extend(args);
409            let outcome = command_output(
410                &isolation.program,
411                wrapped_args.iter().map(String::as_str),
412                repo_dir,
413                &runner_env,
414                timeout_ms,
415            );
416            isolation.cleanup();
417            outcome
418        }
419        "noop" => Ok(CommandOutcome {
420            stdout: String::new(),
421            stderr: String::new(),
422            exit_code: Some(0),
423            timed_out: false,
424        }),
425        other => bail!("unsupported coding benchmark runner: {other}"),
426    }
427}
428
429fn build_codex_exec_args(
430    options: &CodingBenchOptions,
431    repo_dir: &Path,
432    prompt: &str,
433) -> Vec<String> {
434    let mut args = vec![
435        "exec".to_string(),
436        "--json".to_string(),
437        "--color".to_string(),
438        "never".to_string(),
439        "--ignore-user-config".to_string(),
440        "--ignore-rules".to_string(),
441        "--ephemeral".to_string(),
442        "--disable".to_string(),
443        "hooks".to_string(),
444        "--cd".to_string(),
445        repo_dir.to_string_lossy().to_string(),
446        "--model".to_string(),
447        options.model.clone(),
448        "--sandbox".to_string(),
449        "danger-full-access".to_string(),
450    ];
451    if !options.reasoning_effort.trim().is_empty() {
452        args.push("-c".to_string());
453        args.push(format!(
454            "model_reasoning_effort=\"{}\"",
455            toml_escape(&options.reasoning_effort)
456        ));
457    }
458    if let Some(provider) = options.provider.as_deref() {
459        args.push("-c".to_string());
460        args.push(format!("model_provider=\"{}\"", toml_escape(provider)));
461    }
462    args.push(prompt.to_string());
463    args
464}
465
466fn build_prompt(task: &CodingBenchTask, condition_note: Option<&str>) -> String {
467    let mut prompt = String::new();
468    prompt.push_str("You are running an isolated coding benchmark task.\n");
469    if let Some(note) = condition_note {
470        prompt.push_str(note);
471        prompt.push('\n');
472    }
473    prompt.push_str("Only inspect files under the repository root and context files explicitly named above. Do not inspect environment variables, parent directories, CODEX_HOME, HOME, tool caches, benchmark harness artifacts, or hidden tests.\n");
474    prompt.push_str("Modify the repository to satisfy the task. Do not inspect or depend on hidden tests. Keep edits scoped to the task.\n\n");
475    prompt.push_str("Task:\n");
476    prompt.push_str(&task.prompt);
477    prompt.push('\n');
478    prompt
479}
480
481fn ensure_selected_conditions_are_executable(conditions: &[BenchCondition]) -> Result<()> {
482    let unsupported = conditions
483        .iter()
484        .copied()
485        .filter(|condition| !condition.supports_live_execution())
486        .map(BenchCondition::as_str)
487        .collect::<Vec<_>>();
488    if unsupported.is_empty() {
489        return Ok(());
490    }
491    bail!(
492        "coding benchmark live execution is not implemented for {}; use --dry-run for GH931 primary planning or select remem_seeded_sessionstart/curated_file_expert for implemented diagnostics",
493        unsupported.join(", ")
494    )
495}
496
497fn write_hidden_files(task: &CodingBenchTask, repo_dir: &Path) -> Result<()> {
498    for (path, content) in &task.score.hidden_files {
499        write_relative_file(repo_dir, path, content)?;
500    }
501    Ok(())
502}
503
504fn write_relative_file(root: &Path, relative: &str, content: &str) -> Result<()> {
505    validate_relative_path(relative)?;
506    let path = root.join(relative);
507    if let Some(parent) = path.parent() {
508        fs::create_dir_all(parent)
509            .with_context(|| format!("create parent directory {}", parent.display()))?;
510    }
511    fs::write(&path, content).with_context(|| format!("write {}", path.display()))
512}
513
514fn write_artifact(dir: &Path, name: &str, content: &str) -> Result<String> {
515    let path = dir.join(name);
516    let mut file =
517        fs::File::create(&path).with_context(|| format!("create artifact {}", path.display()))?;
518    file.write_all(content.as_bytes())
519        .with_context(|| format!("write artifact {}", path.display()))?;
520    Ok(path.to_string_lossy().to_string())
521}
522
523fn report_artifact_root(json_out: &str, epoch: i64) -> Result<PathBuf> {
524    let parent = Path::new(json_out)
525        .parent()
526        .filter(|path| !path.as_os_str().is_empty())
527        .unwrap_or_else(|| Path::new("."));
528    let path = parent.join("artifacts").join(epoch.to_string());
529    fs::create_dir_all(&path)
530        .with_context(|| format!("create artifact root {}", path.display()))?;
531    Ok(path)
532}
533
534fn unique_temp_dir(condition: BenchCondition, task_id: &str, run_index: usize) -> PathBuf {
535    std::env::temp_dir().join(format!(
536        "remem-coding-bench-{}-{}-{}-{}-{}",
537        condition.as_str(),
538        task_id,
539        run_index,
540        std::process::id(),
541        SystemTime::now()
542            .duration_since(UNIX_EPOCH)
543            .map(|duration| duration.as_nanos())
544            .unwrap_or(0)
545    ))
546}
547
548fn current_epoch() -> i64 {
549    SystemTime::now()
550        .duration_since(UNIX_EPOCH)
551        .map(|duration| duration.as_secs() as i64)
552        .unwrap_or(0)
553}
554
555fn current_git_rev(cwd: &Path) -> Option<String> {
556    let outcome = command_output("git", ["rev-parse", "HEAD"], cwd, &[], 30_000).ok()?;
557    (outcome.exit_code == Some(0)).then(|| outcome.stdout.trim().to_string())
558}
559
560fn runner_version(options: &CodingBenchOptions) -> Option<String> {
561    if options.runner != "codex" {
562        return None;
563    }
564    let outcome = command_output(
565        &options.codex_bin,
566        ["--version"],
567        Path::new("."),
568        &[],
569        30_000,
570    )
571    .ok()?;
572    (outcome.exit_code == Some(0)).then(|| outcome.stdout.trim().to_string())
573}
574
575fn file_sha256(path: &str) -> Result<String> {
576    let bytes = fs::read(path).with_context(|| format!("read fixture for sha256 {path}"))?;
577    let mut hasher = Sha256::new();
578    hasher.update(bytes);
579    Ok(format!("{:x}", hasher.finalize()))
580}
581
582fn current_git_dirty(cwd: &Path) -> Option<bool> {
583    let outcome = command_output("git", ["status", "--porcelain"], cwd, &[], 30_000).ok()?;
584    (outcome.exit_code == Some(0)).then(|| !outcome.stdout.trim().is_empty())
585}
586
587fn report_command(options: &CodingBenchOptions) -> Vec<String> {
588    let mut command = vec![
589        "remem".to_string(),
590        "eval-coding-bench".to_string(),
591        "--fixture".to_string(),
592        options.fixture_path.clone(),
593        "--runs-per-condition".to_string(),
594        options.runs_per_condition.to_string(),
595        "--matrix".to_string(),
596        effective_matrix(options).to_string(),
597        "--task-set".to_string(),
598        options.task_set.clone(),
599        "--runner".to_string(),
600        options.runner.clone(),
601        "--model".to_string(),
602        options.model.clone(),
603        "--reasoning-effort".to_string(),
604        options.reasoning_effort.clone(),
605        "--json-out".to_string(),
606        options.json_out.clone(),
607    ];
608    if options.codex_bin != "codex" {
609        command.push("--codex-bin".to_string());
610        command.push(options.codex_bin.clone());
611    }
612    if let Some(provider) = &options.provider {
613        command.push("--provider".to_string());
614        command.push(provider.clone());
615    }
616    if let Some(condition) = &options.condition {
617        command.push("--condition".to_string());
618        command.push(condition.clone());
619    }
620    if let Some(task) = &options.task {
621        command.push("--task".to_string());
622        command.push(task.clone());
623    }
624    if options.ignore_budget {
625        command.push("--ignore-budget".to_string());
626    }
627    if options.keep_workdirs {
628        command.push("--keep-workdirs".to_string());
629    }
630    command
631}
632
633fn toml_escape(value: &str) -> String {
634    value.replace('\\', "\\\\").replace('"', "\\\"")
635}
636
637#[cfg(test)]
638mod tests {
639    use super::*;
640    use std::time::Duration;
641
642    #[test]
643    fn codex_runner_ignores_host_config_rules_hooks_and_session_files() {
644        let options = CodingBenchOptions {
645            fixture_path: "eval/coding-bench/fixtures/tasks.json".to_string(),
646            runs_per_condition: 1,
647            json_out: "/tmp/remem-coding-bench.json".to_string(),
648            condition: None,
649            matrix: "primary".to_string(),
650            task: None,
651            task_set: "full".to_string(),
652            keep_workdirs: false,
653            dry_run: false,
654            runner: "codex".to_string(),
655            codex_bin: "codex".to_string(),
656            model: "gpt-5.5".to_string(),
657            provider: Some("codexapi".to_string()),
658            reasoning_effort: "medium".to_string(),
659            ignore_budget: true,
660            curator_root: None,
661            memory_config: None,
662            run_phase: "local".to_string(),
663            matrix_namespace: "local".to_string(),
664            verify_live_approval_only: false,
665            live_approval: None,
666            approval_trust_root: None,
667            supervisor_attestation: None,
668            supervisor_bin: None,
669        };
670        let args = build_codex_exec_args(&options, Path::new("/tmp/remem-bench-repo"), "prompt");
671
672        assert!(args.contains(&"--ignore-user-config".to_string()));
673        assert!(args.contains(&"--ignore-rules".to_string()));
674        assert!(args.contains(&"--ephemeral".to_string()));
675        assert!(args
676            .windows(2)
677            .any(|window| window == ["--disable", "hooks"]));
678        assert!(args
679            .windows(2)
680            .any(|window| window == ["--sandbox", "danger-full-access"]));
681        assert!(!args.contains(&"--dangerously-bypass-approvals-and-sandbox".to_string()));
682        assert!(!args.contains(&"--dangerously-bypass-hook-trust".to_string()));
683    }
684
685    #[cfg(unix)]
686    #[test]
687    fn command_output_timeout_terminates_process_group_children() -> Result<()> {
688        let start = Instant::now();
689        let outcome = command_output("sh", ["-c", "sleep 10 & wait"], Path::new("."), &[], 100)?;
690        assert!(outcome.timed_out);
691        assert!(
692            start.elapsed() < Duration::from_secs(3),
693            "timeout should not wait for a grandchild sleep to exit"
694        );
695        Ok(())
696    }
697}