Skip to main content

lean_ctx/shell/
exec.rs

1use std::io::{self, IsTerminal, Read, Write};
2use std::process::{Child, Command, Output, Stdio};
3
4use crate::core::config;
5use crate::core::slow_log;
6use crate::core::tokens::count_tokens;
7
8/// Wait for a child process with output-size and time limits.
9/// Kills the process if either limit is exceeded, returning what was
10/// captured so far. Prevents unbounded memory growth on commands that
11/// produce massive output (e.g. `rg -i "pattern"` over a large tree).
12fn wait_with_limits(mut child: Child, max_bytes: usize, timeout: std::time::Duration) -> Output {
13    let stdout_pipe = child.stdout.take();
14    let stderr_pipe = child.stderr.take();
15    let start = std::time::Instant::now();
16
17    let stdout_handle = std::thread::spawn(move || {
18        let Some(mut pipe) = stdout_pipe else {
19            return (Vec::new(), false);
20        };
21        let mut buf = Vec::with_capacity(max_bytes.min(64 * 1024));
22        let mut chunk = [0u8; 8192];
23        loop {
24            match pipe.read(&mut chunk) {
25                Ok(0) => break,
26                Ok(n) => {
27                    if buf.len() + n > max_bytes {
28                        let remaining = max_bytes.saturating_sub(buf.len());
29                        buf.extend_from_slice(&chunk[..remaining]);
30                        return (buf, true);
31                    }
32                    buf.extend_from_slice(&chunk[..n]);
33                }
34                Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => {}
35                Err(_) => break,
36            }
37        }
38        (buf, false)
39    });
40
41    let stderr_handle = std::thread::spawn(move || {
42        let Some(mut pipe) = stderr_pipe else {
43            return Vec::new();
44        };
45        let mut buf = Vec::new();
46        let mut chunk = [0u8; 4096];
47        const STDERR_LIMIT: usize = 512 * 1024;
48        loop {
49            match pipe.read(&mut chunk) {
50                Ok(0) => break,
51                Ok(n) => {
52                    if buf.len() + n > STDERR_LIMIT {
53                        break;
54                    }
55                    buf.extend_from_slice(&chunk[..n]);
56                }
57                Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => {}
58                Err(_) => break,
59            }
60        }
61        buf
62    });
63
64    let mut timed_out = false;
65    loop {
66        if start.elapsed() > timeout {
67            let _ = child.kill();
68            let _ = child.wait();
69            timed_out = true;
70            break;
71        }
72        match child.try_wait() {
73            Ok(Some(_)) | Err(_) => break,
74            Ok(None) => std::thread::sleep(std::time::Duration::from_millis(50)),
75        }
76    }
77
78    let (mut stdout_buf, stdout_truncated) = stdout_handle.join().unwrap_or_default();
79    let stderr_buf = stderr_handle.join().unwrap_or_default();
80
81    if timed_out || stdout_truncated {
82        let notice = format!(
83            "\n[lean-ctx: output truncated at {} MB / {}s limit]\n",
84            max_bytes / (1024 * 1024),
85            timeout.as_secs()
86        );
87        stdout_buf.extend_from_slice(notice.as_bytes());
88    }
89
90    let status = child.wait().unwrap_or_else(|_| {
91        std::process::Command::new("false")
92            .status()
93            .expect("cannot run `false`")
94    });
95
96    Output {
97        status,
98        stdout: stdout_buf,
99        stderr: stderr_buf,
100    }
101}
102
103/// Execute a command from pre-split argv without going through `sh -c`.
104/// Used by `-t` mode when the shell hook passes `"$@"` — arguments are
105/// already correctly split by the user's shell, so re-serializing them
106/// into a string and re-parsing via `sh -c` would risk mangling complex
107/// quoted arguments (em-dashes, `#`, nested quotes, etc.).
108pub fn exec_argv(args: &[String]) -> i32 {
109    if args.is_empty() {
110        return 127;
111    }
112
113    if std::env::var("LEAN_CTX_DISABLED").is_ok() || std::env::var("LEAN_CTX_ACTIVE").is_ok() {
114        return exec_direct(args);
115    }
116
117    let joined = super::platform::join_command(args);
118    let cfg = config::Config::load();
119    let policy = super::output_policy::classify(&joined, &cfg.excluded_commands);
120
121    if policy.is_protected() {
122        let code = exec_direct(args);
123        crate::core::tool_lifecycle::record_shell_command(0, 0);
124        return code;
125    }
126
127    let code = exec_direct(args);
128    crate::core::tool_lifecycle::record_shell_command(0, 0);
129    code
130}
131
132fn exec_direct(args: &[String]) -> i32 {
133    let status = Command::new(&args[0])
134        .args(&args[1..])
135        .env("LEAN_CTX_ACTIVE", "1")
136        .stdin(Stdio::inherit())
137        .stdout(Stdio::inherit())
138        .stderr(Stdio::inherit())
139        .status();
140
141    match status {
142        Ok(s) => s.code().unwrap_or(1),
143        Err(e) => {
144            tracing::error!("lean-ctx: failed to execute: {e}");
145            127
146        }
147    }
148}
149
150pub fn exec(command: &str) -> i32 {
151    let (shell, shell_flag) = super::platform::shell_and_flag();
152    let command = crate::tools::ctx_shell::normalize_command_for_shell(command);
153    let command = command.as_str();
154
155    if std::env::var("LEAN_CTX_DISABLED").is_ok() || std::env::var("LEAN_CTX_ACTIVE").is_ok() {
156        return exec_inherit(command, &shell, &shell_flag);
157    }
158
159    let cfg = config::Config::load();
160    let force_compress = std::env::var("LEAN_CTX_COMPRESS").is_ok();
161    let raw_mode = std::env::var("LEAN_CTX_RAW").is_ok();
162
163    if raw_mode {
164        return exec_inherit_tracked(command, &shell, &shell_flag);
165    }
166
167    let policy = super::output_policy::classify(command, &cfg.excluded_commands);
168
169    // Passthrough: ALWAYS bypass compression, even with force_compress.
170    if policy == super::output_policy::OutputPolicy::Passthrough {
171        return exec_inherit_tracked(command, &shell, &shell_flag);
172    }
173
174    // Verbatim: bypass compression unless force_compress is set,
175    // in which case use buffered path (compress_if_beneficial will
176    // respect the verbatim classification and only size-cap).
177    if policy == super::output_policy::OutputPolicy::Verbatim && !force_compress {
178        return exec_inherit_tracked(command, &shell, &shell_flag);
179    }
180
181    if !force_compress {
182        if io::stdout().is_terminal() {
183            return exec_inherit_tracked(command, &shell, &shell_flag);
184        }
185        let code = exec_inherit(command, &shell, &shell_flag);
186        crate::core::tool_lifecycle::record_shell_command(0, 0);
187        return code;
188    }
189
190    exec_buffered(command, &shell, &shell_flag, &cfg)
191}
192
193fn exec_inherit(command: &str, shell: &str, shell_flag: &str) -> i32 {
194    let status = Command::new(shell)
195        .arg(shell_flag)
196        .arg(command)
197        .env("LEAN_CTX_ACTIVE", "1")
198        .stdin(Stdio::inherit())
199        .stdout(Stdio::inherit())
200        .stderr(Stdio::inherit())
201        .status();
202
203    match status {
204        Ok(s) => s.code().unwrap_or(1),
205        Err(e) => {
206            tracing::error!("lean-ctx: failed to execute: {e}");
207            127
208        }
209    }
210}
211
212fn exec_inherit_tracked(command: &str, shell: &str, shell_flag: &str) -> i32 {
213    let code = exec_inherit(command, shell, shell_flag);
214    crate::core::tool_lifecycle::record_shell_command(0, 0);
215    code
216}
217
218fn combine_output(stdout: &str, stderr: &str) -> String {
219    if stderr.is_empty() {
220        stdout.to_string()
221    } else if stdout.is_empty() {
222        stderr.to_string()
223    } else {
224        format!("{stdout}\n{stderr}")
225    }
226}
227
228fn exec_buffered(command: &str, shell: &str, shell_flag: &str, cfg: &config::Config) -> i32 {
229    #[cfg(windows)]
230    super::platform::set_console_utf8();
231
232    let start = std::time::Instant::now();
233
234    let mut cmd = Command::new(shell);
235
236    #[cfg(windows)]
237    let ps_tmp_path: Option<tempfile::TempPath>;
238    #[cfg(windows)]
239    {
240        if super::platform::is_powershell(shell) {
241            let ps_script = format!(
242                "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; {}",
243                command
244            );
245            let tmp = tempfile::Builder::new()
246                .prefix("lean-ctx-ps-")
247                .suffix(".ps1")
248                .tempfile()
249                .expect("failed to create temp file for PowerShell script");
250            let tmp_path = tmp.into_temp_path();
251            let _ = std::fs::write(&tmp_path, &ps_script);
252            cmd.args([
253                "-NoProfile",
254                "-ExecutionPolicy",
255                "Bypass",
256                "-File",
257                &tmp_path.to_string_lossy(),
258            ]);
259            ps_tmp_path = Some(tmp_path);
260        } else {
261            cmd.arg(shell_flag);
262            cmd.arg(command);
263            ps_tmp_path = None;
264        }
265    }
266    #[cfg(not(windows))]
267    {
268        cmd.arg(shell_flag);
269        cmd.arg(command);
270    }
271
272    let child = cmd
273        .env("LEAN_CTX_ACTIVE", "1")
274        .stdout(Stdio::piped())
275        .stderr(Stdio::piped())
276        .spawn();
277
278    let child = match child {
279        Ok(c) => c,
280        Err(e) => {
281            tracing::error!("lean-ctx: failed to execute: {e}");
282            #[cfg(windows)]
283            if let Some(ref tmp) = ps_tmp_path {
284                let _ = std::fs::remove_file(tmp);
285            }
286            return 127;
287        }
288    };
289
290    const MAX_BUFFERED_BYTES: usize = 8 * 1024 * 1024; // 8 MB
291    const EXEC_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(2);
292
293    let output = wait_with_limits(child, MAX_BUFFERED_BYTES, EXEC_TIMEOUT);
294
295    let duration_ms = start.elapsed().as_millis();
296    let exit_code = output.status.code().unwrap_or(1);
297    let stdout = super::platform::decode_output(&output.stdout);
298    let stderr = super::platform::decode_output(&output.stderr);
299
300    let full_output = combine_output(&stdout, &stderr);
301    let input_tokens = count_tokens(&full_output);
302
303    let (compressed, output_tokens) =
304        super::compress::compress_and_measure(command, &stdout, &stderr);
305
306    crate::core::tool_lifecycle::record_shell_command(input_tokens, output_tokens);
307
308    if !compressed.is_empty() {
309        let _ = io::stdout().write_all(compressed.as_bytes());
310        if !compressed.ends_with('\n') {
311            let _ = io::stdout().write_all(b"\n");
312        }
313    }
314    let should_tee = match cfg.tee_mode {
315        config::TeeMode::Always => !full_output.trim().is_empty(),
316        config::TeeMode::Failures => exit_code != 0 && !full_output.trim().is_empty(),
317        config::TeeMode::HighCompression => {
318            let orig = full_output.len();
319            let after = compressed.len();
320            let pct = if orig > 0 {
321                ((orig.saturating_sub(after)) as f64 / orig as f64) * 100.0
322            } else {
323                0.0
324            };
325            pct > 70.0 && orig > 100
326        }
327        config::TeeMode::Never => false,
328    };
329    if should_tee {
330        if let Some(path) = super::redact::save_tee(command, &full_output) {
331            if !matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1") {
332                eprintln!("[lean-ctx: full output -> {path} (redacted, 24h TTL)]");
333            }
334        }
335    }
336
337    let threshold = cfg.slow_command_threshold_ms;
338    if threshold > 0 && duration_ms >= threshold as u128 {
339        slow_log::record(command, duration_ms, exit_code);
340    }
341
342    #[cfg(windows)]
343    if let Some(ref tmp) = ps_tmp_path {
344        let _ = std::fs::remove_file(tmp);
345    }
346
347    exit_code
348}
349
350#[cfg(test)]
351mod exec_tests {
352    #[test]
353    fn exec_direct_runs_true() {
354        let code = super::exec_direct(&["true".to_string()]);
355        assert_eq!(code, 0);
356    }
357
358    #[test]
359    fn exec_direct_runs_false() {
360        let code = super::exec_direct(&["false".to_string()]);
361        assert_ne!(code, 0);
362    }
363
364    #[test]
365    fn exec_direct_preserves_args_with_special_chars() {
366        let code = super::exec_direct(&[
367            "echo".to_string(),
368            "hello world".to_string(),
369            "it's here".to_string(),
370            "a \"quoted\" thing".to_string(),
371        ]);
372        assert_eq!(code, 0);
373    }
374
375    #[test]
376    fn exec_direct_nonexistent_returns_127() {
377        let code = super::exec_direct(&["__nonexistent_binary_12345__".to_string()]);
378        assert_eq!(code, 127);
379    }
380
381    #[test]
382    fn exec_argv_empty_returns_127() {
383        let code = super::exec_argv(&[]);
384        assert_eq!(code, 127);
385    }
386
387    #[test]
388    fn exec_argv_runs_simple_command() {
389        let code = super::exec_argv(&["true".to_string()]);
390        assert_eq!(code, 0);
391    }
392
393    #[test]
394    fn exec_argv_passes_through_when_disabled() {
395        std::env::set_var("LEAN_CTX_DISABLED", "1");
396        let code = super::exec_argv(&["true".to_string()]);
397        std::env::remove_var("LEAN_CTX_DISABLED");
398        assert_eq!(code, 0);
399    }
400
401    #[test]
402    fn wait_with_limits_captures_output() {
403        let child = std::process::Command::new("echo")
404            .arg("hello")
405            .stdout(std::process::Stdio::piped())
406            .stderr(std::process::Stdio::piped())
407            .spawn()
408            .unwrap();
409
410        let output = super::wait_with_limits(child, 1024, std::time::Duration::from_secs(5));
411        let stdout = String::from_utf8_lossy(&output.stdout);
412        assert!(
413            stdout.contains("hello"),
414            "expected 'hello' in output: {stdout}"
415        );
416        assert!(output.status.success());
417    }
418
419    #[test]
420    fn wait_with_limits_truncates_large_output() {
421        // Generate ~100 KB of output, limit to 1 KB
422        let child = std::process::Command::new("sh")
423            .args(["-c", "yes 'aaaa' | head -25000"])
424            .stdout(std::process::Stdio::piped())
425            .stderr(std::process::Stdio::piped())
426            .spawn()
427            .unwrap();
428
429        let output = super::wait_with_limits(child, 1024, std::time::Duration::from_secs(10));
430        let stdout = String::from_utf8_lossy(&output.stdout);
431        assert!(
432            stdout.contains("[lean-ctx: output truncated"),
433            "expected truncation notice, got len={}: ...{}",
434            stdout.len(),
435            &stdout[stdout.len().saturating_sub(80)..]
436        );
437    }
438
439    #[test]
440    fn wait_with_limits_timeout_kills_process() {
441        let child = std::process::Command::new("sleep")
442            .arg("60")
443            .stdout(std::process::Stdio::piped())
444            .stderr(std::process::Stdio::piped())
445            .spawn()
446            .unwrap();
447
448        let start = std::time::Instant::now();
449        let output = super::wait_with_limits(child, 1024, std::time::Duration::from_millis(200));
450        let elapsed = start.elapsed();
451
452        assert!(
453            elapsed < std::time::Duration::from_secs(3),
454            "timeout should kill quickly, took {elapsed:?}"
455        );
456        let stdout = String::from_utf8_lossy(&output.stdout);
457        assert!(stdout.contains("[lean-ctx: output truncated"));
458    }
459}