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        let is_powershell =
241            shell.to_lowercase().contains("powershell") || shell.to_lowercase().contains("pwsh");
242        if is_powershell {
243            let ps_script = format!(
244                "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; {}",
245                command
246            );
247            let tmp = tempfile::Builder::new()
248                .prefix("lean-ctx-ps-")
249                .suffix(".ps1")
250                .tempfile()
251                .expect("failed to create temp file for PowerShell script");
252            let tmp_path = tmp.into_temp_path();
253            let _ = std::fs::write(&tmp_path, &ps_script);
254            cmd.args([
255                "-NoProfile",
256                "-ExecutionPolicy",
257                "Bypass",
258                "-File",
259                &tmp_path.to_string_lossy(),
260            ]);
261            ps_tmp_path = Some(tmp_path);
262        } else {
263            cmd.arg(shell_flag);
264            cmd.arg(command);
265            ps_tmp_path = None;
266        }
267    }
268    #[cfg(not(windows))]
269    {
270        cmd.arg(shell_flag);
271        cmd.arg(command);
272    }
273
274    let child = cmd
275        .env("LEAN_CTX_ACTIVE", "1")
276        .stdout(Stdio::piped())
277        .stderr(Stdio::piped())
278        .spawn();
279
280    let child = match child {
281        Ok(c) => c,
282        Err(e) => {
283            tracing::error!("lean-ctx: failed to execute: {e}");
284            #[cfg(windows)]
285            if let Some(ref tmp) = ps_tmp_path {
286                let _ = std::fs::remove_file(tmp);
287            }
288            return 127;
289        }
290    };
291
292    const MAX_BUFFERED_BYTES: usize = 8 * 1024 * 1024; // 8 MB
293    const EXEC_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(2);
294
295    let output = wait_with_limits(child, MAX_BUFFERED_BYTES, EXEC_TIMEOUT);
296
297    let duration_ms = start.elapsed().as_millis();
298    let exit_code = output.status.code().unwrap_or(1);
299    let stdout = super::platform::decode_output(&output.stdout);
300    let stderr = super::platform::decode_output(&output.stderr);
301
302    let full_output = combine_output(&stdout, &stderr);
303    let input_tokens = count_tokens(&full_output);
304
305    let (compressed, output_tokens) =
306        super::compress::compress_and_measure(command, &stdout, &stderr);
307
308    crate::core::tool_lifecycle::record_shell_command(input_tokens, output_tokens);
309
310    if !compressed.is_empty() {
311        let _ = io::stdout().write_all(compressed.as_bytes());
312        if !compressed.ends_with('\n') {
313            let _ = io::stdout().write_all(b"\n");
314        }
315    }
316    let should_tee = match cfg.tee_mode {
317        config::TeeMode::Always => !full_output.trim().is_empty(),
318        config::TeeMode::Failures => exit_code != 0 && !full_output.trim().is_empty(),
319        config::TeeMode::HighCompression => {
320            let orig = full_output.len();
321            let after = compressed.len();
322            let pct = if orig > 0 {
323                ((orig.saturating_sub(after)) as f64 / orig as f64) * 100.0
324            } else {
325                0.0
326            };
327            pct > 70.0 && orig > 100
328        }
329        config::TeeMode::Never => false,
330    };
331    if should_tee {
332        if let Some(path) = super::redact::save_tee(command, &full_output) {
333            if !matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1") {
334                eprintln!("[lean-ctx: full output -> {path} (redacted, 24h TTL)]");
335            }
336        }
337    }
338
339    let threshold = cfg.slow_command_threshold_ms;
340    if threshold > 0 && duration_ms >= threshold as u128 {
341        slow_log::record(command, duration_ms, exit_code);
342    }
343
344    #[cfg(windows)]
345    if let Some(ref tmp) = ps_tmp_path {
346        let _ = std::fs::remove_file(tmp);
347    }
348
349    exit_code
350}
351
352#[cfg(test)]
353mod exec_tests {
354    #[test]
355    fn exec_direct_runs_true() {
356        let code = super::exec_direct(&["true".to_string()]);
357        assert_eq!(code, 0);
358    }
359
360    #[test]
361    fn exec_direct_runs_false() {
362        let code = super::exec_direct(&["false".to_string()]);
363        assert_ne!(code, 0);
364    }
365
366    #[test]
367    fn exec_direct_preserves_args_with_special_chars() {
368        let code = super::exec_direct(&[
369            "echo".to_string(),
370            "hello world".to_string(),
371            "it's here".to_string(),
372            "a \"quoted\" thing".to_string(),
373        ]);
374        assert_eq!(code, 0);
375    }
376
377    #[test]
378    fn exec_direct_nonexistent_returns_127() {
379        let code = super::exec_direct(&["__nonexistent_binary_12345__".to_string()]);
380        assert_eq!(code, 127);
381    }
382
383    #[test]
384    fn exec_argv_empty_returns_127() {
385        let code = super::exec_argv(&[]);
386        assert_eq!(code, 127);
387    }
388
389    #[test]
390    fn exec_argv_runs_simple_command() {
391        let code = super::exec_argv(&["true".to_string()]);
392        assert_eq!(code, 0);
393    }
394
395    #[test]
396    fn exec_argv_passes_through_when_disabled() {
397        std::env::set_var("LEAN_CTX_DISABLED", "1");
398        let code = super::exec_argv(&["true".to_string()]);
399        std::env::remove_var("LEAN_CTX_DISABLED");
400        assert_eq!(code, 0);
401    }
402
403    #[test]
404    fn wait_with_limits_captures_output() {
405        let child = std::process::Command::new("echo")
406            .arg("hello")
407            .stdout(std::process::Stdio::piped())
408            .stderr(std::process::Stdio::piped())
409            .spawn()
410            .unwrap();
411
412        let output = super::wait_with_limits(child, 1024, std::time::Duration::from_secs(5));
413        let stdout = String::from_utf8_lossy(&output.stdout);
414        assert!(
415            stdout.contains("hello"),
416            "expected 'hello' in output: {stdout}"
417        );
418        assert!(output.status.success());
419    }
420
421    #[test]
422    fn wait_with_limits_truncates_large_output() {
423        // Generate ~100 KB of output, limit to 1 KB
424        let child = std::process::Command::new("sh")
425            .args(["-c", "yes 'aaaa' | head -25000"])
426            .stdout(std::process::Stdio::piped())
427            .stderr(std::process::Stdio::piped())
428            .spawn()
429            .unwrap();
430
431        let output = super::wait_with_limits(child, 1024, std::time::Duration::from_secs(10));
432        let stdout = String::from_utf8_lossy(&output.stdout);
433        assert!(
434            stdout.contains("[lean-ctx: output truncated"),
435            "expected truncation notice, got len={}: ...{}",
436            stdout.len(),
437            &stdout[stdout.len().saturating_sub(80)..]
438        );
439    }
440
441    #[test]
442    fn wait_with_limits_timeout_kills_process() {
443        let child = std::process::Command::new("sleep")
444            .arg("60")
445            .stdout(std::process::Stdio::piped())
446            .stderr(std::process::Stdio::piped())
447            .spawn()
448            .unwrap();
449
450        let start = std::time::Instant::now();
451        let output = super::wait_with_limits(child, 1024, std::time::Duration::from_millis(200));
452        let elapsed = start.elapsed();
453
454        assert!(
455            elapsed < std::time::Duration::from_secs(3),
456            "timeout should kill quickly, took {elapsed:?}"
457        );
458        let stdout = String::from_utf8_lossy(&output.stdout);
459        assert!(stdout.contains("[lean-ctx: output truncated"));
460    }
461}