Skip to main content

fxrs_process/
terminal_host_protocol.rs

1//! Bounded wire protocol for the detached terminal supervisor.
2
3use std::io::{Read, Write};
4use std::path::PathBuf;
5
6use fx_core::ToolOutput;
7use serde::de::DeserializeOwned;
8use serde::{Deserialize, Serialize};
9
10use crate::monitor::{MonitorDefinition, MonitorOperation};
11
12pub const PROTOCOL_MINIMUM: u16 = 6;
13pub const PROTOCOL_CURRENT: u16 = 6;
14pub const CAPABILITY_FRAMED_JSON: u64 = 1 << 0;
15pub const CAPABILITY_TERMINAL_SESSIONS: u64 = 1 << 1;
16pub const CAPABILITY_CANCELLATION: u64 = 1 << 2;
17pub const CAPABILITY_TERMINAL_MONITORS: u64 = 1 << 3;
18pub const MAXIMUM_FRAME_BYTES: usize = 1024 * 1024;
19
20#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
21pub struct ProtocolHello {
22    pub minimum: u16,
23    pub current: u16,
24    pub capabilities: u64,
25}
26
27impl ProtocolHello {
28    pub const fn local() -> Self {
29        Self {
30            minimum: PROTOCOL_MINIMUM,
31            current: PROTOCOL_CURRENT,
32            capabilities: CAPABILITY_FRAMED_JSON
33                | CAPABILITY_TERMINAL_SESSIONS
34                | CAPABILITY_CANCELLATION
35                | CAPABILITY_TERMINAL_MONITORS,
36        }
37    }
38
39    pub fn negotiate(self, peer: Self) -> Result<u16, ProtocolError> {
40        if self.minimum == 0
41            || peer.minimum == 0
42            || self.minimum > self.current
43            || peer.minimum > peer.current
44        {
45            return Err(ProtocolError::InvalidHello);
46        }
47        let minimum = self.minimum.max(peer.minimum);
48        let current = self.current.min(peer.current);
49        if minimum > current {
50            return Err(ProtocolError::IncompatibleVersion);
51        }
52        if self.capabilities & CAPABILITY_FRAMED_JSON == 0
53            || peer.capabilities & CAPABILITY_FRAMED_JSON == 0
54        {
55            return Err(ProtocolError::MissingCapability);
56        }
57        Ok(current)
58    }
59}
60
61#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
62#[serde(tag = "kind", rename_all = "snake_case")]
63pub enum HostRequest {
64    Hello {
65        hello: ProtocolHello,
66    },
67    Ping,
68    Terminal {
69        request_id: String,
70        operation: Box<TerminalOperation>,
71    },
72    Cancel {
73        request_id: String,
74    },
75}
76
77#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
78#[serde(tag = "kind", rename_all = "snake_case")]
79pub enum HostResponse {
80    Hello {
81        hello: ProtocolHello,
82        negotiated: u16,
83        instance_id: String,
84    },
85    Pong,
86    Terminal {
87        output: ToolOutput,
88    },
89    Cancelled {
90        accepted: bool,
91    },
92    Failure {
93        code: String,
94        message: String,
95    },
96}
97
98#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
99#[serde(rename_all = "snake_case")]
100pub enum TerminalBackend {
101    Native,
102    Tmux,
103}
104
105#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
106#[serde(tag = "kind", rename_all = "snake_case")]
107pub enum ReturnCondition {
108    Started,
109    Exit,
110    Quiet { duration_ms: u64 },
111    Match { pattern: String },
112}
113
114#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
115#[serde(rename_all = "snake_case")]
116pub enum TerminalKey {
117    Enter,
118    Tab,
119    Escape,
120    Backspace,
121    Delete,
122    Insert,
123    ArrowUp,
124    ArrowDown,
125    ArrowLeft,
126    ArrowRight,
127    Home,
128    End,
129    PageUp,
130    PageDown,
131}
132
133#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
134#[serde(tag = "kind", rename_all = "snake_case")]
135pub enum TerminalWrite {
136    Text { text: String },
137    Keys { keys: Vec<TerminalKey> },
138    Controls { controls: Vec<u8> },
139    Paste { text: String },
140}
141
142#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
143#[serde(rename_all = "snake_case")]
144pub enum TerminalSignal {
145    Hangup,
146    Interrupt,
147    Quit,
148    Terminate,
149    Kill,
150}
151
152#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
153#[serde(rename_all = "snake_case")]
154pub enum TerminalClosePolicy {
155    Graceful,
156    Force,
157}
158
159#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
160#[serde(tag = "action", rename_all = "snake_case")]
161pub enum TerminalOperation {
162    Start {
163        backend: TerminalBackend,
164        workspace_root: PathBuf,
165        cwd: PathBuf,
166        initial_monitors: Vec<MonitorDefinition>,
167        command: Option<String>,
168        shell: PathBuf,
169        arguments: Vec<String>,
170        sandbox_profile: Option<String>,
171        rows: u16,
172        columns: u16,
173        return_when: Option<ReturnCondition>,
174        wait_ceiling_ms: Option<u64>,
175    },
176    Read {
177        session_id: String,
178        cursor_segment: u64,
179        cursor_offset: u64,
180    },
181    Screen {
182        session_id: String,
183    },
184    Write {
185        session_id: String,
186        write: TerminalWrite,
187    },
188    Wait {
189        session_id: String,
190        return_when: ReturnCondition,
191        wait_ceiling_ms: u64,
192    },
193    Monitor {
194        session_id: String,
195        operation: MonitorOperation,
196    },
197    Inspect {
198        session_id: String,
199        after_event_id: Option<u64>,
200        acknowledge_event_id: Option<u64>,
201        max_events: u16,
202    },
203    List {
204        backend: Option<TerminalBackend>,
205    },
206    Resize {
207        session_id: String,
208        rows: u16,
209        columns: u16,
210    },
211    Signal {
212        session_id: String,
213        signal: TerminalSignal,
214    },
215    Close {
216        session_id: String,
217        close_policy: TerminalClosePolicy,
218    },
219}
220
221pub fn write_frame<T: Serialize>(writer: &mut impl Write, value: &T) -> Result<(), ProtocolError> {
222    let payload = serde_json::to_vec(value).map_err(ProtocolError::Encode)?;
223    if payload.is_empty() || payload.len() > MAXIMUM_FRAME_BYTES {
224        return Err(ProtocolError::FrameSize);
225    }
226    let length = u32::try_from(payload.len()).map_err(|_| ProtocolError::FrameSize)?;
227    writer
228        .write_all(&length.to_be_bytes())
229        .map_err(ProtocolError::Io)?;
230    writer.write_all(&payload).map_err(ProtocolError::Io)?;
231    writer.flush().map_err(ProtocolError::Io)
232}
233
234pub fn read_frame<T: DeserializeOwned>(reader: &mut impl Read) -> Result<T, ProtocolError> {
235    let mut prefix = [0_u8; 4];
236    reader.read_exact(&mut prefix).map_err(ProtocolError::Io)?;
237    let length =
238        usize::try_from(u32::from_be_bytes(prefix)).map_err(|_| ProtocolError::FrameSize)?;
239    if length == 0 || length > MAXIMUM_FRAME_BYTES {
240        return Err(ProtocolError::FrameSize);
241    }
242    let mut payload = vec![0; length];
243    reader.read_exact(&mut payload).map_err(ProtocolError::Io)?;
244    serde_json::from_slice(&payload).map_err(ProtocolError::Decode)
245}
246
247#[derive(Debug)]
248pub enum ProtocolError {
249    Io(std::io::Error),
250    Encode(serde_json::Error),
251    Decode(serde_json::Error),
252    FrameSize,
253    InvalidHello,
254    IncompatibleVersion,
255    MissingCapability,
256}
257
258impl std::fmt::Display for ProtocolError {
259    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
260        match self {
261            Self::Io(error) => write!(formatter, "terminal host I/O: {error}"),
262            Self::Encode(error) => write!(formatter, "encode terminal host frame: {error}"),
263            Self::Decode(error) => write!(formatter, "decode terminal host frame: {error}"),
264            Self::FrameSize => formatter.write_str("terminal host frame has an invalid size"),
265            Self::InvalidHello => formatter.write_str("terminal host hello is invalid"),
266            Self::IncompatibleVersion => {
267                formatter.write_str("terminal host protocol versions are incompatible")
268            }
269            Self::MissingCapability => {
270                formatter.write_str("terminal host is missing a required capability")
271            }
272        }
273    }
274}
275
276impl std::error::Error for ProtocolError {
277    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
278        match self {
279            Self::Io(error) => Some(error),
280            Self::Encode(error) | Self::Decode(error) => Some(error),
281            _ => None,
282        }
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    #[test]
291    fn framed_round_trip_is_exact_and_bounded() {
292        let request = HostRequest::Hello {
293            hello: ProtocolHello::local(),
294        };
295        let mut bytes = Vec::new();
296        write_frame(&mut bytes, &request).unwrap();
297        assert_eq!(
298            read_frame::<HostRequest>(&mut bytes.as_slice()).unwrap(),
299            request
300        );
301
302        let oversized = u32::try_from(MAXIMUM_FRAME_BYTES + 1)
303            .unwrap()
304            .to_be_bytes();
305        assert!(matches!(
306            read_frame::<HostRequest>(&mut oversized.as_slice()),
307            Err(ProtocolError::FrameSize)
308        ));
309    }
310
311    #[test]
312    fn negotiation_rejects_invalid_disjoint_and_incapable_peers() {
313        assert_eq!(
314            ProtocolHello::local()
315                .negotiate(ProtocolHello::local())
316                .unwrap(),
317            PROTOCOL_CURRENT
318        );
319        assert!(matches!(
320            ProtocolHello::local().negotiate(ProtocolHello {
321                minimum: 2,
322                current: 2,
323                capabilities: CAPABILITY_FRAMED_JSON,
324            }),
325            Err(ProtocolError::IncompatibleVersion)
326        ));
327        assert!(matches!(
328            ProtocolHello::local().negotiate(ProtocolHello {
329                minimum: PROTOCOL_MINIMUM,
330                current: PROTOCOL_CURRENT,
331                capabilities: 0,
332            }),
333            Err(ProtocolError::MissingCapability)
334        ));
335    }
336}