1use crate::llm::actions::{
2 protocol_trait::{ActionResult, Protocol, Server},
3 ActionDefinition, Parameter, ParameterDefinition,
4};
5use crate::protocol::log_template::LogTemplate;
6use crate::protocol::{EventType, SpawnContext};
7use crate::state::app_state::AppState;
8use anyhow::{bail, ensure, Result};
9use serde_json::{json, Value};
10use std::sync::LazyLock;
11
12#[derive(Default)]
13pub struct NinepProtocol;
14impl NinepProtocol {
15 pub fn new() -> Self {
16 Self
17 }
18}
19
20pub fn parameter(name: &str, kind: &str, description: &str, required: bool) -> Parameter {
21 Parameter {
22 name: name.into(),
23 type_hint: kind.into(),
24 description: description.into(),
25 required,
26 }
27}
28
29pub fn action(
30 name: &str,
31 description: &str,
32 parameters: Vec<Parameter>,
33 example: Value,
34) -> ActionDefinition {
35 let log_template = match name {
36 "ninep_entry" => LogTemplate::new().with_info("-> 9P entry {kind} size={size}"),
37 "ninep_listing" => LogTemplate::new().with_info("-> 9P listing {preview(entries,120)}"),
38 "ninep_content" => LogTemplate::new().with_info("-> 9P content {preview(data,80)}"),
39 "ninep_error" => LogTemplate::new().with_info("-> 9P error {message}"),
40 _ => LogTemplate::new().with_info(format!("-> 9P {name}")),
41 };
42 ActionDefinition {
43 name: name.into(),
44 description: description.into(),
45 parameters,
46 example,
47 log_template: Some(log_template),
48 }
49}
50
51fn entry_action() -> ActionDefinition {
52 action(
53 "ninep_entry",
54 "Say that the path exists and what it is. Rust builds the qid and stat from these fields.",
55 vec![
56 parameter("kind", "string", "What the path is: file or dir", true),
57 parameter(
58 "size",
59 "number",
60 "File length in bytes (0 for a directory)",
61 false,
62 ),
63 parameter(
64 "mode",
65 "number",
66 "Permission bits, e.g. 420 (0644) for a file or 493 (0755) for a directory",
67 false,
68 ),
69 parameter("mtime", "number", "Modification time, Unix seconds", false),
70 parameter(
71 "owner",
72 "string",
73 "Owner and group name shown in listings, default netget",
74 false,
75 ),
76 ],
77 json!({"type":"ninep_entry","kind":"file","size":12}),
78 )
79}
80
81fn not_found_action() -> ActionDefinition {
82 action(
83 "ninep_not_found",
84 "Say that nothing exists at this path; the client gets 'file does not exist'.",
85 vec![],
86 json!({"type":"ninep_not_found"}),
87 )
88}
89
90fn listing_action() -> ActionDefinition {
91 action(
92 "ninep_listing",
93 "List a directory's entries. Rust encodes them as stat entries and pages them to the client.",
94 vec![parameter(
95 "entries",
96 "array",
97 "At most 1024 entries: [{name, kind: file|dir, size?, mode?, mtime?, owner?}]",
98 true,
99 )],
100 json!({"type":"ninep_listing","entries":[{"name":"readme.txt","kind":"file","size":12},{"name":"docs","kind":"dir"}]}),
101 )
102}
103
104fn content_action() -> ActionDefinition {
105 action(
106 "ninep_content",
107 "Supply a file's whole content (at most 1 MiB). Rust serves each read from it at the client's offset.",
108 vec![
109 parameter("data", "string", "The file content", true),
110 parameter("encoding", "string", "utf8 (default) or hex for binary content", false),
111 ],
112 json!({"type":"ninep_content","data":"hello world\n"}),
113 )
114}
115
116fn ok_action() -> ActionDefinition {
117 action(
118 "ninep_ok",
119 "Accept the change (a write, create, remove or stat change); the client is told it succeeded.",
120 vec![],
121 json!({"type":"ninep_ok"}),
122 )
123}
124
125fn error_action() -> ActionDefinition {
126 action(
127 "ninep_error",
128 "Refuse the request with an error string the client shows, e.g. 'permission denied'.",
129 vec![parameter(
130 "message",
131 "string",
132 "Error text, at most 255 bytes",
133 true,
134 )],
135 json!({"type":"ninep_error","message":"permission denied"}),
136 )
137}
138
139fn path_param() -> Parameter {
140 parameter(
141 "path",
142 "string",
143 "Absolute path, e.g. /docs/readme.txt",
144 true,
145 )
146}
147
148fn uname_param() -> Parameter {
149 parameter("uname", "string", "User name the client attached as", true)
150}
151
152pub static STAT_EVENT: LazyLock<EventType> = LazyLock::new(|| {
153 EventType::new(
154 "ninep_stat",
155 "The client walked to or asked about a path; say whether it exists and what it is.",
156 entry_action().example.clone(),
157 )
158 .with_parameters(vec![path_param(), uname_param()])
159 .with_actions(vec![entry_action(), not_found_action(), error_action()])
160});
161
162pub static LIST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
163 EventType::new(
164 "ninep_list",
165 "The client is reading a directory; list its entries.",
166 listing_action().example.clone(),
167 )
168 .with_parameters(vec![path_param(), uname_param()])
169 .with_actions(vec![listing_action(), error_action()])
170});
171
172pub static READ_EVENT: LazyLock<EventType> = LazyLock::new(|| {
173 EventType::new(
174 "ninep_read",
175 "The client is reading a file from its start; supply the whole content.",
176 content_action().example.clone(),
177 )
178 .with_parameters(vec![path_param(), uname_param()])
179 .with_actions(vec![content_action(), error_action()])
180});
181
182pub static WRITE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
183 EventType::new(
184 "ninep_write",
185 "The client wrote bytes into a file at an offset; accept or refuse them.",
186 ok_action().example.clone(),
187 )
188 .with_parameters(vec![
189 path_param(),
190 parameter("offset", "number", "Byte offset of the write", true),
191 parameter("data", "string", "The bytes written, as text", true),
192 parameter(
193 "encoding",
194 "string",
195 "utf8, or hex when the bytes are not UTF-8",
196 true,
197 ),
198 uname_param(),
199 ])
200 .with_actions(vec![ok_action(), error_action()])
201});
202
203pub static CREATE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
204 EventType::new(
205 "ninep_create",
206 "The client is creating a file or directory; accept or refuse.",
207 ok_action().example.clone(),
208 )
209 .with_parameters(vec![
210 path_param(),
211 parameter("kind", "string", "What the path is: file or dir", true),
212 parameter("mode", "number", "Permission bits requested", true),
213 uname_param(),
214 ])
215 .with_actions(vec![ok_action(), error_action()])
216});
217
218pub static REMOVE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
219 EventType::new(
220 "ninep_remove",
221 "The client is removing a file or directory; accept or refuse.",
222 ok_action().example.clone(),
223 )
224 .with_parameters(vec![path_param(), uname_param()])
225 .with_actions(vec![ok_action(), error_action()])
226});
227
228pub static WSTAT_EVENT: LazyLock<EventType> = LazyLock::new(|| {
229 EventType::new(
230 "ninep_wstat",
231 "The client is renaming, truncating or changing the mode of a path; accept or refuse.",
232 ok_action().example.clone(),
233 )
234 .with_parameters(vec![
235 path_param(),
236 parameter(
237 "name",
238 "string",
239 "New name in the same directory, when renaming",
240 false,
241 ),
242 parameter(
243 "length",
244 "number",
245 "New length, when truncating (0 for OTRUNC)",
246 false,
247 ),
248 parameter(
249 "mode",
250 "number",
251 "New permission bits, when changing them",
252 false,
253 ),
254 uname_param(),
255 ])
256 .with_actions(vec![ok_action(), error_action()])
257});
258
259pub fn check_entry(v: &Value, named: bool) -> Result<()> {
261 ensure!(
262 matches!(v["kind"].as_str(), Some("file" | "dir")),
263 "kind must be file or dir"
264 );
265 if named {
266 let name = v["name"]
267 .as_str()
268 .ok_or_else(|| anyhow::anyhow!("each entry needs a name"))?;
269 super::wire::check_name(name)?;
270 ensure!(name != "..", "a listing cannot contain ..");
271 }
272 for key in ["size", "mode", "mtime"] {
273 ensure!(
274 v.get(key).is_none_or(Value::is_u64),
275 "{key} must be a non-negative integer"
276 );
277 }
278 ensure!(
279 v["mode"].as_u64().is_none_or(|m| m <= 0o777),
280 "mode holds permission bits only (at most 0777)"
281 );
282 ensure!(
283 v["mtime"].as_u64().is_none_or(|m| m <= u64::from(u32::MAX)),
284 "mtime must fit 32 bits"
285 );
286 ensure!(
287 v.get("owner")
288 .is_none_or(|o| o.as_str().is_some_and(|s| !s.is_empty() && s.len() <= 64)),
289 "owner must be 1 to 64 bytes"
290 );
291 Ok(())
292}
293
294impl Protocol for NinepProtocol {
295 fn protocol_name(&self) -> &'static str {
296 "9P"
297 }
298 fn stack_name(&self) -> &'static str {
299 "ETH>IP>TCP>9P"
300 }
301 fn description(&self) -> &'static str {
302 "9P2000 file server (Plan 9's file protocol) whose files and directories the handler supplies"
303 }
304 fn keywords(&self) -> Vec<&'static str> {
305 vec!["9p", "9p2000", "plan 9", "styx", "ninep"]
306 }
307 fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
308 vec![]
309 }
310 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
311 vec![
312 entry_action(),
313 not_found_action(),
314 listing_action(),
315 content_action(),
316 ok_action(),
317 error_action(),
318 ]
319 }
320 fn get_event_types(&self) -> Vec<EventType> {
321 vec![
322 STAT_EVENT.clone(),
323 LIST_EVENT.clone(),
324 READ_EVENT.clone(),
325 WRITE_EVENT.clone(),
326 CREATE_EVENT.clone(),
327 REMOVE_EVENT.clone(),
328 WSTAT_EVENT.clone(),
329 ]
330 }
331 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
332 vec![ParameterDefinition {
333 name: "idle_timeout_secs".into(),
334 type_hint: "number".into(),
335 description: "Seconds a connection may stay silent between messages (1..=86400)".into(),
336 required: false,
337 example: json!(600),
338 default: Some(json!(super::IDLE_TIMEOUT.as_secs())),
339 }]
340 }
341 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
342 use crate::protocol::metadata::*;
343 ProtocolMetadataV2::builder()
344 .state(DevelopmentState::Experimental)
345 .privilege_requirement(PrivilegeRequirement::PrivilegedPort(564))
346 .well_known_port(564)
347 .implementation("9P2000 over Tokio TCP, hand-written: version, attach (no auth), walk with partial results and .., open/create/read/write/clunk/remove/stat/wstat, flush, directory reads paged as whole stat entries")
348 .llm_control("Whether each path exists and what it is, directory listings, file contents, and whether each write, create, remove, rename or truncate is accepted")
349 .e2e_testing("tests/server/ninep: raw messages and bounds; 9fans.net/go's plan9/client and knusbaum/go9p's client (Go) as independent clients")
350 .notes("9P2000 only: a 9P2000.u or 9P2000.L client is offered plain 9P2000 and Linux's v9fs will refuse it. No authentication (Tauth is refused, every attach succeeds); uname is passed to the handler. Nothing is stored: a file's content is asked for on each read from offset 0 and served from that answer for the rest of the fid's reads. msize at most 65536, 256 fids per connection, 16 elements per walk, 1 MiB per file, 1024 entries per directory. A handler failure answers Rerror with a generic message, never a fabricated file.")
351 .request_only("Every reply answers the T-message with the same tag; the server never speaks first")
352 .answers_on_failure()
353 .max_inbound_bytes(super::wire::MAX_MSIZE as usize)
354 .build()
355 }
356 fn example_prompt(&self) -> &'static str {
357 "9P file server on port 5640 exporting a docs directory with a readme"
358 }
359 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
360 let llm = json!({"type":"open_server","base_stack":"9p","port":5640,"instruction":"Export /docs containing readme.txt with a short welcome text; refuse writes"});
361 let mut scripted = llm.clone();
362 scripted["event_handlers"] = json!([
363 {"event_pattern":"ninep_stat","handler":{"type":"script","language":"python","code":"import json,sys\np=json.load(sys.stdin)['event']['path']\nt={'/docs':{'type':'ninep_entry','kind':'dir'},'/docs/readme.txt':{'type':'ninep_entry','kind':'file','size':8}}\nprint(json.dumps({'actions':[t.get(p,{'type':'ninep_not_found'})]}))"}},
364 {"event_pattern":"ninep_list","handler":{"type":"script","language":"python","code":"import json,sys\np=json.load(sys.stdin)['event']['path']\ne=[{'name':'docs','kind':'dir'}] if p=='/' else [{'name':'readme.txt','kind':'file','size':8}]\nprint(json.dumps({'actions':[{'type':'ninep_listing','entries':e}]}))"}},
365 {"event_pattern":"ninep_read","handler":{"type":"static","actions":[{"type":"ninep_content","data":"welcome\n"}]}},
366 {"event_pattern":"*","handler":{"type":"static","actions":[{"type":"ninep_error","message":"permission denied"}]}}
367 ]);
368 let mut static_example = llm.clone();
369 static_example["event_handlers"] = json!([
370 {"event_pattern":"ninep_stat","handler":{"type":"static","actions":[{"type":"ninep_entry","kind":"file","size":6}]}},
371 {"event_pattern":"ninep_list","handler":{"type":"static","actions":[{"type":"ninep_listing","entries":[{"name":"hello","kind":"file","size":6}]}]}},
372 {"event_pattern":"ninep_read","handler":{"type":"static","actions":[{"type":"ninep_content","data":"hello\n"}]}},
373 {"event_pattern":"*","handler":{"type":"static","actions":[{"type":"ninep_error","message":"read-only file system"}]}}
374 ]);
375 crate::llm::actions::StartupExamples::new(llm, scripted, static_example)
376 }
377 fn group_name(&self) -> &'static str {
378 "Application"
379 }
380}
381
382impl Server for NinepProtocol {
383 fn spawn(
384 &self,
385 ctx: SpawnContext,
386 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
387 {
388 Box::pin(super::spawn(ctx))
389 }
390 fn execute_action(&self, v: Value) -> Result<ActionResult> {
391 let name = v["type"].as_str().unwrap_or_default().to_string();
392 match name.as_str() {
393 "ninep_entry" => check_entry(&v, false)?,
394 "ninep_listing" => {
395 let entries = v["entries"]
396 .as_array()
397 .ok_or_else(|| anyhow::anyhow!("entries must be an array"))?;
398 ensure!(
399 entries.len() <= super::wire::MAX_ENTRIES,
400 "a listing holds at most {} entries",
401 super::wire::MAX_ENTRIES
402 );
403 for e in entries {
404 check_entry(e, true)?;
405 }
406 }
407 "ninep_content" => {
408 let data = v["data"]
409 .as_str()
410 .ok_or_else(|| anyhow::anyhow!("data must be a string"))?;
411 let bytes = super::wire::from_text(data, v["encoding"].as_str())?;
412 ensure!(
413 bytes.len() <= super::wire::MAX_CONTENT,
414 "content exceeds {} bytes",
415 super::wire::MAX_CONTENT
416 );
417 }
418 "ninep_error" => {
419 let message = v["message"]
420 .as_str()
421 .ok_or_else(|| anyhow::anyhow!("message must be a string"))?;
422 ensure!(
423 !message.is_empty() && message.len() <= 255,
424 "message must be 1 to 255 bytes"
425 );
426 }
427 "ninep_not_found" | "ninep_ok" => {}
428 _ => bail!("Unknown 9P server action"),
429 }
430 Ok(ActionResult::Custom { name, data: v })
431 }
432}