robonix_executor/dispatch/
builtin.rs1use crate::pb::pilot::CapabilityCallResult;
7use serde::Deserialize;
8use std::path::{Path, PathBuf};
9
10fn 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
20fn 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 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
61pub 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
101pub 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
137fn truncate(s: &str, max: usize) -> String {
140 if s.len() <= max {
141 s.to_string()
142 } else {
143 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 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
226const MAX_COMMAND_LEN: usize = 8192;
228const 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 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 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 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 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 let _ = std::fs::remove_file(&test_file);
328 let _ = std::fs::remove_dir(&tmp);
329 }
330
331 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 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 let s = "你好世界测试数据"; let result = truncate(s, 7);
440 assert!(
442 result.contains("truncated"),
443 "should be truncated, got: {result}"
444 );
445 assert!(
447 result.starts_with("你好"),
448 "should keep first 2 chars, got: {result}"
449 );
450 }
451
452 #[test]
453 fn truncate_emoji_boundary() {
454 let s = "A🚀B🚀C"; let result = truncate(s, 3);
458 assert!(result.contains("truncated"), "should be truncated");
459 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}