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