Skip to main content

netget/client/ninep/
actions.rs

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}