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 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; 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 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}