Skip to main content

netget/server/pty/
actions.rs

1//! Pseudo-terminal (PTY) protocol actions implementation.
2//!
3//! Platform: Unix/Linux/macOS only. The server owns the PTY master and role-plays a program with
4//! a terminal: the model decides what appears on the terminal (prompts, output) and how to react
5//! to what a real terminal program (`cat`, `screen`, a shell) types on the slave.
6#![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
19/// PTY protocol action handler.
20///
21/// Stateless: the server ([`crate::server::pty::PtyServer`]) owns the master fd. The model reacts
22/// to bytes a terminal program typed on the slave and decides the bytes that appear on the
23/// terminal — "the model owns the payload, NetGet owns the plumbing", over a pseudo-terminal.
24pub 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    /// A PTY has no host and no port; opt into the port-optional startup path.
40    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        // No user-triggered actions: output is produced in reaction to terminal events.
78        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        // Deterministic: echo each input back to the pty, no LLM call.
135        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        // Protocol parameters go inside `startup_params`. `CommonAction::OpenServer`
145        // (`src/llm/actions/common.rs`) declares a fixed field set -- host, port, interface,
146        // mac_address, send_first, instruction, startup_params, event_handlers, scheduled_tasks --
147        // and serde silently drops anything else, so a top-level `link_path` never reaches
148        // `spawn()` and the server fails to start at all. These examples had it at the top level;
149        // `tests/examples/protocol_examples_test.rs` starts every protocol from its own declared
150        // examples and reported exactly that.
151        //
152        // `send_first` is the exception and stays at the top level: it *is* a declared
153        // `OpenServer` field, and `server_startup` folds it into `startup_params` for any
154        // protocol that declares it.
155        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            // A PTY has no IP address; report the placeholder used by other pathless protocols.
243            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
266/// Turn the `data` field of an outbound action into the exact bytes to write to the PTY, honouring
267/// the action's optional `encoding` field. Mirrors `socket_file`/`named_pipe`.
268fn 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
289/// Shared `encoding` parameter for every action carrying an outbound `data` field.
290fn 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
304/// Action definition for write_pty_output (sync).
305fn 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
339// ============================================================================
340// PTY Action / Event Constants
341// ============================================================================
342
343pub static WRITE_PTY_OUTPUT_ACTION: LazyLock<ActionDefinition> =
344    LazyLock::new(write_pty_output_action);
345
346/// PTY opened event - raised once at startup when `send_first` is set, so the model can emit a
347/// banner/prompt before any input. Emitted only under `send_first` (same pattern as
348/// `socket_file_connection_opened`).
349pub 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
368/// PTY input received event - raised when the terminal program writes (types) on the slave.
369pub 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
399/// Get PTY event types.
400pub fn get_pty_event_types() -> Vec<EventType> {
401    vec![PTY_OPENED_EVENT.clone(), PTY_INPUT_RECEIVED_EVENT.clone()]
402}