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