Skip to main content

netget/client/nfs/
actions.rs

1//! NFS client protocol actions implementation
2
3use crate::llm::actions::{
4    client_trait::{Client, ClientActionResult},
5    protocol_trait::Protocol,
6    ActionDefinition, Parameter,
7};
8use crate::protocol::EventType;
9use crate::state::app_state::AppState;
10use anyhow::{Context, Result};
11use serde_json::json;
12use std::sync::LazyLock;
13
14/// NFS client connected event
15pub static NFS_CLIENT_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
16    EventType::new(
17        "nfs_connected",
18        "NFS client successfully mounted an NFS export. This is your one turn before the \
19         client goes idle: issue an operation, or wait_for_more to do nothing.",
20        json!({"type": "nfs_list_dir", "path": "/"}),
21    )
22    .with_parameters(vec![
23        Parameter {
24            name: "export_path".to_string(),
25            type_hint: "string".to_string(),
26            description: "NFS export path that was mounted".to_string(),
27            required: true,
28        },
29        Parameter {
30            name: "root_fh".to_string(),
31            type_hint: "string".to_string(),
32            description: "Root file handle (hex-encoded)".to_string(),
33            required: true,
34        },
35    ])
36});
37
38/// NFS client file operation result event
39pub static NFS_CLIENT_OPERATION_RESULT_EVENT: LazyLock<EventType> = LazyLock::new(|| {
40    EventType::new(
41        "nfs_operation_result",
42        "Result of an NFS file operation. `success` is false when the server refused or the \
43         path does not exist; `error` then carries the NFS status. Follow up with another \
44         operation, or wait_for_more if there is nothing more to do.",
45        // `EventType::response_example` is rendered into the model's prompt and is
46        // documented as having to be a real protocol action. This used to be
47        // `{"type": "placeholder", ...}`, which `execute_action` rejects as an unknown
48        // action — so after every completed operation the model was handed an example that
49        // could not be executed.
50        json!({"type": "nfs_list_dir", "path": "/"}),
51    )
52    .with_parameters(vec![
53        Parameter {
54            name: "operation".to_string(),
55            type_hint: "string".to_string(),
56            description: "The operation performed (lookup, read, write, etc.)".to_string(),
57            required: true,
58        },
59        Parameter {
60            name: "result".to_string(),
61            type_hint: "object".to_string(),
62            description: "Operation result data".to_string(),
63            required: true,
64        },
65    ])
66});
67
68/// NFS client protocol action handler
69pub struct NfsClientProtocol;
70
71impl NfsClientProtocol {
72    pub fn new() -> Self {
73        Self
74    }
75}
76
77impl Protocol for NfsClientProtocol {
78    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
79        vec![
80            ActionDefinition {
81                name: "nfs_lookup".to_string(),
82                description: "Look up a file or directory by path".to_string(),
83                parameters: vec![Parameter {
84                    name: "path".to_string(),
85                    type_hint: "string".to_string(),
86                    description: "Path to file or directory (relative to root)".to_string(),
87                    required: true,
88                }],
89                example: json!({
90                    "type": "nfs_lookup",
91                    "path": "/documents/readme.txt"
92                }),
93                log_template: None,
94            },
95            ActionDefinition {
96                name: "nfs_read_file".to_string(),
97                description: "Read contents of a file".to_string(),
98                parameters: vec![
99                    Parameter {
100                        name: "path".to_string(),
101                        type_hint: "string".to_string(),
102                        description: "Path to file to read".to_string(),
103                        required: true,
104                    },
105                    Parameter {
106                        name: "offset".to_string(),
107                        type_hint: "number".to_string(),
108                        description: "Byte offset to start reading from (default: 0)".to_string(),
109                        required: false,
110                    },
111                    Parameter {
112                        name: "count".to_string(),
113                        type_hint: "number".to_string(),
114                        description: "Number of bytes to read (default: 4096)".to_string(),
115                        required: false,
116                    },
117                ],
118                example: json!({
119                    "type": "nfs_read_file",
120                    "path": "/readme.txt",
121                    "offset": 0,
122                    "count": 4096
123                }),
124                log_template: None,
125            },
126            ActionDefinition {
127                name: "nfs_write_file".to_string(),
128                description: "Write data to a file".to_string(),
129                parameters: vec![
130                    Parameter {
131                        name: "path".to_string(),
132                        type_hint: "string".to_string(),
133                        description: "Path to file to write".to_string(),
134                        required: true,
135                    },
136                    Parameter {
137                        name: "data".to_string(),
138                        type_hint: "string".to_string(),
139                        description: "Data to write".to_string(),
140                        required: true,
141                    },
142                    Parameter {
143                        name: "offset".to_string(),
144                        type_hint: "number".to_string(),
145                        description: "Byte offset to start writing at (default: 0)".to_string(),
146                        required: false,
147                    },
148                ],
149                example: json!({
150                    "type": "nfs_write_file",
151                    "path": "/data.txt",
152                    "data": "Hello, World!",
153                    "offset": 0
154                }),
155                log_template: None,
156            },
157            ActionDefinition {
158                name: "nfs_list_dir".to_string(),
159                description: "List contents of a directory".to_string(),
160                parameters: vec![Parameter {
161                    name: "path".to_string(),
162                    type_hint: "string".to_string(),
163                    description: "Path to directory to list (default: /)".to_string(),
164                    required: false,
165                }],
166                example: json!({
167                    "type": "nfs_list_dir",
168                    "path": "/documents"
169                }),
170                log_template: None,
171            },
172            ActionDefinition {
173                name: "nfs_get_attr".to_string(),
174                description: "Get file or directory attributes".to_string(),
175                parameters: vec![Parameter {
176                    name: "path".to_string(),
177                    type_hint: "string".to_string(),
178                    description: "Path to file or directory".to_string(),
179                    required: true,
180                }],
181                example: json!({
182                    "type": "nfs_get_attr",
183                    "path": "/readme.txt"
184                }),
185                log_template: None,
186            },
187            ActionDefinition {
188                name: "nfs_create_file".to_string(),
189                description: "Create a new file".to_string(),
190                parameters: vec![
191                    Parameter {
192                        name: "path".to_string(),
193                        type_hint: "string".to_string(),
194                        description: "Path to new file".to_string(),
195                        required: true,
196                    },
197                    Parameter {
198                        name: "mode".to_string(),
199                        type_hint: "number".to_string(),
200                        description: "File permissions in octal (default: 0644)".to_string(),
201                        required: false,
202                    },
203                ],
204                example: json!({
205                    "type": "nfs_create_file",
206                    "path": "/newfile.txt",
207                    "mode": 0o644
208                }),
209                log_template: None,
210            },
211            ActionDefinition {
212                name: "nfs_mkdir".to_string(),
213                description: "Create a new directory".to_string(),
214                parameters: vec![
215                    Parameter {
216                        name: "path".to_string(),
217                        type_hint: "string".to_string(),
218                        description: "Path to new directory".to_string(),
219                        required: true,
220                    },
221                    Parameter {
222                        name: "mode".to_string(),
223                        type_hint: "number".to_string(),
224                        description: "Directory permissions in octal (default: 0755)".to_string(),
225                        required: false,
226                    },
227                ],
228                example: json!({
229                    "type": "nfs_mkdir",
230                    "path": "/newdir",
231                    "mode": 0o755
232                }),
233                log_template: None,
234            },
235            ActionDefinition {
236                name: "nfs_remove".to_string(),
237                description: "Remove a file from the mounted export by path. Unlinks a regular \
238                              file only — a directory needs the server's own rmdir and is \
239                              refused here — and the removal is immediate and not reversible."
240                    .to_string(),
241                parameters: vec![Parameter {
242                    name: "path".to_string(),
243                    type_hint: "string".to_string(),
244                    description: "Path to file to remove".to_string(),
245                    required: true,
246                }],
247                example: json!({
248                    "type": "nfs_remove",
249                    "path": "/oldfile.txt"
250                }),
251                log_template: None,
252            },
253            ActionDefinition {
254                name: "nfs_rmdir".to_string(),
255                description: "Remove a directory".to_string(),
256                parameters: vec![Parameter {
257                    name: "path".to_string(),
258                    type_hint: "string".to_string(),
259                    description: "Path to directory to remove".to_string(),
260                    required: true,
261                }],
262                example: json!({
263                    "type": "nfs_rmdir",
264                    "path": "/olddir"
265                }),
266                log_template: None,
267            },
268            ActionDefinition {
269                name: "disconnect".to_string(),
270                description: "Disconnect from the NFS server".to_string(),
271                parameters: vec![],
272                example: json!({
273                    "type": "disconnect"
274                }),
275                log_template: None,
276            },
277        ]
278    }
279
280    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
281        vec![ActionDefinition {
282            name: "wait_for_more".to_string(),
283            description: "Wait for more operations before responding".to_string(),
284            parameters: vec![],
285            example: json!({
286                "type": "wait_for_more"
287            }),
288            log_template: None,
289        }]
290    }
291
292    fn get_startup_parameters(&self) -> Vec<crate::llm::actions::ParameterDefinition> {
293        use crate::llm::actions::ParameterDefinition;
294        vec![
295            ParameterDefinition {
296                name: "portmapper_port".to_string(),
297                description: "Port to ask for the MOUNT and NFS ports on (default 111). \
298                              Override it when the server multiplexes all three RPC programs \
299                              on one port, as NetGet's own NFS server does"
300                    .to_string(),
301                type_hint: "number".to_string(),
302                required: false,
303                example: json!(111),
304                default: None,
305            },
306            ParameterDefinition {
307                name: "mount_port".to_string(),
308                description: "MOUNT program port. Skips the portmapper lookup for MOUNT"
309                    .to_string(),
310                type_hint: "number".to_string(),
311                required: false,
312                example: json!(2049),
313                default: None,
314            },
315            ParameterDefinition {
316                name: "nfs_port".to_string(),
317                description: "NFS program port (normally 2049). Skips the portmapper lookup \
318                              for NFS"
319                    .to_string(),
320                type_hint: "number".to_string(),
321                required: false,
322                example: json!(2049),
323                default: None,
324            },
325            ParameterDefinition {
326                name: "privileged_source_port".to_string(),
327                description: "Bind the local end to a port below 1024, which many real NFS \
328                              servers demand and which requires root. Default true"
329                    .to_string(),
330                type_hint: "boolean".to_string(),
331                required: false,
332                example: json!(false),
333                default: None,
334            },
335        ]
336    }
337
338    fn protocol_name(&self) -> &'static str {
339        "NFS"
340    }
341
342    /// The two events this client actually raises.
343    ///
344    /// These used to be freshly-built `EventType`s declaring the same two ids with **no
345    /// parameters** and a `{"type": "placeholder"}` example, while `mod.rs` emitted the
346    /// `LazyLock` statics above. So the registry, the TUI and handler validation saw a
347    /// different shape from the one that fires, and both advertised an example the executor
348    /// rejects. Clone the statics instead, so there is exactly one declaration of each.
349    fn get_event_types(&self) -> Vec<EventType> {
350        vec![
351            NFS_CLIENT_CONNECTED_EVENT.clone(),
352            NFS_CLIENT_OPERATION_RESULT_EVENT.clone(),
353        ]
354    }
355
356    fn stack_name(&self) -> &'static str {
357        "ETH>IP>TCP>RPC>NFS"
358    }
359
360    fn keywords(&self) -> Vec<&'static str> {
361        vec!["nfs", "nfs client", "connect to nfs"]
362    }
363
364    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
365        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
366
367        ProtocolMetadataV2::builder()
368            .state(DevelopmentState::Experimental)
369            .implementation("nfs3_client crate for NFSv3 protocol")
370            .llm_control("Full control over file operations (read, write, create, delete)")
371            .e2e_testing("NetGet NFS server as test target")
372            .build()
373    }
374
375    fn description(&self) -> &'static str {
376        "NFS client for mounting and accessing network file systems"
377    }
378
379    fn example_prompt(&self) -> &'static str {
380        "Connect to NFS server at 192.168.1.100:/export/data and read /readme.txt"
381    }
382
383    fn group_name(&self) -> &'static str {
384        "File Sharing"
385    }
386    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
387        use crate::llm::actions::StartupExamples;
388        use serde_json::json;
389
390        StartupExamples::new(
391            // LLM mode: LLM controls NFS operations
392            json!({
393                "type": "open_client",
394                "remote_addr": "192.168.1.100:/export/data",
395                "base_stack": "nfs",
396                "instruction": "Read /readme.txt and list the root directory"
397            }),
398            // Script mode: Code-based file operations
399            json!({
400                "type": "open_client",
401                "remote_addr": "192.168.1.100:/export/data",
402                "base_stack": "nfs",
403                "event_handlers": [{
404                    "event_pattern": "nfs_operation_result",
405                    "handler": {
406                        "type": "script",
407                        "language": "python",
408                        "code": "<nfs_client_handler>"
409                    }
410                }]
411            }),
412            // Static mode: Fixed file read
413            json!({
414                "type": "open_client",
415                "remote_addr": "192.168.1.100:/export/data",
416                "base_stack": "nfs",
417                "event_handlers": [
418                    {
419                        "event_pattern": "nfs_connected",
420                        "handler": {
421                            "type": "static",
422                            "actions": [{
423                                "type": "nfs_list_dir",
424                                "path": "/"
425                            }]
426                        }
427                    },
428                    {
429                        "event_pattern": "nfs_operation_result",
430                        "handler": {
431                            "type": "static",
432                            "actions": [{
433                                "type": "disconnect"
434                            }]
435                        }
436                    }
437                ]
438            }),
439        )
440    }
441}
442
443impl Client for NfsClientProtocol {
444    fn connect(
445        &self,
446        ctx: crate::protocol::ConnectContext,
447    ) -> std::pin::Pin<
448        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
449    > {
450        Box::pin(async move {
451            use crate::client::nfs::NfsClient;
452            NfsClient::connect_with_llm_actions(
453                ctx.remote_addr,
454                ctx.llm_client,
455                ctx.state,
456                ctx.status_tx,
457                ctx.client_id,
458                ctx.startup_params,
459            )
460            .await
461        })
462    }
463
464    fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
465        let action_type = action
466            .get("type")
467            .and_then(|v| v.as_str())
468            .context("Missing 'type' field in action")?;
469
470        match action_type {
471            "nfs_lookup" | "nfs_read_file" | "nfs_write_file" | "nfs_list_dir" | "nfs_get_attr"
472            | "nfs_create_file" | "nfs_mkdir" | "nfs_remove" | "nfs_rmdir" => {
473                // These operations are handled asynchronously in the main loop
474                Ok(ClientActionResult::Custom {
475                    name: action_type.to_string(),
476                    data: action,
477                })
478            }
479            "disconnect" => Ok(ClientActionResult::Disconnect),
480            "wait_for_more" => Ok(ClientActionResult::WaitForMore),
481            _ => Err(anyhow::anyhow!(
482                "Unknown NFS client action: {}",
483                action_type
484            )),
485        }
486    }
487}