1use std::collections::HashMap;
2use std::io::{self, Read};
3use std::path::Path;
4use std::process::{Command, Stdio};
5use std::sync::{
6 atomic::{AtomicBool, AtomicUsize, Ordering},
7 mpsc::{self, Receiver, Sender},
8 Arc,
9};
10use std::thread::{self, JoinHandle};
11use std::time::{Duration, Instant};
12
13#[cfg(unix)]
14use std::os::fd::AsRawFd;
15
16use serde::{Deserialize, Serialize};
17use serde_json::{json, Value};
18
19use crate::cancellation::CancellationToken;
20
21pub const COMMAND_TIMEOUT: Duration = Duration::from_secs(10 * 60);
22pub const COMMAND_OUTPUT_CAP: usize = 64 * 1024;
23const CAPTURE_SHUTDOWN_GRACE: Duration = Duration::from_millis(100);
24
25#[derive(Debug)]
26struct CmdArguments {
27 command: String,
28 background: bool,
29}
30
31#[derive(Debug)]
32pub(crate) struct BackgroundCompletion {
33 pub id: String,
34 pub result: CmdResult,
35}
36
37#[derive(Debug)]
38struct BackgroundJob {
39 cancellation: CancellationToken,
40 handle: JoinHandle<()>,
41}
42
43#[derive(Debug)]
44pub(crate) struct BackgroundCommands {
45 next_id: u64,
46 jobs: HashMap<String, BackgroundJob>,
47 completed_tx: Sender<BackgroundCompletion>,
48 completed_rx: Receiver<BackgroundCompletion>,
49 active: Arc<AtomicUsize>,
50}
51
52impl Default for BackgroundCommands {
53 fn default() -> Self {
54 let (completed_tx, completed_rx) = mpsc::channel();
55 Self {
56 next_id: 1,
57 jobs: HashMap::new(),
58 completed_tx,
59 completed_rx,
60 active: Arc::new(AtomicUsize::new(0)),
61 }
62 }
63}
64
65impl BackgroundCommands {
66 fn start(
67 &mut self,
68 command: String,
69 cwd: &Path,
70 api_key_env: &str,
71 secret: Option<&str>,
72 ) -> Value {
73 let id = format!("background-{}", self.next_id);
74 self.next_id += 1;
75 let cancellation = CancellationToken::new();
76 let worker_cancellation = cancellation.clone();
77 let worker_id = id.clone();
78 let worker_command = command.clone();
79 let worker_cwd = cwd.to_path_buf();
80 let worker_api_key_env = api_key_env.to_owned();
81 let worker_secret = secret.map(str::to_owned);
82 let completed = self.completed_tx.clone();
83 let handle = thread::spawn(move || {
84 let result = execute_command_with_cancellation(
85 &worker_command,
86 &worker_cwd,
87 &worker_api_key_env,
88 worker_secret.as_deref(),
89 COMMAND_TIMEOUT,
90 COMMAND_OUTPUT_CAP,
91 Some(&worker_cancellation),
92 );
93 let _ = completed.send(BackgroundCompletion {
94 id: worker_id,
95 result,
96 });
97 });
98 self.jobs.insert(
99 id.clone(),
100 BackgroundJob {
101 cancellation,
102 handle,
103 },
104 );
105 self.active.store(self.jobs.len(), Ordering::Relaxed);
106 json!({
107 "background_id": id,
108 "status": "running",
109 "command": redact_secret(&command, secret),
110 })
111 }
112
113 pub(crate) fn take_completions(&mut self) -> Vec<BackgroundCompletion> {
114 let mut completions = Vec::new();
115 while let Ok(completion) = self.completed_rx.try_recv() {
116 if let Some(job) = self.jobs.remove(&completion.id) {
117 let _ = job.handle.join();
118 }
119 completions.push(completion);
120 }
121 self.active.store(self.jobs.len(), Ordering::Relaxed);
122 completions
123 }
124
125 pub(crate) fn active_count_handle(&self) -> Arc<AtomicUsize> {
126 Arc::clone(&self.active)
127 }
128
129 pub(crate) fn has_active(&self) -> bool {
130 !self.jobs.is_empty()
131 }
132
133 pub(crate) fn has_completed(&self) -> bool {
134 self.jobs.values().any(|job| job.handle.is_finished())
135 }
136}
137
138impl Drop for BackgroundCommands {
139 fn drop(&mut self) {
140 for job in self.jobs.values() {
141 job.cancellation.cancel();
142 }
143 for (_, job) in self.jobs.drain() {
144 let _ = job.handle.join();
145 }
146 self.active.store(self.jobs.len(), Ordering::Relaxed);
147 }
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
151pub struct CmdResult {
152 pub command: String,
153 pub exit_code: Option<i32>,
154 pub timed_out: bool,
155 pub stdout: String,
156 pub stderr: String,
157 pub stdout_truncated: bool,
158 pub stderr_truncated: bool,
159 #[serde(default, skip_serializing_if = "is_false")]
160 pub canceled: bool,
161 #[serde(skip_serializing_if = "Option::is_none")]
162 pub error: Option<String>,
163}
164
165impl CmdResult {
166 fn error(arguments: &str, message: impl Into<String>) -> Self {
167 Self {
168 command: arguments.to_owned(),
169 exit_code: None,
170 timed_out: false,
171 stdout: String::new(),
172 stderr: String::new(),
173 stdout_truncated: false,
174 stderr_truncated: false,
175 canceled: false,
176 error: Some(message.into()),
177 }
178 }
179
180 pub(crate) fn canceled(command: impl Into<String>, message: impl Into<String>) -> Self {
181 Self {
182 command: command.into(),
183 exit_code: None,
184 timed_out: false,
185 stdout: String::new(),
186 stderr: String::new(),
187 stdout_truncated: false,
188 stderr_truncated: false,
189 canceled: true,
190 error: Some(message.into()),
191 }
192 }
193}
194
195fn is_false(value: &bool) -> bool {
196 !*value
197}
198
199#[derive(Debug)]
200struct CapturedOutput {
201 bytes: Vec<u8>,
202 truncated: bool,
203}
204
205pub fn execute(arguments: &str, cwd: &Path, api_key_env: &str, secret: Option<&str>) -> CmdResult {
206 execute_with_cancellation(arguments, cwd, api_key_env, secret, None)
207}
208
209pub(crate) fn execute_with_cancellation(
210 arguments: &str,
211 cwd: &Path,
212 api_key_env: &str,
213 secret: Option<&str>,
214 cancellation: Option<&CancellationToken>,
215) -> CmdResult {
216 let parsed = match parse_arguments(arguments) {
217 Ok(parsed) if !parsed.background => parsed,
218 Ok(_) => {
219 return CmdResult::error("{}", "background cmd requires the managed command executor")
220 }
221 Err(result) => return result,
222 };
223 if cancellation.is_some_and(|token| token.is_cancelled()) {
224 return CmdResult::canceled(
225 redact_secret(&parsed.command, secret),
226 "command canceled before execution",
227 );
228 }
229 execute_command_with_cancellation(
230 &parsed.command,
231 cwd,
232 api_key_env,
233 secret,
234 COMMAND_TIMEOUT,
235 COMMAND_OUTPUT_CAP,
236 cancellation,
237 )
238}
239
240pub(crate) fn execute_managed(
241 arguments: &str,
242 cwd: &Path,
243 api_key_env: &str,
244 secret: Option<&str>,
245 cancellation: Option<&CancellationToken>,
246 background_commands: &mut BackgroundCommands,
247) -> Value {
248 let parsed = match parse_arguments(arguments) {
249 Ok(parsed) => parsed,
250 Err(result) => return serde_json::to_value(result).expect("CmdResult serializes"),
251 };
252 if cancellation.is_some_and(|token| token.is_cancelled()) {
253 return serde_json::to_value(CmdResult::canceled(
254 redact_secret(&parsed.command, secret),
255 "command canceled before execution",
256 ))
257 .expect("CmdResult serializes");
258 }
259 if parsed.background {
260 return background_commands.start(parsed.command, cwd, api_key_env, secret);
261 }
262 serde_json::to_value(execute_command_with_cancellation(
263 &parsed.command,
264 cwd,
265 api_key_env,
266 secret,
267 COMMAND_TIMEOUT,
268 COMMAND_OUTPUT_CAP,
269 cancellation,
270 ))
271 .expect("CmdResult serializes")
272}
273
274fn parse_arguments(arguments: &str) -> Result<CmdArguments, CmdResult> {
275 let value: Value = serde_json::from_str(arguments)
276 .map_err(|_| CmdResult::error("{}", "cmd arguments must be a JSON object"))?;
277 let Some(object) = value.as_object() else {
278 return Err(CmdResult::error(
279 "{}",
280 "cmd arguments must be a JSON object",
281 ));
282 };
283 if object.is_empty()
284 || object.len() > 2
285 || !object.contains_key("command")
286 || object
287 .keys()
288 .any(|key| !matches!(key.as_str(), "command" | "background"))
289 {
290 return Err(CmdResult::error(
291 "{}",
292 "cmd arguments must contain command and optional background",
293 ));
294 }
295 let Some(command) = object.get("command").and_then(Value::as_str) else {
296 return Err(CmdResult::error("{}", "cmd command must be a string"));
297 };
298 let background = match object.get("background") {
299 Some(Value::Bool(background)) => *background,
300 Some(_) => return Err(CmdResult::error("{}", "cmd background must be a boolean")),
301 None => false,
302 };
303 Ok(CmdArguments {
304 command: command.to_owned(),
305 background,
306 })
307}
308
309pub fn execute_command(
310 command: &str,
311 cwd: &Path,
312 api_key_env: &str,
313 secret: Option<&str>,
314 timeout: Duration,
315 output_cap: usize,
316) -> CmdResult {
317 execute_command_with_cancellation(command, cwd, api_key_env, secret, timeout, output_cap, None)
318}
319
320pub(crate) fn execute_command_with_cancellation(
321 command: &str,
322 cwd: &Path,
323 api_key_env: &str,
324 secret: Option<&str>,
325 timeout: Duration,
326 output_cap: usize,
327 cancellation: Option<&CancellationToken>,
328) -> CmdResult {
329 if cancellation.is_some_and(|token| token.is_cancelled()) {
330 return CmdResult::canceled(
331 redact_secret(command, secret),
332 "command canceled before execution",
333 );
334 }
335
336 let shell = std::env::var_os("SHELL")
337 .filter(|shell| !shell.is_empty())
338 .unwrap_or_else(|| "/bin/sh".into());
339 let mut process = Command::new(shell);
340 process
341 .arg("-lc")
342 .arg(command)
343 .current_dir(cwd)
344 .stdin(Stdio::null())
345 .stdout(Stdio::piped())
346 .stderr(Stdio::piped());
347
348 if !api_key_env.is_empty() {
354 process.env_remove(api_key_env);
355 }
356
357 #[cfg(unix)]
358 {
359 use std::os::unix::process::CommandExt;
360
361 unsafe {
364 process.pre_exec(|| {
365 if libc::setpgid(0, 0) == -1 {
366 return Err(io::Error::last_os_error());
367 }
368 Ok(())
369 });
370 }
371 }
372
373 let mut child = match process.spawn() {
374 Ok(child) => child,
375 Err(_) => {
376 return CmdResult {
377 command: redact_secret(command, secret),
378 exit_code: None,
379 timed_out: false,
380 stdout: String::new(),
381 stderr: String::new(),
382 stdout_truncated: false,
383 stderr_truncated: false,
384 canceled: false,
385 error: Some("unable to start command".to_owned()),
386 }
387 }
388 };
389
390 let capture_stop = Arc::new(AtomicBool::new(false));
391 let stdout_reader = child
392 .stdout
393 .take()
394 .map(|stdout| spawn_capture(stdout, output_cap, Arc::clone(&capture_stop)));
395 let stderr_reader = child
396 .stderr
397 .take()
398 .map(|stderr| spawn_capture(stderr, output_cap, Arc::clone(&capture_stop)));
399
400 let child_id = child.id();
401 let deadline = Instant::now() + timeout;
402 let mut timed_out = false;
403 let mut canceled = false;
404 let status = loop {
405 if cancellation.is_some_and(|token| token.is_cancelled()) {
406 canceled = true;
407 kill_process_group(child_id);
408 let _ = child.kill();
409 break child.wait().ok();
410 }
411 match child.try_wait() {
412 Ok(Some(status)) => {
413 if cancellation.is_some_and(|token| token.is_cancelled()) {
414 canceled = true;
415 }
416 break Some(status);
417 }
418 Ok(None) => {
419 if Instant::now() >= deadline {
420 timed_out = true;
421 kill_process_group(child_id);
422 let _ = child.kill();
423 break child.wait().ok();
424 }
425 thread::sleep(Duration::from_millis(10));
426 }
427 Err(_) => {
428 canceled = cancellation.is_some_and(|token| token.is_cancelled());
429 kill_process_group(child_id);
430 let _ = child.kill();
431 break child.wait().ok();
432 }
433 }
434 };
435
436 if !timed_out {
437 kill_process_group(child_id);
440 }
441
442 capture_stop.store(true, Ordering::Release);
443 let stdout_capture = join_capture(stdout_reader);
444 let stderr_capture = join_capture(stderr_reader);
445 let (stdout, stdout_truncated) = bounded_output(&stdout_capture, output_cap, secret);
446 let (stderr, stderr_truncated) = bounded_output(&stderr_capture, output_cap, secret);
447
448 CmdResult {
449 command: redact_secret(command, secret),
450 exit_code: (!canceled)
451 .then(|| status.and_then(|status| status.code()))
452 .flatten(),
453 timed_out,
454 stdout,
455 stderr,
456 stdout_truncated,
457 stderr_truncated,
458 canceled,
459 error: canceled.then_some("command canceled".to_owned()),
460 }
461}
462
463#[cfg(unix)]
464fn spawn_capture<R>(mut reader: R, cap: usize, stop: Arc<AtomicBool>) -> JoinHandle<CapturedOutput>
465where
466 R: Read + Send + AsRawFd + 'static,
467{
468 let _ = set_nonblocking(reader.as_raw_fd());
469 thread::spawn(move || capture_output(&mut reader, cap, &stop))
470}
471
472#[cfg(not(unix))]
473fn spawn_capture<R>(mut reader: R, cap: usize, stop: Arc<AtomicBool>) -> JoinHandle<CapturedOutput>
474where
475 R: Read + Send + 'static,
476{
477 thread::spawn(move || capture_output(&mut reader, cap, &stop))
478}
479
480fn capture_output<R>(reader: &mut R, cap: usize, stop: &AtomicBool) -> CapturedOutput
481where
482 R: Read,
483{
484 let mut bytes = Vec::with_capacity(cap.min(8192));
485 let mut buffer = [0_u8; 8192];
486 let mut truncated = false;
487 let mut shutdown_incomplete = false;
488 let mut shutdown_deadline = None;
489 loop {
490 if stop.load(Ordering::Acquire) {
491 shutdown_deadline.get_or_insert_with(|| Instant::now() + CAPTURE_SHUTDOWN_GRACE);
492 if shutdown_deadline.is_some_and(|deadline| Instant::now() >= deadline) {
493 shutdown_incomplete = true;
494 break;
495 }
496 }
497
498 match reader.read(&mut buffer) {
499 Ok(0) => break,
500 Ok(read) => {
501 let remaining = cap.saturating_sub(bytes.len());
502 if remaining > 0 {
503 bytes.extend_from_slice(&buffer[..read.min(remaining)]);
504 }
505 if read > remaining {
506 truncated = true;
507 }
508 }
509 Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
510 thread::sleep(Duration::from_millis(1));
511 }
512 Err(_) => break,
513 }
514 }
515 CapturedOutput {
516 bytes,
517 truncated: truncated || shutdown_incomplete,
518 }
519}
520
521#[cfg(unix)]
522fn set_nonblocking(fd: std::os::fd::RawFd) -> io::Result<()> {
523 let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
524 if flags == -1 {
525 return Err(io::Error::last_os_error());
526 }
527 let result = unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) };
528 if result == -1 {
529 Err(io::Error::last_os_error())
530 } else {
531 Ok(())
532 }
533}
534
535fn bounded_output(
536 captured: &CapturedOutput,
537 output_cap: usize,
538 secret: Option<&str>,
539) -> (String, bool) {
540 let text = redact_secret(&String::from_utf8_lossy(&captured.bytes), secret);
541 let truncated =
542 captured.truncated || captured.bytes.len() > output_cap || text.len() > output_cap;
543 let mut end = text.len().min(output_cap);
544 while end > 0 && !text.is_char_boundary(end) {
545 end -= 1;
546 }
547 (text[..end].to_owned(), truncated)
548}
549
550fn join_capture(reader: Option<JoinHandle<CapturedOutput>>) -> CapturedOutput {
551 let Some(reader) = reader else {
552 return CapturedOutput {
553 bytes: Vec::new(),
554 truncated: false,
555 };
556 };
557
558 let deadline = Instant::now() + CAPTURE_SHUTDOWN_GRACE;
559 while !reader.is_finished() && Instant::now() < deadline {
560 thread::sleep(Duration::from_millis(1));
561 }
562 if reader.is_finished() {
563 reader.join().unwrap_or(CapturedOutput {
564 bytes: Vec::new(),
565 truncated: false,
566 })
567 } else {
568 CapturedOutput {
572 bytes: Vec::new(),
573 truncated: true,
574 }
575 }
576}
577
578fn kill_process_group(child_id: u32) {
579 #[cfg(unix)]
580 {
581 unsafe {
584 let _ = libc::kill(-(child_id as libc::pid_t), libc::SIGKILL);
585 }
586 }
587 #[cfg(not(unix))]
588 let _ = child_id;
589}
590
591pub fn redact_secret(text: &str, secret: Option<&str>) -> String {
592 crate::redaction::redact_secret(text, secret)
593}
594
595pub(crate) fn canceled_result(arguments: &str, secret: &str) -> CmdResult {
596 let command = parse_arguments(arguments)
597 .ok()
598 .map(|arguments| arguments.command)
599 .map(|command| redact_secret(&command, Some(secret)))
600 .unwrap_or_else(|| "{}".to_owned());
601 CmdResult::canceled(command, "command canceled before execution")
602}
603
604#[cfg(test)]
605mod tests {
606 use super::*;
607 use std::fs;
608 use std::sync::atomic::{AtomicU64, Ordering};
609 use std::sync::Mutex;
610 use std::time::{SystemTime, UNIX_EPOCH};
611
612 static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
613 static COMMAND_TEST_LOCK: Mutex<()> = Mutex::new(());
614
615 fn temporary_directory() -> std::path::PathBuf {
616 loop {
617 let stamp = SystemTime::now()
618 .duration_since(UNIX_EPOCH)
619 .expect("clock")
620 .as_nanos();
621 let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
622 let path = std::env::temp_dir().join(format!(
623 "lucy-command-{stamp}-{}-{counter}",
624 std::process::id()
625 ));
626 match fs::create_dir(&path) {
627 Ok(()) => return path,
628 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
629 Err(error) => panic!("temp directory: {error}"),
630 }
631 }
632 }
633
634 #[test]
635 fn captures_nonzero_exit_and_both_streams() {
636 let _test_lock = COMMAND_TEST_LOCK.lock().expect("command test lock");
637 let cwd = temporary_directory();
638 let result = execute_command(
639 "printf out; printf err >&2; exit 7",
640 &cwd,
641 "LUCY_API_KEY",
642 None,
643 Duration::from_secs(2),
644 COMMAND_OUTPUT_CAP,
645 );
646 assert_eq!(result.exit_code, Some(7));
647 assert!(!result.timed_out);
648 assert_eq!(result.stdout, "out");
649 assert_eq!(result.stderr, "err");
650 fs::remove_dir_all(cwd).expect("remove temp directory");
651 }
652
653 #[test]
654 fn caps_streams_independently_and_marks_truncation() {
655 let _test_lock = COMMAND_TEST_LOCK.lock().expect("command test lock");
656 let cwd = temporary_directory();
657 let result = execute_command(
658 "printf 123456789; printf abcdefghij >&2",
659 &cwd,
660 "LUCY_API_KEY",
661 None,
662 Duration::from_secs(2),
663 4,
664 );
665 assert_eq!(result.stdout, "1234");
666 assert_eq!(result.stderr, "abcd");
667 assert!(result.stdout_truncated);
668 assert!(result.stderr_truncated);
669 fs::remove_dir_all(cwd).expect("remove temp directory");
670 }
671
672 #[cfg(unix)]
673 #[test]
674 fn bounds_lossy_invalid_utf8_output_and_marks_truncation() {
675 let _test_lock = COMMAND_TEST_LOCK.lock().expect("command test lock");
676 let cwd = temporary_directory();
677 let result = execute_command(
678 r"printf '\377\376\375\374'; printf '\377\376\375\374' >&2",
679 &cwd,
680 "LUCY_API_KEY",
681 None,
682 Duration::from_secs(2),
683 4,
684 );
685 assert!(result.stdout.len() <= 4);
686 assert!(result.stderr.len() <= 4);
687 assert!(result.stdout_truncated);
688 assert!(result.stderr_truncated);
689 fs::remove_dir_all(cwd).expect("remove temp directory");
690 }
691
692 #[test]
693 fn timeout_kills_the_command_group() {
694 let _test_lock = COMMAND_TEST_LOCK.lock().expect("command test lock");
695 let cwd = temporary_directory();
696 let result = execute_command(
697 "sleep 30",
698 &cwd,
699 "LUCY_API_KEY",
700 None,
701 Duration::from_millis(80),
702 COMMAND_OUTPUT_CAP,
703 );
704 assert!(result.timed_out);
705 assert!(result.exit_code.is_none() || result.exit_code != Some(0));
706 fs::remove_dir_all(cwd).expect("remove temp directory");
707 }
708
709 #[test]
710 fn cancellation_kills_a_running_command_group() {
711 let _test_lock = COMMAND_TEST_LOCK.lock().expect("command test lock");
712 let cwd = temporary_directory();
713 let token = CancellationToken::new();
714 let worker_token = token.clone();
715 let worker_cwd = cwd.clone();
716 let started = Instant::now();
717 let worker = thread::spawn(move || {
718 execute_command_with_cancellation(
719 "sleep 30",
720 &worker_cwd,
721 "LUCY_API_KEY",
722 None,
723 Duration::from_secs(30),
724 COMMAND_OUTPUT_CAP,
725 Some(&worker_token),
726 )
727 });
728 thread::sleep(Duration::from_millis(80));
729 token.cancel();
730 let result = worker.join().expect("command worker");
731 assert!(result.canceled);
732 assert_eq!(result.error.as_deref(), Some("command canceled"));
733 assert!(started.elapsed() < Duration::from_secs(2));
734 fs::remove_dir_all(cwd).expect("remove temp directory");
735 }
736
737 #[cfg(unix)]
738 #[test]
739 fn timeout_capture_returns_when_a_descendant_escapes_the_process_group() {
740 let _test_lock = COMMAND_TEST_LOCK.lock().expect("command test lock");
741 let python_available = Command::new("python3")
742 .arg("--version")
743 .stdout(Stdio::null())
744 .stderr(Stdio::null())
745 .status()
746 .map(|status| status.success())
747 .unwrap_or(false);
748 if !python_available {
749 return;
750 }
751
752 let cwd = temporary_directory();
753 let started = Instant::now();
754 let result = execute_command(
755 "python3 -c 'import os,time; os.setsid(); open(\"ready\",\"w\").close(); time.sleep(1)' & while [ ! -f ready ]; do sleep 0.01; done; sleep 2",
756 &cwd,
757 "LUCY_API_KEY",
758 None,
759 Duration::from_millis(300),
760 COMMAND_OUTPUT_CAP,
761 );
762 assert!(result.timed_out);
763 assert!(result.stdout_truncated);
764 assert!(
765 started.elapsed() < Duration::from_secs(1),
766 "capture cleanup exceeded the bounded grace period: {:?}",
767 started.elapsed()
768 );
769 fs::remove_dir_all(cwd).expect("remove temp directory");
770 }
771
772 #[test]
773 fn output_redacts_the_provider_key() {
774 let _test_lock = COMMAND_TEST_LOCK.lock().expect("command test lock");
775 let cwd = temporary_directory();
776 let result = execute_command(
777 "printf secret-key",
778 &cwd,
779 "LUCY_API_KEY",
780 Some("secret-key"),
781 Duration::from_secs(2),
782 COMMAND_OUTPUT_CAP,
783 );
784 assert!(!result.stdout.contains("secret-key"));
785 assert_eq!(result.stdout, "[REDACTED]");
786 fs::remove_dir_all(cwd).expect("remove temp directory");
787 }
788
789 #[test]
790 fn redaction_stays_within_the_capture_byte_bound() {
791 let _test_lock = COMMAND_TEST_LOCK.lock().expect("command test lock");
792 let cwd = temporary_directory();
793 let result = execute_command(
794 "printf x",
795 &cwd,
796 "LUCY_API_KEY",
797 Some("x"),
798 Duration::from_secs(2),
799 1,
800 );
801 assert_eq!(result.stdout.len(), 1);
802 assert!(!result.stdout.contains('x'));
803 fs::remove_dir_all(cwd).expect("remove temp directory");
804 }
805
806 #[test]
807 fn collision_markers_do_not_reintroduce_the_provider_key() {
808 let _test_lock = COMMAND_TEST_LOCK.lock().expect("command test lock");
809 let cwd = temporary_directory();
810 for secret in ["REDACTED", "[REDACTED]"] {
811 let command = format!("printf '{secret}'");
812 let result = execute_command(
813 &command,
814 &cwd,
815 "LUCY_API_KEY",
816 Some(secret),
817 Duration::from_secs(2),
818 COMMAND_OUTPUT_CAP,
819 );
820 assert!(!result.stdout.contains(secret));
821 assert!(!result.command.contains(secret));
822 }
823 fs::remove_dir_all(cwd).expect("remove temp directory");
824 }
825
826 #[test]
827 fn rejects_extra_command_arguments() {
828 let _test_lock = COMMAND_TEST_LOCK.lock().expect("command test lock");
829 let cwd = temporary_directory();
830 let result = execute(
831 r#"{"command":"pwd","extra":true}"#,
832 &cwd,
833 "LUCY_API_KEY",
834 None,
835 );
836 assert_eq!(
837 result.error.as_deref(),
838 Some("cmd arguments must contain command and optional background")
839 );
840 let result = execute(r#"{"command":1}"#, &cwd, "LUCY_API_KEY", None);
841 assert_eq!(
842 result.error.as_deref(),
843 Some("cmd command must be a string")
844 );
845 let result = execute(
846 r#"{"command":"pwd","background":"yes"}"#,
847 &cwd,
848 "LUCY_API_KEY",
849 None,
850 );
851 assert_eq!(
852 result.error.as_deref(),
853 Some("cmd background must be a boolean")
854 );
855 fs::remove_dir_all(cwd).expect("remove temp directory");
856 }
857
858 #[test]
859 fn managed_background_command_returns_immediately_and_completes() {
860 let _test_lock = COMMAND_TEST_LOCK.lock().expect("command test lock");
861 let cwd = temporary_directory();
862 let mut background = BackgroundCommands::default();
863 let result = execute_managed(
864 r#"{"command":"sleep 0.2; printf done","background":true}"#,
865 &cwd,
866 "LUCY_API_KEY",
867 None,
868 None,
869 &mut background,
870 );
871 assert_eq!(result["status"], "running");
872 assert_eq!(result["background_id"], "background-1");
873 assert!(background.has_active());
874 assert!(!background.has_completed());
875
876 let deadline = Instant::now() + Duration::from_secs(2);
877 while !background.has_completed() && Instant::now() < deadline {
878 thread::sleep(Duration::from_millis(10));
879 }
880 let completions = background.take_completions();
881 assert_eq!(completions.len(), 1);
882 assert_eq!(completions[0].id, "background-1");
883 assert_eq!(completions[0].result.exit_code, Some(0));
884 assert_eq!(completions[0].result.stdout, "done");
885 assert!(!background.has_active());
886 fs::remove_dir_all(cwd).expect("remove temp directory");
887 }
888
889 fn unique_env_name(prefix: &str) -> String {
890 let stamp = SystemTime::now()
891 .duration_since(UNIX_EPOCH)
892 .expect("clock")
893 .as_nanos();
894 let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
895 format!("{prefix}_{stamp}_{counter}")
896 }
897
898 #[test]
899 fn preserves_normal_inherited_environment_variable() {
900 let _test_lock = COMMAND_TEST_LOCK.lock().expect("command test lock");
901 let cwd = temporary_directory();
902 let var_name = unique_env_name("LUCY_TEST_INHERIT");
903 std::env::set_var(&var_name, "still-here");
904 let result = execute_command(
905 &format!("printf ${var_name}"),
906 &cwd,
907 &unique_env_name("LUCY_TEST_UNUSED"),
908 None,
909 Duration::from_secs(2),
910 COMMAND_OUTPUT_CAP,
911 );
912 assert_eq!(result.stdout, "still-here");
913 std::env::remove_var(&var_name);
914 fs::remove_dir_all(cwd).expect("remove temp directory");
915 }
916
917 #[test]
918 fn removes_active_provider_credential_from_foreground_command() {
919 let _test_lock = COMMAND_TEST_LOCK.lock().expect("command test lock");
920 let cwd = temporary_directory();
921 let var_name = unique_env_name("LUCY_TEST_SECRET");
922 std::env::set_var(&var_name, "super-secret-key");
923 let result = execute_command(
924 &format!("printf ${var_name}"),
925 &cwd,
926 &var_name,
927 Some("super-secret-key"),
928 Duration::from_secs(2),
929 COMMAND_OUTPUT_CAP,
930 );
931 assert!(
932 !result.stdout.contains("super-secret-key"),
933 "credential leaked into foreground command output"
934 );
935 std::env::remove_var(&var_name);
936 fs::remove_dir_all(cwd).expect("remove temp directory");
937 }
938
939 #[test]
940 fn removes_active_provider_credential_from_background_command() {
941 let _test_lock = COMMAND_TEST_LOCK.lock().expect("command test lock");
942 let cwd = temporary_directory();
943 let var_name = unique_env_name("LUCY_TEST_BG_SECRET");
944 std::env::set_var(&var_name, "bg-secret-key");
945 let mut background = BackgroundCommands::default();
946 let _ = execute_managed(
947 &format!(r#"{{"command":"printf ${var_name}","background":true}}"#),
948 &cwd,
949 &var_name,
950 Some("bg-secret-key"),
951 None,
952 &mut background,
953 );
954 let deadline = Instant::now() + Duration::from_secs(2);
955 while !background.has_completed() && Instant::now() < deadline {
956 thread::sleep(Duration::from_millis(10));
957 }
958 let completions = background.take_completions();
959 assert_eq!(completions.len(), 1);
960 assert!(
961 !completions[0].result.stdout.contains("bg-secret-key"),
962 "credential leaked into background command output"
963 );
964 std::env::remove_var(&var_name);
965 fs::remove_dir_all(cwd).expect("remove temp directory");
966 }
967}