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