Skip to main content

robonix_executor/dispatch/
builtin.rs

1// SPDX-License-Identifier: MulanPSL-2.0
2// Author: wheatfox <wheatfox17@icloud.com>
3//
4// dispatch/builtin.rs — built-in tool implementations
5
6use crate::pb::pilot::CapabilityCallResult;
7use serde::Deserialize;
8use std::path::{Path, PathBuf};
9
10/// Workspace root for file operations. All file paths are resolved relative to
11/// this directory, and path traversal beyond it is rejected.
12/// > `wheatfox's note: the definition of this "workspace" is where all the built-in tools
13/// > like read_file, write_file, exec command will be ran, as the linux CWD`
14fn workspace_root() -> PathBuf {
15    std::env::var("ROBONIX_WORKSPACE")
16        .map(PathBuf::from)
17        .unwrap_or_else(|_| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
18}
19
20/// Resolve a user-supplied path and ensure it stays within the workspace root.
21/// Returns the canonical path on success, or an error if the path escapes the
22/// allowed directory (path traversal).
23fn safe_resolve(user_path: &str) -> anyhow::Result<PathBuf> {
24    let root = workspace_root()
25        .canonicalize()
26        .unwrap_or_else(|_| workspace_root());
27    let candidate = if Path::new(user_path).is_absolute() {
28        PathBuf::from(user_path)
29    } else {
30        root.join(user_path)
31    };
32    // Canonicalize to resolve ".." components. If the file doesn't exist yet
33    // (write_file), canonicalize the parent directory instead.
34    let resolved = if candidate.exists() {
35        candidate.canonicalize()?
36    } else {
37        let parent = candidate
38            .parent()
39            .ok_or_else(|| anyhow::anyhow!("invalid path: no parent directory"))?;
40        let parent_resolved = parent.canonicalize().map_err(|_| {
41            anyhow::anyhow!("parent directory does not exist: {}", parent.display())
42        })?;
43        parent_resolved.join(
44            candidate
45                .file_name()
46                .ok_or_else(|| anyhow::anyhow!("invalid path: no file name"))?,
47        )
48    };
49    if !resolved.starts_with(&root) {
50        anyhow::bail!(
51            "path traversal denied: {} resolves outside workspace {}",
52            user_path,
53            root.display()
54        );
55    }
56    Ok(resolved)
57}
58
59use crate::pb::pilot::CapabilityCall;
60
61/// One in-process builtin (no network, runs in executor's own process).
62/// `call.contract_id`'s last segment names the operation —
63/// e.g. `robonix/system/executor/builtin/read_file` → `read_file`.
64pub async fn execute(call: &CapabilityCall) -> CapabilityCallResult {
65    let op = call
66        .contract_id
67        .rsplit_once('/')
68        .map(|(_, leaf)| leaf)
69        .unwrap_or(call.contract_id.as_str());
70    let result = run(op, &call.args_json).await;
71    let mut out = CapabilityCallResult {
72        call_id: call.call_id.clone(),
73        provider_id: call.provider_id.clone(),
74        contract_id: call.contract_id.clone(),
75        ..Default::default()
76    };
77    match result {
78        Ok(s) => {
79            out.success = true;
80            out.output = s;
81        }
82        Err(e) => {
83            out.success = false;
84            out.error = e.to_string();
85        }
86    }
87    out
88}
89
90async fn run(op: &str, args_json: &str) -> anyhow::Result<String> {
91    match op {
92        "read_file" => read_file(args_json),
93        "write_file" => write_file(args_json),
94        "patch_file" => patch_file(args_json),
95        "list_dir" => list_dir(args_json),
96        "run_command" => run_command(args_json).await,
97        other => anyhow::bail!("unknown builtin: {}", other),
98    }
99}
100
101/// Static metadata for the 5 builtin ops. Used by main.rs to declare them
102/// against atlas at startup so pilot can discover them like any other provider.
103pub struct BuiltinSpec {
104    pub op: &'static str,
105    pub description: &'static str,
106    pub input_schema_json: &'static str,
107}
108
109pub const BUILTINS: &[BuiltinSpec] = &[
110    BuiltinSpec {
111        op: "read_file",
112        description: "Read a file and return its contents",
113        input_schema_json: r#"{"type":"object","properties":{"path":{"type":"string","description":"Absolute or relative file path"}},"required":["path"]}"#,
114    },
115    BuiltinSpec {
116        op: "write_file",
117        description: "Write content to a file (creates or overwrites)",
118        input_schema_json: r#"{"type":"object","properties":{"path":{"type":"string"},"content":{"type":"string"}},"required":["path","content"]}"#,
119    },
120    BuiltinSpec {
121        op: "patch_file",
122        description: "Replace the first occurrence of a string in a file",
123        input_schema_json: r#"{"type":"object","properties":{"path":{"type":"string"},"old":{"type":"string"},"new":{"type":"string"}},"required":["path","old","new"]}"#,
124    },
125    BuiltinSpec {
126        op: "list_dir",
127        description: "List files and directories at a path",
128        input_schema_json: r#"{"type":"object","properties":{"path":{"type":"string","description":"Directory path (default: current dir)"}}}"#,
129    },
130    BuiltinSpec {
131        op: "run_command",
132        description: "Run a shell command and return stdout/stderr",
133        input_schema_json: r#"{"type":"object","properties":{"command":{"type":"string"}},"required":["command"]}"#,
134    },
135];
136
137// ── Helpers ───────────────────────────────────────────────────────────────────
138
139fn truncate(s: &str, max: usize) -> String {
140    if s.len() <= max {
141        s.to_string()
142    } else {
143        // Find a valid UTF-8 char boundary at or before `max` to avoid panic.
144        let mut end = max;
145        while end > 0 && !s.is_char_boundary(end) {
146            end -= 1;
147        }
148        format!("{}…(truncated, {} bytes total)", &s[..end], s.len())
149    }
150}
151
152#[derive(Deserialize)]
153struct ReadArgs {
154    path: String,
155}
156#[derive(Deserialize)]
157struct WriteArgs {
158    path: String,
159    content: String,
160}
161#[derive(Deserialize)]
162struct PatchArgs {
163    path: String,
164    old: String,
165    new: String,
166}
167#[derive(Deserialize)]
168struct ListArgs {
169    path: Option<String>,
170}
171#[derive(Deserialize)]
172struct CmdArgs {
173    command: String,
174}
175
176fn read_file(args: &str) -> anyhow::Result<String> {
177    let a: ReadArgs = serde_json::from_str(args)?;
178    let path = safe_resolve(&a.path)?;
179    Ok(truncate(&std::fs::read_to_string(path)?, 8000))
180}
181
182fn write_file(args: &str) -> anyhow::Result<String> {
183    let a: WriteArgs = serde_json::from_str(args)?;
184    let path = safe_resolve(&a.path)?;
185    if let Some(parent) = path.parent() {
186        std::fs::create_dir_all(parent).ok();
187    }
188    std::fs::write(&path, &a.content)?;
189    Ok(format!(
190        "wrote {} bytes to {}",
191        a.content.len(),
192        path.display()
193    ))
194}
195
196fn patch_file(args: &str) -> anyhow::Result<String> {
197    // TODO: use standard tools like sed, awk, etc.
198    let a: PatchArgs = serde_json::from_str(args)?;
199    let path = safe_resolve(&a.path)?;
200    let content = std::fs::read_to_string(&path)?;
201    if !content.contains(&a.old) {
202        anyhow::bail!("old string not found in file");
203    }
204    std::fs::write(&path, content.replacen(&a.old, &a.new, 1))?;
205    Ok(format!("patched {}", path.display()))
206}
207
208fn list_dir(args: &str) -> anyhow::Result<String> {
209    let a: ListArgs = serde_json::from_str(args)?;
210    let dir = a.path.as_deref().unwrap_or(".");
211    let resolved = safe_resolve(dir)?;
212    let mut items: Vec<String> = std::fs::read_dir(resolved)?
213        .flatten()
214        .map(|e| {
215            let ft = e
216                .file_type()
217                .map(|t| if t.is_dir() { "dir" } else { "file" })
218                .unwrap_or("?");
219            format!("{} {}", ft, e.file_name().to_string_lossy())
220        })
221        .collect();
222    items.sort();
223    Ok(items.join("\n"))
224}
225
226/// Maximum command length to prevent abuse.
227const MAX_COMMAND_LEN: usize = 8192;
228/// Command execution timeout.
229const COMMAND_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
230
231async fn run_command(args: &str) -> anyhow::Result<String> {
232    let a: CmdArgs = serde_json::from_str(args)?;
233    if a.command.len() > MAX_COMMAND_LEN {
234        anyhow::bail!(
235            "command too long ({} bytes, max {})",
236            a.command.len(),
237            MAX_COMMAND_LEN
238        );
239    }
240    let child = tokio::process::Command::new("bash")
241        .arg("-c")
242        .arg(&a.command)
243        .output();
244    let out = tokio::time::timeout(COMMAND_TIMEOUT, child)
245        .await
246        .map_err(|_| anyhow::anyhow!("command timed out after {}s", COMMAND_TIMEOUT.as_secs()))?
247        .map_err(|e| anyhow::anyhow!("failed to execute command: {e}"))?;
248    let mut result = String::new();
249    let stdout = String::from_utf8_lossy(&out.stdout);
250    let stderr = String::from_utf8_lossy(&out.stderr);
251    if !stdout.is_empty() {
252        result.push_str(&truncate(&stdout, 4000));
253    }
254    if !stderr.is_empty() {
255        if !result.is_empty() {
256            result.push('\n');
257        }
258        result.push_str("stderr: ");
259        result.push_str(&truncate(&stderr, 2000));
260    }
261    if result.is_empty() {
262        result = format!("exit code: {}", out.status.code().unwrap_or(-1));
263    }
264    Ok(result)
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    #[test]
272    fn path_traversal_dotdot_is_rejected() {
273        // Set workspace to a temp dir so we have a known root
274        let tmp = std::env::temp_dir().join("rbnx_test_ws");
275        std::fs::create_dir_all(&tmp).unwrap();
276        unsafe { std::env::set_var("ROBONIX_WORKSPACE", tmp.to_str().unwrap()) };
277
278        // ../../../etc/passwd must be rejected
279        let result = safe_resolve("../../../etc/passwd");
280        assert!(
281            result.is_err(),
282            "path traversal with ../../../etc/passwd should fail"
283        );
284        let err_msg = result.unwrap_err().to_string();
285        assert!(
286            err_msg.contains("path traversal denied") || err_msg.contains("does not exist"),
287            "error should mention traversal or nonexistent path, got: {err_msg}"
288        );
289    }
290
291    #[test]
292    fn path_traversal_absolute_outside_workspace_is_rejected() {
293        let tmp = std::env::temp_dir().join("rbnx_test_ws2");
294        std::fs::create_dir_all(&tmp).unwrap();
295        unsafe { std::env::set_var("ROBONIX_WORKSPACE", tmp.to_str().unwrap()) };
296
297        // /etc/hostname is a real file outside workspace
298        let result = safe_resolve("/etc/hostname");
299        assert!(
300            result.is_err(),
301            "absolute path /etc/hostname outside workspace should fail"
302        );
303        let err_msg = result.unwrap_err().to_string();
304        assert!(
305            err_msg.contains("path traversal denied"),
306            "error should mention traversal, got: {err_msg}"
307        );
308    }
309
310    #[test]
311    fn path_resolve_within_workspace_is_allowed() {
312        let tmp = std::env::temp_dir().join("rbnx_test_ws3");
313        std::fs::create_dir_all(&tmp).unwrap();
314        // Create a test file inside workspace
315        let test_file = tmp.join("allowed.txt");
316        std::fs::write(&test_file, "hello").unwrap();
317        unsafe { std::env::set_var("ROBONIX_WORKSPACE", tmp.to_str().unwrap()) };
318
319        let result = safe_resolve("allowed.txt");
320        assert!(
321            result.is_ok(),
322            "path within workspace should be allowed: {:?}",
323            result.err()
324        );
325
326        // Cleanup
327        let _ = std::fs::remove_file(&test_file);
328        let _ = std::fs::remove_dir(&tmp);
329    }
330
331    // CapabilityCall builder — tests dispatch by contract_id leaf, so each
332    // call's contract_id is `<anything>/<op>` and the leaf names the op.
333    fn call(call_id: &str, op: &str, args_json: &str) -> CapabilityCall {
334        CapabilityCall {
335            call_id: call_id.to_string(),
336            provider_id: "executor".to_string(),
337            contract_id: format!("robonix/system/executor/builtin/{op}"),
338            args_json: args_json.to_string(),
339        }
340    }
341
342    #[tokio::test]
343    async fn read_file_rejects_path_traversal() {
344        let tmp = std::env::temp_dir().join("rbnx_test_read");
345        std::fs::create_dir_all(&tmp).unwrap();
346        unsafe { std::env::set_var("ROBONIX_WORKSPACE", tmp.to_str().unwrap()) };
347
348        let result = execute(&call(
349            "test-1",
350            "read_file",
351            r#"{"path": "../../../etc/passwd"}"#,
352        ))
353        .await;
354        assert!(!result.success, "read_file should fail for path traversal");
355        assert!(
356            result.error.contains("traversal") || result.error.contains("does not exist"),
357            "error should explain traversal, got: {}",
358            result.error
359        );
360    }
361
362    #[tokio::test]
363    async fn write_file_rejects_path_traversal() {
364        let tmp = std::env::temp_dir().join("rbnx_test_write");
365        std::fs::create_dir_all(&tmp).unwrap();
366        unsafe { std::env::set_var("ROBONIX_WORKSPACE", tmp.to_str().unwrap()) };
367
368        let result = execute(&call(
369            "test-2",
370            "write_file",
371            r#"{"path": "/tmp/outside_workspace_evil.txt", "content": "pwned"}"#,
372        ))
373        .await;
374        assert!(
375            !result.success,
376            "write_file should fail for path outside workspace"
377        );
378
379        // File should NOT have been created
380        assert!(
381            !std::path::Path::new("/tmp/outside_workspace_evil.txt").exists(),
382            "file outside workspace must not be created"
383        );
384    }
385
386    #[tokio::test]
387    async fn run_command_rejects_oversized_command() {
388        let long_cmd = "a".repeat(MAX_COMMAND_LEN + 1);
389        let args = format!(r#"{{"command": "{}"}}"#, long_cmd);
390        let result = execute(&call("test-3", "run_command", &args)).await;
391        assert!(!result.success, "oversized command should be rejected");
392        assert!(
393            result.error.contains("command too long"),
394            "error should mention 'command too long', got: {}",
395            result.error
396        );
397    }
398
399    #[tokio::test]
400    async fn run_command_accepts_normal_command() {
401        let result = execute(&call(
402            "test-4",
403            "run_command",
404            r#"{"command": "echo hello"}"#,
405        ))
406        .await;
407        assert!(
408            result.success,
409            "normal command should succeed: {}",
410            result.error
411        );
412        assert!(
413            result.output.contains("hello"),
414            "output should contain 'hello', got: {}",
415            result.output
416        );
417    }
418
419    #[test]
420    fn truncate_ascii_works() {
421        let s = "hello world";
422        assert_eq!(truncate(s, 100), "hello world");
423        let t = truncate(s, 5);
424        assert!(
425            t.starts_with("hello"),
426            "should start with 'hello', got: {t}"
427        );
428        assert!(
429            t.contains("truncated"),
430            "should contain 'truncated', got: {t}"
431        );
432    }
433
434    #[test]
435    fn truncate_multibyte_utf8_does_not_panic() {
436        // Each Chinese char is 3 bytes in UTF-8
437        let s = "你好世界测试数据"; // 8 chars × 3 bytes = 24 bytes
438        // Truncate at byte 7 — in the middle of the 3rd char '世'
439        let result = truncate(s, 7);
440        // Should NOT panic, and should truncate at a valid boundary
441        assert!(
442            result.contains("truncated"),
443            "should be truncated, got: {result}"
444        );
445        // The truncated prefix should be valid UTF-8 (it is, since we're returning a String)
446        assert!(
447            result.starts_with("你好"),
448            "should keep first 2 chars, got: {result}"
449        );
450    }
451
452    #[test]
453    fn truncate_emoji_boundary() {
454        // 🚀 is 4 bytes in UTF-8
455        let s = "A🚀B🚀C"; // 1 + 4 + 1 + 4 + 1 = 11 bytes
456        // Truncate at byte 3, which is in the middle of 🚀
457        let result = truncate(s, 3);
458        assert!(result.contains("truncated"), "should be truncated");
459        // Should only keep "A" since 🚀 starts at byte 1 and ends at byte 5
460        assert!(
461            result.starts_with("A"),
462            "should start with 'A', got: {result}"
463        );
464    }
465
466    #[tokio::test]
467    async fn unknown_builtin_returns_error() {
468        let result = execute(&call("test-5", "evil_tool", "{}")).await;
469        assert!(!result.success);
470        assert!(result.error.contains("unknown builtin"));
471    }
472}