1use crate::llm::actions::{
2 client_trait::{Client, ClientActionResult},
3 protocol_trait::Protocol,
4 ActionDefinition, ParameterDefinition,
5};
6use crate::protocol::{ConnectContext, EventType};
7use crate::server::ninep::{
8 actions::{action, parameter},
9 wire,
10};
11use crate::state::app_state::AppState;
12use anyhow::{bail, ensure, Result};
13use serde_json::{json, Value};
14use std::sync::LazyLock;
15
16#[derive(Default)]
17pub struct NinepClientProtocol;
18impl NinepClientProtocol {
19 pub fn new() -> Self {
20 Self
21 }
22}
23
24fn path_param() -> crate::llm::actions::Parameter {
25 parameter(
26 "path",
27 "string",
28 "Absolute path on the server, e.g. /docs/readme.txt",
29 true,
30 )
31}
32
33fn ls_action() -> ActionDefinition {
34 action(
35 "ninep_ls",
36 "List a directory; the entries arrive in ninep_result.",
37 vec![path_param()],
38 json!({"type":"ninep_ls","path":"/"}),
39 )
40}
41
42fn cat_action() -> ActionDefinition {
43 action(
44 "ninep_cat",
45 "Read a whole file (at most 1 MiB); the content arrives in ninep_result.",
46 vec![path_param()],
47 json!({"type":"ninep_cat","path":"/docs/readme.txt"}),
48 )
49}
50
51fn write_action() -> ActionDefinition {
52 action(
53 "ninep_write",
54 "Write a file: replace its content, or append to it; create it first when create is true.",
55 vec![
56 path_param(),
57 parameter("data", "string", "Content to write", true),
58 parameter("encoding", "string", "utf8 (default) or hex", false),
59 parameter(
60 "append",
61 "boolean",
62 "Append after the current end instead of replacing",
63 false,
64 ),
65 parameter(
66 "create",
67 "boolean",
68 "Create the file when it does not exist",
69 false,
70 ),
71 ],
72 json!({"type":"ninep_write","path":"/notes.txt","data":"hello\n","create":true}),
73 )
74}
75
76fn stat_action() -> ActionDefinition {
77 action(
78 "ninep_stat",
79 "Read a path's stat (kind, size, mode, owner, mtime).",
80 vec![path_param()],
81 json!({"type":"ninep_stat","path":"/docs"}),
82 )
83}
84
85fn mkdir_action() -> ActionDefinition {
86 action(
87 "ninep_mkdir",
88 "Create a directory at the path.",
89 vec![path_param()],
90 json!({"type":"ninep_mkdir","path":"/new"}),
91 )
92}
93
94fn remove_action() -> ActionDefinition {
95 action(
96 "ninep_remove",
97 "Remove a file or an empty directory.",
98 vec![path_param()],
99 json!({"type":"ninep_remove","path":"/notes.txt"}),
100 )
101}
102
103fn rename_action() -> ActionDefinition {
104 action(
105 "ninep_rename",
106 "Rename a path within its directory (9P cannot move between directories).",
107 vec![
108 path_param(),
109 parameter("name", "string", "New name, without a directory", true),
110 ],
111 json!({"type":"ninep_rename","path":"/notes.txt","name":"old-notes.txt"}),
112 )
113}
114
115fn disconnect_action() -> ActionDefinition {
116 action(
117 "disconnect",
118 "Clunk everything and close the 9P connection",
119 vec![],
120 json!({"type":"disconnect"}),
121 )
122}
123
124fn actions() -> Vec<ActionDefinition> {
125 vec![
126 ls_action(),
127 cat_action(),
128 write_action(),
129 stat_action(),
130 mkdir_action(),
131 remove_action(),
132 rename_action(),
133 disconnect_action(),
134 ]
135}
136
137pub static CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
138 EventType::new(
139 "ninep_connected",
140 "Attached to the 9P server's root; ready for file operations.",
141 ls_action().example.clone(),
142 )
143 .with_parameters(vec![
144 parameter(
145 "version",
146 "string",
147 "Protocol version the server agreed to",
148 true,
149 ),
150 parameter("msize", "number", "Negotiated maximum message size", true),
151 parameter("uname", "string", "User name attached as", true),
152 ])
153 .with_actions(actions())
154});
155
156pub static RESULT_EVENT: LazyLock<EventType> = LazyLock::new(|| {
157 EventType::new(
158 "ninep_result",
159 "The outcome of one file operation.",
160 cat_action().example.clone(),
161 )
162 .with_parameters(vec![
163 parameter(
164 "op",
165 "string",
166 "ls, cat, write, stat, mkdir, remove or rename",
167 true,
168 ),
169 path_param(),
170 parameter(
171 "ok",
172 "boolean",
173 "Whether the server accepted the operation",
174 true,
175 ),
176 parameter(
177 "error",
178 "string",
179 "The server's error text when ok is false",
180 false,
181 ),
182 parameter(
183 "entries",
184 "array",
185 "For ls: [{name, kind, size, mode, owner, mtime}]",
186 false,
187 ),
188 parameter("data", "string", "For cat: the content", false),
189 parameter(
190 "encoding",
191 "string",
192 "For cat: utf8, or hex for binary content",
193 false,
194 ),
195 parameter(
196 "stat",
197 "object",
198 "For stat: {name, kind, size, mode, owner, mtime}",
199 false,
200 ),
201 parameter(
202 "bytes_written",
203 "number",
204 "For write: bytes the server accepted",
205 false,
206 ),
207 ])
208 .with_actions(actions())
209});
210
211pub fn check(v: &Value) -> Result<()> {
212 let op = v["type"].as_str().unwrap_or_default();
213 ensure!(
214 matches!(
215 op,
216 "ninep_ls"
217 | "ninep_cat"
218 | "ninep_write"
219 | "ninep_stat"
220 | "ninep_mkdir"
221 | "ninep_remove"
222 | "ninep_rename"
223 ),
224 "Unknown 9P client action"
225 );
226 let path = v["path"]
227 .as_str()
228 .ok_or_else(|| anyhow::anyhow!("path must be a string"))?;
229 wire::elements(path)?;
230 match op {
231 "ninep_write" => {
232 let data = v["data"]
233 .as_str()
234 .ok_or_else(|| anyhow::anyhow!("data must be a string"))?;
235 let bytes = wire::from_text(data, v["encoding"].as_str())?;
236 ensure!(bytes.len() <= wire::MAX_CONTENT, "data exceeds 1 MiB");
237 for key in ["append", "create"] {
238 ensure!(
239 v.get(key).is_none_or(Value::is_boolean),
240 "{key} must be a boolean"
241 );
242 }
243 }
244 "ninep_rename" => {
245 let name = v["name"]
246 .as_str()
247 .ok_or_else(|| anyhow::anyhow!("name must be a string"))?;
248 wire::check_name(name)?;
249 ensure!(name != "..", "invalid name");
250 }
251 "ninep_mkdir" | "ninep_remove" => {
252 ensure!(path != "/", "not the root");
253 }
254 _ => {}
255 }
256 Ok(())
257}
258
259impl Protocol for NinepClientProtocol {
260 fn protocol_name(&self) -> &'static str {
261 "9P"
262 }
263 fn stack_name(&self) -> &'static str {
264 "ETH>IP>TCP>9P"
265 }
266 fn keywords(&self) -> Vec<&'static str> {
267 vec!["9p", "9p2000", "plan 9", "styx", "ninep"]
268 }
269 fn description(&self) -> &'static str {
270 "9P2000 client: list, read, write, create, remove, rename and stat files on a 9P server"
271 }
272 fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
273 actions()
274 }
275 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
276 vec![]
277 }
278 fn get_event_types(&self) -> Vec<EventType> {
279 vec![CONNECTED_EVENT.clone(), RESULT_EVENT.clone()]
280 }
281 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
282 vec![
283 ParameterDefinition {
284 name: "uname".into(),
285 type_hint: "string".into(),
286 description: "User name sent in Tattach".into(),
287 required: false,
288 example: json!("glenda"),
289 default: Some(json!(super::DEFAULT_UNAME)),
290 },
291 ParameterDefinition {
292 name: "aname".into(),
293 type_hint: "string".into(),
294 description: "File tree to attach to (aname); empty for the server's default"
295 .into(),
296 required: false,
297 example: json!(""),
298 default: None,
299 },
300 ]
301 }
302 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
303 use crate::protocol::metadata::*;
304 ProtocolMetadataV2::builder()
305 .state(DevelopmentState::Experimental)
306 .privilege_requirement(PrivilegeRequirement::None)
307 .well_known_port(564)
308 .implementation("9P2000 over Tokio TCP: version, attach without auth, and per action a walk from the root fid, then open/create/read/write/stat/wstat/remove and clunk")
309 .llm_control("Which files to list, read, write, create, remove or rename, and what to do with each result")
310 .e2e_testing("tests/client/ninep: NetGet's own server for every operation; knusbaum/go9p's file server (Go) as the independent peer")
311 .notes("9P2000 only, no authentication. msize is offered at 65536; a file read is capped at 1 MiB, a listing at 1024 entries, a path at 16 elements per walk (longer paths walk in steps). Requests run one at a time; each has a 30 s deadline.")
312 .max_inbound_bytes(wire::MAX_MSIZE as usize)
313 .build()
314 }
315 fn example_prompt(&self) -> &'static str {
316 "Connect to the 9P server at 127.0.0.1:564, list the root and read readme.txt"
317 }
318 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
319 let llm = json!({"type":"open_client","protocol":"9p","remote_addr":"127.0.0.1:564","instruction":"List the root and read every text file"});
320 let mut static_example = llm.clone();
321 static_example["event_handlers"] = json!([
322 {"event_pattern":"ninep_connected","handler":{"type":"static","actions":[{"type":"ninep_ls","path":"/"}]}},
323 {"event_pattern":"ninep_result","handler":{"type":"static","actions":[{"type":"disconnect"}]}}
324 ]);
325 let mut scripted = static_example.clone();
326 scripted["event_handlers"][1]["handler"] = json!({"type":"script","language":"python","code":"import json,sys\ne=json.load(sys.stdin)['event']\na=[{'type':'ninep_cat','path':'/'+x['name']} for x in e.get('entries',[]) if x['kind']=='file']\nprint(json.dumps({'actions':a or [{'type':'disconnect'}]}))"});
327 crate::llm::actions::StartupExamples::new(llm, scripted, static_example)
328 }
329 fn group_name(&self) -> &'static str {
330 "Application"
331 }
332}
333
334impl Client for NinepClientProtocol {
335 fn connect(
336 &self,
337 ctx: ConnectContext,
338 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
339 {
340 Box::pin(super::connect(ctx))
341 }
342 fn execute_action(&self, v: Value) -> Result<ClientActionResult> {
343 match v["type"].as_str() {
344 Some("disconnect") => Ok(ClientActionResult::Disconnect),
345 Some(name) if name.starts_with("ninep_") => {
346 check(&v)?;
347 Ok(ClientActionResult::Custom {
348 name: name.to_string(),
349 data: v,
350 })
351 }
352 _ => bail!("Unknown 9P client action"),
353 }
354 }
355}