Skip to main content

devflow_core/
monitor.rs

1//! Background monitor daemon.
2//!
3//! Spawns a detached child process that *owns* the coding agent: it launches
4//! the agent, captures its stdout and exit code into `.devflow/`, and — when
5//! the agent exits — runs `devflow advance` to advance the stage machine.
6//!
7//! Owning the agent is the key fix over a CLI-scoped capture thread: because
8//! the monitor outlives `devflow start`, the agent's stdout keeps flowing into
9//! the capture file and its exit code is still reaped after the CLI exits.
10//!
11//! This is the core automation primitive — no cron, no scheduler,
12//! no agent cooperation needed.
13
14use crate::git::hermetic_command;
15use crate::state::State;
16use std::path::Path;
17use std::process::Stdio;
18use std::time::Duration;
19use tracing::{debug, info};
20
21/// Errors produced by monitor operations.
22#[derive(Debug, thiserror::Error)]
23pub enum MonitorError {
24    /// Spawning the monitor process failed.
25    #[error("failed to spawn monitor: {0}")]
26    Io(#[from] std::io::Error),
27    /// Project path is not valid UTF-8.
28    #[error("project path is not valid UTF-8")]
29    NonUtf8Path,
30    /// Could not determine the current executable path.
31    #[error("could not determine devflow binary path")]
32    NoBinaryPath,
33}
34
35/// Spawn a background monitor that owns the agent for the given workflow state.
36///
37/// The monitor is a detached shell process that:
38/// 1. Launches the agent (`program` + `args`) with stdout redirected to the
39///    phase stdout file, recording the agent PID to the agent-pid file
40/// 2. Waits for the agent to exit and records its exit code to the exit file
41/// 3. Runs `devflow advance --phase N` to advance the workflow through its
42///    remaining stages
43///
44/// Returns the PID of the spawned monitor.
45pub fn spawn_monitor(
46    state: &State,
47    program: &str,
48    args: &[String],
49    envs: &[(String, String)],
50) -> Result<u32, MonitorError> {
51    spawn_monitor_inner(state, program, args, envs, true)
52}
53
54fn spawn_monitor_inner(
55    state: &State,
56    program: &str,
57    args: &[String],
58    envs: &[(String, String)],
59    run_advance: bool,
60) -> Result<u32, MonitorError> {
61    let project_root = state
62        .project_root
63        .to_str()
64        .ok_or(MonitorError::NonUtf8Path)?;
65
66    let binary = std::env::current_exe()
67        .map_err(|_| MonitorError::NoBinaryPath)?
68        .to_str()
69        .ok_or(MonitorError::NonUtf8Path)?
70        .to_string();
71
72    info!(
73        "spawning monitor for phase {}: {program} {}",
74        state.phase,
75        args.join(" ")
76    );
77
78    let stdout_file = crate::agent_result::stdout_path(&state.project_root, state.phase);
79    let stderr_file = crate::agent_result::stderr_path(&state.project_root, state.phase);
80    let exit_file = crate::agent_result::exit_code_path(&state.project_root, state.phase);
81    let pid_file = crate::agent_result::agent_pid_path(&state.project_root, state.phase);
82
83    // Ensure the capture directory exists before the detached process runs.
84    if let Some(parent) = stdout_file.parent() {
85        crate::workflow::ensure_devflow_dir(parent)?;
86    }
87
88    let stdout_file = stdout_file.to_str().ok_or(MonitorError::NonUtf8Path)?;
89    let stderr_file = stderr_file.to_str().ok_or(MonitorError::NonUtf8Path)?;
90    let exit_file = exit_file.to_str().ok_or(MonitorError::NonUtf8Path)?;
91    let pid_file = pid_file.to_str().ok_or(MonitorError::NonUtf8Path)?;
92
93    // The agent runs in its worktree when worktree mode is active; otherwise it
94    // runs in the project root. Capture/state files and the `devflow check`
95    // calls below always use the main project root, regardless of cwd.
96    let workdir_path = state
97        .worktree_path
98        .as_deref()
99        .unwrap_or(&state.project_root);
100    let workdir = workdir_path.to_str().ok_or(MonitorError::NonUtf8Path)?;
101
102    // Shell script that launches the agent in the background, captures its
103    // stdout and exit code, then advances the workflow. Because this process
104    // is the agent's parent, capture survives the CLI exiting.
105    //
106    // stderr is captured to a separate file so it cannot corrupt the (possibly
107    // JSON) stdout capture that DevFlow parses for DEVFLOW_RESULT. Inspect
108    // .devflow/phase-NN-stderr.log for agent error output on failures.
109    //
110    // `devflow advance --phase N` evaluates the agent result, moves the stage
111    // machine forward, and (for an agent stage) spawns the next monitor
112    // itself. The phase is recorded here at spawn time so advance's identity
113    // never depends on a shared state singleton (13-DEFERRED-CR-03): under
114    // `devflow parallel`, each phase's monitor advances exactly its own
115    // stage machine.
116    //
117    // Traps SIGTERM and SIGINT for clean shutdown. WR-08 (13-REVIEW.md):
118    // the trap must also kill the backgrounded agent ($apid) — previously
119    // it only exited the monitor shell itself, orphaning the agent so it
120    // kept running/committing unsupervised with nothing left to call
121    // `devflow advance` once it finished. `apid` is initialized to empty
122    // before the trap is installed so a signal arriving before the agent is
123    // even backgrounded doesn't reference an unset variable.
124    let advance_tail = if run_advance {
125        format!(
126            "; {binary} advance {project_root} --phase {phase}",
127            binary = shell_escape(&binary),
128            project_root = shell_escape(project_root),
129            phase = state.phase,
130        )
131    } else {
132        String::new()
133    };
134    let script = format!(
135        "apid=''; cleanup() {{ [ -n \"$apid\" ] && kill \"$apid\" 2>/dev/null; exit 0; }}; \
136         trap cleanup TERM INT; \
137         cd {workdir} || exit 1; \
138         \"$@\" > {stdout_file} 2>{stderr_file} & \
139         apid=$!; echo $apid > {pid_file}; \
140         wait $apid; echo $? > {exit_file}{advance_tail}",
141        workdir = shell_escape(workdir),
142        stdout_file = shell_escape(stdout_file),
143        stderr_file = shell_escape(stderr_file),
144        exit_file = shell_escape(exit_file),
145        pid_file = shell_escape(pid_file),
146    );
147
148    // 27-REVIEW WR-03: built through `hermetic_command`, not a bare
149    // `Command::new("sh")`. This is the spawn that launches the coding agent
150    // itself, and the comment below is precisely the hazard: whatever
151    // environment this `sh` carries rides down into the agent and into every
152    // git command the agent runs. An inherited `GIT_DIR` here would silently
153    // retarget the phase's real commits at a repository the operator never
154    // named — the worst case this phase exists to prevent, on its
155    // highest-consequence call site.
156    //
157    // Ordering is load-bearing: `hermetic_command` does its `env_remove`s at
158    // construction, and `.envs(...)` below runs after, so an adapter that
159    // deliberately sets one of these variables still wins. Deliberate
160    // configuration survives; inherited pollution does not. That is what
161    // keeps Codex's unsigned-commit override (`GIT_CONFIG_*`) working.
162    let child = hermetic_command("sh", workdir_path)
163        .arg("-c")
164        .arg(&script)
165        .arg("sh")
166        .arg(program)
167        .args(args)
168        // Adapter-scoped env (e.g. Codex's unsigned-commit override) rides
169        // the whole monitor chain: sh → agent → its git children (13-06).
170        .envs(envs.iter().map(|(k, v)| (k.as_str(), v.as_str())))
171        .stdin(Stdio::null())
172        .stdout(Stdio::null())
173        .stderr(Stdio::null())
174        .spawn()?;
175
176    let pid = child.id();
177    info!("monitor spawned with pid {pid}");
178    Ok(pid)
179}
180
181/// Poll for the agent PID that the monitor records, for up to ~1 second.
182///
183/// Returns the PID once the monitor has launched the agent, or `None` if it
184/// does not appear in time (the monitor still runs; only the display PID is lost).
185pub fn wait_for_agent_pid(project_root: &Path, phase: u32) -> Option<u32> {
186    let path = crate::agent_result::agent_pid_path(project_root, phase);
187    debug!("polling for agent PID for phase {phase}");
188    for _ in 0..50 {
189        if let Ok(contents) = std::fs::read_to_string(&path)
190            && let Ok(pid) = contents.trim().parse::<u32>()
191        {
192            return Some(pid);
193        }
194        std::thread::sleep(Duration::from_millis(20));
195    }
196    debug!("agent PID not found for phase {phase} after polling");
197    None
198}
199
200/// Escape a string for safe use in a single-quoted shell context.
201fn shell_escape(s: &str) -> String {
202    format!("'{}'", s.replace('\'', "'\\''"))
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use crate::mode::Mode;
209    use crate::stage::Stage;
210    use crate::state::{AgentKind, State};
211
212    fn state_in(root: &Path) -> State {
213        let mut state = State::new(4, AgentKind::Claude, Mode::Auto, root.to_path_buf());
214        state.stage = Stage::Code;
215        state
216    }
217
218    #[test]
219    fn shell_escape_wraps_basic_strings() {
220        assert_eq!(shell_escape("hello"), "'hello'");
221        assert_eq!(shell_escape("hello world"), "'hello world'");
222        assert_eq!(shell_escape("/tmp/devflow"), "'/tmp/devflow'");
223    }
224
225    #[test]
226    fn shell_escape_handles_single_quotes() {
227        assert_eq!(shell_escape("can't"), "'can'\\''t'");
228        assert_eq!(shell_escape("a'b'c"), "'a'\\''b'\\''c'");
229    }
230
231    #[test]
232    fn shell_escape_handles_empty_string() {
233        assert_eq!(shell_escape(""), "''");
234    }
235
236    #[test]
237    fn wait_for_agent_pid_returns_pid_when_file_exists() {
238        let dir = tempfile::tempdir().unwrap();
239        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
240        std::fs::write(
241            crate::agent_result::agent_pid_path(dir.path(), 4),
242            "12345\n",
243        )
244        .unwrap();
245
246        assert_eq!(wait_for_agent_pid(dir.path(), 4), Some(12345));
247    }
248
249    #[test]
250    fn wait_for_agent_pid_returns_none_when_file_missing() {
251        let dir = tempfile::tempdir().unwrap();
252
253        assert_eq!(wait_for_agent_pid(dir.path(), 4), None);
254    }
255
256    #[test]
257    fn wait_for_agent_pid_returns_none_for_garbage_content() {
258        let dir = tempfile::tempdir().unwrap();
259        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
260        std::fs::write(
261            crate::agent_result::agent_pid_path(dir.path(), 4),
262            "not-a-pid",
263        )
264        .unwrap();
265
266        assert_eq!(wait_for_agent_pid(dir.path(), 4), None);
267    }
268
269    #[test]
270    fn spawn_monitor_captures_agent_pid_and_output() {
271        let dir = tempfile::tempdir().unwrap();
272        let state = state_in(dir.path());
273        // Stub agent: write a known marker to stdout, then exit cleanly.
274        let args = vec!["-c".to_string(), "echo MONITOR_READY".to_string()];
275
276        let monitor_pid = spawn_monitor(&state, "sh", &args, &[]).unwrap();
277        assert!(monitor_pid > 0);
278
279        // Observable side effect #1: the monitor records the agent PID to its
280        // pid file with valid numeric content.
281        let agent_pid = wait_for_agent_pid(dir.path(), state.phase)
282            .expect("monitor should record the agent pid");
283        assert!(agent_pid > 0);
284
285        // Observable side effect #2: the agent's stdout is captured to the
286        // phase stdout file (proving the monitor actually ran the agent).
287        let stdout_path = crate::agent_result::stdout_path(dir.path(), state.phase);
288        let mut captured = String::new();
289        for _ in 0..100 {
290            if let Ok(contents) = std::fs::read_to_string(&stdout_path)
291                && contents.contains("MONITOR_READY")
292            {
293                captured = contents;
294                break;
295            }
296            std::thread::sleep(Duration::from_millis(20));
297        }
298        assert!(
299            captured.contains("MONITOR_READY"),
300            "expected MONITOR_READY in captured stdout, got {captured:?}"
301        );
302    }
303
304    /// WR-08 (13-REVIEW.md): sending SIGTERM/SIGINT to the monitor must also
305    /// terminate the agent it owns. Before the fix, `cleanup()` only exited
306    /// the monitor shell, leaving the agent orphaned and running/committing
307    /// unsupervised with nothing left to call `devflow advance` for it.
308    /// A one-line identity/state summary of a pid, for failure diagnostics.
309    /// `Name`/`State`/`PPid` come from `/proc/<pid>/status`; the cmdline
310    /// distinguishes a shell that exec'd its command from one that forked it.
311    /// Test-only; never used in a decision.
312    fn proc_snapshot(pid: u32) -> String {
313        let Ok(status) = std::fs::read_to_string(format!("/proc/{pid}/status")) else {
314            return format!("GONE (no /proc/{pid})");
315        };
316        let field = |key: &str| {
317            status
318                .lines()
319                .find(|l| l.starts_with(key))
320                .map(|l| l.split_whitespace().skip(1).collect::<Vec<_>>().join(" "))
321                .unwrap_or_else(|| "?".into())
322        };
323        let cmdline = std::fs::read(format!("/proc/{pid}/cmdline"))
324            .map(|raw| {
325                let joined = raw
326                    .split(|&b| b == 0)
327                    .filter(|a| !a.is_empty())
328                    .map(|a| String::from_utf8_lossy(a).into_owned())
329                    .collect::<Vec<_>>()
330                    .join(" ");
331                if joined.is_empty() {
332                    "<empty>".to_string()
333                } else {
334                    joined
335                }
336            })
337            .unwrap_or_else(|e| format!("<unreadable: {e}>"));
338        format!(
339            "ALIVE Name={} State={} PPid={} cmdline=[{cmdline}]",
340            field("Name:"),
341            field("State:"),
342            field("PPid:")
343        )
344    }
345
346    #[test]
347    fn sigterm_to_monitor_also_kills_the_agent() {
348        let dir = tempfile::tempdir().unwrap();
349        let state = state_in(dir.path());
350        // Stub agent that runs long enough to observe: sleeps well past the
351        // window this test needs to send SIGTERM and check liveness.
352        let args = vec!["-c".to_string(), "sleep 30".to_string()];
353
354        let monitor_pid = spawn_monitor(&state, "sh", &args, &[]).unwrap();
355        let agent_pid = wait_for_agent_pid(dir.path(), state.phase)
356            .expect("monitor should record the agent pid");
357        assert!(
358            crate::agent::agent_running(agent_pid),
359            "agent should be running before SIGTERM"
360        );
361
362        // Snapshot both processes before signalling. This assertion fails in
363        // containerised CI and cannot be reproduced locally, and a bare
364        // "still running" message discards everything that could explain it
365        // — the same antipattern that made 999.47 expensive to diagnose.
366        let monitor_before = proc_snapshot(monitor_pid);
367        let agent_before = proc_snapshot(agent_pid);
368
369        // SIGTERM the monitor, as an operator (or lock.rs's stale-holder
370        // reclaim path) would to abort a run.
371        let kill_rc = unsafe { libc::kill(monitor_pid as libc::pid_t, libc::SIGTERM) };
372        let kill_err = if kill_rc == 0 {
373            "ok".to_string()
374        } else {
375            format!("errno {}", std::io::Error::last_os_error())
376        };
377
378        // The agent should be killed promptly by the monitor's trap —
379        // poll rather than sleep a fixed amount to keep this fast and
380        // avoid flaking under load. (Window widened to 5s: at 2s this
381        // still flaked under a fully parallel workspace test run.)
382        //
383        // 2026-07-26: this was widened 5s -> 15s for the containerised CI
384        // job and STILL failed, then reverted to 5s. That widening was a
385        // mistake: 15s is far beyond any plausible trap-and-kill latency,
386        // so the agent is not being reaped SLOWLY, it is not being reaped.
387        // Buying silence with a bigger number would have hidden a real
388        // defect behind a green check — the exact false negative this
389        // repository keeps getting bitten by.
390        //
391        // The trap mechanism itself is verified working: DevFlow's real
392        // monitor script shape was run under both `bash` and `dash` (the
393        // container's /bin/sh is dash, the Fedora host's is bash) and both
394        // killed the backgrounded agent correctly. So the defect is in how
395        // the agent is spawned or identified under container timing, not in
396        // the shell trap — see 999.47, whose confirmed transient fork/exec
397        // window is the prime suspect for the same class of failure here.
398        //
399        // Leave this red until that is fixed. Do NOT widen it again.
400        let mut still_running = true;
401        for _ in 0..250 {
402            if !crate::agent::agent_running(agent_pid) {
403                still_running = false;
404                break;
405            }
406            std::thread::sleep(Duration::from_millis(20));
407        }
408        let monitor_after = proc_snapshot(monitor_pid);
409        let agent_after = proc_snapshot(agent_pid);
410        let pidfile =
411            std::fs::read_to_string(crate::agent_result::agent_pid_path(dir.path(), state.phase))
412                .unwrap_or_else(|e| format!("<unreadable: {e}>"));
413
414        assert!(
415            !still_running,
416            "agent (pid {agent_pid}) was orphaned — still running after monitor SIGTERM\n\
417             \x20 monitor pid:      {monitor_pid}\n\
418             \x20 kill(TERM) rc:    {kill_rc} ({kill_err})\n\
419             \x20 monitor before:   {monitor_before}\n\
420             \x20 monitor after:    {monitor_after}\n\
421             \x20 agent pid:        {agent_pid}\n\
422             \x20 agent before:     {agent_before}\n\
423             \x20 agent after:      {agent_after}\n\
424             \x20 pidfile contents: {}\n\
425             Read the monitor's `after` line first. GONE means the shell died \
426             without running its trap — most likely SIGTERM arrived before \
427             `trap` was installed, or it was killed rather than handling the \
428             signal, either way leaving the agent unreaped. STILL ALIVE means \
429             the trap never fired or `kill $apid` failed, so compare the agent \
430             pid against the pidfile and check the agent's PPid: if PPid is not \
431             the monitor, `$!` did not name the process we are polling. If the \
432             agent's Name is `sh` rather than `sleep`, the agent shell forked \
433             rather than exec'd, so killing it leaves its own child behind.",
434            pidfile.trim()
435        );
436    }
437
438    #[test]
439    fn spawn_monitor_runs_agent_in_worktree_but_captures_in_project_root() {
440        let dir = tempfile::tempdir().unwrap();
441        let worktree = dir.path().join(".worktrees/phase-04");
442        std::fs::create_dir_all(&worktree).unwrap();
443        let mut state = state_in(dir.path());
444        state.worktree_path = Some(worktree.clone());
445
446        // Stub agent: print its cwd so the test proves the monitor changed
447        // directories before launching the agent.
448        let args = vec!["-c".to_string(), "pwd; echo WORKTREE_READY".to_string()];
449
450        let monitor_pid = spawn_monitor(&state, "sh", &args, &[]).unwrap();
451        assert!(monitor_pid > 0);
452
453        let agent_pid = wait_for_agent_pid(dir.path(), state.phase)
454            .expect("monitor should record the agent pid in the main project");
455        assert!(agent_pid > 0);
456
457        let stdout_path = crate::agent_result::stdout_path(dir.path(), state.phase);
458        let mut captured = String::new();
459        for _ in 0..100 {
460            if let Ok(contents) = std::fs::read_to_string(&stdout_path)
461                && contents.contains("WORKTREE_READY")
462            {
463                captured = contents;
464                break;
465            }
466            std::thread::sleep(Duration::from_millis(20));
467        }
468
469        assert!(
470            captured.contains(&worktree.display().to_string()),
471            "agent did not run in worktree cwd; captured stdout: {captured:?}"
472        );
473        assert!(
474            stdout_path.exists(),
475            "stdout capture missing in main .devflow"
476        );
477        assert!(
478            !crate::agent_result::stdout_path(&worktree, state.phase).exists(),
479            "stdout capture should not be written under the worktree"
480        );
481    }
482
483    /// Build the fixture repositories through the scrubbing constructor, as
484    /// every other test module in this phase does (`version.rs:1102`).
485    ///
486    /// A bare `Command::new("git")` here would itself inherit an ambient
487    /// hostile `GIT_DIR` — so under this phase's own acceptance command
488    /// (`GIT_DIR=<throwaway>/.git cargo test -p devflow-core ...`) the
489    /// fixture setup would target the throwaway repository instead of
490    /// `root`, and the test below would fail for a reason that has nothing
491    /// to do with the behavior it is guarding.
492    fn git(root: &Path, args: &[&str]) {
493        let ok = crate::test_support::git_command(root)
494            .args(args)
495            .output()
496            .unwrap()
497            .status
498            .success();
499        assert!(ok, "git {args:?} failed");
500    }
501
502    fn init_repo(root: &Path) {
503        git(root, &["init", "-q"]);
504        git(root, &["config", "user.email", "test@example.com"]);
505        git(root, &["config", "user.name", "Test"]);
506    }
507
508    /// 27-REVIEW WR-03: the `sh` this function spawns owns the coding
509    /// agent, and whatever environment rides down with it reaches every git
510    /// command the agent runs (`sh` -> agent -> agent's git children). This
511    /// proves the scrub with a real spawned agent process, not by
512    /// inspecting the `Command` object: the agent shells out to
513    /// `git rev-parse --absolute-git-dir`, and the resolved path must be
514    /// the caller's own workdir, never a hostile `GIT_DIR` pointed at an
515    /// unrelated foreign repository.
516    ///
517    /// Mirrors `tag_reads_resolve_caller_root_under_a_hostile_git_dir`
518    /// (version.rs, 27-03/WR-01): `GIT_DIR` is never set on this test
519    /// process itself (Rust 2024 `unsafe`, unsound under threaded tests —
520    /// Phase 25 D-14), only on one freshly spawned child re-invoking this
521    /// binary filtered to this test.
522    #[test]
523    fn spawn_monitor_agent_git_calls_resolve_workdir_not_a_hostile_git_dir() {
524        const INNER_ROOT: &str = "DEVFLOW_27_MONITOR_INNER_ROOT";
525
526        if let Ok(root) = std::env::var(INNER_ROOT) {
527            // Inner mode: GIT_DIR points at a foreign repository unrelated
528            // to `root`, scoped to this child process only.
529            let root = std::path::PathBuf::from(root);
530            let state = state_in(&root);
531            let args = vec![
532                "-c".to_string(),
533                "git rev-parse --absolute-git-dir".to_string(),
534            ];
535
536            spawn_monitor(&state, "sh", &args, &[]).unwrap();
537            wait_for_agent_pid(&root, state.phase).expect("monitor should record the agent pid");
538
539            let stdout_path = crate::agent_result::stdout_path(&root, state.phase);
540            let mut captured = String::new();
541            for _ in 0..100 {
542                if let Ok(contents) = std::fs::read_to_string(&stdout_path)
543                    && !contents.trim().is_empty()
544                {
545                    captured = contents;
546                    break;
547                }
548                std::thread::sleep(Duration::from_millis(20));
549            }
550
551            let resolved = std::fs::canonicalize(captured.trim())
552                .expect("agent's reported git-dir must exist on disk");
553            let expected =
554                std::fs::canonicalize(root.join(".git")).expect("caller repo .git must exist");
555            assert_eq!(
556                resolved, expected,
557                "agent's git call resolved to a hostile GIT_DIR's \
558                 repository instead of the caller's own workdir: \
559                 got {resolved:?}, want {expected:?}"
560            );
561            return;
562        }
563
564        // Outer mode: a real repository at `root`, and an unrelated
565        // foreign repository whose .git must never leak into the agent's
566        // environment.
567        let dir = tempfile::tempdir().unwrap();
568        let root = dir.path().join("caller-repo");
569        std::fs::create_dir_all(&root).unwrap();
570        init_repo(&root);
571
572        let foreign = tempfile::tempdir().unwrap();
573        init_repo(foreign.path());
574
575        let exe = std::env::current_exe().expect("current_exe for child re-invocation");
576        let out = std::process::Command::new(&exe)
577            // Substring filter, NOT `--exact`: the binary's real test name
578            // is module-qualified (`monitor::tests::spawn_monitor_...`), so
579            // `--exact` against the bare name matches nothing, runs zero
580            // tests, and still exits 0 — a false green.
581            .arg("spawn_monitor_agent_git_calls_resolve_workdir_not_a_hostile_git_dir")
582            .arg("--test-threads=1")
583            .env(INNER_ROOT, root.to_str().unwrap())
584            .env("GIT_DIR", foreign.path().join(".git"))
585            .output()
586            .expect("spawn hostile child test process");
587
588        let stdout = String::from_utf8_lossy(&out.stdout);
589        // Assert the child actually RAN the test, not merely that it
590        // exited 0. A filter that matches nothing exits 0 with "0 passed".
591        assert!(
592            stdout.contains("1 passed"),
593            "child test process must have run exactly the inner test; \
594             stdout:\n{stdout}"
595        );
596        assert!(
597            out.status.success(),
598            "monitor-spawned agent (hostile GIT_DIR pointed at an \
599             unrelated foreign repository) must still resolve its git \
600             calls against the caller's own workdir; child exit status \
601             {:?}\nstdout:\n{stdout}",
602            out.status
603        );
604    }
605
606    #[test]
607    fn spawn_monitor_treats_agent_args_as_literal_argv() {
608        let dir = tempfile::tempdir().unwrap();
609        let state = state_in(dir.path());
610        let payload = "value; touch INJECTED";
611        let args = vec![
612            "-c".to_string(),
613            "printf '%s\\n' \"$0\"; echo ARGV_SAFE".to_string(),
614            payload.to_string(),
615        ];
616
617        spawn_monitor(&state, "sh", &args, &[]).unwrap();
618        wait_for_agent_pid(dir.path(), state.phase).expect("monitor should record the agent pid");
619
620        let stdout_path = crate::agent_result::stdout_path(dir.path(), state.phase);
621        let mut captured = String::new();
622        for _ in 0..100 {
623            if let Ok(contents) = std::fs::read_to_string(&stdout_path)
624                && contents.contains("ARGV_SAFE")
625            {
626                captured = contents;
627                break;
628            }
629            std::thread::sleep(Duration::from_millis(20));
630        }
631
632        assert!(
633            captured.contains(payload),
634            "literal argv missing: {captured:?}"
635        );
636        assert!(captured.contains("ARGV_SAFE"));
637        assert!(!dir.path().join("INJECTED").exists());
638    }
639}