1#[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}