Skip to main content

fxrs_process/
terminal.rs

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                // Session ownership is capability authority. Once a reviewed
316                // start created the session, control actions do not repeatedly
317                // ask the user for the same process authority.
318                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}