1#![cfg(unix)]
7
8use crate::llm::actions::{
9 protocol_trait::{ActionResult, Protocol, Server},
10 ActionDefinition, Parameter,
11};
12use crate::protocol::log_template::LogTemplate;
13use crate::protocol::EventType;
14use crate::state::app_state::AppState;
15use anyhow::{Context, Result};
16use serde_json::json;
17use std::sync::LazyLock;
18
19pub struct PtyProtocol;
25
26impl PtyProtocol {
27 pub fn new() -> Self {
28 Self
29 }
30}
31
32impl Default for PtyProtocol {
33 fn default() -> Self {
34 Self::new()
35 }
36}
37
38impl Protocol for PtyProtocol {
39 fn default_binding(&self) -> Option<crate::protocol::BindingDefaults> {
41 Some(crate::protocol::BindingDefaults {
42 mac_address: None,
43 interface: None,
44 host: None,
45 port: None,
46 })
47 }
48
49 fn get_startup_parameters(&self) -> Vec<crate::llm::actions::ParameterDefinition> {
50 vec![
51 crate::llm::actions::ParameterDefinition {
52 name: "link_path".to_string(),
53 type_hint: "string".to_string(),
54 description: "Optional filesystem path to symlink to the allocated slave PTY device \
55 (e.g. /dev/ttys003), so a terminal program can open it at a stable path. \
56 Without it the slave device name is only visible in the logs. A terminal client \
57 then opens this path: `screen ./netget.pty`, `cat ./netget.pty`."
58 .to_string(),
59 required: false,
60 example: serde_json::json!("./netget.pty"),
61 default: None,
62 },
63 crate::llm::actions::ParameterDefinition {
64 name: "send_first".to_string(),
65 type_hint: "boolean".to_string(),
66 description: "If true, raise a pty_opened event as soon as the PTY is ready so the \
67 model can print an initial banner or prompt onto the terminal before any input."
68 .to_string(),
69 required: false,
70 example: serde_json::json!(true),
71 default: None,
72 },
73 ]
74 }
75
76 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
77 Vec::new()
79 }
80
81 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
82 vec![write_pty_output_action()]
83 }
84
85 fn protocol_name(&self) -> &'static str {
86 "PTY"
87 }
88
89 fn get_event_types(&self) -> Vec<EventType> {
90 get_pty_event_types()
91 }
92
93 fn stack_name(&self) -> &'static str {
94 "PTY"
95 }
96
97 fn keywords(&self) -> Vec<&'static str> {
98 vec!["pseudo_terminal", "pty_master", "fake_tty"]
99 }
100
101 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
102 use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
103
104 ProtocolMetadataV2::builder()
105 .state(DevelopmentState::Experimental)
106 .implementation("libc::openpty; slave set raw (cfmakeraw); master via tokio AsyncFd")
107 .llm_control("Full byte stream: reacts to terminal input, writes bytes onto the PTY")
108 .e2e_testing("Real terminal client opens the slave device and drives it (std::fs)")
109 .notes(
110 "Pseudo-terminal. The server holds the master and role-plays a program with a \
111 terminal; a real terminal program opens the slave (symlinked at `link_path`). The \
112 slave is put in raw mode (no echo, no canonical line buffering) so the model owns \
113 the exact byte stream. Validated against a std::fs client opening the slave \
114 device, not NetGet-against-NetGet.",
115 )
116 .build()
117 }
118
119 fn description(&self) -> &'static str {
120 "Pseudo-terminal server: the LLM role-plays a program driving a terminal"
121 }
122
123 fn example_prompt(&self) -> &'static str {
124 "Open a PTY symlinked at ./netget.pty, print the prompt 'netget$ ' on connect, and answer the 'whoami' command with 'root'"
125 }
126
127 fn group_name(&self) -> &'static str {
128 "Core"
129 }
130
131 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
132 use crate::llm::actions::StartupExamples;
133
134 let script = r#"import json, sys
136data = json.load(sys.stdin)
137event = data["event"]
138if data["event_type_id"] == "pty_input_received":
139 actions = [{"type": "write_pty_output", "data": str(event.get("data", ""))}]
140else:
141 actions = []
142print(json.dumps({"actions": actions}))"#;
143
144 StartupExamples::new(
156 json!({
157 "type": "open_server",
158 "base_stack": "pty",
159 "startup_params": { "link_path": "./netget.pty" },
160 "send_first": true,
161 "instruction": "Print 'netget$ ' then act as a minimal shell"
162 }),
163 json!({
164 "type": "open_server",
165 "base_stack": "pty",
166 "startup_params": { "link_path": "./netget.pty" },
167 "event_handlers": [{
168 "event_pattern": "pty_input_received",
169 "handler": {
170 "type": "script",
171 "language": "python",
172 "code": script
173 }
174 }]
175 }),
176 json!({
177 "type": "open_server",
178 "base_stack": "pty",
179 "startup_params": { "link_path": "./netget.pty" },
180 "send_first": true,
181 "event_handlers": [
182 {
183 "event_pattern": "pty_opened",
184 "handler": {
185 "type": "static",
186 "actions": [{
187 "type": "write_pty_output",
188 "data": "netget$ "
189 }]
190 }
191 },
192 {
193 "event_pattern": "pty_input_received",
194 "handler": {
195 "type": "static",
196 "actions": [{
197 "type": "write_pty_output",
198 "data": "root\n"
199 }]
200 }
201 }
202 ]
203 }),
204 )
205 }
206}
207
208impl Server for PtyProtocol {
209 fn spawn(
210 &self,
211 ctx: crate::protocol::SpawnContext,
212 ) -> std::pin::Pin<
213 Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
214 > {
215 Box::pin(async move {
216 let link_path = ctx
217 .startup_params
218 .as_ref()
219 .map(|p| p.get_optional_string("link_path"))
220 .transpose()?
221 .flatten();
222
223 let send_first = ctx
224 .startup_params
225 .as_ref()
226 .map(|p| p.get_optional_bool("send_first"))
227 .transpose()?
228 .flatten()
229 .unwrap_or(false);
230
231 use crate::server::pty::PtyServer;
232 PtyServer::spawn_with_llm_actions(
233 link_path.map(std::path::PathBuf::from),
234 send_first,
235 ctx.llm_client,
236 ctx.state,
237 ctx.status_tx,
238 ctx.server_id,
239 )
240 .await?;
241
242 Ok("127.0.0.1:0".parse().unwrap())
244 })
245 }
246
247 fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
248 let action_type = action
249 .get("type")
250 .and_then(|v| v.as_str())
251 .context("Missing 'type' field in action")?;
252
253 match action_type {
254 "write_pty_output" => {
255 let data = action
256 .get("data")
257 .and_then(|v| v.as_str())
258 .context("Missing 'data' parameter")?;
259 Ok(ActionResult::Output(decode_outbound_data(data, &action)?))
260 }
261 _ => Err(anyhow::anyhow!("Unknown pty action: {action_type}")),
262 }
263 }
264}
265
266fn decode_outbound_data(data: &str, action: &serde_json::Value) -> Result<Vec<u8>> {
269 let encoding = action
270 .get("encoding")
271 .and_then(|v| v.as_str())
272 .unwrap_or("utf8");
273
274 match encoding {
275 "utf8" => Ok(data.as_bytes().to_vec()),
276 "hex" => hex::decode(data).map_err(|e| {
277 anyhow::anyhow!(
278 "'data' was declared as \"encoding\": \"hex\" but is not valid hex ({e}). \
279 Hex payloads are two hex digits per byte with no separators. To write this \
280 value as literal text, omit 'encoding' or set it to \"utf8\"."
281 )
282 }),
283 other => Err(anyhow::anyhow!(
284 "Invalid 'encoding' value {other:?}. Valid values are \"utf8\" (default) and \"hex\"."
285 )),
286 }
287}
288
289fn encoding_parameter() -> Parameter {
291 Parameter {
292 name: "encoding".to_string(),
293 type_hint: "string".to_string(),
294 description: "How to convert 'data' into the bytes written to the terminal. \"utf8\" (the \
295 default when omitted) writes the characters of 'data' unchanged - use it for text. \
296 \"hex\" decodes 'data' as hex-encoded bytes, two hex digits per byte - use it for \
297 control sequences or binary, e.g. {\"data\": \"1b5b326a\", \"encoding\": \"hex\"} writes \
298 the ANSI clear-screen sequence. No other values are accepted"
299 .to_string(),
300 required: false,
301 }
302}
303
304fn write_pty_output_action() -> ActionDefinition {
306 ActionDefinition {
307 name: "write_pty_output".to_string(),
308 description: "Write bytes onto the pseudo-terminal so they appear to the terminal program \
309 reading the slave (its 'screen'). The 'data' field holds the payload and the optional \
310 'encoding' field says how to turn it into bytes: omit 'encoding' (or use \"utf8\") to \
311 write characters as-is, or set \"encoding\": \"hex\" to write 'data' decoded from hex \
312 (useful for ANSI escape sequences)."
313 .to_string(),
314 parameters: vec![
315 Parameter {
316 name: "data".to_string(),
317 type_hint: "string".to_string(),
318 description:
319 "Text to display on the terminal. Interpreted according to 'encoding'; \
320 by default written as-is (UTF-8). Include your own newlines."
321 .to_string(),
322 required: true,
323 },
324 encoding_parameter(),
325 ],
326 example: json!({
327 "type": "write_pty_output",
328 "data": "netget$ ",
329 "encoding": "utf8"
330 }),
331 log_template: Some(
332 LogTemplate::new()
333 .with_info("-> PTY {data_len}B")
334 .with_debug("PTY write_pty_output: data_len={data_len}"),
335 ),
336 }
337}
338
339pub static WRITE_PTY_OUTPUT_ACTION: LazyLock<ActionDefinition> =
344 LazyLock::new(write_pty_output_action);
345
346pub static PTY_OPENED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
350 EventType::new(
351 "pty_opened",
352 "Pseudo-terminal is ready (send an initial banner/prompt if desired)",
353 json!({
354 "type": "write_pty_output",
355 "data": "netget$ "
356 }),
357 )
358 .with_parameters(vec![Parameter {
359 name: "slave_path".to_string(),
360 type_hint: "string".to_string(),
361 description: "Filesystem path of the slave PTY device a terminal program should open."
362 .to_string(),
363 required: false,
364 }])
365 .with_actions(vec![WRITE_PTY_OUTPUT_ACTION.clone()])
366});
367
368pub static PTY_INPUT_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
370 EventType::new(
371 "pty_input_received",
372 "A terminal program typed/wrote data on the pseudo-terminal",
373 json!({
374 "type": "write_pty_output",
375 "data": "root\n"
376 }),
377 )
378 .with_parameters(vec![
379 Parameter {
380 name: "data".to_string(),
381 type_hint: "string".to_string(),
382 description: "The bytes the terminal program wrote. Read it according to 'encoding'."
383 .to_string(),
384 required: true,
385 },
386 Parameter {
387 name: "encoding".to_string(),
388 type_hint: "string".to_string(),
389 description:
390 "How to read 'data': \"utf8\" means literal text, \"hex\" means the bytes \
391 hex-encoded (used when they are not all printable ASCII)."
392 .to_string(),
393 required: true,
394 },
395 ])
396 .with_actions(vec![WRITE_PTY_OUTPUT_ACTION.clone()])
397});
398
399pub fn get_pty_event_types() -> Vec<EventType> {
401 vec![PTY_OPENED_EVENT.clone(), PTY_INPUT_RECEIVED_EVENT.clone()]
402}