1use 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}