1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3use std::time::Duration;
4
5use fx_core::{
6 BoxFuture, CommandReview, PermissionRequest, PreparedToolAction, PreparedToolCall, Tool,
7 ToolContext, ToolEffect, ToolError, ToolOutput, ToolPreparation, ToolReview,
8};
9use fx_workspace::resolve_existing;
10use serde::Deserialize;
11use serde_json::{Value, json};
12
13use crate::monitor::{MonitorDefinition, MonitorOperation};
14use crate::native_terminal::{
15 ClosePolicy, NamedKey, SessionBackend, StartSpec, TerminalSessionHost, TerminalSignal,
16 WaitCondition, WritePayload, default_dimensions,
17};
18use crate::terminal_host::RoutingTerminalHost;
19use crate::{MAX_COMMAND_BYTES, Profile, TerminalExec, resolve_shell};
20
21const MAX_WRITE_BYTES: usize = 64 * 1024;
22const MAX_WRITE_ITEMS: usize = 4096;
23const MAX_MATCH_BYTES: usize = 4096;
24
25const DESCRIPTION: &str = "Select one action and set fields unused by that action to null or omit them. exec runs one captured foreground command. start/read/screen/write/wait/monitor/inspect/list/resize/signal/close control PTY sessions. On Unix, native sessions are owned by a detached private companion and survive agent-client restarts while that host lives; backend=tmux is advertised when installed and is restart-durable. Monitors are durable, non-consuming observations; retrieve and acknowledge their events with inspect. Omitted profile means user; profile=clean skips Bash/zsh startup files.";
26
27#[derive(Clone)]
28pub struct Terminal {
29 foreground: TerminalExec,
30 sessions: Arc<dyn TerminalSessionHost>,
31}
32
33impl Default for Terminal {
34 fn default() -> Self {
35 Self {
36 foreground: TerminalExec::default(),
37 sessions: Arc::new(RoutingTerminalHost::discover()),
38 }
39 }
40}
41
42impl std::fmt::Debug for Terminal {
43 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44 formatter
45 .debug_struct("Terminal")
46 .field("foreground", &self.foreground)
47 .finish_non_exhaustive()
48 }
49}
50
51impl Terminal {
52 pub fn with_login_shell(path: impl Into<PathBuf>) -> Self {
53 Self {
54 foreground: TerminalExec::with_login_shell(path),
55 sessions: Arc::new(RoutingTerminalHost::discover()),
56 }
57 }
58
59 #[cfg(all(test, unix))]
60 fn with_local_login_shell(path: impl Into<PathBuf>) -> Self {
61 Self {
62 foreground: TerminalExec::with_login_shell(path),
63 sessions: Arc::new(RoutingTerminalHost::local()),
64 }
65 }
66}
67
68#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
69#[serde(rename_all = "snake_case")]
70enum Action {
71 Exec,
72 Start,
73 Read,
74 Screen,
75 Write,
76 Wait,
77 Monitor,
78 Inspect,
79 List,
80 Resize,
81 Signal,
82 Close,
83}
84
85impl Action {
86 fn as_str(self) -> &'static str {
87 match self {
88 Self::Exec => "exec",
89 Self::Start => "start",
90 Self::Read => "read",
91 Self::Screen => "screen",
92 Self::Write => "write",
93 Self::Wait => "wait",
94 Self::Monitor => "monitor",
95 Self::Inspect => "inspect",
96 Self::List => "list",
97 Self::Resize => "resize",
98 Self::Signal => "signal",
99 Self::Close => "close",
100 }
101 }
102}
103
104#[derive(Clone, Debug, Deserialize)]
105#[serde(deny_unknown_fields)]
106struct Input {
107 action: Action,
108 #[serde(default)]
109 session_id: Option<String>,
110 #[serde(default)]
111 cwd: Option<String>,
112 #[serde(default)]
113 command: Option<String>,
114 #[serde(default)]
115 profile: Option<Profile>,
116 #[serde(default)]
117 shell: Option<ShellInput>,
118 #[serde(default)]
119 backend: Option<Backend>,
120 #[serde(default)]
121 return_when: Option<ReturnInput>,
122 #[serde(default)]
123 wait_ceiling_ms: Option<u64>,
124 #[serde(default)]
125 dimensions: Option<DimensionsInput>,
126 #[serde(default)]
127 initial_monitors: Vec<MonitorDefinition>,
128 #[serde(default)]
129 cursor_segment: Option<u64>,
130 #[serde(default)]
131 cursor_offset: Option<u64>,
132 #[serde(default)]
133 after_event_id: Option<u64>,
134 #[serde(default)]
135 acknowledge_event_id: Option<u64>,
136 #[serde(default)]
137 max_events: Option<u16>,
138 #[serde(default)]
139 write: Option<WriteInput>,
140 #[serde(default)]
141 monitor: Option<MonitorOperation>,
142 #[serde(default)]
143 rows: Option<u16>,
144 #[serde(default)]
145 columns: Option<u16>,
146 #[serde(default)]
147 signal: Option<SignalInput>,
148 #[serde(default)]
149 close_policy: Option<ClosePolicyInput>,
150}
151
152#[derive(Clone, Debug, Deserialize)]
153#[serde(deny_unknown_fields)]
154struct ShellInput {
155 #[serde(default)]
156 kind: ShellKind,
157 #[serde(default)]
158 path: Option<String>,
159 #[serde(default)]
160 clean_start: bool,
161}
162
163#[derive(Clone, Copy, Debug, Default, Deserialize)]
164#[serde(rename_all = "snake_case")]
165enum ShellKind {
166 #[default]
167 UserLogin,
168 Executable,
169}
170
171#[derive(Clone, Copy, Debug, Deserialize)]
172#[serde(rename_all = "snake_case")]
173enum Backend {
174 Native,
175 Tmux,
176}
177
178#[derive(Clone, Debug, Deserialize)]
179#[serde(deny_unknown_fields)]
180struct ReturnInput {
181 kind: ReturnKind,
182 #[serde(default)]
183 duration_ms: Option<u64>,
184 #[serde(default)]
185 pattern: Option<String>,
186}
187
188#[derive(Clone, Copy, Debug, Deserialize)]
189#[serde(rename_all = "snake_case")]
190enum ReturnKind {
191 Started,
192 Exit,
193 Quiet,
194 Match,
195}
196
197#[derive(Clone, Copy, Debug, Deserialize)]
198#[serde(deny_unknown_fields)]
199struct DimensionsInput {
200 rows: u16,
201 columns: u16,
202}
203
204#[derive(Clone, Debug, Deserialize)]
205#[serde(deny_unknown_fields)]
206struct WriteInput {
207 kind: PayloadKind,
208 #[serde(default)]
209 text: Option<String>,
210 #[serde(default)]
211 keys: Vec<NamedKeyInput>,
212 #[serde(default)]
213 controls: Vec<u8>,
214}
215
216#[derive(Clone, Copy, Debug, Deserialize)]
217#[serde(rename_all = "snake_case")]
218enum PayloadKind {
219 Text,
220 Keys,
221 Controls,
222 Paste,
223}
224
225#[derive(Clone, Copy, Debug, Deserialize)]
226#[serde(rename_all = "snake_case")]
227enum NamedKeyInput {
228 Enter,
229 Tab,
230 Escape,
231 Backspace,
232 Delete,
233 Insert,
234 ArrowUp,
235 ArrowDown,
236 ArrowLeft,
237 ArrowRight,
238 Home,
239 End,
240 PageUp,
241 PageDown,
242}
243
244#[derive(Clone, Copy, Debug, Deserialize)]
245#[serde(rename_all = "snake_case")]
246enum SignalInput {
247 Hangup,
248 Interrupt,
249 Quit,
250 Terminate,
251 Kill,
252}
253
254#[derive(Clone, Copy, Debug, Deserialize)]
255#[serde(rename_all = "snake_case")]
256enum ClosePolicyInput {
257 Graceful,
258 Force,
259}
260
261impl Tool for Terminal {
262 fn name(&self) -> &str {
263 "terminal"
264 }
265
266 fn description(&self) -> &str {
267 DESCRIPTION
268 }
269
270 fn input_schema(&self) -> Value {
271 terminal_schema(self.sessions.backends())
272 }
273
274 fn effect(&self, arguments: &Value) -> Result<ToolEffect, ToolError> {
275 let input = decode(arguments)?;
276 Ok(match input.action {
277 Action::Read | Action::Screen | Action::Inspect | Action::List => ToolEffect::Read,
278 Action::Exec
279 | Action::Start
280 | Action::Write
281 | Action::Wait
282 | Action::Monitor
283 | Action::Resize
284 | Action::Signal
285 | Action::Close => ToolEffect::Process,
286 })
287 }
288
289 fn project_context_targets(
290 &self,
291 context: &ToolContext,
292 arguments: &Value,
293 ) -> Result<Vec<PathBuf>, ToolError> {
294 let input = decode(arguments)?;
295 if !matches!(input.action, Action::Exec | Action::Start) {
296 return Ok(Vec::new());
297 }
298 let cwd = resolve_existing(&context.workspace_root, input.cwd.as_deref().unwrap_or("."))?;
299 Ok(vec![cwd.absolute])
300 }
301
302 fn prepare(
303 &self,
304 context: &ToolContext,
305 arguments: &Value,
306 ) -> Result<ToolPreparation, ToolError> {
307 let input = decode(arguments)?;
308 match input.action {
309 Action::Exec => self.foreground.prepare(context, arguments),
310 Action::Start => self.prepare_start(context, input),
311 Action::Monitor if input.monitor.as_ref().and_then(custom_probe).is_some() => {
312 self.prepare_monitor(input)
313 }
314 _ => Ok(ToolPreparation::Direct {
315 permission_requests: Vec::new(),
319 irreversible: false,
320 }),
321 }
322 }
323
324 fn execute<'a>(
325 &'a self,
326 context: &'a ToolContext,
327 arguments: Value,
328 ) -> BoxFuture<'a, Result<ToolOutput, ToolError>> {
329 Box::pin(async move {
330 let input = decode(&arguments)?;
331 let sessions = self.sessions.clone();
332 let cancellation = context.cancellation.clone();
333 tokio::task::spawn_blocking(move || execute_session(sessions, input, cancellation))
334 .await
335 .map_err(|error| ToolError::Execution(format!("terminal task failed: {error}")))?
336 })
337 }
338}
339
340impl Terminal {
341 fn prepare_start(
342 &self,
343 context: &ToolContext,
344 input: Input,
345 ) -> Result<ToolPreparation, ToolError> {
346 let requested_cwd = input.cwd.as_deref().unwrap_or(".");
347 let cwd = resolve_existing(&context.workspace_root, requested_cwd)?.absolute;
348 if !cwd.is_dir() {
349 return Err(ToolError::InvalidArguments(format!(
350 "terminal cwd is not a directory: {}",
351 cwd.display()
352 )));
353 }
354 let (shell, profile_label, clean_start) = match input.shell.as_ref() {
355 None => {
356 let profile = input.profile.unwrap_or_default();
357 (
358 resolve_shell(self.foreground.login_shell.as_deref())?,
359 profile.as_str().to_owned(),
360 profile == Profile::Clean,
361 )
362 }
363 Some(shell) if matches!(shell.kind, ShellKind::UserLogin) => (
364 resolve_shell(self.foreground.login_shell.as_deref())?,
365 "user".into(),
366 false,
367 ),
368 Some(shell) => {
369 let path = shell.path.as_deref().ok_or_else(|| {
370 ToolError::InvalidArguments(
371 "terminal start executable shell requires path".into(),
372 )
373 })?;
374 let path = PathBuf::from(path);
375 if !path.is_absolute() {
376 return Err(ToolError::InvalidArguments(
377 "terminal start executable shell path must be absolute".into(),
378 ));
379 }
380 let path = path.canonicalize().map_err(|error| {
381 ToolError::Execution(format!("{}: {error}", path.display()))
382 })?;
383 (path, "custom".into(), shell.clean_start)
384 }
385 };
386 let profile = if clean_start {
387 Profile::Clean
388 } else {
389 Profile::User
390 };
391 let command = input.command.clone().filter(|command| !command.is_empty());
392 let arguments = start_arguments(&shell, profile, command.as_deref())?;
393 let (rows, columns) = input
394 .dimensions
395 .map_or_else(default_dimensions, |value| (value.rows, value.columns));
396 let return_when = input.return_when.map(build_wait_condition).transpose()?;
397 let non_immediate = return_when
398 .as_ref()
399 .is_some_and(|condition| !matches!(condition, WaitCondition::Started));
400 let wait_ceiling = input.wait_ceiling_ms.map(Duration::from_millis);
401 if non_immediate && wait_ceiling.is_none() {
402 return Err(ToolError::InvalidArguments(
403 "terminal start requires wait_ceiling_ms for a non-immediate return condition"
404 .into(),
405 ));
406 }
407 let command_target = command
408 .clone()
409 .unwrap_or_else(|| shell.display().to_string());
410 let spec = StartSpec {
411 backend: match input.backend.unwrap_or(Backend::Native) {
412 Backend::Native => SessionBackend::Native,
413 Backend::Tmux => SessionBackend::Tmux,
414 },
415 workspace_root: context.workspace_root.canonicalize().map_err(|error| {
416 ToolError::Execution(format!("workspace is unavailable: {error}"))
417 })?,
418 cwd: cwd.clone(),
419 initial_monitors: input.initial_monitors,
420 command,
421 shell: shell.clone(),
422 arguments,
423 sandbox_profile: crate::sandbox::profile(context)?,
424 rows,
425 columns,
426 return_when,
427 wait_ceiling,
428 };
429 let permission = PermissionRequest::new(self.name(), &command_target, ToolEffect::Process)
430 .with_grant_target(start_grant_target(
431 &command_target,
432 &cwd,
433 &shell,
434 &profile_label,
435 ));
436 let mut permissions = vec![permission];
437 for definition in &spec.initial_monitors {
438 if let Some((command, probe_cwd)) = definition_custom_probe(definition) {
439 permissions.push(
440 PermissionRequest::new(self.name(), command, ToolEffect::Process)
441 .with_grant_target(format!(
442 "@fx-terminal-monitor:{}:{probe_cwd}:{command}",
443 probe_cwd.len()
444 )),
445 );
446 }
447 }
448 let review = CommandReview {
449 command: command_target,
450 cwd,
451 shell,
452 profile: profile_label,
453 };
454 Ok(ToolPreparation::Prepared(PreparedToolCall::new(
455 self.name(),
456 permissions,
457 false,
458 Some(ToolReview::Command(review)),
459 PreparedStart {
460 sessions: self.sessions.clone(),
461 spec,
462 },
463 )))
464 }
465
466 fn prepare_monitor(&self, input: Input) -> Result<ToolPreparation, ToolError> {
467 let session_id = input.session_id.ok_or_else(|| {
468 ToolError::InvalidArguments("terminal monitor requires session_id".into())
469 })?;
470 let operation = input.monitor.ok_or_else(|| {
471 ToolError::InvalidArguments("terminal monitor requires monitor operation".into())
472 })?;
473 let Some((command, cwd)) = custom_probe(&operation) else {
474 return Err(ToolError::InvalidArguments(
475 "terminal monitor has no custom probe".into(),
476 ));
477 };
478 let target = format!("@fx-terminal-monitor:{}:{cwd}:{command}", cwd.len());
479 let permission = PermissionRequest::new(self.name(), command, ToolEffect::Process)
480 .with_grant_target(target);
481 Ok(ToolPreparation::Prepared(PreparedToolCall::new(
482 self.name(),
483 vec![permission],
484 false,
485 None,
486 PreparedMonitor {
487 sessions: self.sessions.clone(),
488 session_id,
489 operation,
490 },
491 )))
492 }
493}
494
495struct PreparedStart {
496 sessions: Arc<dyn TerminalSessionHost>,
497 spec: StartSpec,
498}
499
500impl PreparedToolAction for PreparedStart {
501 fn commit<'a>(
502 self: Box<Self>,
503 context: &'a ToolContext,
504 ) -> BoxFuture<'a, Result<ToolOutput, ToolError>> {
505 let cancellation = context.cancellation.clone();
506 Box::pin(async move {
507 tokio::task::spawn_blocking(move || self.sessions.start(self.spec, cancellation))
508 .await
509 .map_err(|error| ToolError::Execution(format!("terminal start failed: {error}")))?
510 })
511 }
512}
513
514struct PreparedMonitor {
515 sessions: Arc<dyn TerminalSessionHost>,
516 session_id: String,
517 operation: MonitorOperation,
518}
519
520impl PreparedToolAction for PreparedMonitor {
521 fn commit<'a>(
522 self: Box<Self>,
523 _context: &'a ToolContext,
524 ) -> BoxFuture<'a, Result<ToolOutput, ToolError>> {
525 Box::pin(async move {
526 tokio::task::spawn_blocking(move || {
527 self.sessions.monitor(&self.session_id, self.operation)
528 })
529 .await
530 .map_err(|error| ToolError::Execution(format!("terminal monitor failed: {error}")))?
531 })
532 }
533}
534
535fn custom_probe(operation: &MonitorOperation) -> Option<(&str, &str)> {
536 let definition = match operation {
537 MonitorOperation::Add { definition } | MonitorOperation::Update { definition, .. } => {
538 definition
539 }
540 MonitorOperation::Pause { .. }
541 | MonitorOperation::Resume { .. }
542 | MonitorOperation::Remove { .. } => return None,
543 };
544 definition_custom_probe(definition)
545}
546
547fn definition_custom_probe(definition: &MonitorDefinition) -> Option<(&str, &str)> {
548 match &definition.condition {
549 crate::monitor::MonitorCondition::CustomProbe { command, cwd } => Some((command, cwd)),
550 _ => None,
551 }
552}
553
554fn execute_session(
555 sessions: Arc<dyn TerminalSessionHost>,
556 input: Input,
557 cancellation: Arc<dyn fx_core::CancellationSignal>,
558) -> Result<ToolOutput, ToolError> {
559 let session_id = || {
560 input.session_id.as_deref().ok_or_else(|| {
561 ToolError::InvalidArguments("terminal action requires session_id".into())
562 })
563 };
564 match input.action {
565 Action::Exec => Err(ToolError::PermissionDenied(
566 "terminal exec requires canonical tool runtime authorization".into(),
567 )),
568 Action::Start => Err(ToolError::PermissionDenied(
569 "terminal start requires canonical tool runtime authorization".into(),
570 )),
571 Action::Read => sessions.read(
572 session_id()?,
573 input.cursor_segment.ok_or_else(|| {
574 ToolError::InvalidArguments("terminal read requires cursor_segment".into())
575 })?,
576 input.cursor_offset.unwrap_or(0),
577 ),
578 Action::Screen => sessions.screen(session_id()?),
579 Action::Write => sessions.write(
580 session_id()?,
581 build_write_payload(input.write.ok_or_else(|| {
582 ToolError::InvalidArguments("terminal write requires write payload".into())
583 })?)?,
584 ),
585 Action::Wait => sessions.wait(
586 session_id()?,
587 build_wait_condition(input.return_when.ok_or_else(|| {
588 ToolError::InvalidArguments("terminal wait requires return_when".into())
589 })?)?,
590 Duration::from_millis(input.wait_ceiling_ms.ok_or_else(|| {
591 ToolError::InvalidArguments("terminal wait requires wait_ceiling_ms".into())
592 })?),
593 cancellation,
594 ),
595 Action::Monitor => sessions.monitor(
596 session_id()?,
597 input.monitor.ok_or_else(|| {
598 ToolError::InvalidArguments("terminal monitor requires monitor operation".into())
599 })?,
600 ),
601 Action::Inspect => sessions.inspect(
602 session_id()?,
603 input.after_event_id,
604 input.acknowledge_event_id,
605 usize::from(input.max_events.unwrap_or(256)),
606 ),
607 Action::List => sessions.list(input.backend.map(map_backend)),
608 Action::Resize => sessions.resize(
609 session_id()?,
610 input.rows.ok_or_else(|| {
611 ToolError::InvalidArguments("terminal resize requires rows".into())
612 })?,
613 input.columns.ok_or_else(|| {
614 ToolError::InvalidArguments("terminal resize requires columns".into())
615 })?,
616 ),
617 Action::Signal => sessions.signal(
618 session_id()?,
619 map_signal(input.signal.ok_or_else(|| {
620 ToolError::InvalidArguments("terminal signal requires signal".into())
621 })?),
622 ),
623 Action::Close => sessions.close(
624 session_id()?,
625 match input.close_policy.ok_or_else(|| {
626 ToolError::InvalidArguments("terminal close requires close_policy".into())
627 })? {
628 ClosePolicyInput::Graceful => ClosePolicy::Graceful,
629 ClosePolicyInput::Force => ClosePolicy::Force,
630 },
631 ),
632 }
633}
634
635fn decode(arguments: &Value) -> Result<Input, ToolError> {
636 let object = arguments.as_object().ok_or_else(|| {
637 ToolError::InvalidArguments("terminal arguments must be an object".into())
638 })?;
639 let action: Action = serde_json::from_value(
640 object
641 .get("action")
642 .cloned()
643 .ok_or_else(|| ToolError::InvalidArguments("terminal action is required".into()))?,
644 )
645 .map_err(|error| ToolError::InvalidArguments(error.to_string()))?;
646 let allowed = allowed_fields(action);
647 let invalid: Vec<_> = object
648 .iter()
649 .filter(|(_, value)| !value.is_null())
650 .map(|(name, _)| name.as_str())
651 .filter(|name| !allowed.contains(name))
652 .collect();
653 if !invalid.is_empty() {
654 return Err(ToolError::InvalidArguments(format!(
655 "terminal {} does not accept fields: {}",
656 action.as_str(),
657 invalid.join(", ")
658 )));
659 }
660 let input: Input = serde_json::from_value(arguments.clone())
661 .map_err(|error| ToolError::InvalidArguments(error.to_string()))?;
662 validate_input(&input)?;
663 Ok(input)
664}
665
666fn validate_input(input: &Input) -> Result<(), ToolError> {
667 if input.profile.is_some() && input.shell.is_some() {
668 return Err(ToolError::InvalidArguments(
669 "terminal start fields profile and shell are mutually exclusive".into(),
670 ));
671 }
672 if let Some(command) = input.command.as_deref()
673 && (command.len() > MAX_COMMAND_BYTES || command.contains('\0'))
674 {
675 return Err(ToolError::InvalidArguments(
676 "terminal command must be NUL-free and at most 65536 bytes".into(),
677 ));
678 }
679 if input.action == Action::Exec && input.command.as_deref().is_none_or(str::is_empty) {
680 return Err(ToolError::InvalidArguments(
681 "terminal exec requires a nonempty command".into(),
682 ));
683 }
684 if let Some(session_id) = input.session_id.as_deref()
685 && (session_id.is_empty() || session_id.len() > 128 || session_id.contains('\0'))
686 {
687 return Err(ToolError::InvalidArguments(
688 "terminal session_id is invalid".into(),
689 ));
690 }
691 if input.wait_ceiling_ms == Some(0) {
692 return Err(ToolError::InvalidArguments(
693 "terminal wait_ceiling_ms must be positive".into(),
694 ));
695 }
696 if input.max_events == Some(0) || input.max_events.is_some_and(|value| value > 256) {
697 return Err(ToolError::InvalidArguments(
698 "terminal max_events must be between 1 and 256".into(),
699 ));
700 }
701 if let Some(operation) = &input.monitor {
702 operation
703 .validate()
704 .map_err(|error| ToolError::InvalidArguments(error.to_string()))?;
705 }
706 if input.initial_monitors.len() > 32 {
707 return Err(ToolError::InvalidArguments(
708 "terminal start accepts at most 32 initial monitors".into(),
709 ));
710 }
711 for definition in &input.initial_monitors {
712 definition
713 .validate()
714 .map_err(|error| ToolError::InvalidArguments(error.to_string()))?;
715 }
716 let _ = input.backend;
717 let _ = input.after_event_id;
718 let _ = input.acknowledge_event_id;
719 Ok(())
720}
721
722fn allowed_fields(action: Action) -> &'static [&'static str] {
723 match action {
724 Action::Exec => &["action", "command", "cwd", "profile"],
725 Action::Start => &[
726 "action",
727 "cwd",
728 "command",
729 "profile",
730 "shell",
731 "backend",
732 "return_when",
733 "wait_ceiling_ms",
734 "dimensions",
735 "initial_monitors",
736 ],
737 Action::Read => &["action", "session_id", "cursor_segment", "cursor_offset"],
738 Action::Screen => &["action", "session_id"],
739 Action::Write => &["action", "session_id", "write"],
740 Action::Wait => &["action", "session_id", "return_when", "wait_ceiling_ms"],
741 Action::Monitor => &["action", "session_id", "monitor"],
742 Action::Inspect => &[
743 "action",
744 "session_id",
745 "after_event_id",
746 "acknowledge_event_id",
747 "max_events",
748 ],
749 Action::List => &["action", "backend"],
750 Action::Resize => &["action", "session_id", "rows", "columns"],
751 Action::Signal => &["action", "session_id", "signal"],
752 Action::Close => &["action", "session_id", "close_policy"],
753 }
754}
755
756fn build_wait_condition(input: ReturnInput) -> Result<WaitCondition, ToolError> {
757 match input.kind {
758 ReturnKind::Started => Ok(WaitCondition::Started),
759 ReturnKind::Exit => Ok(WaitCondition::Exit),
760 ReturnKind::Quiet => input
761 .duration_ms
762 .filter(|duration| *duration > 0)
763 .map(Duration::from_millis)
764 .map(WaitCondition::Quiet)
765 .ok_or_else(|| {
766 ToolError::InvalidArguments("terminal quiet return requires duration_ms".into())
767 }),
768 ReturnKind::Match => input
769 .pattern
770 .filter(|pattern| !pattern.is_empty() && pattern.len() <= MAX_MATCH_BYTES)
771 .map(WaitCondition::Match)
772 .ok_or_else(|| {
773 ToolError::InvalidArguments("terminal match return requires pattern".into())
774 }),
775 }
776}
777
778fn build_write_payload(input: WriteInput) -> Result<WritePayload, ToolError> {
779 match input.kind {
780 PayloadKind::Text | PayloadKind::Paste => {
781 let text = input.text.filter(|text| !text.is_empty()).ok_or_else(|| {
782 ToolError::InvalidArguments("terminal text/paste write requires text".into())
783 })?;
784 if text.len() > MAX_WRITE_BYTES {
785 return Err(ToolError::InvalidArguments(
786 "terminal write is larger than 65536 bytes".into(),
787 ));
788 }
789 Ok(if matches!(input.kind, PayloadKind::Text) {
790 WritePayload::Text(text)
791 } else {
792 WritePayload::Paste(text)
793 })
794 }
795 PayloadKind::Keys => {
796 if input.keys.is_empty() || input.keys.len() > MAX_WRITE_ITEMS {
797 return Err(ToolError::InvalidArguments(
798 "terminal keys write requires 1 to 4096 keys".into(),
799 ));
800 }
801 Ok(WritePayload::Keys(
802 input.keys.into_iter().map(map_key).collect(),
803 ))
804 }
805 PayloadKind::Controls => {
806 if input.controls.is_empty()
807 || input.controls.len() > MAX_WRITE_ITEMS
808 || input
809 .controls
810 .iter()
811 .any(|value| !matches!(value, b'?' | b'@'..=b'_' | b'a'..=b'z'))
812 {
813 return Err(ToolError::InvalidArguments(
814 "terminal controls require 1 to 4096 printable control designators".into(),
815 ));
816 }
817 Ok(WritePayload::Controls(input.controls))
818 }
819 }
820}
821
822fn map_key(key: NamedKeyInput) -> NamedKey {
823 match key {
824 NamedKeyInput::Enter => NamedKey::Enter,
825 NamedKeyInput::Tab => NamedKey::Tab,
826 NamedKeyInput::Escape => NamedKey::Escape,
827 NamedKeyInput::Backspace => NamedKey::Backspace,
828 NamedKeyInput::Delete => NamedKey::Delete,
829 NamedKeyInput::Insert => NamedKey::Insert,
830 NamedKeyInput::ArrowUp => NamedKey::ArrowUp,
831 NamedKeyInput::ArrowDown => NamedKey::ArrowDown,
832 NamedKeyInput::ArrowLeft => NamedKey::ArrowLeft,
833 NamedKeyInput::ArrowRight => NamedKey::ArrowRight,
834 NamedKeyInput::Home => NamedKey::Home,
835 NamedKeyInput::End => NamedKey::End,
836 NamedKeyInput::PageUp => NamedKey::PageUp,
837 NamedKeyInput::PageDown => NamedKey::PageDown,
838 }
839}
840
841fn map_signal(signal: SignalInput) -> TerminalSignal {
842 match signal {
843 SignalInput::Hangup => TerminalSignal::Hangup,
844 SignalInput::Interrupt => TerminalSignal::Interrupt,
845 SignalInput::Quit => TerminalSignal::Quit,
846 SignalInput::Terminate => TerminalSignal::Terminate,
847 SignalInput::Kill => TerminalSignal::Kill,
848 }
849}
850
851fn map_backend(backend: Backend) -> SessionBackend {
852 match backend {
853 Backend::Native => SessionBackend::Native,
854 Backend::Tmux => SessionBackend::Tmux,
855 }
856}
857
858fn start_arguments(
859 shell: &Path,
860 profile: Profile,
861 command: Option<&str>,
862) -> Result<Vec<String>, ToolError> {
863 let mut arguments = match (shell.file_name().and_then(|name| name.to_str()), profile) {
864 (Some("bash"), Profile::Clean) => {
865 vec!["--noprofile".into(), "--norc".into(), "-i".into()]
866 }
867 (Some("bash"), Profile::User) => vec!["--login".into(), "-i".into()],
868 (Some("zsh"), Profile::Clean) => vec!["-f".into(), "-i".into()],
869 (Some("zsh"), Profile::User) => vec!["-l".into(), "-i".into()],
870 _ => {
871 return Err(ToolError::InvalidArguments(
872 "terminal start supports only Bash and zsh".into(),
873 ));
874 }
875 };
876 if let Some(command) = command {
877 arguments.push("-c".into());
878 arguments.push(command.into());
879 }
880 Ok(arguments)
881}
882
883fn start_grant_target(command: &str, cwd: &Path, shell: &Path, profile: &str) -> String {
884 let shell = shell.to_string_lossy();
885 let cwd = cwd.to_string_lossy();
886 format!(
887 "@fx-terminal-env:{profile}:{}:{shell}:{}:{cwd}:{command}",
888 shell.len(),
889 cwd.len()
890 )
891}
892
893fn monitor_operation_schema() -> Value {
894 json!({
895 "type": ["object", "null"],
896 "properties": {
897 "kind": {"type": "string", "enum": ["add", "update", "pause", "resume", "remove"]},
898 "monitor_id": {"type": ["string", "null"], "maxLength": crate::monitor::MAXIMUM_MONITOR_ID_BYTES},
899 "definition": monitor_definition_schema()
900 },
901 "required": ["kind"],
902 "additionalProperties": false
903 })
904}
905
906fn monitor_definition_schema() -> Value {
907 json!({
908 "type": ["object", "null"],
909 "properties": {
910 "condition": {
911 "type": "object",
912 "properties": {
913 "kind": {"type": "string", "enum": ["process_exit", "exit_code", "signal", "output_contains", "output_matches", "output_quiet", "screen_matches", "tcp_ready", "http_ready", "path_exists", "path_changed", "path_size", "custom_probe"]},
914 "pattern": {"type": ["string", "null"], "maxLength": crate::monitor::MAXIMUM_AUTHORITY_TEXT_BYTES},
915 "duration_ms": {"type": ["integer", "null"], "minimum": crate::monitor::MINIMUM_SCHEDULE_MS, "maximum": crate::monitor::MAXIMUM_SCHEDULE_MS},
916 "exit_code": {"type": ["integer", "null"]},
917 "signal": {"type": ["string", "null"], "enum": ["hangup", "interrupt", "quit", "terminate", "kill", null]},
918 "host": {"type": ["string", "null"], "maxLength": crate::monitor::MAXIMUM_AUTHORITY_TEXT_BYTES},
919 "port": {"type": ["integer", "null"], "minimum": 1, "maximum": 65535},
920 "path": {"type": ["string", "null"], "maxLength": crate::monitor::MAXIMUM_AUTHORITY_TEXT_BYTES},
921 "minimum_bytes": {"type": ["integer", "null"], "minimum": 0},
922 "command": {"type": ["string", "null"], "maxLength": crate::monitor::MAXIMUM_COMMAND_BYTES},
923 "cwd": {"type": ["string", "null"], "maxLength": crate::monitor::MAXIMUM_AUTHORITY_TEXT_BYTES}
924 },
925 "required": ["kind"],
926 "additionalProperties": false
927 },
928 "check_interval_ms": {"type": ["integer", "null"], "minimum": crate::monitor::MINIMUM_SCHEDULE_MS, "maximum": crate::monitor::MAXIMUM_SCHEDULE_MS},
929 "notify": {
930 "type": "object",
931 "properties": {
932 "kind": {"type": "string", "enum": ["on_match", "on_state_change", "on_exit", "every_check", "every_n_checks", "interval"]},
933 "count": {"type": ["integer", "null"], "minimum": 1, "maximum": 1000000},
934 "interval_ms": {"type": ["integer", "null"], "minimum": crate::monitor::MINIMUM_SCHEDULE_MS, "maximum": crate::monitor::MAXIMUM_SCHEDULE_MS}
935 },
936 "required": ["kind"],
937 "additionalProperties": false
938 },
939 "lifetime": {
940 "type": "object",
941 "properties": {
942 "kind": {"type": "string", "enum": ["until_match", "until_session_end", "duration"]},
943 "duration_ms": {"type": ["integer", "null"], "minimum": 1, "maximum": crate::monitor::MAXIMUM_LIFETIME_MS}
944 },
945 "required": ["kind"],
946 "additionalProperties": false
947 }
948 },
949 "required": ["condition", "notify", "lifetime"],
950 "additionalProperties": false
951 })
952}
953
954fn terminal_schema(backends: &[&str]) -> Value {
955 let mut backend_values: Vec<Value> = backends.iter().map(|backend| json!(backend)).collect();
956 backend_values.push(Value::Null);
957 json!({
958 "type": "object",
959 "properties": {
960 "action": {"type": "string", "enum": ["exec", "start", "read", "screen", "write", "wait", "monitor", "inspect", "list", "resize", "signal", "close"]},
961 "session_id": {"type": ["string", "null"]},
962 "cwd": {"type": ["string", "null"]},
963 "command": {"type": ["string", "null"], "maxLength": MAX_COMMAND_BYTES},
964 "profile": {"type": ["string", "null"], "enum": ["clean", "user", null]},
965 "shell": {
966 "type": ["object", "null"],
967 "properties": {
968 "kind": {"type": "string", "enum": ["user_login", "executable"]},
969 "path": {"type": ["string", "null"]},
970 "clean_start": {"type": "boolean"}
971 },
972 "additionalProperties": false
973 },
974 "backend": {"type": ["string", "null"], "enum": backend_values},
975 "return_when": {
976 "type": ["object", "null"],
977 "properties": {
978 "kind": {"type": "string", "enum": ["started", "exit", "quiet", "match"]},
979 "duration_ms": {"type": ["integer", "null"], "minimum": 1},
980 "pattern": {"type": ["string", "null"], "maxLength": MAX_MATCH_BYTES}
981 },
982 "required": ["kind"],
983 "additionalProperties": false
984 },
985 "wait_ceiling_ms": {"type": ["integer", "null"], "minimum": 1},
986 "dimensions": {
987 "type": ["object", "null"],
988 "properties": {
989 "rows": {"type": "integer", "minimum": 1, "maximum": 4096},
990 "columns": {"type": "integer", "minimum": 1, "maximum": 4096}
991 },
992 "required": ["rows", "columns"],
993 "additionalProperties": false
994 },
995 "initial_monitors": {"type": "array", "maxItems": 32, "items": monitor_definition_schema()},
996 "cursor_segment": {"type": ["integer", "null"], "minimum": 1},
997 "cursor_offset": {"type": ["integer", "null"], "minimum": 0},
998 "after_event_id": {"type": ["integer", "null"], "minimum": 0},
999 "acknowledge_event_id": {"type": ["integer", "null"], "minimum": 1},
1000 "max_events": {"type": ["integer", "null"], "minimum": 1, "maximum": 256},
1001 "write": {
1002 "type": ["object", "null"],
1003 "properties": {
1004 "kind": {"type": "string", "enum": ["text", "keys", "controls", "paste"]},
1005 "text": {"type": ["string", "null"], "maxLength": MAX_WRITE_BYTES},
1006 "keys": {"type": "array", "maxItems": MAX_WRITE_ITEMS, "items": {"type": "string", "enum": ["enter", "tab", "escape", "backspace", "delete", "insert", "arrow_up", "arrow_down", "arrow_left", "arrow_right", "home", "end", "page_up", "page_down"]}},
1007 "controls": {"type": "array", "maxItems": MAX_WRITE_ITEMS, "items": {"type": "integer", "minimum": 0, "maximum": 127}}
1008 },
1009 "required": ["kind"],
1010 "additionalProperties": false
1011 },
1012 "monitor": monitor_operation_schema(),
1013 "rows": {"type": ["integer", "null"], "minimum": 1, "maximum": 4096},
1014 "columns": {"type": ["integer", "null"], "minimum": 1, "maximum": 4096},
1015 "signal": {"type": ["string", "null"], "enum": ["hangup", "interrupt", "quit", "terminate", "kill", null]},
1016 "close_policy": {"type": ["string", "null"], "enum": ["graceful", "force", null]}
1017 },
1018 "required": ["action"],
1019 "additionalProperties": false
1020 })
1021}
1022
1023#[cfg(all(test, unix))]
1024mod tests {
1025 use std::time::Instant;
1026
1027 use fx_core::ToolPreparation;
1028
1029 use super::*;
1030
1031 fn shell() -> PathBuf {
1032 ["/bin/zsh", "/bin/bash"]
1033 .into_iter()
1034 .map(PathBuf::from)
1035 .find(|path| path.is_file())
1036 .expect("test host has Bash or zsh")
1037 }
1038
1039 fn context() -> ToolContext {
1040 ToolContext::new(std::env::current_dir().unwrap())
1041 }
1042
1043 async fn start(tool: &Terminal, arguments: Value) -> ToolOutput {
1044 let context = context();
1045 let ToolPreparation::Prepared(prepared) = tool.prepare(&context, &arguments).unwrap()
1046 else {
1047 panic!("terminal start must prepare owned execution")
1048 };
1049 prepared.commit(&context).await.unwrap()
1050 }
1051
1052 async fn call(tool: &Terminal, arguments: Value) -> ToolOutput {
1053 tool.execute(&context(), arguments).await.unwrap()
1054 }
1055
1056 fn session_id(output: &ToolOutput) -> String {
1057 output.structured.as_ref().unwrap()["success"]["start"]["session"]["session_id"]
1058 .as_str()
1059 .unwrap()
1060 .to_owned()
1061 }
1062
1063 #[test]
1064 fn schema_advertises_only_the_implemented_terminal_actions() {
1065 let schema = Terminal::default().input_schema();
1066 let actions = schema["properties"]["action"]["enum"].as_array().unwrap();
1067 assert!(actions.contains(&json!("exec")));
1068 assert!(actions.contains(&json!("start")));
1069 assert!(actions.contains(&json!("screen")));
1070 assert!(actions.contains(&json!("close")));
1071 assert!(actions.contains(&json!("monitor")));
1072 assert_eq!(
1073 schema["properties"]["monitor"]["properties"]["definition"]["properties"]["condition"]
1074 ["properties"]["kind"]["enum"][7],
1075 "tcp_ready"
1076 );
1077 let backends = schema["properties"]["backend"]["enum"].as_array().unwrap();
1078 assert!(backends.contains(&json!("native")));
1079 assert!(backends.contains(&Value::Null));
1080 }
1081
1082 #[tokio::test(flavor = "current_thread")]
1083 async fn native_pty_session_supports_start_read_screen_write_wait_resize_and_close() {
1084 let tool = Terminal::with_local_login_shell(shell());
1085 let started = start(
1086 &tool,
1087 json!({
1088 "action": "start",
1089 "profile": "clean",
1090 "command": "printf ready; IFS= read -r line; printf '\\ngot:%s\\n' \"$line\"",
1091 "return_when": {"kind": "match", "pattern": "ready"},
1092 "wait_ceiling_ms": 2000,
1093 "dimensions": {"rows": 8, "columns": 40}
1094 }),
1095 )
1096 .await;
1097 assert!(!started.is_error, "{}", started.content);
1098 assert_eq!(
1099 started.structured.as_ref().unwrap()["success"]["start"]["outcome"],
1100 json!({"condition_met": {}})
1101 );
1102 let id = session_id(&started);
1103
1104 let first = call(
1105 &tool,
1106 json!({
1107 "action": "read",
1108 "session_id": id,
1109 "cursor_segment": 1,
1110 "cursor_offset": 0
1111 }),
1112 )
1113 .await;
1114 assert!(first.content.contains("ready"), "{}", first.content);
1115 let cursor =
1116 first.structured.as_ref().unwrap()["success"]["read"]["raw_range"]["end"]["offset"]
1117 .as_u64()
1118 .unwrap();
1119
1120 let screen = call(&tool, json!({"action": "screen", "session_id": id})).await;
1121 let snapshot = &screen.structured.as_ref().unwrap()["success"]["screen"]["snapshot"];
1122 assert_eq!(snapshot["dimensions"], json!({"rows": 8, "columns": 40}));
1123 assert!(
1124 snapshot["cells"]
1125 .as_array()
1126 .unwrap()
1127 .iter()
1128 .any(|cell| cell["text"] == "r")
1129 );
1130
1131 let resized = call(
1132 &tool,
1133 json!({"action": "resize", "session_id": id, "rows": 10, "columns": 50}),
1134 )
1135 .await;
1136 assert_eq!(
1137 resized.structured.as_ref().unwrap()["success"]["resize"]["dimensions"],
1138 json!({"rows": 10, "columns": 50})
1139 );
1140
1141 let written = call(
1142 &tool,
1143 json!({
1144 "action": "write",
1145 "session_id": id,
1146 "write": {"kind": "text", "text": "hello\n"}
1147 }),
1148 )
1149 .await;
1150 assert_eq!(
1151 written.structured.as_ref().unwrap()["success"]["write"]["accepted_bytes"],
1152 6
1153 );
1154
1155 let before_wait = Instant::now();
1156 let waited = call(
1157 &tool,
1158 json!({
1159 "action": "wait",
1160 "session_id": id,
1161 "return_when": {"kind": "exit"},
1162 "wait_ceiling_ms": 2000
1163 }),
1164 )
1165 .await;
1166 assert!(before_wait.elapsed() < Duration::from_secs(3));
1167 assert!(
1168 waited.structured.as_ref().unwrap()["success"]["wait"]["outcome"]
1169 .get("exited")
1170 .is_some(),
1171 "{}",
1172 waited.content
1173 );
1174
1175 let second = call(
1176 &tool,
1177 json!({
1178 "action": "read",
1179 "session_id": id,
1180 "cursor_segment": 1,
1181 "cursor_offset": cursor
1182 }),
1183 )
1184 .await;
1185 assert!(second.content.contains("got:hello"), "{}", second.content);
1186
1187 let listed = call(&tool, json!({"action": "list"})).await;
1188 assert_eq!(
1189 listed.structured.as_ref().unwrap()["success"]["list"]["sessions"]
1190 .as_array()
1191 .unwrap()
1192 .len(),
1193 1
1194 );
1195 let inspected = call(&tool, json!({"action": "inspect", "session_id": id})).await;
1196 assert_eq!(
1197 inspected.structured.as_ref().unwrap()["success"]["inspect"]["cwd"],
1198 context().workspace_root.display().to_string()
1199 );
1200
1201 let closed = call(
1202 &tool,
1203 json!({"action": "close", "session_id": id, "close_policy": "force"}),
1204 )
1205 .await;
1206 assert_eq!(
1207 closed.structured.as_ref().unwrap()["success"]["close"]["session"]["lifecycle"],
1208 "closed"
1209 );
1210 let listed = call(&tool, json!({"action": "list"})).await;
1211 assert!(
1212 listed.structured.as_ref().unwrap()["success"]["list"]["sessions"]
1213 .as_array()
1214 .unwrap()
1215 .is_empty()
1216 );
1217 }
1218
1219 #[test]
1220 fn action_field_ownership_rejects_cross_action_arguments() {
1221 let error = decode(&json!({
1222 "action": "read",
1223 "session_id": "terminal-a",
1224 "cursor_segment": 1,
1225 "command": "pwd"
1226 }))
1227 .unwrap_err();
1228 assert!(
1229 error
1230 .to_string()
1231 .contains("does not accept fields: command")
1232 );
1233 }
1234}