1#![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
17pub 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
38impl Protocol for SocketFileProtocol {
40 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 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 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 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
234impl 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 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 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 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" | "close_connection" => Ok(ActionResult::CloseConnection),
304 _ => Err(anyhow::anyhow!("Unknown socket file action: {action_type}")),
305 }
306 }
307}
308
309impl SocketFileProtocol {
310 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
321fn 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
352fn 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
362fn 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
389fn 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
407fn 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
424pub 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
434pub 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
455pub 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
486pub 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}