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
8fn 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
103pub 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 if policy == super::output_policy::OutputPolicy::Passthrough {
171 return exec_inherit_tracked(command, &shell, &shell_flag);
172 }
173
174 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; 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 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}