Skip to main content

lean_ctx_server/server/
execute.rs

1// SPDX-License-Identifier: Apache-2.0
2use fs2::FileExt;
3use std::io::Read;
4use std::process::Stdio;
5use std::sync::{Arc, Mutex, atomic::AtomicBool, mpsc};
6use std::time::{Duration, Instant};
7
8const READER_RESULT_TIMEOUT: Duration = Duration::from_secs(2);
9
10/// Prefix of the timeout notice this module appends to a killed command.
11const TIMEOUT_MARKER: &str = "ERROR: command timed out after ";
12
13fn is_build_or_test_command(command: &str) -> bool {
14    let tokens: Vec<_> = command
15        .split(|ch: char| ch.is_ascii_whitespace() || matches!(ch, ';' | '&' | '|'))
16        .filter(|token| !token.is_empty())
17        .collect();
18    tokens.windows(2).any(|pair| {
19        let tool = std::path::Path::new(pair[0])
20            .file_name()
21            .and_then(|name| name.to_str())
22            .unwrap_or(pair[0]);
23        (tool == "cargo"
24            && matches!(
25                pair[1],
26                "build" | "check" | "clippy" | "test" | "run" | "bench" | "install" | "fast"
27            ))
28            || (matches!(tool, "npm" | "pnpm" | "yarn" | "bun")
29                && matches!(pair[1], "test" | "build" | "check"))
30            || (tool == "lean-ctx" && pair[1] == "dev-install")
31            || (matches!(tool, "make" | "gmake")
32                && matches!(
33                    pair[1],
34                    "build"
35                        | "check"
36                        | "dev"
37                        | "install"
38                        | "install-release"
39                        | "test"
40                        | "preflight"
41                        | "preflight-fast"
42                ))
43    }) || tokens.windows(3).any(|triple| {
44        matches!(triple[0], "npm" | "pnpm" | "yarn" | "bun")
45            && triple[1] == "run"
46            && matches!(triple[2], "test" | "build" | "check" | "lint" | "typecheck")
47    }) || tokens.iter().any(|token| {
48        std::path::Path::new(token)
49            .file_name()
50            .and_then(|name| name.to_str())
51            .is_some_and(|name| matches!(name, "rustc" | "dev-install.sh" | "install.sh"))
52    })
53}
54
55fn acquire_build_lease(
56    command: &str,
57    cancel: Option<&AtomicBool>,
58) -> Result<Option<std::fs::File>, String> {
59    let config = lean_ctx::core::config::Config::load();
60    if !config.agents.serialize_build_commands || !is_build_or_test_command(command) {
61        return Ok(None);
62    }
63    let dir = lean_ctx::core::data_dir::lean_ctx_data_dir()?.join("resources");
64    std::fs::create_dir_all(&dir).map_err(|error| error.to_string())?;
65    let path = dir.join("build.lock");
66    let file = std::fs::OpenOptions::new()
67        .create(true)
68        .truncate(false)
69        .read(true)
70        .write(true)
71        .open(&path)
72        .map_err(|error| format!("open build lease {}: {error}", path.display()))?;
73    loop {
74        match file.try_lock_exclusive() {
75            Ok(()) => return Ok(Some(file)),
76            Err(error) if lock_is_contended(&error) => {
77                if cancel.is_some_and(|flag| flag.load(std::sync::atomic::Ordering::Acquire)) {
78                    return Err(
79                        "build cancelled while waiting for the machine-wide slot".to_string()
80                    );
81                }
82                std::thread::sleep(Duration::from_millis(100));
83            }
84            Err(error) => {
85                return Err(format!("acquire build lease {}: {error}", path.display()));
86            }
87        }
88    }
89}
90
91fn lock_is_contended(error: &std::io::Error) -> bool {
92    error.kind() == std::io::ErrorKind::WouldBlock
93        || cfg!(windows) && matches!(error.raw_os_error(), Some(32 | 33))
94}
95
96/// The child's own output preceding the timeout marker, or `None` when the
97/// output carries no marker. `Some("")` means the command timed out having
98/// produced nothing recoverable.
99///
100/// Single source of truth for "was anything captured before the timeout?":
101/// `call_tool` decides `isError` with it (#1086) and `ctx_shell` decides
102/// whether the notice is worth archiving (#995). It lives beside the code that
103/// writes the marker because both callers previously re-derived it by matching
104/// the whole output, so enriching the notice broke them silently (#1173).
105pub(crate) fn output_before_timeout_marker(output: &str) -> Option<&str> {
106    output.find(TIMEOUT_MARKER).map(|idx| output[..idx].trim())
107}
108
109#[cfg(test)]
110pub(crate) fn execute_command_in(command: &str, cwd: &str) -> (String, i32) {
111    execute_command_with_env(command, cwd, &std::collections::HashMap::new(), None)
112}
113
114#[cfg(test)]
115pub(crate) fn execute_command_with_env(
116    command: &str,
117    cwd: &str,
118    extra_env: &std::collections::HashMap<String, String>,
119    timeout_ms: Option<u64>,
120) -> (String, i32) {
121    execute_command_with_env_cancellable(command, cwd, extra_env, timeout_ms, None, false, None)
122}
123
124/// Execute a command under the normal shell policy, with an optional cooperative
125/// cancellation signal used by explicit background jobs. The signal is checked
126/// by the same watchdog that enforces `timeout_ms`, so cancellation kills the
127/// complete process tree — the Unix session or the Windows job — rather than
128/// only the shell leader.
129///
130/// `idle_keyed` turns `timeout_ms` into an *idle* budget instead of a wall-clock
131/// one: the clock resets whenever the child produces new bytes (#1113/#1173). A
132/// poll loop emitting a few hundred bytes over ten minutes is not the runaway
133/// this watchdog protects against.
134///
135/// Every job managed by `background_shell` sets it, foreground runs included —
136/// those detach at the soft cap and become exactly such a job. Only the
137/// degraded no-session path runs strict wall-clock. Two bounds keep it honest:
138/// genuinely runaway output hits the byte cap, which freezes the captured
139/// length and so restarts the clock, and `LEAN_CTX_SHELL_BG_MAX_MS` (default
140/// 1h) caps lifetime absolutely.
141pub(crate) struct ShellExecution {
142    pub output: String,
143    pub exit_code: i32,
144    pub completeness: lean_ctx::core::context_admission::OutputCompleteness,
145    /// Q13: what the caller has to be told about the call's processes — a
146    /// fixed, content-free line (`shell::containment`). It is NOT part of
147    /// `output`: whoever delivers the result puts it in front of the text
148    /// with [`with_notice`], after everything that admits, compresses or
149    /// shortens that text, so none of it can drop or alter the line.
150    pub notice: Option<&'static str>,
151}
152
153/// Puts a call's notice, if it has one, in front of the text delivered for
154/// it, as a line of its own. The last step of a delivery, applied once.
155pub(crate) fn with_notice(notice: Option<&'static str>, text: String) -> String {
156    match notice {
157        Some(notice) => format!("{notice}\n{text}"),
158        None => text,
159    }
160}
161
162/// Takes a notice off the front of a result that arrived as plain text (the
163/// protected launcher's supervisor runs the shell and answers with text
164/// only), so that it travels on as the typed field.
165#[cfg(unix)]
166fn split_notice(text: String) -> (Option<&'static str>, String) {
167    match leading_notice(&text) {
168        Some(notice) => {
169            let rest = text.get(notice.len() + 1..).unwrap_or_default().to_string();
170            (Some(notice), rest)
171        }
172        None => (None, text),
173    }
174}
175
176/// The notice a delivered text starts with, as a line of its own.
177#[cfg(unix)]
178pub(crate) fn leading_notice(text: &str) -> Option<&'static str> {
179    use lean_ctx::shell::containment::{END_NOTICE, HELPER_NOTICE, RELEASE_NOTICE, UNCLEAR_NOTICE};
180    [HELPER_NOTICE, END_NOTICE, UNCLEAR_NOTICE, RELEASE_NOTICE]
181        .into_iter()
182        .find(|notice| {
183            text.strip_prefix(notice)
184                .is_some_and(|rest| rest.is_empty() || rest.starts_with('\n'))
185        })
186}
187
188/// No notice exists without process containment.
189#[cfg(not(unix))]
190pub(crate) fn leading_notice(_text: &str) -> Option<&'static str> {
191    None
192}
193
194/// Keeps a call's notice in front of a text that replaced the one it stood
195/// on: the digest of a result too large to deliver inline says nothing about
196/// the call's processes by itself.
197pub(crate) fn keep_notice(notice: Option<&'static str>, text: String) -> String {
198    if leading_notice(&text).is_some() {
199        text
200    } else {
201        with_notice(notice, text)
202    }
203}
204
205impl ShellExecution {
206    fn complete(output: String, exit_code: i32) -> Self {
207        Self {
208            output,
209            exit_code,
210            completeness: lean_ctx::core::context_admission::OutputCompleteness::complete(),
211            notice: None,
212        }
213    }
214
215    /// A brokered run: the supervisor captured it, so this process cannot
216    /// vouch for the readers. Brokered execution exists on Unix only.
217    #[cfg(unix)]
218    fn unknown_capture(output: String, exit_code: i32) -> Self {
219        Self {
220            output,
221            exit_code,
222            completeness: lean_ctx::core::context_admission::OutputCompleteness {
223                reader_incomplete: true,
224                ..lean_ctx::core::context_admission::OutputCompleteness::complete()
225            },
226            notice: None,
227        }
228    }
229}
230
231/// Hand this executor to the protected supervisor
232/// (`core::protected_execution`), which runs brokered `ctx_shell` commands
233/// with it. The CLI calls this at start-up; the first registration wins.
234#[cfg(unix)]
235pub fn register_protected_shell_runner() {
236    lean_ctx::core::protected_execution::register_shell_runner(
237        execute_command_with_env_cancellable,
238    );
239}
240
241// Production caller: core::protected_execution (via
242// `register_protected_shell_runner`), which is unix-only.
243#[cfg(any(unix, test))]
244pub(crate) fn execute_command_with_env_cancellable(
245    command: &str,
246    cwd: &str,
247    extra_env: &std::collections::HashMap<String, String>,
248    timeout_ms: Option<u64>,
249    cancel: Option<&AtomicBool>,
250    idle_keyed: bool,
251    live: Option<&std::sync::Mutex<String>>,
252) -> (String, i32) {
253    // Kept as a compatibility wrapper. Raw progressive output is no longer
254    // written to caller-owned buffers.
255    let _ = live;
256    let execution = execute_command_with_env_cancellable_captured(
257        command, cwd, extra_env, timeout_ms, cancel, idle_keyed,
258    );
259    // Nothing shortens the text on this path: the notice goes in front here.
260    (
261        with_notice(execution.notice, execution.output),
262        execution.exit_code,
263    )
264}
265
266pub(crate) fn execute_command_with_env_captured(
267    command: &str,
268    cwd: &str,
269    extra_env: &std::collections::HashMap<String, String>,
270    timeout_ms: Option<u64>,
271) -> ShellExecution {
272    execute_command_with_env_cancellable_captured(command, cwd, extra_env, timeout_ms, None, false)
273}
274
275pub(crate) fn execute_command_with_env_cancellable_captured(
276    command: &str,
277    cwd: &str,
278    extra_env: &std::collections::HashMap<String, String>,
279    timeout_ms: Option<u64>,
280    cancel: Option<&AtomicBool>,
281    idle_keyed: bool,
282) -> ShellExecution {
283    // Store isolation for model-directed subprocesses of a protected session.
284    //
285    // macOS refuses a nested `sandbox_apply` under the whole-MCP profile, so
286    // this process cannot wrap its own children in the launcher's rules. The
287    // request goes to the launcher's supervisor instead, which starts the child
288    // from outside that sandbox under the profile the launcher pinned. Decided
289    // *before* the build lease and before any caller-supplied `extra_env`: the
290    // supervisor takes the machine-wide lease itself (taking it on both sides
291    // would deadlock), and a tool call can neither select nor drop the rules. A
292    // protected session that cannot reach its supervisor runs nothing.
293    //
294    // This is the one exec seam `ctx_shell` has — foreground, background and the
295    // interpreter heredoc reroute all arrive here — and the Engine's own
296    // persistence never passes through it.
297    #[cfg(unix)]
298    let protected_prefix: Option<Vec<String>> = {
299        use lean_ctx::core::protected_execution::{Mode, launch_mode, sandbox_prefix};
300        match launch_mode() {
301            Err(error) => return ShellExecution::complete(format!("ERROR: {error}"), 1),
302            Ok(Mode::Brokered(client)) => {
303                let (output, exit_code) = client.shell(
304                    command, cwd, extra_env, timeout_ms, cancel, idle_keyed, None,
305                );
306                let (notice, output) = split_notice(output);
307                return ShellExecution {
308                    notice,
309                    ..ShellExecution::unknown_capture(output, exit_code)
310                };
311            }
312            Ok(Mode::Sandboxed(profile)) => Some(sandbox_prefix(&profile)),
313            Ok(Mode::Direct) => None,
314        }
315    };
316    #[cfg(not(unix))]
317    let protected_prefix = match lean_ctx::core::protected_store::protected_child_prefix() {
318        Ok(prefix) => prefix,
319        Err(error) => return ShellExecution::complete(format!("ERROR: {error}"), 1),
320    };
321
322    let _build_lease = match acquire_build_lease(command, cancel) {
323        Ok(lease) => lease,
324        Err(error) => return ShellExecution::complete(format!("ERROR: {error}"), 130),
325    };
326    let resource_config = lean_ctx::core::config::Config::load().agents;
327    let (shell, flag) = lean_ctx::shell::shell_and_flag();
328    let normalized_cmd = crate::tools::ctx_shell::normalize_command_for_shell(command);
329    let normalized_cmd = lean_ctx::shell::platform::zsh_safe_command(&normalized_cmd, &shell);
330    let dir = std::path::Path::new(cwd);
331    let mut cmd = std::process::Command::new(&shell);
332    if let Some([program, arguments @ ..]) = protected_prefix.as_deref() {
333        cmd = std::process::Command::new(program);
334        cmd.args(arguments).arg(&shell);
335    }
336    if cfg!(windows) && lean_ctx::shell::platform::is_powershell(&shell) {
337        cmd.args(["-NoProfile", "-ExecutionPolicy", "Bypass"]);
338    }
339    cmd.arg(&flag)
340        .arg(&normalized_cmd)
341        .env("GIT_TERMINAL_PROMPT", "0")
342        .stdin(Stdio::null());
343    lean_ctx::shell::reentry::mark_child(&mut cmd);
344
345    if !extra_env.contains_key("GIT_PAGER") {
346        cmd.env("GIT_PAGER", "cat");
347    }
348    if !extra_env.contains_key("PAGER") {
349        cmd.env("PAGER", "cat");
350    }
351
352    ensure_utf8_locale(&mut cmd, extra_env);
353    lean_ctx::shell::platform::apply_profile_free_env(&mut cmd);
354
355    // Auto-forward agent runtime env vars (CODEX_THREAD_ID, CLAUDE_*, …) so
356    // session-aware commands run through ctx_shell can see the active session.
357    //   1. From this process's own env — covers agents that pass the vars to the
358    //      MCP server process.
359    //   2. From the captured agent-env store — covers agents like Codex where the
360    //      vars live only in the native agent shell, not the MCP server process
361    //      (#370). Hooks / `lean-ctx -c` capture them; the process env wins on
362    //      conflict, and explicit `extra_env` (below) wins over both.
363    let mut forwarded: std::collections::HashSet<String> = std::collections::HashSet::new();
364    for (key, val) in std::env::vars() {
365        if lean_ctx::core::agent_runtime_env::is_forwardable(&key) {
366            cmd.env(&key, &val);
367            forwarded.insert(key);
368        }
369    }
370    for (key, val) in lean_ctx::core::agent_runtime_env::load() {
371        if !forwarded.contains(&key) {
372            cmd.env(&key, &val);
373        }
374    }
375
376    // Explicit env vars from tool call (highest priority)
377    for (key, val) in extra_env {
378        cmd.env(key, val);
379    }
380    if is_build_or_test_command(command) {
381        if resource_config.cargo_build_jobs > 0 && !extra_env.contains_key("CARGO_BUILD_JOBS") {
382            cmd.env(
383                "CARGO_BUILD_JOBS",
384                resource_config.cargo_build_jobs.to_string(),
385            );
386        }
387        if resource_config.shared_cargo_target
388            && !extra_env.contains_key("CARGO_TARGET_DIR")
389            && let Ok(data_dir) = lean_ctx::core::data_dir::lean_ctx_data_dir()
390        {
391            cmd.env(
392                "CARGO_TARGET_DIR",
393                data_dir.join("build-cache/cargo-target"),
394            );
395        }
396    }
397    if dir.is_dir() {
398        cmd.current_dir(dir);
399    } else {
400        return ShellExecution::complete(
401            format!("ERROR: working directory does not exist or is not a directory: {cwd}"),
402            1,
403        );
404    }
405    let cap = lean_ctx::core::limits::max_shell_bytes();
406
407    // Isolate the shell in its own process group on Unix. A timeout must kill
408    // descendants too; otherwise a child can retain a pipe write-end and make
409    // the caller report an empty result despite bytes already captured (#995).
410    //
411    // A new *session* rather than just a group: it also drops the controlling
412    // terminal. Otherwise an interactive descendant (`zsh -i`, `bash -i`, a
413    // test suite that spawns one) takes the terminal's foreground with
414    // tcsetpgrp, and the agent that launched the MCP server is stopped by
415    // SIGTTIN — "zsh: suspended claude", with mouse reporting still on.
416    // The session leader's pid is its pgid, so the timeout kill is unchanged.
417    //
418    // Q13: on Unix the shell's parent is this process's spawn helper
419    // (`shell::containment`), which starts it with that same `setsid` and
420    // ends its session should this process die during the call. Everything
421    // decided above — command line, environment, directory, sandbox prefix —
422    // is handed over as it is. Without a helper the shell is started here, as
423    // before, and the result says so in its first line.
424    //
425    // Windows has no process group to kill: contain the tree in a job object
426    // instead, so a timeout or cancel ends the `python -` a heredoc started
427    // rather than only its shell (#1920).
428    lean_ctx::shell::process_tree::ProcessTree::prepare(&mut cmd);
429
430    let started = match lean_ctx::shell::process_tree::ShellChild::start(&mut cmd) {
431        Ok(started) => started,
432        Err(e) => return ShellExecution::complete(format!("ERROR: {e}"), 1),
433    };
434    let mut child = started.child;
435    // Stream each pipe into a shared, cap-bounded buffer that the main thread can
436    // read at any time. Crucially this lets a timed-out wait recover the bytes
437    // captured so far instead of discarding all output (#945).
438    let (out_buf, out_done) = spawn_capture(started.stdout, cap);
439    let (err_buf, err_done) = spawn_capture(started.stderr, cap);
440
441    let timeout = command_timeout(command, timeout_ms);
442    let start = Instant::now();
443    let hard_deadline = idle_keyed.then(|| start + streaming_max_lifetime());
444    let mut last_len = 0usize;
445    let mut last_output_at = start;
446    // Named on timeout so a multi-segment pipeline says *which* part hung (#1086).
447    let mut still_running: Vec<String> = Vec::new();
448    let (code, timed_out, cancelled) = loop {
449        match child.try_wait() {
450            Ok(Some(status)) => {
451                break (
452                    lean_ctx::shell::exit_status::exit_code(status),
453                    false,
454                    false,
455                );
456            }
457            Ok(None) => {
458                if cancel.is_some_and(|signal| signal.load(std::sync::atomic::Ordering::Acquire)) {
459                    child.kill();
460                    child.wait();
461                    break (130, false, true);
462                }
463                let idle_for = if idle_keyed {
464                    let len = captured_len(&out_buf) + captured_len(&err_buf);
465                    if len != last_len {
466                        last_len = len;
467                        last_output_at = Instant::now();
468                    }
469                    last_output_at.elapsed()
470                } else {
471                    start.elapsed()
472                };
473                if idle_for >= timeout || hard_deadline.is_some_and(|d| Instant::now() >= d) {
474                    still_running = running_segments(child.id(), &normalized_cmd);
475                    child.kill();
476                    child.wait();
477                    break (124, true, false);
478                }
479                std::thread::sleep(Duration::from_millis(25));
480            }
481            Err(_) => {
482                child.kill();
483                break (1, false, false);
484            }
485        }
486    };
487    // Q13: a shell whose helper went away before its exit was known has no
488    // parent that answers for it; the caller is told with the fixed notice.
489    // Such a call's outcome is not known in full — whether the command ran
490    // at all, or how it ended — so its capture counts as incomplete.
491    let lost_notice = child.lost_notice();
492    // A command that exited on its own keeps what it deliberately left running,
493    // as the Unix session does; a killed tree has nothing left to release. A
494    // release that could not be handed over is said to the caller as well.
495    let release_notice = child.release();
496    let containment_notice = started.notice.or(lost_notice).or(release_notice);
497
498    // Bounded grace period for the readers to reach EOF, but always read the
499    // shared buffers afterwards so output is never silently lost (#945). A reader
500    // that misses the deadline means something still holds the pipe open; we then
501    // surface the partial capture plus an explicit note rather than nothing.
502    let reader_deadline = Instant::now() + READER_RESULT_TIMEOUT;
503    let out_complete = wait_for_reader(&out_done, reader_deadline);
504    let err_complete = wait_for_reader(&err_done, reader_deadline);
505    let (out_bytes, out_trunc, out_read_failed) = snapshot(&out_buf);
506    let (err_bytes, err_trunc, err_read_failed) = snapshot(&err_buf);
507    let reader_incomplete = !out_complete || !err_complete || out_read_failed || err_read_failed;
508    // The legacy decoder may replace invalid UTF-8 bytes. On Windows this also
509    // conservatively flags ACP decoding, whose fallback does not prove lossless.
510    let decode_lossy =
511        std::str::from_utf8(&out_bytes).is_err() || std::str::from_utf8(&err_bytes).is_err();
512
513    let stdout =
514        lean_ctx::shell::resolve_carriage_returns(&lean_ctx::shell::decode_output(&out_bytes));
515    let stderr =
516        lean_ctx::shell::resolve_carriage_returns(&lean_ctx::shell::decode_output(&err_bytes));
517    // On failure both streams are labeled so the agent can attribute the error
518    // (#812); success keeps the plain join.
519    let mut text = lean_ctx::shell::combine_streams(&stdout, &stderr, code);
520
521    if out_trunc || err_trunc {
522        text.push_str(&format!(
523            "\n[truncated: cap={}B stdout={}B stderr={}B]",
524            cap,
525            out_bytes.len(),
526            err_bytes.len()
527        ));
528    }
529    // The command finished but a reader never hit EOF: a leftover process is
530    // holding the pipe open. We still return what was captured; flag it so the
531    // agent knows the tail may be missing instead of seeing a bare exit code
532    // (#945). Suppressed on timeout, which carries its own message below.
533    if reader_incomplete && !timed_out {
534        if !text.is_empty() && !text.ends_with('\n') {
535            text.push('\n');
536        }
537        text.push_str(&format!(
538            "[lean-ctx: output reader still draining after {}s — a background process is likely \
539             holding the pipe open; output above may be partial]",
540            READER_RESULT_TIMEOUT.as_secs()
541        ));
542    }
543    if timed_out {
544        if !text.ends_with('\n') && !text.is_empty() {
545            text.push('\n');
546        }
547        text.push_str(&format!(
548            "{TIMEOUT_MARKER}{}ms{}",
549            timeout.as_millis(),
550            if idle_keyed {
551                " without new output"
552            } else {
553                ""
554            }
555        ));
556        // #1086: for a compound command the captured output is often complete
557        // for every segment but one. Name what is still alive in the child's
558        // process group so the caller can fix that part instead of re-running
559        // the whole pipeline.
560        //
561        // Joined with "; " rather than " | " (#1654): these are processes, not
562        // stages, and a pipe character asserted a relationship the process
563        // table never established — rendering a line that was not runnable and
564        // named a stage the caller never wrote.
565        if !still_running.is_empty() {
566            text.push_str(&format!(
567                "\n[still running at timeout: {}]",
568                still_running.join("; ")
569            ));
570        }
571    }
572    // #1655/#1662: a recursive walk does not read `.gitignore`, so a nested
573    // checkout the other ctx_* tools never see can dominate it. Say so after
574    // the fact — the command's own traversal is left alone.
575    //
576    // Not only on timeout (#1662): `grep -r … | head` is the shape agents
577    // actually type, and it is the shape where the cost hides best. `head`
578    // closes early, the ten visible lines arrive at once, and the walk keeps
579    // running for two more minutes behind a result that already looked
580    // finished. Reported as "no redirect, no hint" — because the only place
581    // this was emitted was a timeout that a fast-looking command never
582    // reached.
583    if let Some(hint) = super::walk_hint::walk_hint(command, cwd) {
584        if !text.is_empty() && !text.ends_with('\n') {
585            text.push('\n');
586        }
587        text.push_str(&hint);
588    }
589    if cancelled {
590        if !text.ends_with('\n') && !text.is_empty() {
591            text.push('\n');
592        }
593        // #1246: a cancel is always caller-requested, so it is not an error.
594        text.push_str("[cancelled: command stopped on request]");
595    }
596
597    ShellExecution {
598        output: text,
599        exit_code: code,
600        // Q13: fixed and content-free; carried beside the text, not in it.
601        notice: containment_notice,
602        completeness: lean_ctx::core::context_admission::OutputCompleteness {
603            truncated: out_trunc || err_trunc,
604            timed_out,
605            reader_incomplete: reader_incomplete || lost_notice.is_some(),
606            cancelled,
607            decode_lossy,
608        },
609    }
610}
611
612/// Shared, cap-bounded capture buffer for one child pipe. The reader thread
613/// appends here as bytes arrive — not only at EOF — so the caller can recover
614/// whatever was read so far even if the reader never reaches EOF, e.g. when a
615/// backgrounded or otherwise-inherited process keeps the pipe's write-end open
616/// after the direct child exits (#945).
617#[derive(Default)]
618struct CaptureBuf {
619    bytes: Vec<u8>,
620    truncated: bool,
621    read_failed: bool,
622}
623
624/// Spawn a reader that streams `pipe` into a shared [`CaptureBuf`] (bounded to
625/// `cap` bytes) and signals completion (EOF or read error) over the returned
626/// channel. The buffer is readable at any time via [`snapshot`], so a timed-out
627/// wait yields partial output instead of nothing.
628fn spawn_capture<R: Read + Send + 'static>(
629    pipe: Option<R>,
630    cap: usize,
631) -> (Arc<Mutex<CaptureBuf>>, mpsc::Receiver<()>) {
632    let shared = Arc::new(Mutex::new(CaptureBuf::default()));
633    let (done_tx, done_rx) = mpsc::channel();
634    let writer = Arc::clone(&shared);
635    std::thread::spawn(move || {
636        if let Some(mut r) = pipe {
637            let mut buf = [0u8; 8192];
638            loop {
639                match r.read(&mut buf) {
640                    Ok(0) => break,
641                    Err(ref error) if error.kind() == std::io::ErrorKind::Interrupted => {}
642                    Err(_) => {
643                        writer
644                            .lock()
645                            .unwrap_or_else(std::sync::PoisonError::into_inner)
646                            .read_failed = true;
647                        break;
648                    }
649                    Ok(n) => {
650                        let mut s = writer
651                            .lock()
652                            .unwrap_or_else(std::sync::PoisonError::into_inner);
653                        if s.bytes.len() < cap {
654                            let remaining = cap - s.bytes.len();
655                            let take = remaining.min(n);
656                            s.bytes.extend_from_slice(&buf[..take]);
657                            if take < n {
658                                s.truncated = true;
659                            }
660                        } else {
661                            s.truncated = true;
662                        }
663                    }
664                }
665            }
666        }
667        let _ = done_tx.send(());
668    });
669    (shared, done_rx)
670}
671
672/// Snapshot a capture buffer (clone bytes + truncation flag) under its lock.
673/// Safe to call while the reader is still writing — yields a consistent prefix.
674fn snapshot(buf: &Arc<Mutex<CaptureBuf>>) -> (Vec<u8>, bool, bool) {
675    let s = buf
676        .lock()
677        .unwrap_or_else(std::sync::PoisonError::into_inner);
678    (s.bytes.clone(), s.truncated, s.read_failed)
679}
680
681/// Bytes captured so far, used as the liveness signal for an idle-keyed
682/// timeout. Cheaper than [`snapshot`], which clones the whole buffer.
683fn captured_len(buf: &Arc<Mutex<CaptureBuf>>) -> usize {
684    buf.lock()
685        .unwrap_or_else(std::sync::PoisonError::into_inner)
686        .bytes
687        .len()
688}
689
690/// Absolute ceiling on an idle-keyed (background) job, so a command that keeps
691/// emitting output forever still cannot outlive the daemon's patience (#1173).
692fn streaming_max_lifetime() -> Duration {
693    Duration::from_millis(
694        std::env::var("LEAN_CTX_SHELL_BG_MAX_MS")
695            .ok()
696            .and_then(|v| v.parse().ok())
697            .filter(|&ms: &u64| ms > 0)
698            .unwrap_or(3_600_000),
699    )
700}
701
702/// One live process in the timed-out child's group.
703#[cfg(unix)]
704struct LiveProc {
705    pid: u32,
706    ppid: u32,
707    args: String,
708}
709
710/// Command lines still alive in the timed-out child's process group (#1086).
711///
712/// These are **processes**, not pipeline segments. The distinction matters
713/// because a segment can spawn its own children into the same group: `go run x`
714/// execs a compiled binary under `$TMPDIR`, which then shows up here alongside
715/// its parent. Rendering the set as `a | b` claimed a pipeline the process
716/// table never established, and pointed the caller at a stage they never wrote
717/// (#1654).
718///
719/// Rows still carrying the *whole* command are the un-exec'd shell wrapper and
720/// say nothing specific, so they are dropped — note the leader is not simply
721/// skipped by pid, because `sh -c 'a; b'` execs into its final segment and so
722/// *is* the leader.
723///
724/// Best-effort: an unavailable or unparsable `ps` yields no attribution.
725#[cfg(unix)]
726fn running_segments(pgid: u32, command: &str) -> Vec<String> {
727    // POSIX-portable field selection; `ps -g` differs between BSD and Linux.
728    // `ppid` is read so a descendant can be labelled as one rather than
729    // presented as a sibling stage.
730    let Ok(out) = std::process::Command::new("ps")
731        .args(["-A", "-o", "pid=,ppid=,pgid=,args="])
732        .output()
733    else {
734        return Vec::new();
735    };
736    let needle = command.split_whitespace().collect::<Vec<_>>().join(" ");
737    let live: Vec<LiveProc> = String::from_utf8_lossy(&out.stdout)
738        .lines()
739        .filter_map(|line| parse_ps_row(line, pgid, &needle))
740        .collect();
741
742    describe_live(&live)
743}
744
745/// Render the live set, marking any process whose parent is also in the set.
746///
747/// A descendant is shown under its parent rather than as a peer, so the caller
748/// can tell "the stage I wrote" from "something that stage started".
749#[cfg(unix)]
750fn describe_live(live: &[LiveProc]) -> Vec<String> {
751    let pids: std::collections::HashSet<u32> = live.iter().map(|p| p.pid).collect();
752    live.iter()
753        .filter(|p| !pids.contains(&p.ppid))
754        .map(|parent| {
755            let kids: Vec<&str> = live
756                .iter()
757                .filter(|c| c.ppid == parent.pid)
758                .map(|c| c.args.as_str())
759                .collect();
760            if kids.is_empty() {
761                parent.args.clone()
762            } else {
763                format!("{} (spawned: {})", parent.args, kids.join(", "))
764            }
765        })
766        .collect()
767}
768
769/// One `pid ppid pgid args` row: `Some(..)` iff it belongs to `pgid` and names
770/// a narrower command than the one we launched. `needle` is the whole command
771/// with runs of whitespace collapsed, matching how `ps` renders `args`.
772#[cfg(unix)]
773fn parse_ps_row(line: &str, pgid: u32, needle: &str) -> Option<LiveProc> {
774    let rest = line.trim_start();
775    let (pid, rest) = rest.split_once(char::is_whitespace)?;
776    let rest = rest.trim_start();
777    let (ppid, rest) = rest.split_once(char::is_whitespace)?;
778    let args = rest
779        .trim_start()
780        .split_once(char::is_whitespace)
781        .and_then(|(row_pgid, args)| {
782            (row_pgid.parse::<u32>().ok()? == pgid).then_some(args.trim())
783        })?;
784    if args.is_empty() || args.contains(needle) {
785        return None;
786    }
787    Some(LiveProc {
788        pid: pid.parse().ok()?,
789        ppid: ppid.parse().ok()?,
790        args: args.chars().take(200).collect(),
791    })
792}
793
794#[cfg(not(unix))]
795fn running_segments(_pgid: u32, _command: &str) -> Vec<String> {
796    // No process groups here, so a descendant cannot be attributed to this run.
797    Vec::new()
798}
799
800/// Block until the reader signals completion or `deadline` passes. Returns true
801/// iff the reader completed (reached EOF) within the deadline.
802fn wait_for_reader(done: &mpsc::Receiver<()>, deadline: Instant) -> bool {
803    let remaining = deadline.saturating_duration_since(Instant::now());
804    done.recv_timeout(remaining).is_ok()
805}
806
807fn ensure_utf8_locale(
808    cmd: &mut std::process::Command,
809    extra_env: &std::collections::HashMap<String, String>,
810) {
811    if extra_env.contains_key("LC_ALL") || extra_env.contains_key("LC_CTYPE") {
812        return;
813    }
814    lean_ctx::shell::platform::apply_utf8_locale(cmd);
815}
816
817fn command_timeout(command: &str, timeout_ms: Option<u64>) -> Duration {
818    // Single source of truth: operator env pin > per-call `timeout_ms` >
819    // per-tier env/config > built-in heavy/normal ceilings. Keeps this path
820    // identical to the interactive hook (`shell::exec::shell_timeout`).
821    lean_ctx::shell::shell_timeout_with_override(command, timeout_ms)
822}
823
824#[cfg(all(test, target_os = "macos"))]
825#[path = "execute_supervised_tests.rs"]
826mod supervised_tests;
827
828#[cfg(all(test, unix))]
829#[path = "execute_containment_tests.rs"]
830mod containment_tests;
831
832#[cfg(test)]
833mod tests {
834    use super::{
835        command_timeout, ensure_utf8_locale, execute_command_in, is_build_or_test_command,
836    };
837
838    #[test]
839    fn resource_broker_build_slot_classifier_is_precise() {
840        for command in [
841            "cargo test --lib",
842            "cargo fast",
843            "cd rust && cargo fast --features dev-tools",
844            "make dev",
845            "make build",
846            "make preflight-fast",
847            "make install-release",
848            "./install.sh --build-only",
849            "bash rust/dev-install.sh --fast",
850            "./rust/dev-install.sh --release",
851            "lean-ctx dev-install",
852            "/opt/bin/lean-ctx dev-install --fast",
853            "cd rust && cargo clippy --all-features",
854            "/opt/bin/cargo build --release",
855            "npm test",
856            "npm run typecheck",
857            "pnpm build",
858            "rustc main.rs",
859        ] {
860            assert!(is_build_or_test_command(command), "{command}");
861        }
862        for command in [
863            "git status",
864            "cargo metadata",
865            "make help",
866            "lean-ctx --version",
867            "npm view lean-ctx",
868            "rg cargo",
869        ] {
870            assert!(!is_build_or_test_command(command), "{command}");
871        }
872    }
873
874    #[test]
875    fn resource_broker_serializes_build_leases() {
876        let isolation = lean_ctx::core::data_dir::isolated_data_dir();
877        // Off by default: a user's builds are never queued behind other sessions.
878        assert!(
879            super::acquire_build_lease("cargo test", None)
880                .expect("lease")
881                .is_none()
882        );
883        std::fs::write(
884            isolation.path().join("config.toml"),
885            "[agents]\nserialize_build_commands = true\n",
886        )
887        .expect("opt in");
888        let first = super::acquire_build_lease("cargo test first", None)
889            .expect("first lease")
890            .expect("build command gets lease");
891        let acquired = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
892        let worker_acquired = std::sync::Arc::clone(&acquired);
893        let worker = std::thread::spawn(move || {
894            let _second = super::acquire_build_lease("cargo test second", None)
895                .expect("second lease")
896                .expect("build command gets lease");
897            worker_acquired.store(true, std::sync::atomic::Ordering::Release);
898        });
899        std::thread::sleep(std::time::Duration::from_millis(50));
900        assert!(!acquired.load(std::sync::atomic::Ordering::Acquire));
901        drop(first);
902        worker.join().expect("lease waiter");
903        assert!(acquired.load(std::sync::atomic::Ordering::Acquire));
904    }
905
906    #[test]
907    fn command_timeout_delegates_to_shell_timeout() {
908        // `command_timeout` is a thin alias for `shell::exec::shell_timeout`
909        // (full precedence coverage lives there). Smoke-test the delegation:
910        // heavy beats normal, and the universal MS override pins both.
911        let _lock = lean_ctx::core::data_dir::test_env_lock();
912        let saved = std::env::var("LEAN_CTX_SHELL_TIMEOUT_MS").ok();
913        lean_ctx::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_MS");
914
915        assert!(
916            command_timeout("cargo install --path .", None) > command_timeout("git status", None)
917        );
918
919        lean_ctx::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_MS", "5000");
920        assert_eq!(
921            command_timeout("cargo install --path .", None),
922            std::time::Duration::from_secs(5)
923        );
924        assert_eq!(
925            command_timeout("git status", None),
926            std::time::Duration::from_secs(5)
927        );
928
929        lean_ctx::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_MS");
930        if let Some(v) = saved {
931            lean_ctx::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_MS", v);
932        }
933    }
934
935    /// The one exec seam: foreground runs, detached background jobs and the
936    /// interpreter-heredoc reroute all build their command here, so covering it
937    /// once covers all three. Synthetic store, isolated data dir — never the
938    /// developer's installed knowledge.
939    #[cfg(target_os = "macos")]
940    #[test]
941    fn the_supervisor_runs_the_shell_under_the_pinned_child_rules() {
942        let _lock = lean_ctx::core::data_dir::test_env_lock();
943        let isolation = lean_ctx::core::data_dir::isolated_data_dir();
944        let fact = isolation.path().join("knowledge/knowledge.json");
945        std::fs::create_dir_all(fact.parent().unwrap()).unwrap();
946        std::fs::write(&fact, "synthetic-provider-fact").unwrap();
947        let work = tempfile::tempdir().unwrap();
948        let cwd = work.path().to_str().unwrap();
949        let read_fact = format!("cat {}", fact.display());
950        let write_fact = format!("printf downgraded > {}", fact.display());
951
952        // Community: the store is an ordinary file, the shell is unwrapped.
953        lean_ctx::core::protected_store::unpin_synthetic_session();
954        let (text, code) = execute_command_in(&read_fact, cwd);
955        assert_eq!(code, 0, "{text}");
956        assert!(text.contains("synthetic-provider-fact"), "{text}");
957
958        let _session = lean_ctx::core::protected_store::pin_synthetic_session(isolation.path())
959            .expect("pinned rules");
960        lean_ctx::core::protected_execution::with_test_supervisor(|| {
961            let (text, code) = execute_command_in(&read_fact, cwd);
962            assert_ne!(code, 0, "{text}");
963            assert!(!text.contains("synthetic-provider-fact"), "{text}");
964            let (text, code) = execute_command_in(&write_fact, cwd);
965            assert_ne!(code, 0, "{text}");
966            // Ordinary project work in the session's own directory is untouched.
967            let (text, code) =
968                execute_command_in("printf 'fn main() {}' > main.rs && cat main.rs", cwd);
969            assert_eq!(code, 0, "{text}");
970            assert!(text.contains("fn main()"), "{text}");
971        });
972        // The Engine keeps persisting from outside the child rules.
973        assert_eq!(
974            std::fs::read_to_string(&fact).unwrap(),
975            "synthetic-provider-fact"
976        );
977        std::fs::write(&fact, "engine-write").unwrap();
978
979        // A protected session whose rules went missing runs nothing at all —
980        // there is no unconfined fallback shell.
981        lean_ctx::test_env::remove_var(lean_ctx::core::protected_store::CHILD_PROFILE_ENV);
982        let (text, code) = execute_command_in("printf ran-unconfined", cwd);
983        assert_ne!(code, 0, "{text}");
984        assert!(text.starts_with("ERROR:"), "{text}");
985        assert!(!text.contains("ran-unconfined"), "{text}");
986    }
987
988    /// #1113/#1173: a poll loop that emits a short line every so often must
989    /// outlive the wall-clock budget — the cap protects against runaway output,
990    /// not against a long-lived monitor. Wall-clock mode still kills it.
991    #[test]
992    #[cfg_attr(windows, ignore)]
993    fn idle_keyed_timeout_survives_a_trickling_loop() {
994        let _lock = lean_ctx::core::data_dir::test_env_lock();
995        lean_ctx::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_MS");
996        let env = std::collections::HashMap::new();
997        // Emits every 100ms for ~900ms under a 400ms budget.
998        let cmd = "for i in 1 2 3 4 5 6 7 8 9; do printf T; sleep 0.1; done; printf DONE";
999
1000        let (idle_out, idle_code) = super::execute_command_with_env_cancellable(
1001            cmd,
1002            ".",
1003            &env,
1004            Some(400),
1005            None,
1006            true,
1007            None,
1008        );
1009        assert_eq!(
1010            idle_code, 0,
1011            "output kept resetting the idle clock: {idle_out}"
1012        );
1013        assert!(idle_out.contains("DONE"));
1014
1015        let (wall_out, wall_code) = super::execute_command_with_env_cancellable(
1016            cmd,
1017            ".",
1018            &env,
1019            Some(400),
1020            None,
1021            false,
1022            None,
1023        );
1024        assert_eq!(wall_code, 124, "wall-clock mode must still enforce the cap");
1025        assert!(wall_out.contains("timed out"));
1026    }
1027
1028    /// #1086: a timed-out pipeline names the segment that was still running,
1029    /// so the caller fixes that part instead of re-running everything.
1030    #[test]
1031    #[cfg_attr(windows, ignore)]
1032    fn timeout_names_the_still_running_segment() {
1033        let _lock = lean_ctx::core::data_dir::test_env_lock();
1034        lean_ctx::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_MS");
1035        let env = std::collections::HashMap::new();
1036        let (out, code) = super::execute_command_with_env_cancellable(
1037            "printf FIRST_OK; sleep 47",
1038            ".",
1039            &env,
1040            Some(600),
1041            None,
1042            false,
1043            None,
1044        );
1045        assert_eq!(code, 124);
1046        assert!(
1047            out.contains("FIRST_OK"),
1048            "partial output must survive: {out}"
1049        );
1050        assert!(
1051            out.contains("still running at timeout") && out.contains("47"),
1052            "the hung segment must be named: {out}"
1053        );
1054    }
1055
1056    #[test]
1057    #[cfg(unix)]
1058    fn ps_row_names_segments_and_drops_the_whole_command() {
1059        // Rows are `pid ppid pgid args`.
1060        let whole = "printf hi; grep -rn needle /repo";
1061        assert_eq!(
1062            super::parse_ps_row(" 4242 4200 4200 grep -rn needle /repo", 4200, whole)
1063                .map(|p| p.args),
1064            Some("grep -rn needle /repo".to_string()),
1065            "a segment must be named"
1066        );
1067        // The un-exec'd shell wrapper still carries the whole command.
1068        assert!(
1069            super::parse_ps_row(
1070                "4200 1 4200 /bin/sh -c printf hi; grep -rn needle /repo",
1071                4200,
1072                whole
1073            )
1074            .is_none()
1075        );
1076        // Another job's process group.
1077        assert!(super::parse_ps_row("4242 4200 9999 sleep 5", 4200, whole).is_none());
1078        assert!(super::parse_ps_row("garbage", 4200, whole).is_none());
1079    }
1080
1081    // --- GH #1654: descendants are not pipeline stages ---
1082
1083    /// The reporter's case. `go run ./x` execs the binary it just compiled,
1084    /// which joins the same process group. Rendering the set as `a | b` claimed
1085    /// a pipeline the process table never established, producing a line that
1086    /// is not runnable and naming a stage the caller never wrote.
1087    #[test]
1088    #[cfg(unix)]
1089    fn a_spawned_child_is_labelled_not_presented_as_a_stage() {
1090        let live = vec![
1091            super::LiveProc {
1092                pid: 100,
1093                ppid: 1,
1094                args: "go run ./ --template x".into(),
1095            },
1096            super::LiveProc {
1097                pid: 101,
1098                ppid: 100,
1099                args: "/var/folders/T/go-build/b001/exe/evcc --template x".into(),
1100            },
1101        ];
1102        let out = super::describe_live(&live);
1103        assert_eq!(out.len(), 1, "the child is not a peer entry: {out:?}");
1104        assert!(out[0].starts_with("go run ./"), "{out:?}");
1105        assert!(
1106            out[0].contains("(spawned: /var/folders/T/go-build/b001/exe/evcc"),
1107            "the descendant is named as one: {out:?}"
1108        );
1109    }
1110
1111    /// Real pipeline siblings share a parent that is *not* in the set (the
1112    /// shell wrapper was dropped for carrying the whole command), so both stay
1113    /// top-level.
1114    #[test]
1115    #[cfg(unix)]
1116    fn genuine_pipeline_siblings_both_stay_top_level() {
1117        let live = vec![
1118            super::LiveProc {
1119                pid: 200,
1120                ppid: 50,
1121                args: "grep -rn needle /repo".into(),
1122            },
1123            super::LiveProc {
1124                pid: 201,
1125                ppid: 50,
1126                args: "head -30".into(),
1127            },
1128        ];
1129        let mut out = super::describe_live(&live);
1130        out.sort();
1131        assert_eq!(out, vec!["grep -rn needle /repo", "head -30"]);
1132    }
1133
1134    /// The rendered separator must not be shell syntax — that was the whole
1135    /// defect. `;` reads as a list; `|` read as a pipeline that did not exist.
1136    #[test]
1137    #[cfg(unix)]
1138    fn the_timeout_line_does_not_imply_a_pipeline() {
1139        let joined = ["a".to_string(), "b".to_string()].join("; ");
1140        assert!(!joined.contains('|'), "no pipe may be invented: {joined}");
1141    }
1142
1143    #[test]
1144    fn ensure_utf8_locale_sets_fallback_when_none_inherited() {
1145        let _env_lock = lean_ctx::core::data_dir::test_env_lock();
1146        let empty: std::collections::HashMap<String, String> = std::collections::HashMap::new();
1147        let mut cmd = std::process::Command::new("true");
1148
1149        // Temporarily unset locale vars to test fallback
1150        let saved = (
1151            std::env::var("LC_ALL").ok(),
1152            std::env::var("LC_CTYPE").ok(),
1153            std::env::var("LANG").ok(),
1154        );
1155        lean_ctx::test_env::remove_var("LC_ALL");
1156        lean_ctx::test_env::remove_var("LC_CTYPE");
1157        lean_ctx::test_env::remove_var("LANG");
1158
1159        ensure_utf8_locale(&mut cmd, &empty);
1160
1161        // Restore
1162        if let Some(v) = saved.0 {
1163            lean_ctx::test_env::set_var("LC_ALL", v);
1164        }
1165        if let Some(v) = saved.1 {
1166            lean_ctx::test_env::set_var("LC_CTYPE", v);
1167        }
1168        if let Some(v) = saved.2 {
1169            lean_ctx::test_env::set_var("LANG", v);
1170        }
1171
1172        // Command internal env isn't inspectable, but we verify the fn doesn't panic
1173        // and the real integration test below checks byte-level correctness.
1174    }
1175
1176    #[test]
1177    fn ensure_utf8_locale_skips_when_extra_env_has_lc_all() {
1178        let mut extra = std::collections::HashMap::new();
1179        extra.insert("LC_ALL".to_string(), "C".to_string());
1180        let mut cmd = std::process::Command::new("true");
1181        ensure_utf8_locale(&mut cmd, &extra);
1182        // Should not panic or override
1183    }
1184
1185    #[test]
1186    #[cfg_attr(windows, ignore)]
1187    fn utf8_bytes_survive_shell_roundtrip() {
1188        let _lock = lean_ctx::core::data_dir::test_env_lock();
1189        let (output, code) = execute_command_in(
1190            "printf '\\xD0\\x9F\\xD1\\x80\\xD0\\xB8\\xD0\\xB2\\xD0\\xB5\\xD1\\x82'",
1191            ".",
1192        );
1193        assert_eq!(code, 0, "printf failed: {output}");
1194        assert_eq!(output, "Привет", "Cyrillic bytes must survive roundtrip");
1195    }
1196
1197    #[test]
1198    #[cfg_attr(windows, ignore)] // ReadToEnd() blocks indefinitely on Windows CI
1199    fn execute_command_closes_stdin() {
1200        let _lock = lean_ctx::core::data_dir::test_env_lock();
1201        let command = "sh -c 'if read -t 1 line; then echo 67890; else echo 12345; fi'";
1202        let (output, code) = execute_command_in(command, ".");
1203        assert_eq!(code, 0, "command failed: {output}");
1204        assert!(
1205            output.contains("12345"),
1206            "child process should receive EOF on stdin, got: {output}"
1207        );
1208    }
1209
1210    /// #1881: a command terminated by a signal reports `128 + signal`, not 1.
1211    /// Exit 1 plus output would even pass as a grep-style success (#1090).
1212    #[test]
1213    #[cfg(unix)]
1214    fn execute_command_reports_signal_termination_as_128_plus_signal() {
1215        let (output, code) = execute_command_in("echo before-kill; kill -TERM $$", ".");
1216        assert_eq!(code, 143, "SIGTERM must map to 143, got {code}: {output}");
1217        assert!(output.contains("before-kill"), "output kept: {output}");
1218
1219        let (_, code) = execute_command_in("kill -KILL $$", ".");
1220        assert_eq!(code, 137, "SIGKILL must map to 137");
1221    }
1222
1223    /// #945: a command that finishes but leaves a process holding the stdout
1224    /// pipe open (here a backgrounded `sleep`) must NOT lose the foreground
1225    /// output. The old `recv_timeout(...).unwrap_or_default()` discarded
1226    /// everything on the reader timeout; now the captured prefix survives and
1227    /// the still-draining reader is flagged instead of returning a bare exit.
1228    #[test]
1229    #[cfg_attr(windows, ignore)] // POSIX backgrounding (`&`) + sleep
1230    fn background_pipe_holder_keeps_foreground_output() {
1231        let _lock = lean_ctx::core::data_dir::test_env_lock();
1232        let (output, code) = execute_command_in("echo REPRO_CANARY_945; sleep 4 &", ".");
1233        assert_eq!(code, 0, "command should succeed: {output:?}");
1234        assert!(
1235            output.contains("REPRO_CANARY_945"),
1236            "foreground stdout must survive a lingering background pipe holder, got: {output:?}"
1237        );
1238        assert!(
1239            output.contains("output reader still draining"),
1240            "an incomplete reader must be flagged, got: {output:?}"
1241        );
1242    }
1243
1244    /// A command runs without a controlling terminal, so an interactive
1245    /// descendant cannot take the terminal's foreground from the agent.
1246    /// (Vacuous where the test runner itself has no terminal, as in CI.)
1247    #[test]
1248    #[cfg(unix)]
1249    fn command_has_no_controlling_terminal() {
1250        let (output, code) = execute_command_in(
1251            "if (exec 3</dev/tty) 2>/dev/null; then echo HAS_TTY; else echo NO_TTY; fi",
1252            ".",
1253        );
1254        assert_eq!(code, 0, "{output:?}");
1255        assert!(output.contains("NO_TTY"), "got: {output:?}");
1256    }
1257
1258    #[test]
1259    #[cfg_attr(windows, ignore)]
1260    fn forwards_captured_agent_runtime_env() {
1261        // #370: the MCP server process lacks CODEX_THREAD_ID; a hook captured it
1262        // from the agent shell. ctx_shell must still forward it to the child.
1263        // The capture lands in `state_dir()`, so all category dirs must be
1264        // isolated — pinning only the data dir let parallel tests race it.
1265        let _iso = lean_ctx::core::data_dir::isolated_data_dir();
1266
1267        // Simulate a hook capturing the var from the native agent environment.
1268        lean_ctx::test_env::remove_var("CODEX_THREAD_ID");
1269        lean_ctx::test_env::set_var("CODEX_THREAD_ID", "thread-from-hook");
1270        lean_ctx::core::agent_runtime_env::capture();
1271        // The MCP server process itself does not carry the var.
1272        lean_ctx::test_env::remove_var("CODEX_THREAD_ID");
1273
1274        let (output, code) = execute_command_in("printf 'TID=%s' \"$CODEX_THREAD_ID\"", ".");
1275
1276        assert_eq!(code, 0, "command failed: {output}");
1277        assert!(
1278            output.contains("TID=thread-from-hook"),
1279            "captured agent runtime var must be forwarded, got: {output}"
1280        );
1281    }
1282
1283    /// Per-call `timeout_ms` must reach the kill loop: a command that sleeps
1284    /// past its 200ms budget is killed with the timeout exit code and message.
1285    #[test]
1286    #[cfg_attr(windows, ignore)] // POSIX sleep
1287    fn per_call_timeout_kills_long_command() {
1288        let _lock = lean_ctx::core::data_dir::test_env_lock();
1289        let (output, code) = super::execute_command_with_env(
1290            "sleep 3",
1291            ".",
1292            &std::collections::HashMap::new(),
1293            Some(200),
1294        );
1295        assert_eq!(code, 124, "timed-out command must exit 124: {output}");
1296        assert!(
1297            output.contains("timed out after 200ms"),
1298            "timeout message must carry the per-call budget, got: {output}"
1299        );
1300    }
1301
1302    /// #995: bytes emitted before a timeout remain visible; a timeout notice is
1303    /// additive rather than a replacement for useful subprocess output.
1304    #[test]
1305    #[cfg_attr(windows, ignore)] // POSIX sleep
1306    fn per_call_timeout_preserves_partial_output() {
1307        let _lock = lean_ctx::core::data_dir::test_env_lock();
1308        let (output, code) = super::execute_command_with_env(
1309            "printf TIMEOUT_PARTIAL_995; sleep 3",
1310            ".",
1311            &std::collections::HashMap::new(),
1312            Some(200),
1313        );
1314        assert_eq!(code, 124, "timed-out command must exit 124: {output}");
1315        assert!(
1316            output.contains("TIMEOUT_PARTIAL_995"),
1317            "stdout emitted before timeout must be preserved: {output:?}"
1318        );
1319        assert!(
1320            output.contains("timed out after 200ms"),
1321            "timeout notice must remain explicit: {output:?}"
1322        );
1323    }
1324
1325    #[test]
1326    fn git_version_returns_when_git_is_available() {
1327        let _lock = lean_ctx::core::data_dir::test_env_lock();
1328        let git_available = std::process::Command::new("git")
1329            .arg("--version")
1330            .stdout(std::process::Stdio::null())
1331            .stderr(std::process::Stdio::null())
1332            .status()
1333            .is_ok();
1334        if !git_available {
1335            return;
1336        }
1337
1338        let (output, code) = execute_command_in("git --version", ".");
1339        assert_eq!(code, 0, "git command failed: {output}");
1340        assert!(
1341            output.to_ascii_lowercase().contains("git version"),
1342            "unexpected git output: {output}"
1343        );
1344    }
1345}