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 #[cfg(unix)]
349 {
350 use std::os::unix::process::CommandExt;
351
352 unsafe {
355 process.pre_exec(|| {
356 if libc::setpgid(0, 0) == -1 {
357 return Err(io::Error::last_os_error());
358 }
359 Ok(())
360 });
361 }
362 }
363
364 let mut child = match process.spawn() {
365 Ok(child) => child,
366 Err(_) => {
367 return CmdResult {
368 command: redact_secret(command, secret),
369 exit_code: None,
370 timed_out: false,
371 stdout: String::new(),
372 stderr: String::new(),
373 stdout_truncated: false,
374 stderr_truncated: false,
375 canceled: false,
376 error: Some("unable to start command".to_owned()),
377 }
378 }
379 };
380
381 let capture_stop = Arc::new(AtomicBool::new(false));
382 let stdout_reader = child
383 .stdout
384 .take()
385 .map(|stdout| spawn_capture(stdout, output_cap, Arc::clone(&capture_stop)));
386 let stderr_reader = child
387 .stderr
388 .take()
389 .map(|stderr| spawn_capture(stderr, output_cap, Arc::clone(&capture_stop)));
390
391 let child_id = child.id();
392 let deadline = Instant::now() + timeout;
393 let mut timed_out = false;
394 let mut canceled = false;
395 let status = loop {
396 if cancellation.is_some_and(|token| token.is_cancelled()) {
397 canceled = true;
398 kill_process_group(child_id);
399 let _ = child.kill();
400 break child.wait().ok();
401 }
402 match child.try_wait() {
403 Ok(Some(status)) => {
404 if cancellation.is_some_and(|token| token.is_cancelled()) {
405 canceled = true;
406 }
407 break Some(status);
408 }
409 Ok(None) => {
410 if Instant::now() >= deadline {
411 timed_out = true;
412 kill_process_group(child_id);
413 let _ = child.kill();
414 break child.wait().ok();
415 }
416 thread::sleep(Duration::from_millis(10));
417 }
418 Err(_) => {
419 canceled = cancellation.is_some_and(|token| token.is_cancelled());
420 kill_process_group(child_id);
421 let _ = child.kill();
422 break child.wait().ok();
423 }
424 }
425 };
426
427 if !timed_out {
428 kill_process_group(child_id);
431 }
432
433 capture_stop.store(true, Ordering::Release);
434 let stdout_capture = join_capture(stdout_reader);
435 let stderr_capture = join_capture(stderr_reader);
436 let (stdout, stdout_truncated) = bounded_output(&stdout_capture, output_cap, secret);
437 let (stderr, stderr_truncated) = bounded_output(&stderr_capture, output_cap, secret);
438
439 CmdResult {
440 command: redact_secret(command, secret),
441 exit_code: (!canceled)
442 .then(|| status.and_then(|status| status.code()))
443 .flatten(),
444 timed_out,
445 stdout,
446 stderr,
447 stdout_truncated,
448 stderr_truncated,
449 canceled,
450 error: canceled.then_some("command canceled".to_owned()),
451 }
452}
453
454#[cfg(unix)]
455fn spawn_capture<R>(mut reader: R, cap: usize, stop: Arc<AtomicBool>) -> JoinHandle<CapturedOutput>
456where
457 R: Read + Send + AsRawFd + 'static,
458{
459 let _ = set_nonblocking(reader.as_raw_fd());
460 thread::spawn(move || capture_output(&mut reader, cap, &stop))
461}
462
463#[cfg(not(unix))]
464fn spawn_capture<R>(mut reader: R, cap: usize, stop: Arc<AtomicBool>) -> JoinHandle<CapturedOutput>
465where
466 R: Read + Send + 'static,
467{
468 thread::spawn(move || capture_output(&mut reader, cap, &stop))
469}
470
471fn capture_output<R>(reader: &mut R, cap: usize, stop: &AtomicBool) -> CapturedOutput
472where
473 R: Read,
474{
475 let mut bytes = Vec::with_capacity(cap.min(8192));
476 let mut buffer = [0_u8; 8192];
477 let mut truncated = false;
478 let mut shutdown_incomplete = false;
479 let mut shutdown_deadline = None;
480 loop {
481 if stop.load(Ordering::Acquire) {
482 shutdown_deadline.get_or_insert_with(|| Instant::now() + CAPTURE_SHUTDOWN_GRACE);
483 if shutdown_deadline.is_some_and(|deadline| Instant::now() >= deadline) {
484 shutdown_incomplete = true;
485 break;
486 }
487 }
488
489 match reader.read(&mut buffer) {
490 Ok(0) => break,
491 Ok(read) => {
492 let remaining = cap.saturating_sub(bytes.len());
493 if remaining > 0 {
494 bytes.extend_from_slice(&buffer[..read.min(remaining)]);
495 }
496 if read > remaining {
497 truncated = true;
498 }
499 }
500 Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
501 thread::sleep(Duration::from_millis(1));
502 }
503 Err(_) => break,
504 }
505 }
506 CapturedOutput {
507 bytes,
508 truncated: truncated || shutdown_incomplete,
509 }
510}
511
512#[cfg(unix)]
513fn set_nonblocking(fd: std::os::fd::RawFd) -> io::Result<()> {
514 let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
515 if flags == -1 {
516 return Err(io::Error::last_os_error());
517 }
518 let result = unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) };
519 if result == -1 {
520 Err(io::Error::last_os_error())
521 } else {
522 Ok(())
523 }
524}
525
526fn bounded_output(
527 captured: &CapturedOutput,
528 output_cap: usize,
529 secret: Option<&str>,
530) -> (String, bool) {
531 let text = redact_secret(&String::from_utf8_lossy(&captured.bytes), secret);
532 let truncated =
533 captured.truncated || captured.bytes.len() > output_cap || text.len() > output_cap;
534 let mut end = text.len().min(output_cap);
535 while end > 0 && !text.is_char_boundary(end) {
536 end -= 1;
537 }
538 (text[..end].to_owned(), truncated)
539}
540
541fn join_capture(reader: Option<JoinHandle<CapturedOutput>>) -> CapturedOutput {
542 let Some(reader) = reader else {
543 return CapturedOutput {
544 bytes: Vec::new(),
545 truncated: false,
546 };
547 };
548
549 let deadline = Instant::now() + CAPTURE_SHUTDOWN_GRACE;
550 while !reader.is_finished() && Instant::now() < deadline {
551 thread::sleep(Duration::from_millis(1));
552 }
553 if reader.is_finished() {
554 reader.join().unwrap_or(CapturedOutput {
555 bytes: Vec::new(),
556 truncated: false,
557 })
558 } else {
559 CapturedOutput {
563 bytes: Vec::new(),
564 truncated: true,
565 }
566 }
567}
568
569fn kill_process_group(child_id: u32) {
570 #[cfg(unix)]
571 {
572 unsafe {
575 let _ = libc::kill(-(child_id as libc::pid_t), libc::SIGKILL);
576 }
577 }
578 #[cfg(not(unix))]
579 let _ = child_id;
580}
581
582pub fn redact_secret(text: &str, secret: Option<&str>) -> String {
583 crate::redaction::redact_secret(text, secret)
584}
585
586pub(crate) fn canceled_result(arguments: &str, secret: &str) -> CmdResult {
587 let command = parse_arguments(arguments)
588 .ok()
589 .map(|arguments| arguments.command)
590 .map(|command| redact_secret(&command, Some(secret)))
591 .unwrap_or_else(|| "{}".to_owned());
592 CmdResult::canceled(command, "command canceled before execution")
593}
594
595#[cfg(test)]
596mod tests {
597 use super::*;
598 use std::fs;
599 use std::sync::atomic::{AtomicU64, Ordering};
600 use std::sync::Mutex;
601 use std::time::{SystemTime, UNIX_EPOCH};
602
603 static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
604 static COMMAND_TEST_LOCK: Mutex<()> = Mutex::new(());
605
606 fn temporary_directory() -> std::path::PathBuf {
607 loop {
608 let stamp = SystemTime::now()
609 .duration_since(UNIX_EPOCH)
610 .expect("clock")
611 .as_nanos();
612 let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
613 let path = std::env::temp_dir().join(format!(
614 "lucy-command-{stamp}-{}-{counter}",
615 std::process::id()
616 ));
617 match fs::create_dir(&path) {
618 Ok(()) => return path,
619 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
620 Err(error) => panic!("temp directory: {error}"),
621 }
622 }
623 }
624
625 #[test]
626 fn captures_nonzero_exit_and_both_streams() {
627 let _test_lock = COMMAND_TEST_LOCK.lock().expect("command test lock");
628 let cwd = temporary_directory();
629 let result = execute_command(
630 "printf out; printf err >&2; exit 7",
631 &cwd,
632 "LUCY_API_KEY",
633 None,
634 Duration::from_secs(2),
635 COMMAND_OUTPUT_CAP,
636 );
637 assert_eq!(result.exit_code, Some(7));
638 assert!(!result.timed_out);
639 assert_eq!(result.stdout, "out");
640 assert_eq!(result.stderr, "err");
641 fs::remove_dir_all(cwd).expect("remove temp directory");
642 }
643
644 #[test]
645 fn caps_streams_independently_and_marks_truncation() {
646 let _test_lock = COMMAND_TEST_LOCK.lock().expect("command test lock");
647 let cwd = temporary_directory();
648 let result = execute_command(
649 "printf 123456789; printf abcdefghij >&2",
650 &cwd,
651 "LUCY_API_KEY",
652 None,
653 Duration::from_secs(2),
654 4,
655 );
656 assert_eq!(result.stdout, "1234");
657 assert_eq!(result.stderr, "abcd");
658 assert!(result.stdout_truncated);
659 assert!(result.stderr_truncated);
660 fs::remove_dir_all(cwd).expect("remove temp directory");
661 }
662
663 #[cfg(unix)]
664 #[test]
665 fn bounds_lossy_invalid_utf8_output_and_marks_truncation() {
666 let _test_lock = COMMAND_TEST_LOCK.lock().expect("command test lock");
667 let cwd = temporary_directory();
668 let result = execute_command(
669 r"printf '\377\376\375\374'; printf '\377\376\375\374' >&2",
670 &cwd,
671 "LUCY_API_KEY",
672 None,
673 Duration::from_secs(2),
674 4,
675 );
676 assert!(result.stdout.len() <= 4);
677 assert!(result.stderr.len() <= 4);
678 assert!(result.stdout_truncated);
679 assert!(result.stderr_truncated);
680 fs::remove_dir_all(cwd).expect("remove temp directory");
681 }
682
683 #[test]
684 fn timeout_kills_the_command_group() {
685 let _test_lock = COMMAND_TEST_LOCK.lock().expect("command test lock");
686 let cwd = temporary_directory();
687 let result = execute_command(
688 "sleep 30",
689 &cwd,
690 "LUCY_API_KEY",
691 None,
692 Duration::from_millis(80),
693 COMMAND_OUTPUT_CAP,
694 );
695 assert!(result.timed_out);
696 assert!(result.exit_code.is_none() || result.exit_code != Some(0));
697 fs::remove_dir_all(cwd).expect("remove temp directory");
698 }
699
700 #[test]
701 fn cancellation_kills_a_running_command_group() {
702 let _test_lock = COMMAND_TEST_LOCK.lock().expect("command test lock");
703 let cwd = temporary_directory();
704 let token = CancellationToken::new();
705 let worker_token = token.clone();
706 let worker_cwd = cwd.clone();
707 let started = Instant::now();
708 let worker = thread::spawn(move || {
709 execute_command_with_cancellation(
710 "sleep 30",
711 &worker_cwd,
712 "LUCY_API_KEY",
713 None,
714 Duration::from_secs(30),
715 COMMAND_OUTPUT_CAP,
716 Some(&worker_token),
717 )
718 });
719 thread::sleep(Duration::from_millis(80));
720 token.cancel();
721 let result = worker.join().expect("command worker");
722 assert!(result.canceled);
723 assert_eq!(result.error.as_deref(), Some("command canceled"));
724 assert!(started.elapsed() < Duration::from_secs(2));
725 fs::remove_dir_all(cwd).expect("remove temp directory");
726 }
727
728 #[cfg(unix)]
729 #[test]
730 fn timeout_capture_returns_when_a_descendant_escapes_the_process_group() {
731 let _test_lock = COMMAND_TEST_LOCK.lock().expect("command test lock");
732 let python_available = Command::new("python3")
733 .arg("--version")
734 .stdout(Stdio::null())
735 .stderr(Stdio::null())
736 .status()
737 .map(|status| status.success())
738 .unwrap_or(false);
739 if !python_available {
740 return;
741 }
742
743 let cwd = temporary_directory();
744 let started = Instant::now();
745 let result = execute_command(
746 "python3 -c 'import os,time; os.setsid(); open(\"ready\",\"w\").close(); time.sleep(1)' & while [ ! -f ready ]; do sleep 0.01; done; sleep 2",
747 &cwd,
748 "LUCY_API_KEY",
749 None,
750 Duration::from_millis(300),
751 COMMAND_OUTPUT_CAP,
752 );
753 assert!(result.timed_out);
754 assert!(result.stdout_truncated);
755 assert!(
756 started.elapsed() < Duration::from_secs(1),
757 "capture cleanup exceeded the bounded grace period: {:?}",
758 started.elapsed()
759 );
760 fs::remove_dir_all(cwd).expect("remove temp directory");
761 }
762
763 #[test]
764 fn output_redacts_the_provider_key() {
765 let _test_lock = COMMAND_TEST_LOCK.lock().expect("command test lock");
766 let cwd = temporary_directory();
767 let result = execute_command(
768 "printf secret-key",
769 &cwd,
770 "LUCY_API_KEY",
771 Some("secret-key"),
772 Duration::from_secs(2),
773 COMMAND_OUTPUT_CAP,
774 );
775 assert!(!result.stdout.contains("secret-key"));
776 assert_eq!(result.stdout, "[REDACTED]");
777 fs::remove_dir_all(cwd).expect("remove temp directory");
778 }
779
780 #[test]
781 fn redaction_stays_within_the_capture_byte_bound() {
782 let _test_lock = COMMAND_TEST_LOCK.lock().expect("command test lock");
783 let cwd = temporary_directory();
784 let result = execute_command(
785 "printf x",
786 &cwd,
787 "LUCY_API_KEY",
788 Some("x"),
789 Duration::from_secs(2),
790 1,
791 );
792 assert_eq!(result.stdout.len(), 1);
793 assert!(!result.stdout.contains('x'));
794 fs::remove_dir_all(cwd).expect("remove temp directory");
795 }
796
797 #[test]
798 fn collision_markers_do_not_reintroduce_the_provider_key() {
799 let _test_lock = COMMAND_TEST_LOCK.lock().expect("command test lock");
800 let cwd = temporary_directory();
801 for secret in ["REDACTED", "[REDACTED]"] {
802 let command = format!("printf '{secret}'");
803 let result = execute_command(
804 &command,
805 &cwd,
806 "LUCY_API_KEY",
807 Some(secret),
808 Duration::from_secs(2),
809 COMMAND_OUTPUT_CAP,
810 );
811 assert!(!result.stdout.contains(secret));
812 assert!(!result.command.contains(secret));
813 }
814 fs::remove_dir_all(cwd).expect("remove temp directory");
815 }
816
817 #[test]
818 fn rejects_extra_command_arguments() {
819 let _test_lock = COMMAND_TEST_LOCK.lock().expect("command test lock");
820 let cwd = temporary_directory();
821 let result = execute(
822 r#"{"command":"pwd","extra":true}"#,
823 &cwd,
824 "LUCY_API_KEY",
825 None,
826 );
827 assert_eq!(
828 result.error.as_deref(),
829 Some("cmd arguments must contain command and optional background")
830 );
831 let result = execute(r#"{"command":1}"#, &cwd, "LUCY_API_KEY", None);
832 assert_eq!(
833 result.error.as_deref(),
834 Some("cmd command must be a string")
835 );
836 let result = execute(
837 r#"{"command":"pwd","background":"yes"}"#,
838 &cwd,
839 "LUCY_API_KEY",
840 None,
841 );
842 assert_eq!(
843 result.error.as_deref(),
844 Some("cmd background must be a boolean")
845 );
846 fs::remove_dir_all(cwd).expect("remove temp directory");
847 }
848
849 #[test]
850 fn managed_background_command_returns_immediately_and_completes() {
851 let _test_lock = COMMAND_TEST_LOCK.lock().expect("command test lock");
852 let cwd = temporary_directory();
853 let mut background = BackgroundCommands::default();
854 let result = execute_managed(
855 r#"{"command":"sleep 0.2; printf done","background":true}"#,
856 &cwd,
857 "LUCY_API_KEY",
858 None,
859 None,
860 &mut background,
861 );
862 assert_eq!(result["status"], "running");
863 assert_eq!(result["background_id"], "background-1");
864 assert!(background.has_active());
865 assert!(!background.has_completed());
866
867 let deadline = Instant::now() + Duration::from_secs(2);
868 while !background.has_completed() && Instant::now() < deadline {
869 thread::sleep(Duration::from_millis(10));
870 }
871 let completions = background.take_completions();
872 assert_eq!(completions.len(), 1);
873 assert_eq!(completions[0].id, "background-1");
874 assert_eq!(completions[0].result.exit_code, Some(0));
875 assert_eq!(completions[0].result.stdout, "done");
876 assert!(!background.has_active());
877 fs::remove_dir_all(cwd).expect("remove temp directory");
878 }
879}