Skip to main content

lucy/
command.rs

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        // Each command gets its own process group so a timed-out shell and its
344        // finite descendants can be cleaned up together.
345        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        // A shell can finish while a background child remains in its process
420        // group. Lucy has no background-process API, so clean that group too.
421        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        // Non-blocking capture normally exits after the shutdown grace. If a
551        // platform refuses that setup, detach rather than blocking the command
552        // harness on a descendant-owned pipe forever.
553        CapturedOutput {
554            bytes: Vec::new(),
555            truncated: true,
556        }
557    }
558}
559
560fn kill_process_group(child_id: u32) {
561    #[cfg(unix)]
562    {
563        // The child is the process-group leader created above. Ignore errors:
564        // it may already have exited, and child.wait below remains authoritative.
565        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}