Skip to main content

netget/server/socket_file/
actions.rs

1//! Socket file protocol actions implementation
2//!
3//! Platform: Unix/Linux only (uses Unix domain sockets)
4#![cfg(unix)]
5
6use crate::llm::actions::{
7    protocol_trait::{ActionResult, Protocol, Server},
8    ActionDefinition, Parameter,
9};
10use crate::protocol::log_template::LogTemplate;
11use crate::protocol::EventType;
12use crate::state::app_state::AppState;
13use anyhow::{Context, Result};
14use serde_json::json;
15use std::sync::LazyLock;
16
17/// Socket file protocol action handler
18///
19/// Stateless: the server ([`crate::server::socket_file::SocketFileServer`]) owns the
20/// connection map, because only it holds the write halves. This type previously kept a
21/// second, always-empty map of its own to back `send_to_connection` / `list_connections`
22/// async actions; nothing ever inserted into it and nothing routed their results to a
23/// connection, so those actions were advertised to the model and did nothing. They are gone.
24pub struct SocketFileProtocol;
25
26impl SocketFileProtocol {
27    pub fn new() -> Self {
28        Self
29    }
30}
31
32impl Default for SocketFileProtocol {
33    fn default() -> Self {
34        Self::new()
35    }
36}
37
38// Implement Protocol trait (common functionality)
39impl Protocol for SocketFileProtocol {
40    /// A Unix socket has no host and no port.
41    ///
42    /// Without this, `server_startup.rs` treats the protocol as "unmigrated" and *requires* a
43    /// `port`, so `start_server {"protocol": "socket_file", "socket_path": "..."}` failed with
44    /// "requires 'port' parameter" and there was no way to start this protocol over MCP at
45    /// all. Declaring empty binding defaults opts into the path where port is optional; the
46    /// listen address that path computes is ignored, since the server binds `socket_path`.
47    fn default_binding(&self) -> Option<crate::protocol::BindingDefaults> {
48        Some(crate::protocol::BindingDefaults {
49            mac_address: None,
50            interface: None,
51            host: None,
52            port: None,
53        })
54    }
55
56    fn get_startup_parameters(&self) -> Vec<crate::llm::actions::ParameterDefinition> {
57        vec![
58            crate::llm::actions::ParameterDefinition {
59                name: "socket_path".to_string(),
60                type_hint: "string".to_string(),
61                description: "Filesystem path for the Unix domain socket file (e.g., ./netget.sock)".to_string(),
62                required: true,
63                example: serde_json::json!("./netget.sock"),
64                default: None,
65            },
66            crate::llm::actions::ParameterDefinition {
67                name: "send_first".to_string(),
68                type_hint: "boolean".to_string(),
69                description: "Whether the server should send the first message after connection (e.g., for greeting banners)".to_string(),
70                required: false,
71                example: serde_json::json!(false),
72                default: None,
73            },
74            crate::llm::actions::ParameterDefinition {
75                name: "first_byte_timeout_secs".to_string(),
76                type_hint: "number".to_string(),
77                description: "Seconds a connected peer may send nothing before the server closes \
78                              it. Default 300, matching the window a `manual` rule gives a human \
79                              to answer one event - a peer is often NetGet's own socket_file \
80                              client waiting for someone to use [ send message ]."
81                    .to_string(),
82                required: false,
83                example: serde_json::json!(300),
84                default: Some(serde_json::json!(super::FIRST_BYTE_READ_TIMEOUT.as_secs())),
85            },
86            crate::llm::actions::ParameterDefinition {
87                name: "idle_timeout_secs".to_string(),
88                type_hint: "number".to_string(),
89                description: "Seconds an established session may be silent between messages \
90                              before the server closes it. Default 900. A message still being \
91                              answered never counts as silence."
92                    .to_string(),
93                required: false,
94                example: serde_json::json!(900),
95                default: Some(serde_json::json!(super::IDLE_BETWEEN_MESSAGES_TIMEOUT.as_secs())),
96            },
97        ]
98    }
99
100    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
101        // No user-triggered actions: a Unix socket server has nothing to say to a connection
102        // outside a request/response exchange, and there is no mechanism to route an async
103        // action's output to a specific connection.
104        Vec::new()
105    }
106
107    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
108        vec![
109            send_socket_data_action(),
110            wait_for_more_action(),
111            close_this_connection_action(),
112        ]
113    }
114
115    fn protocol_name(&self) -> &'static str {
116        "SOCKET_FILE"
117    }
118
119    fn get_event_types(&self) -> Vec<EventType> {
120        get_socket_file_event_types()
121    }
122
123    fn stack_name(&self) -> &'static str {
124        "UNIX_SOCKET"
125    }
126
127    fn keywords(&self) -> Vec<&'static str> {
128        vec!["socket_file", "unix_socket", "ipc"]
129    }
130
131    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
132        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
133
134        ProtocolMetadataV2::builder()
135            .state(DevelopmentState::Experimental)
136            .implementation("Manual Unix domain socket handling with tokio; socket file chmod 0600")
137            .llm_control("Full byte stream control - all sent/received data")
138            .e2e_testing("A real, independent tokio::net::UnixStream peer; no third-party client")
139            .notes(
140                "Unix domain socket for IPC - a filesystem socket file instead of IP:port. The \
141                 socket node is chmod'd to 0600 right after bind, because bind creates it \
142                 0777 & ~umask and both Linux and macOS check write permission on the node at \
143                 connect(2): the default would otherwise let any local user speak to it. The \
144                 peer is a local descriptor, so no independent third-party client exists and \
145                 nothing here supports a rating above Experimental.",
146            )
147            .build()
148    }
149
150    fn description(&self) -> &'static str {
151        "Unix domain socket server for inter-process communication"
152    }
153
154    fn example_prompt(&self) -> &'static str {
155        "Create socket file at ./myapp.sock and echo back any data received"
156    }
157
158    fn group_name(&self) -> &'static str {
159        "Core"
160    }
161
162    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
163        use crate::llm::actions::StartupExamples;
164
165        // Deterministic: reply "OK" to every message on the unix socket, no LLM
166        // call.
167        let script = r#"import json, sys
168data = json.load(sys.stdin)
169event = data["event"]
170if data["event_type_id"] == "socket_file_data_received":
171    actions = [{"type": "send_socket_data", "data": "OK"}]
172else:
173    actions = []
174print(json.dumps({"actions": actions}))"#;
175
176        // Protocol parameters go inside `startup_params`. `CommonAction::OpenServer`
177        // (`src/llm/actions/common.rs`) declares a fixed field set -- host, port, interface,
178        // mac_address, send_first, instruction, startup_params, event_handlers, scheduled_tasks --
179        // and serde silently drops anything else, so a top-level `socket_path` never reaches
180        // `spawn()` and the server fails to start at all. These examples had it at the top level;
181        // `tests/examples/protocol_examples_test.rs` starts every protocol from its own declared
182        // examples and reported exactly that.
183        StartupExamples::new(
184            json!({
185                "type": "open_server",
186                "base_stack": "socket_file",
187                "startup_params": { "socket_path": "./netget.sock" },
188                "instruction": "Unix socket IPC server that echoes data"
189            }),
190            json!({
191                "type": "open_server",
192                "base_stack": "socket_file",
193                "startup_params": { "socket_path": "./netget.sock" },
194                "event_handlers": [{
195                    "event_pattern": "socket_file_data_received",
196                    "handler": {
197                        "type": "script",
198                        "language": "python",
199                        "code": script
200                    }
201                }]
202            }),
203            json!({
204                "type": "open_server",
205                "base_stack": "socket_file",
206                "startup_params": { "socket_path": "./netget.sock" },
207                "event_handlers": [
208                    {
209                        "event_pattern": "socket_file_connection_opened",
210                        "handler": {
211                            "type": "static",
212                            "actions": [{
213                                "type": "send_socket_data",
214                                "data": "READY\n"
215                            }]
216                        }
217                    },
218                    {
219                        "event_pattern": "socket_file_data_received",
220                        "handler": {
221                            "type": "static",
222                            "actions": [{
223                                "type": "send_socket_data",
224                                "data": "ACK\n"
225                            }]
226                        }
227                    }
228                ]
229            }),
230        )
231    }
232}
233
234// Implement Server trait (server-specific functionality)
235impl Server for SocketFileProtocol {
236    fn spawn(
237        &self,
238        ctx: crate::protocol::SpawnContext,
239    ) -> std::pin::Pin<
240        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
241    > {
242        Box::pin(async move {
243            // Extract socket_path and send_first from startup_params
244            let socket_path = ctx
245                .startup_params
246                .as_ref()
247                .map(|p| p.get_string("socket_path"))
248                .transpose()?
249                .ok_or_else(|| anyhow::anyhow!("socket_path parameter is required"))?;
250
251            let send_first = ctx
252                .startup_params
253                .as_ref()
254                .map(|p| p.get_optional_bool("send_first"))
255                .transpose()?
256                .flatten()
257                .unwrap_or(false);
258
259            // Both read bounds are the operator's to tune; the defaults are argued beside
260            // FIRST_BYTE_READ_TIMEOUT and IDLE_BETWEEN_MESSAGES_TIMEOUT in mod.rs.
261            let secs = |name: &str| -> anyhow::Result<Option<u64>> {
262                Ok(ctx
263                    .startup_params
264                    .as_ref()
265                    .map(|p| p.get_optional_u64(name))
266                    .transpose()?
267                    .flatten())
268            };
269            let first_byte_timeout_secs = secs("first_byte_timeout_secs")?;
270            let idle_timeout_secs = secs("idle_timeout_secs")?;
271
272            use crate::server::socket_file::SocketFileServer;
273            let socket_path_buf = std::path::PathBuf::from(socket_path);
274            let _result_path = SocketFileServer::spawn_with_llm_actions(
275                socket_path_buf,
276                ctx.llm_client,
277                ctx.state,
278                ctx.status_tx,
279                send_first,
280                ctx.server_id,
281                first_byte_timeout_secs,
282                idle_timeout_secs,
283            )
284            .await?;
285
286            // Return a dummy SocketAddr since Unix sockets don't have IP addresses
287            // Store the actual socket path in the server instance
288            Ok("127.0.0.1:0".parse().unwrap())
289        })
290    }
291
292    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
293        let action_type = action
294            .get("type")
295            .and_then(|v| v.as_str())
296            .context("Missing 'type' field in action")?;
297
298        match action_type {
299            "send_socket_data" => self.execute_send_socket_data(action),
300            "wait_for_more" => Ok(ActionResult::WaitForMore),
301            // `close_this_connection` is the advertised name; `close_connection` is accepted
302            // as an alias because models reach for it out of habit.
303            "close_this_connection" | "close_connection" => Ok(ActionResult::CloseConnection),
304            _ => Err(anyhow::anyhow!("Unknown socket file action: {action_type}")),
305        }
306    }
307}
308
309impl SocketFileProtocol {
310    /// Execute send_socket_data sync action
311    fn execute_send_socket_data(&self, action: serde_json::Value) -> Result<ActionResult> {
312        let data = action
313            .get("data")
314            .and_then(|v| v.as_str())
315            .context("Missing 'data' parameter")?;
316
317        Ok(ActionResult::Output(decode_outbound_data(data, &action)?))
318    }
319}
320
321/// Turn the `data` field of an outbound action into the exact bytes to put on the wire,
322/// honouring the action's optional `encoding` field.
323///
324/// - `encoding` absent or `"utf8"`: the string's UTF-8 bytes are sent verbatim.
325/// - `encoding` = `"hex"`: `data` is decoded as hex, so `"48656c6c6f"` sends `Hello`.
326///
327/// There is deliberately no auto-detection: `"48656c6c6f"` is both valid text and valid hex,
328/// so the caller must say which it means. Inbound data carries the same `encoding` field on
329/// the `socket_file_data_received` event, which makes echoing a payload back symmetric.
330fn decode_outbound_data(data: &str, action: &serde_json::Value) -> Result<Vec<u8>> {
331    let encoding = action
332        .get("encoding")
333        .and_then(|v| v.as_str())
334        .unwrap_or("utf8");
335
336    match encoding {
337        "utf8" => Ok(data.as_bytes().to_vec()),
338        "hex" => hex::decode(data).map_err(|e| {
339            anyhow::anyhow!(
340                "'data' was declared as \"encoding\": \"hex\" but is not valid hex ({e}). \
341                 Hex payloads are two hex digits per byte with no separators. To send this \
342                 value as literal text, omit 'encoding' or set it to \"utf8\"."
343            )
344        }),
345        other => Err(anyhow::anyhow!(
346            "Invalid 'encoding' value {other:?}. Valid values are \"utf8\" (default, send the \
347             characters of 'data' as-is) and \"hex\" (decode 'data' as hex-encoded bytes)."
348        )),
349    }
350}
351
352/// Shared `encoding` parameter for every action that carries an outbound `data` field.
353fn encoding_parameter() -> Parameter {
354    Parameter {
355        name: "encoding".to_string(),
356        type_hint: "string".to_string(),
357        description: "How to convert 'data' into the bytes put on the wire. \"utf8\" (the default when omitted) sends the characters of 'data' unchanged - use it for text protocols. \"hex\" decodes 'data' as hex-encoded bytes, two hex digits per byte - use it for binary protocols, e.g. {\"data\": \"48656c6c6f\", \"encoding\": \"hex\"} sends the 5 bytes 'Hello', whereas the same 'data' without \"encoding\": \"hex\" sends the 10 characters 4-8-6-5-6-c-6-c-6-f. No other values are accepted".to_string(),
358        required: false,
359    }
360}
361
362/// Action definition for send_socket_data (sync)
363fn send_socket_data_action() -> ActionDefinition {
364    ActionDefinition {
365        name: "send_socket_data".to_string(),
366        description: "Send data over the current Unix domain socket connection. The 'data' field holds the payload and the optional 'encoding' field says how to turn it into bytes: omit 'encoding' (or use \"utf8\") to send the string's characters as-is, or set \"encoding\": \"hex\" to send 'data' decoded from hex. There is no auto-detection - a string like \"48656c6c6f\" is sent literally unless you set \"encoding\": \"hex\".".to_string(),
367        parameters: vec![
368            Parameter {
369                name: "data".to_string(),
370                type_hint: "string".to_string(),
371                description: "Data to send. Interpreted according to 'encoding': by default the characters of this string are sent as-is (UTF-8)".to_string(),
372                required: true,
373            },
374            encoding_parameter(),
375        ],
376        example: json!({
377            "type": "send_socket_data",
378            "data": "ACK\n",
379            "encoding": "utf8"
380        }),
381        log_template: Some(
382            LogTemplate::new()
383                .with_info("-> SOCK {data_len}B")
384                .with_debug("SOCK send_socket_data: data_len={data_len}"),
385        ),
386    }
387}
388
389/// Action definition for wait_for_more (sync)
390fn wait_for_more_action() -> ActionDefinition {
391    ActionDefinition {
392        name: "wait_for_more".to_string(),
393        description: "Wait for more data before responding (accumulate incomplete data)"
394            .to_string(),
395        parameters: vec![],
396        example: json!({
397            "type": "wait_for_more"
398        }),
399        log_template: Some(
400            LogTemplate::new()
401                .with_info("-> SOCK waiting for more")
402                .with_debug("SOCK wait_for_more"),
403        ),
404    }
405}
406
407/// Action definition for close_this_connection (sync)
408fn close_this_connection_action() -> ActionDefinition {
409    ActionDefinition {
410        name: "close_this_connection".to_string(),
411        description: "Close the current socket file connection".to_string(),
412        parameters: vec![],
413        example: json!({
414            "type": "close_this_connection"
415        }),
416        log_template: Some(
417            LogTemplate::new()
418                .with_info("-> SOCK close connection")
419                .with_debug("SOCK close_this_connection"),
420        ),
421    }
422}
423
424// ============================================================================
425// Socket File Action Constants
426// ============================================================================
427
428pub static SEND_SOCKET_DATA_ACTION: LazyLock<ActionDefinition> =
429    LazyLock::new(send_socket_data_action);
430pub static WAIT_FOR_MORE_ACTION: LazyLock<ActionDefinition> = LazyLock::new(wait_for_more_action);
431pub static CLOSE_THIS_CONNECTION_ACTION: LazyLock<ActionDefinition> =
432    LazyLock::new(close_this_connection_action);
433
434// ============================================================================
435// Socket File Event Type Constants
436// ============================================================================
437
438/// Socket file connection opened event - triggered when new connection is established
439pub static SOCKET_FILE_CONNECTION_OPENED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
440    EventType::new(
441        "socket_file_connection_opened",
442        "New Unix domain socket connection established (send initial greeting/banner if needed)",
443        json!({
444            "type": "send_socket_data",
445            "data": "READY\n"
446        }),
447    )
448    .with_parameters(vec![])
449    .with_actions(vec![
450        SEND_SOCKET_DATA_ACTION.clone(),
451        CLOSE_THIS_CONNECTION_ACTION.clone(),
452    ])
453});
454
455/// Socket file data received event - triggered when data is received on connection
456pub static SOCKET_FILE_DATA_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
457    EventType::new(
458        "socket_file_data_received",
459        "Data received on Unix domain socket connection",
460        json!({
461            "type": "send_socket_data",
462            "data": "ACK\n"
463        }),
464    )
465    .with_parameters(vec![
466        Parameter {
467            name: "data".to_string(),
468            type_hint: "string".to_string(),
469            description: "The data received from the client. Read it according to the 'encoding' field of this event".to_string(),
470            required: true,
471        },
472        Parameter {
473            name: "encoding".to_string(),
474            type_hint: "string".to_string(),
475            description: "How to read 'data': \"utf8\" means 'data' is the received bytes as literal text, \"hex\" means 'data' is the received bytes hex-encoded (two hex digits per byte, used whenever the bytes are not all printable ASCII). To echo the received bytes back unchanged, pass the same 'data' and 'encoding' to send_socket_data".to_string(),
476            required: true,
477        },
478    ])
479    .with_actions(vec![
480        SEND_SOCKET_DATA_ACTION.clone(),
481        WAIT_FOR_MORE_ACTION.clone(),
482        CLOSE_THIS_CONNECTION_ACTION.clone(),
483    ])
484});
485
486/// Get socket file event types
487pub fn get_socket_file_event_types() -> Vec<EventType> {
488    vec![
489        SOCKET_FILE_CONNECTION_OPENED_EVENT.clone(),
490        SOCKET_FILE_DATA_RECEIVED_EVENT.clone(),
491    ]
492}