Skip to main content

fxrs_process/
lib.rs

1//! Native process tools.
2//!
3//! Foreground execution is kept in a separate crate so process-group support,
4//! Tokio process I/O, and shell startup policy are linked only by hosts that
5//! advertise execution.
6
7#[cfg(unix)]
8mod hosted_terminal;
9pub mod monitor;
10mod native_terminal;
11mod sandbox;
12mod terminal;
13mod terminal_host;
14pub mod terminal_host_protocol;
15#[cfg(unix)]
16pub mod terminal_host_server;
17#[cfg(unix)]
18mod terminal_monitor_store;
19#[cfg(unix)]
20mod terminal_observation;
21#[cfg(unix)]
22mod tmux_terminal;
23
24pub use terminal::Terminal;
25
26use std::path::{Path, PathBuf};
27use std::process::{ExitStatus, Stdio};
28use std::time::{Duration, Instant};
29
30use fx_core::{
31    BoxFuture, CommandReview, PermissionRequest, PreparedToolAction, PreparedToolCall,
32    RegistryError, SandboxMode, Tool, ToolContext, ToolEffect, ToolError, ToolOutput,
33    ToolPreparation, ToolRegistry, ToolReview,
34};
35use fx_workspace::resolve_existing;
36#[cfg(windows)]
37use process_wrap::tokio::JobObject;
38#[cfg(unix)]
39use process_wrap::tokio::ProcessGroup;
40use process_wrap::tokio::{CommandWrap, KillOnDrop};
41use serde::Deserialize;
42use serde_json::{Value, json};
43use tokio::io::{AsyncRead, AsyncReadExt};
44
45const MAX_COMMAND_BYTES: usize = 64 * 1024;
46const POLL_INTERVAL: Duration = Duration::from_millis(10);
47
48const DESCRIPTION: &str = "Run one foreground command and return a captured result. The omitted profile defaults to user; profile=clean skips Bash/zsh startup files. This initial Rust slice supports action=exec only; durable terminal sessions will add the remaining actions without changing the tool identity.";
49
50#[derive(Clone, Debug, Default)]
51pub struct TerminalExec {
52    login_shell: Option<PathBuf>,
53}
54
55impl TerminalExec {
56    pub fn with_login_shell(path: impl Into<PathBuf>) -> Self {
57        Self {
58            login_shell: Some(path.into()),
59        }
60    }
61}
62
63#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
64#[serde(rename_all = "snake_case")]
65enum Action {
66    Exec,
67}
68
69#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
70#[serde(rename_all = "snake_case")]
71enum Profile {
72    Clean,
73    #[default]
74    User,
75}
76
77impl Profile {
78    fn as_str(self) -> &'static str {
79        match self {
80            Self::Clean => "clean",
81            Self::User => "user",
82        }
83    }
84}
85
86#[derive(Debug, Deserialize)]
87#[serde(deny_unknown_fields)]
88struct Input {
89    action: Action,
90    command: String,
91    #[serde(default)]
92    cwd: Option<String>,
93    #[serde(default)]
94    profile: Profile,
95}
96
97impl Tool for TerminalExec {
98    fn name(&self) -> &str {
99        "terminal"
100    }
101
102    fn description(&self) -> &str {
103        DESCRIPTION
104    }
105
106    fn input_schema(&self) -> Value {
107        json!({
108            "type": "object",
109            "properties": {
110                "action": { "type": "string", "enum": ["exec"] },
111                "command": { "type": "string", "maxLength": MAX_COMMAND_BYTES },
112                "cwd": { "type": "string" },
113                "profile": { "type": "string", "enum": ["clean", "user"] }
114            },
115            "required": ["action", "command"],
116            "additionalProperties": false
117        })
118    }
119
120    fn effect(&self, arguments: &Value) -> Result<ToolEffect, ToolError> {
121        let input = decode(arguments)?;
122        match input.action {
123            Action::Exec => Ok(ToolEffect::Process),
124        }
125    }
126
127    fn project_context_targets(
128        &self,
129        context: &ToolContext,
130        arguments: &Value,
131    ) -> Result<Vec<PathBuf>, ToolError> {
132        let input = decode(arguments)?;
133        let cwd = resolve_existing(&context.workspace_root, input.cwd.as_deref().unwrap_or("."))?;
134        Ok(vec![cwd.absolute])
135    }
136
137    fn prepare(
138        &self,
139        context: &ToolContext,
140        arguments: &Value,
141    ) -> Result<ToolPreparation, ToolError> {
142        let input = decode(arguments)?;
143        let prepared = prepare_command(context, input, self.login_shell.as_deref())?;
144        let permission =
145            PermissionRequest::new(self.name(), prepared.command.clone(), ToolEffect::Process)
146                .with_grant_target(grant_target(&prepared));
147        let review = CommandReview {
148            command: prepared.command.clone(),
149            cwd: prepared.cwd.clone(),
150            shell: prepared.shell.clone(),
151            profile: prepared.profile.as_str().into(),
152        };
153        Ok(ToolPreparation::Prepared(PreparedToolCall::new(
154            self.name(),
155            vec![permission],
156            false,
157            Some(ToolReview::Command(review)),
158            prepared,
159        )))
160    }
161
162    fn execute<'a>(
163        &'a self,
164        _context: &'a ToolContext,
165        _arguments: Value,
166    ) -> BoxFuture<'a, Result<ToolOutput, ToolError>> {
167        Box::pin(async {
168            Err(ToolError::PermissionDenied(
169                "terminal execution requires canonical tool runtime authorization".into(),
170            ))
171        })
172    }
173}
174
175pub fn register_process_tools(registry: &mut ToolRegistry) -> Result<(), RegistryError> {
176    registry.register(Terminal::default())
177}
178
179fn decode(arguments: &Value) -> Result<Input, ToolError> {
180    let input: Input = serde_json::from_value(arguments.clone())
181        .map_err(|error| ToolError::InvalidArguments(error.to_string()))?;
182    if input.command.trim().is_empty()
183        || input.command.len() > MAX_COMMAND_BYTES
184        || input.command.contains('\0')
185    {
186        return Err(ToolError::InvalidArguments(
187            "terminal exec command must be nonempty, NUL-free, and at most 65536 bytes".into(),
188        ));
189    }
190    Ok(input)
191}
192
193fn prepare_command(
194    context: &ToolContext,
195    input: Input,
196    shell_override: Option<&Path>,
197) -> Result<PreparedCommand, ToolError> {
198    let requested_cwd = input.cwd.as_deref().unwrap_or(".");
199    let cwd = resolve_existing(&context.workspace_root, requested_cwd)?.absolute;
200    if !cwd.is_dir() {
201        return Err(ToolError::InvalidArguments(format!(
202            "terminal cwd is not a directory: {}",
203            cwd.display()
204        )));
205    }
206    let shell = resolve_shell(shell_override)?;
207    Ok(PreparedCommand {
208        command: input.command,
209        cwd,
210        shell,
211        profile: input.profile,
212        max_output_bytes: context.limits.max_result_bytes,
213        timeout: context.limits.command_timeout,
214        sandbox: context.sandbox,
215        sandbox_profile: sandbox::profile(context)?,
216    })
217}
218
219fn resolve_shell(shell_override: Option<&Path>) -> Result<PathBuf, ToolError> {
220    let candidate = shell_override
221        .map(Path::to_owned)
222        .or_else(|| {
223            std::env::var_os("SHELL")
224                .filter(|value| !value.is_empty())
225                .map(Into::into)
226        })
227        .or_else(default_shell)
228        .ok_or_else(|| ToolError::Execution("no Bash or zsh login shell is available".into()))?;
229    if !candidate.is_absolute() {
230        return Err(ToolError::InvalidArguments(
231            "the configured login shell must be absolute".into(),
232        ));
233    }
234    let shell = candidate
235        .canonicalize()
236        .map_err(|error| ToolError::Execution(format!("{}: {error}", candidate.display())))?;
237    match shell.file_name().and_then(|name| name.to_str()) {
238        Some("bash" | "zsh") => Ok(shell),
239        _ => Err(ToolError::InvalidArguments(format!(
240            "unsupported login shell {}; only Bash and zsh are supported",
241            shell.display()
242        ))),
243    }
244}
245
246fn default_shell() -> Option<PathBuf> {
247    ["/bin/zsh", "/bin/bash"]
248        .into_iter()
249        .map(PathBuf::from)
250        .find(|path| path.is_file())
251}
252
253fn grant_target(command: &PreparedCommand) -> String {
254    let shell = command.shell.to_string_lossy();
255    let cwd = command.cwd.to_string_lossy();
256    format!(
257        "@fx-terminal-env:{}:{}:{}:{}:{}:{}:{}",
258        command.profile.as_str(),
259        match command.sandbox {
260            SandboxMode::None => "none",
261            SandboxMode::Os => "os",
262        },
263        shell.len(),
264        shell,
265        cwd.len(),
266        cwd,
267        command.command
268    )
269}
270
271struct PreparedCommand {
272    command: String,
273    cwd: PathBuf,
274    shell: PathBuf,
275    profile: Profile,
276    max_output_bytes: usize,
277    timeout: Option<Duration>,
278    sandbox: SandboxMode,
279    sandbox_profile: Option<String>,
280}
281
282impl PreparedToolAction for PreparedCommand {
283    fn commit<'a>(
284        self: Box<Self>,
285        context: &'a ToolContext,
286    ) -> BoxFuture<'a, Result<ToolOutput, ToolError>> {
287        Box::pin(async move { run_command(*self, context).await })
288    }
289}
290
291enum Termination {
292    Natural(ExitStatus),
293    Cancelled,
294    TimedOut,
295}
296
297async fn run_command(
298    prepared: PreparedCommand,
299    context: &ToolContext,
300) -> Result<ToolOutput, ToolError> {
301    if context.cancellation.is_cancelled() {
302        return Err(ToolError::Cancelled);
303    }
304    let args = shell_arguments(prepared.profile, &prepared.shell, &prepared.command)?;
305    let (program, arguments) = match prepared.sandbox_profile.as_deref() {
306        Some(profile) => {
307            let mut wrapped = vec!["-p".to_owned(), profile.to_owned()];
308            wrapped.push(prepared.shell.display().to_string());
309            wrapped.extend(args);
310            (Path::new(sandbox::MACOS_SANDBOX_EXEC), wrapped)
311        }
312        None => (prepared.shell.as_path(), args),
313    };
314    let mut command = tokio::process::Command::new(program);
315    command
316        .args(arguments)
317        .current_dir(&prepared.cwd)
318        .stdin(Stdio::null())
319        .stdout(Stdio::piped())
320        .stderr(Stdio::piped());
321
322    let mut wrapped = CommandWrap::from(command);
323    wrapped.wrap(KillOnDrop);
324    #[cfg(unix)]
325    wrapped.wrap(ProcessGroup::leader());
326    #[cfg(windows)]
327    wrapped.wrap(JobObject);
328    let mut child = wrapped
329        .spawn()
330        .map_err(|error| ToolError::Execution(format!("could not start command: {error}")))?;
331    let stdout = child
332        .stdout()
333        .take()
334        .ok_or_else(|| ToolError::Execution("command stdout pipe is unavailable".into()))?;
335    let stderr = child
336        .stderr()
337        .take()
338        .ok_or_else(|| ToolError::Execution("command stderr pipe is unavailable".into()))?;
339    let capture_limit = prepared.max_output_bytes;
340    let stdout_task = tokio::spawn(capture_stream(stdout, capture_limit));
341    let stderr_task = tokio::spawn(capture_stream(stderr, capture_limit));
342    let started = Instant::now();
343
344    let termination = loop {
345        tokio::select! {
346            status = child.wait() => {
347                break Termination::Natural(status.map_err(|error| {
348                    ToolError::Execution(format!("could not wait for command: {error}"))
349                })?);
350            }
351            () = tokio::time::sleep(POLL_INTERVAL) => {
352                let cause = if context.cancellation.is_cancelled() {
353                    Some(Termination::Cancelled)
354                } else if prepared.timeout.is_some_and(|timeout| started.elapsed() >= timeout) {
355                    Some(Termination::TimedOut)
356                } else {
357                    None
358                };
359                if let Some(cause) = cause {
360                    Box::into_pin(child.kill()).await.map_err(|error| {
361                        ToolError::Execution(format!("could not terminate command: {error}"))
362                    })?;
363                    break cause;
364                }
365            }
366        }
367    };
368
369    let stdout = stdout_task
370        .await
371        .map_err(|error| ToolError::Execution(format!("stdout reader failed: {error}")))??;
372    let stderr = stderr_task
373        .await
374        .map_err(|error| ToolError::Execution(format!("stderr reader failed: {error}")))??;
375    Ok(format_result(
376        &prepared,
377        termination,
378        stdout,
379        stderr,
380        started.elapsed(),
381    ))
382}
383
384fn shell_arguments(
385    profile: Profile,
386    shell: &Path,
387    command: &str,
388) -> Result<Vec<String>, ToolError> {
389    let name = shell.file_name().and_then(|name| name.to_str());
390    match (name, profile) {
391        (Some("bash"), Profile::Clean) => Ok(vec![
392            "--noprofile".into(),
393            "--norc".into(),
394            "-c".into(),
395            command.into(),
396        ]),
397        (Some("bash"), Profile::User) => Ok(vec![
398            "--login".into(),
399            "-O".into(),
400            "expand_aliases".into(),
401            "-c".into(),
402            command.into(),
403        ]),
404        (Some("zsh"), Profile::Clean) => Ok(vec!["-f".into(), "-c".into(), command.into()]),
405        (Some("zsh"), Profile::User) => {
406            Ok(vec!["-l".into(), "-i".into(), "-c".into(), command.into()])
407        }
408        _ => Err(ToolError::InvalidArguments(
409            "only Bash and zsh are supported".into(),
410        )),
411    }
412}
413
414#[derive(Debug)]
415struct CapturedStream {
416    head: Vec<u8>,
417    tail: Vec<u8>,
418    total_bytes: usize,
419    max_bytes: usize,
420}
421
422impl CapturedStream {
423    fn new(max_bytes: usize) -> Self {
424        Self {
425            head: Vec::new(),
426            tail: Vec::new(),
427            total_bytes: 0,
428            max_bytes,
429        }
430    }
431
432    fn append(&mut self, bytes: &[u8]) {
433        self.total_bytes = self.total_bytes.saturating_add(bytes.len());
434        let head_limit = self.max_bytes.div_ceil(2);
435        let tail_limit = self.max_bytes.saturating_sub(head_limit);
436        if self.head.len() < head_limit {
437            let take = (head_limit - self.head.len()).min(bytes.len());
438            self.head.extend_from_slice(&bytes[..take]);
439        }
440        if tail_limit == 0 {
441            return;
442        }
443        if bytes.len() >= tail_limit {
444            self.tail.clear();
445            self.tail
446                .extend_from_slice(&bytes[bytes.len() - tail_limit..]);
447        } else {
448            let overflow = self
449                .tail
450                .len()
451                .saturating_add(bytes.len())
452                .saturating_sub(tail_limit);
453            if overflow > 0 {
454                self.tail.drain(..overflow);
455            }
456            self.tail.extend_from_slice(bytes);
457        }
458    }
459
460    fn render(&self, budget: usize) -> String {
461        if self.total_bytes == 0 {
462            return String::new();
463        }
464        if self.total_bytes <= budget {
465            let overlap = self
466                .head
467                .len()
468                .saturating_add(self.tail.len())
469                .saturating_sub(self.total_bytes);
470            let mut bytes = self.head.clone();
471            bytes.extend_from_slice(&self.tail[overlap..]);
472            return String::from_utf8_lossy(&bytes).into_owned();
473        }
474        let head_budget = budget.div_ceil(2);
475        let tail_budget = budget.saturating_sub(head_budget);
476        let head = &self.head[..self.head.len().min(head_budget)];
477        let tail = &self.tail[self.tail.len().saturating_sub(tail_budget)..];
478        format!(
479            "{}\n[... {} bytes truncated ...]\n{}",
480            String::from_utf8_lossy(head),
481            self.total_bytes.saturating_sub(budget),
482            String::from_utf8_lossy(tail)
483        )
484    }
485}
486
487async fn capture_stream(
488    mut stream: impl AsyncRead + Unpin,
489    max_bytes: usize,
490) -> Result<CapturedStream, ToolError> {
491    let mut captured = CapturedStream::new(max_bytes);
492    let mut chunk = [0u8; 8192];
493    loop {
494        let count = stream.read(&mut chunk).await.map_err(|error| {
495            ToolError::Execution(format!("could not read command output: {error}"))
496        })?;
497        if count == 0 {
498            return Ok(captured);
499        }
500        captured.append(&chunk[..count]);
501    }
502}
503
504fn format_result(
505    prepared: &PreparedCommand,
506    termination: Termination,
507    stdout: CapturedStream,
508    stderr: CapturedStream,
509    duration: Duration,
510) -> ToolOutput {
511    #[cfg(unix)]
512    use std::os::unix::process::ExitStatusExt as _;
513
514    let (exit_code, signal, timed_out, cancelled) = match termination {
515        Termination::Natural(status) => (
516            status.code().map(i64::from),
517            {
518                #[cfg(unix)]
519                {
520                    status.signal().and_then(|value| u32::try_from(value).ok())
521                }
522                #[cfg(not(unix))]
523                {
524                    None
525                }
526            },
527            false,
528            false,
529        ),
530        Termination::Cancelled => (None, None, false, true),
531        Termination::TimedOut => (None, None, true, false),
532    };
533    let stdout_bytes = stdout.total_bytes;
534    let stderr_bytes = stderr.total_bytes;
535    let total_bytes = stdout_bytes.saturating_add(stderr_bytes);
536    let truncated = total_bytes > prepared.max_output_bytes;
537    let (stdout_budget, stderr_budget) = if !truncated {
538        (prepared.max_output_bytes, prepared.max_output_bytes)
539    } else if stderr_bytes == 0 {
540        (prepared.max_output_bytes, 0)
541    } else if stdout_bytes == 0 {
542        (0, prepared.max_output_bytes)
543    } else {
544        let stdout_budget = prepared.max_output_bytes.div_ceil(2);
545        (
546            stdout_budget,
547            prepared.max_output_bytes.saturating_sub(stdout_budget),
548        )
549    };
550    let stdout_text = stdout.render(stdout_budget);
551    let stderr_text = stderr.render(stderr_budget);
552    let sandbox_denied = prepared.sandbox_profile.is_some() && sandbox::denied(&stderr_text);
553    let duration_ms = u64::try_from(duration.as_millis()).unwrap_or(u64::MAX);
554    let structured = json!({
555        "kind": "foreground",
556        "command": prepared.command,
557        "cwd": prepared.cwd,
558        "exit_code": exit_code,
559        "signal": signal,
560        "timed_out": timed_out,
561        "duration_ms": duration_ms,
562        "stdout_bytes": stdout_bytes,
563        "stderr_bytes": stderr_bytes,
564        "truncated": truncated,
565        "output_file": null,
566        "stdout_file": null,
567        "stderr_file": null,
568        "sandbox": match prepared.sandbox { SandboxMode::None => "none", SandboxMode::Os => "os" },
569        "sandbox_denied": sandbox_denied
570    });
571    let failed =
572        cancelled || timed_out || signal.is_some() || exit_code.is_some_and(|code| code != 0);
573    let content = if cancelled {
574        format!(
575            "command cancelled\n{}",
576            output_envelopes(&stdout_text, &stderr_text)
577        )
578    } else if timed_out {
579        let timeout = prepared.timeout.map_or_else(
580            || "timeout=true\n".to_owned(),
581            |value| format!("timeout=true\ntimeout_ms={}\n", value.as_millis()),
582        );
583        format!(
584            "{timeout}command timed out and was terminated\n{}",
585            output_envelopes(&stdout_text, &stderr_text)
586        )
587    } else if failed {
588        serde_json::to_string(&json!({
589            "error": {
590                "tool_name": "terminal",
591                "message": if exit_code.is_some() {
592                    "Command exited with non-zero status"
593                } else {
594                    "Command terminated before completing successfully"
595                },
596                "details": {
597                    "command": prepared.command,
598                    "cwd": prepared.cwd,
599                    "exit_code": exit_code,
600                    "signal": signal,
601                    "stderr": stderr_text.trim()
602                },
603                "suggestion": "Inspect stderr and the command context, then fix the command or explain the blocker rather than retrying unchanged."
604            }
605        }))
606        .expect("static command error JSON is serializable")
607    } else {
608        let status = exit_code.map_or_else(
609            || "process finished\n".to_owned(),
610            |code| format!("exit_code={code}\n"),
611        );
612        format!("{status}{}", output_envelopes(&stdout_text, &stderr_text))
613    };
614    let original_bytes = if truncated {
615        content.len().max(total_bytes)
616    } else {
617        content.len()
618    };
619    ToolOutput {
620        original_bytes,
621        content,
622        is_error: failed,
623        structured: Some(structured),
624        truncated,
625        durable_content: None,
626    }
627}
628
629fn output_envelopes(stdout: &str, stderr: &str) -> String {
630    let stdout = stdout.trim_matches([' ', '\r', '\n', '\t']);
631    let stderr = stderr.trim_matches([' ', '\r', '\n', '\t']);
632    let mut output = String::new();
633    if !stdout.is_empty() {
634        output.push_str("<stdout>\n");
635        output.push_str(stdout);
636        output.push_str("\n</stdout>\n");
637    }
638    if !stderr.is_empty() {
639        output.push_str("<stderr>\n");
640        output.push_str(stderr);
641        output.push_str("\n</stderr>\n");
642    }
643    if output.is_empty() {
644        output.push_str("(no output)\n");
645    }
646    output
647}
648
649#[cfg(all(test, unix))]
650mod tests {
651    #[cfg(target_os = "macos")]
652    use std::fs;
653    use std::sync::Arc;
654    use std::sync::atomic::{AtomicBool, Ordering};
655
656    use fx_core::{CancellationSignal, ToolPreparation};
657
658    use super::*;
659
660    fn shell() -> PathBuf {
661        ["/bin/zsh", "/bin/bash"]
662            .into_iter()
663            .map(PathBuf::from)
664            .find(|path| path.is_file())
665            .expect("test host has Bash or zsh")
666    }
667
668    fn context() -> ToolContext {
669        ToolContext::new(std::env::current_dir().unwrap())
670    }
671
672    async fn approved_call(
673        tool: &TerminalExec,
674        context: &ToolContext,
675        arguments: Value,
676    ) -> ToolOutput {
677        let ToolPreparation::Prepared(prepared) = tool.prepare(context, &arguments).unwrap() else {
678            panic!("terminal must prepare an owned command")
679        };
680        prepared.commit(context).await.unwrap()
681    }
682
683    #[tokio::test(flavor = "current_thread")]
684    async fn executes_with_zig_compatible_envelopes_and_metadata() {
685        let tool = TerminalExec::with_login_shell(shell());
686        let output = approved_call(
687            &tool,
688            &context(),
689            json!({
690                "action": "exec",
691                "profile": "clean",
692                "command": "printf 'hello'; printf 'warn' >&2"
693            }),
694        )
695        .await;
696        assert!(!output.is_error, "{}", output.content);
697        assert!(output.content.contains("exit_code=0"));
698        assert!(output.content.contains("<stdout>\nhello\n</stdout>"));
699        assert!(output.content.contains("<stderr>\nwarn\n</stderr>"));
700        let metadata = output.structured.unwrap();
701        assert_eq!(metadata["exit_code"], 0);
702        assert_eq!(metadata["stdout_bytes"], 5);
703        assert_eq!(metadata["stderr_bytes"], 4);
704    }
705
706    #[tokio::test(flavor = "current_thread")]
707    async fn nonzero_exit_uses_the_zig_failure_contract_and_retains_metadata() {
708        let tool = TerminalExec::with_login_shell(shell());
709        let output = approved_call(
710            &tool,
711            &context(),
712            json!({
713                "action": "exec",
714                "profile": "clean",
715                "command": "printf out; printf warn >&2; exit 7"
716            }),
717        )
718        .await;
719        assert!(output.is_error);
720        let failure: Value = serde_json::from_str(&output.content).unwrap();
721        assert_eq!(
722            failure["error"]["message"],
723            "Command exited with non-zero status"
724        );
725        assert_eq!(failure["error"]["details"]["exit_code"], 7);
726        assert_eq!(failure["error"]["details"]["stderr"], "warn");
727        let metadata = output.structured.unwrap();
728        assert_eq!(metadata["exit_code"], 7);
729        assert_eq!(metadata["stdout_bytes"], 3);
730        assert_eq!(metadata["stderr_bytes"], 4);
731    }
732
733    #[tokio::test(flavor = "current_thread")]
734    async fn large_output_is_bounded_with_head_tail_metadata() {
735        let tool = TerminalExec::with_login_shell(shell());
736        let mut context = context();
737        context.limits.max_result_bytes = 8;
738        let output = approved_call(
739            &tool,
740            &context,
741            json!({
742                "action": "exec",
743                "profile": "clean",
744                "command": "printf 0123456789abcdef"
745            }),
746        )
747        .await;
748        assert!(!output.is_error);
749        assert!(output.truncated);
750        assert!(output.content.contains("bytes truncated"));
751        assert!(output.content.contains("0123"));
752        assert!(output.content.contains("cdef"));
753        assert!(output.original_bytes >= 16);
754        let metadata = output.structured.unwrap();
755        assert_eq!(metadata["stdout_bytes"], 16);
756        assert_eq!(metadata["truncated"], true);
757    }
758
759    #[tokio::test(flavor = "current_thread")]
760    async fn timeout_terminates_the_process_group_and_preserves_output() {
761        let tool = TerminalExec::with_login_shell(shell());
762        let mut context = context();
763        context.limits.command_timeout = Some(Duration::from_millis(80));
764        let started = Instant::now();
765        let output = approved_call(
766            &tool,
767            &context,
768            json!({
769                "action": "exec",
770                "profile": "clean",
771                "command": "printf before; sleep 5 & wait"
772            }),
773        )
774        .await;
775        assert!(started.elapsed() < Duration::from_secs(2));
776        assert!(output.is_error);
777        assert!(output.content.contains("timeout=true"));
778        assert!(output.content.contains("before"));
779        assert_eq!(output.structured.unwrap()["timed_out"], true);
780    }
781
782    struct AtomicCancellation(Arc<AtomicBool>);
783
784    impl CancellationSignal for AtomicCancellation {
785        fn is_cancelled(&self) -> bool {
786            self.0.load(Ordering::Acquire)
787        }
788    }
789
790    #[tokio::test(flavor = "current_thread")]
791    async fn cancellation_terminates_an_in_flight_command() {
792        let tool = TerminalExec::with_login_shell(shell());
793        let cancelled = Arc::new(AtomicBool::new(false));
794        let mut context = context();
795        context.cancellation = Arc::new(AtomicCancellation(cancelled.clone()));
796        tokio::spawn(async move {
797            tokio::time::sleep(Duration::from_millis(50)).await;
798            cancelled.store(true, Ordering::Release);
799        });
800        let output = approved_call(
801            &tool,
802            &context,
803            json!({
804                "action": "exec",
805                "profile": "clean",
806                "command": "printf started; sleep 5"
807            }),
808        )
809        .await;
810        assert!(output.is_error);
811        assert!(output.content.contains("command cancelled"));
812        assert!(output.content.contains("started"));
813    }
814
815    #[cfg(target_os = "macos")]
816    #[tokio::test(flavor = "current_thread")]
817    async fn os_sandbox_allows_workspace_writes_and_denies_outside_writes() {
818        let root = std::env::current_dir()
819            .unwrap()
820            .join("target")
821            .join(format!("fx-sandbox-test-{}", std::process::id()));
822        let workspace = root.join("workspace");
823        let outside = root.join("outside.txt");
824        fs::create_dir_all(&workspace).unwrap();
825        let mut context = ToolContext::new(workspace.clone());
826        context.sandbox = SandboxMode::Os;
827        let tool = TerminalExec::with_login_shell(shell());
828
829        let allowed = approved_call(
830            &tool,
831            &context,
832            json!({
833                "action": "exec",
834                "profile": "clean",
835                "command": "printf allowed > inside.txt"
836            }),
837        )
838        .await;
839        assert!(!allowed.is_error, "{}", allowed.content);
840        assert_eq!(
841            fs::read_to_string(workspace.join("inside.txt")).unwrap(),
842            "allowed"
843        );
844        assert_eq!(allowed.structured.as_ref().unwrap()["sandbox"], "os");
845
846        let denied = approved_call(
847            &tool,
848            &context,
849            json!({
850                "action": "exec",
851                "profile": "clean",
852                "command": format!("printf blocked > '{}'", outside.display())
853            }),
854        )
855        .await;
856        assert!(denied.is_error, "outside write unexpectedly succeeded");
857        assert_eq!(denied.structured.as_ref().unwrap()["sandbox_denied"], true);
858        assert!(!outside.exists());
859
860        fs::remove_dir_all(root).unwrap();
861    }
862
863    #[test]
864    fn session_grant_binds_shell_cwd_and_profile_but_rules_match_command() {
865        let tool = TerminalExec::with_login_shell(shell());
866        let context = context();
867        let ToolPreparation::Prepared(prepared) = tool
868            .prepare(
869                &context,
870                &json!({"action": "exec", "command": "pwd", "profile": "clean"}),
871            )
872            .unwrap()
873        else {
874            panic!("terminal must prepare")
875        };
876        let permission = &prepared.permission_requests[0];
877        assert_eq!(permission.target, "pwd");
878        assert!(
879            permission
880                .grant_target
881                .starts_with("@fx-terminal-env:clean:")
882        );
883        assert_ne!(permission.target, permission.grant_target);
884        assert!(matches!(prepared.review, Some(ToolReview::Command(_))));
885    }
886}