1use crate::pb::pilot::CapabilityCallResult;
7use crate::plan_runtime::PlanRuntime;
8use robonix_atlas::client::AtlasClient;
9use robonix_atlas::pb as atlas_pb;
10use serde::Deserialize;
11use std::path::{Path, PathBuf};
12
13fn workspace_root() -> PathBuf {
18 std::env::var("ROBONIX_WORKSPACE")
19 .map(PathBuf::from)
20 .unwrap_or_else(|_| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
21}
22
23fn safe_resolve(user_path: &str) -> anyhow::Result<PathBuf> {
27 let root = workspace_root()
28 .canonicalize()
29 .unwrap_or_else(|_| workspace_root());
30 let candidate = if Path::new(user_path).is_absolute() {
31 PathBuf::from(user_path)
32 } else {
33 root.join(user_path)
34 };
35 let resolved = if candidate.exists() {
38 candidate.canonicalize()?
39 } else {
40 let parent = candidate
41 .parent()
42 .ok_or_else(|| anyhow::anyhow!("invalid path: no parent directory"))?;
43 let parent_resolved = parent.canonicalize().map_err(|_| {
44 anyhow::anyhow!("parent directory does not exist: {}", parent.display())
45 })?;
46 parent_resolved.join(
47 candidate
48 .file_name()
49 .ok_or_else(|| anyhow::anyhow!("invalid path: no file name"))?,
50 )
51 };
52 if !resolved.starts_with(&root) {
53 anyhow::bail!(
54 "path traversal denied: {} resolves outside workspace {}",
55 user_path,
56 root.display()
57 );
58 }
59 Ok(resolved)
60}
61
62use crate::pb::pilot::CapabilityCall;
63
64pub async fn execute(
68 call: &CapabilityCall,
69 runtime: &PlanRuntime,
70 self_provider_id: &str,
71 atlas: &mut AtlasClient,
72 plan_id: &str,
73) -> CapabilityCallResult {
74 let op = call
75 .contract_id
76 .rsplit_once('/')
77 .map(|(_, leaf)| leaf)
78 .unwrap_or(call.contract_id.as_str());
79 if op == "cancel_plan" {
80 return runtime
81 .cancel_plan_builtin(call, self_provider_id, atlas)
82 .await;
83 }
84 if op == "stop_plan_at" {
85 return runtime.stop_plan_at_builtin(call).await;
86 }
87 if op == "get_plan_status" {
88 return runtime.get_plan_status_builtin(call).await;
89 }
90 if op == "get_all_plans" {
91 return runtime.get_all_plans_builtin(call).await;
92 }
93 if op == "read_capability_doc" {
94 return read_capability_doc(call, atlas).await;
95 }
96
97 let result = run(op, &call.args_json, runtime, plan_id).await;
98 let mut out = CapabilityCallResult {
99 call_id: call.call_id.clone(),
100 provider_id: call.provider_id.clone(),
101 contract_id: call.contract_id.clone(),
102 ..Default::default()
103 };
104 match result {
105 Ok(s) => {
106 out.success = true;
107 out.output = s;
108 }
109 Err(e) => {
110 out.success = false;
111 out.error = e.to_string();
112 }
113 }
114 out
115}
116
117async fn run(
118 op: &str,
119 args_json: &str,
120 runtime: &PlanRuntime,
121 plan_id: &str,
122) -> anyhow::Result<String> {
123 match op {
124 "read_file" => read_file(args_json),
125 "write_file" => write_file(args_json),
126 "patch_file" => patch_file(args_json),
127 "list_dir" => list_dir(args_json),
128 "run_command" => run_command(args_json, runtime, plan_id).await,
129 other => anyhow::bail!("unknown builtin: {}", other),
130 }
131}
132
133pub struct BuiltinSpec {
136 pub op: &'static str,
137 pub description: &'static str,
138 pub input_schema_json: &'static str,
139}
140
141pub const BUILTINS: &[BuiltinSpec] = &[
142 BuiltinSpec {
143 op: "read_file",
144 description: "Read a file and return its contents",
145 input_schema_json: r#"{"type":"object","properties":{"path":{"type":"string","description":"Absolute or relative file path"}},"required":["path"]}"#,
146 },
147 BuiltinSpec {
148 op: "write_file",
149 description: "Write content to a file (creates or overwrites)",
150 input_schema_json: r#"{"type":"object","properties":{"path":{"type":"string"},"content":{"type":"string"}},"required":["path","content"]}"#,
151 },
152 BuiltinSpec {
153 op: "patch_file",
154 description: "Replace the first occurrence of a string in a file",
155 input_schema_json: r#"{"type":"object","properties":{"path":{"type":"string"},"old":{"type":"string"},"new":{"type":"string"}},"required":["path","old","new"]}"#,
156 },
157 BuiltinSpec {
158 op: "list_dir",
159 description: "List files and directories at a path",
160 input_schema_json: r#"{"type":"object","properties":{"path":{"type":"string","description":"Directory path (default: current dir)"}}}"#,
161 },
162 BuiltinSpec {
163 op: "run_command",
164 description: "Run a shell command and return stdout/stderr",
165 input_schema_json: r#"{"type":"object","properties":{"command":{"type":"string"}},"required":["command"]}"#,
166 },
167 BuiltinSpec {
168 op: "cancel_plan",
169 description: "Cancellation for an in-flight RTDL plan by plan_id",
170 input_schema_json: r#"{"type":"object","properties":{"plan_id":{"type":"string","description":"RTDL Plan.plan_id to cancel"},"wait_ms":{"type":"integer","minimum":0,"description":"Optional milliseconds to wait for the target plan to stop; default 5000"}},"required":["plan_id"]}"#,
171 },
172 BuiltinSpec {
173 op: "get_all_plans",
174 description: "List every in-flight RTDL plan with its plan_id, a short description of the task, op_count, cancelled flag, and number of armed stop points. Call this to discover which plans are currently running, then inspect one with get_plan_status before stopping it. Takes no arguments.",
175 input_schema_json: r#"{"type":"object","properties":{}}"#,
176 },
177 BuiltinSpec {
178 op: "get_plan_status",
179 description: "Inspect an in-flight RTDL plan: returns its ops, each with op_id, kind, description, current state (pending/running/succeeded/failed/canceled/timeout/paused) and any armed stop_point. Call this to find the op_id and live progress of a running plan before issuing stop_plan_at or cancel_plan. Errors if the plan is not active (stale/wrong id or already finished) — use get_all_plans to list running plans.",
180 input_schema_json: r#"{"type":"object","properties":{"plan_id":{"type":"string","description":"RTDL Plan.plan_id to inspect"}},"required":["plan_id"]}"#,
181 },
182 BuiltinSpec {
183 op: "stop_plan_at",
184 description: "Set a stop point on an in-flight RTDL plan: when execution reaches the op with the given op_id, cancel the whole plan. Use 'on_complete' (default) to stop right after that op finishes, or 'on_enter' to stop the moment it is reached, before it runs. op_ids are the per-node identifiers shown in RTDL node_state events.",
185 input_schema_json: r#"{"type":"object","properties":{"plan_id":{"type":"string","description":"RTDL Plan.plan_id to set the stop point on"},"op_id":{"type":"string","description":"Node op_id at which to stop (cancel) the plan"},"when":{"type":"string","enum":["on_enter","on_complete"],"description":"on_enter = before the op runs; on_complete = after it finishes. Default on_complete."}},"required":["plan_id","op_id"]}"#,
186 },
187 BuiltinSpec {
188 op: "read_capability_doc",
189 description: "Read a capability provider's full CAPABILITY.md manual by provider_id. Call this to learn how to use a provider before invoking it. Only providers listed as having a doc carry one; never guess or read a file path for docs.",
190 input_schema_json: r#"{"type":"object","properties":{"provider_id":{"type":"string","description":"The provider_id whose CAPABILITY.md to read"}},"required":["provider_id"]}"#,
191 },
192];
193
194#[derive(Deserialize)]
195struct DocArgs {
196 provider_id: String,
197}
198
199async fn read_capability_doc(
208 call: &CapabilityCall,
209 atlas: &mut AtlasClient,
210) -> CapabilityCallResult {
211 let mut out = CapabilityCallResult {
212 call_id: call.call_id.clone(),
213 provider_id: call.provider_id.clone(),
214 contract_id: call.contract_id.clone(),
215 ..Default::default()
216 };
217 let provider_id = match serde_json::from_str::<DocArgs>(&call.args_json) {
218 Ok(a) => a.provider_id.trim().to_string(),
219 Err(e) => {
220 out.error = format!("invalid read_capability_doc args: {e}");
221 return out;
222 }
223 };
224 if provider_id.is_empty() {
225 out.error = "read_capability_doc: provider_id is required".to_string();
226 return out;
227 }
228 match atlas
229 .query_capabilities(&provider_id, "", atlas_pb::Transport::Unspecified)
230 .await
231 {
232 Ok(providers) => match providers.iter().find(|p| p.id == provider_id) {
233 Some(p) if !p.capability_md.is_empty() => {
234 out.success = true;
235 out.output = truncate(&p.capability_md, 12000);
236 }
237 Some(_) => {
238 out.error = format!("provider '{provider_id}' registered no CAPABILITY.md");
239 }
240 None => {
241 out.error = format!("no provider '{provider_id}' registered in atlas");
242 }
243 },
244 Err(e) => {
245 out.error = format!("atlas query for '{provider_id}' failed: {e}");
246 }
247 }
248 out
249}
250
251fn truncate(s: &str, max: usize) -> String {
254 if s.len() <= max {
255 s.to_string()
256 } else {
257 let mut end = max;
259 while end > 0 && !s.is_char_boundary(end) {
260 end -= 1;
261 }
262 format!("{}…(truncated, {} bytes total)", &s[..end], s.len())
263 }
264}
265
266#[derive(Deserialize)]
267struct ReadArgs {
268 path: String,
269}
270#[derive(Deserialize)]
271struct WriteArgs {
272 path: String,
273 content: String,
274}
275#[derive(Deserialize)]
276struct PatchArgs {
277 path: String,
278 old: String,
279 new: String,
280}
281#[derive(Deserialize)]
282struct ListArgs {
283 path: Option<String>,
284}
285#[derive(Deserialize)]
286struct CmdArgs {
287 command: String,
288}
289
290fn read_file(args: &str) -> anyhow::Result<String> {
291 let a: ReadArgs = serde_json::from_str(args)?;
292 let path = safe_resolve(&a.path)?;
293 Ok(truncate(&std::fs::read_to_string(path)?, 8000))
294}
295
296fn write_file(args: &str) -> anyhow::Result<String> {
297 let a: WriteArgs = serde_json::from_str(args)?;
298 let path = safe_resolve(&a.path)?;
299 if let Some(parent) = path.parent() {
300 std::fs::create_dir_all(parent).ok();
301 }
302 std::fs::write(&path, &a.content)?;
303 Ok(format!(
304 "wrote {} bytes to {}",
305 a.content.len(),
306 path.display()
307 ))
308}
309
310fn patch_file(args: &str) -> anyhow::Result<String> {
311 let a: PatchArgs = serde_json::from_str(args)?;
313 let path = safe_resolve(&a.path)?;
314 let content = std::fs::read_to_string(&path)?;
315 if !content.contains(&a.old) {
316 anyhow::bail!("old string not found in file");
317 }
318 std::fs::write(&path, content.replacen(&a.old, &a.new, 1))?;
319 Ok(format!("patched {}", path.display()))
320}
321
322fn list_dir(args: &str) -> anyhow::Result<String> {
323 let a: ListArgs = serde_json::from_str(args)?;
324 let dir = a.path.as_deref().unwrap_or(".");
325 let resolved = safe_resolve(dir)?;
326 let mut items: Vec<String> = std::fs::read_dir(resolved)?
327 .flatten()
328 .map(|e| {
329 let ft = e
330 .file_type()
331 .map(|t| if t.is_dir() { "dir" } else { "file" })
332 .unwrap_or("?");
333 format!("{} {}", ft, e.file_name().to_string_lossy())
334 })
335 .collect();
336 items.sort();
337 Ok(items.join("\n"))
338}
339
340const MAX_COMMAND_LEN: usize = 8192;
342const COMMAND_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
344
345async fn run_command(args: &str, runtime: &PlanRuntime, plan_id: &str) -> anyhow::Result<String> {
346 let a: CmdArgs = serde_json::from_str(args)?;
347 if a.command.len() > MAX_COMMAND_LEN {
348 anyhow::bail!(
349 "command too long ({} bytes, max {})",
350 a.command.len(),
351 MAX_COMMAND_LEN
352 );
353 }
354 if runtime.is_cancelled(plan_id).await {
355 anyhow::bail!("command canceled before spawn");
356 }
357
358 use std::process::Stdio;
359 let mut command = tokio::process::Command::new("bash");
360 command
361 .arg("-c")
362 .arg(&a.command)
363 .stdin(Stdio::null())
364 .stdout(Stdio::piped())
365 .stderr(Stdio::piped())
366 .kill_on_drop(true)
367 .process_group(0);
368 let child = command
369 .spawn()
370 .map_err(|e| anyhow::anyhow!("failed to execute command: {e}"))?;
371 let pgid = child
372 .id()
373 .ok_or_else(|| anyhow::anyhow!("spawned command has no pid"))?;
374 let output = child.wait_with_output();
375 tokio::pin!(output);
376
377 enum StopReason {
378 Canceled,
379 Timeout,
380 }
381 let canceled = async {
382 loop {
383 if runtime.is_cancelled(plan_id).await {
384 break;
385 }
386 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
387 }
388 };
389 tokio::pin!(canceled);
390 let deadline = tokio::time::sleep(COMMAND_TIMEOUT);
391 tokio::pin!(deadline);
392
393 let stop_reason = tokio::select! {
394 result = &mut output => {
395 let out = result.map_err(|e| anyhow::anyhow!("failed to execute command: {e}"))?;
396 return format_command_output(out);
397 }
398 _ = &mut canceled => StopReason::Canceled,
399 _ = &mut deadline => StopReason::Timeout,
400 };
401
402 terminate_command_group(pgid, nix::sys::signal::Signal::SIGTERM);
403 if tokio::time::timeout(std::time::Duration::from_secs(2), &mut output)
404 .await
405 .is_err()
406 {
407 terminate_command_group(pgid, nix::sys::signal::Signal::SIGKILL);
408 let _ = output.await;
409 }
410 match stop_reason {
411 StopReason::Canceled => anyhow::bail!("command canceled"),
412 StopReason::Timeout => {
413 anyhow::bail!("command timed out after {}s", COMMAND_TIMEOUT.as_secs())
414 }
415 }
416}
417
418fn terminate_command_group(pgid: u32, signal: nix::sys::signal::Signal) {
419 use nix::sys::signal::killpg;
420 use nix::unistd::Pid;
421 match killpg(Pid::from_raw(pgid as i32), signal) {
422 Ok(()) | Err(nix::errno::Errno::ESRCH) => {}
423 Err(error) => robonix_scribe::warn!(
424 "[executor] signal {:?} command pgid {} failed: {}",
425 signal,
426 pgid,
427 error
428 ),
429 }
430}
431
432fn format_command_output(out: std::process::Output) -> anyhow::Result<String> {
433 let mut result = String::new();
434 let stdout = String::from_utf8_lossy(&out.stdout);
435 let stderr = String::from_utf8_lossy(&out.stderr);
436 if !stdout.is_empty() {
437 result.push_str(&truncate(&stdout, 4000));
438 }
439 if !stderr.is_empty() {
440 if !result.is_empty() {
441 result.push('\n');
442 }
443 result.push_str("stderr: ");
444 result.push_str(&truncate(&stderr, 2000));
445 }
446 if result.is_empty() {
447 result = format!("exit code: {}", out.status.code().unwrap_or(-1));
448 }
449 Ok(result)
450}
451
452#[cfg(test)]
453mod tests {
454 use super::*;
455
456 #[test]
457 fn path_traversal_dotdot_is_rejected() {
458 let tmp = std::env::temp_dir().join("rbnx_test_ws");
460 std::fs::create_dir_all(&tmp).unwrap();
461 unsafe { std::env::set_var("ROBONIX_WORKSPACE", tmp.to_str().unwrap()) };
462
463 let result = safe_resolve("../../../etc/passwd");
465 assert!(
466 result.is_err(),
467 "path traversal with ../../../etc/passwd should fail"
468 );
469 let err_msg = result.unwrap_err().to_string();
470 assert!(
471 err_msg.contains("path traversal denied") || err_msg.contains("does not exist"),
472 "error should mention traversal or nonexistent path, got: {err_msg}"
473 );
474 }
475
476 #[test]
477 fn path_traversal_absolute_outside_workspace_is_rejected() {
478 let tmp = std::env::temp_dir().join("rbnx_test_ws2");
479 std::fs::create_dir_all(&tmp).unwrap();
480 unsafe { std::env::set_var("ROBONIX_WORKSPACE", tmp.to_str().unwrap()) };
481
482 let result = safe_resolve("/etc/hostname");
484 assert!(
485 result.is_err(),
486 "absolute path /etc/hostname outside workspace should fail"
487 );
488 let err_msg = result.unwrap_err().to_string();
489 assert!(
490 err_msg.contains("path traversal denied"),
491 "error should mention traversal, got: {err_msg}"
492 );
493 }
494
495 #[test]
496 fn path_resolve_within_workspace_is_allowed() {
497 let tmp = std::env::temp_dir().join("rbnx_test_ws3");
498 std::fs::create_dir_all(&tmp).unwrap();
499 let test_file = tmp.join("allowed.txt");
501 std::fs::write(&test_file, "hello").unwrap();
502 unsafe { std::env::set_var("ROBONIX_WORKSPACE", tmp.to_str().unwrap()) };
503
504 let result = safe_resolve("allowed.txt");
505 assert!(
506 result.is_ok(),
507 "path within workspace should be allowed: {:?}",
508 result.err()
509 );
510
511 let _ = std::fs::remove_file(&test_file);
513 let _ = std::fs::remove_dir(&tmp);
514 }
515
516 #[test]
517 fn truncate_ascii_works() {
518 let s = "hello world";
519 assert_eq!(truncate(s, 100), "hello world");
520 let t = truncate(s, 5);
521 assert!(
522 t.starts_with("hello"),
523 "should start with 'hello', got: {t}"
524 );
525 assert!(
526 t.contains("truncated"),
527 "should contain 'truncated', got: {t}"
528 );
529 }
530
531 #[test]
532 fn truncate_multibyte_utf8_does_not_panic() {
533 let s = "€€€€€€€€"; let result = truncate(s, 7);
537 assert!(
539 result.contains("truncated"),
540 "should be truncated, got: {result}"
541 );
542 assert!(
544 result.starts_with("€€"),
545 "should keep first 2 chars, got: {result}"
546 );
547 }
548
549 #[test]
550 fn truncate_emoji_boundary() {
551 let s = "A🚀B🚀C"; let result = truncate(s, 3);
555 assert!(result.contains("truncated"), "should be truncated");
556 assert!(
558 result.starts_with("A"),
559 "should start with 'A', got: {result}"
560 );
561 }
562}