1mod artifacts;
2mod backend;
3mod command_shell;
4mod design;
5mod discovery;
6mod edit;
7mod exec;
8mod exec_output;
9mod files;
10mod goals;
11mod hunk_output;
12mod media;
13mod paging;
14mod patch;
15#[cfg(test)]
16mod remote_test_support;
17mod response_format;
18mod search;
19mod shell;
20mod workflow;
21mod workspace;
22
23use std::path::PathBuf;
24use std::sync::Arc;
25
26use roder_api::extension::ToolProviderId;
27use serde_json::json;
28
29pub use roder_api::tools::*;
30
31pub use workspace::ToolPathScope;
32use workspace::Workspace;
33
34use backend::{LocalWorkspaceBackend, WorkspaceBackendHandle};
35
36#[derive(Debug, Default)]
37pub struct EchoToolContributor;
38
39impl ToolContributor for EchoToolContributor {
40 fn id(&self) -> ToolProviderId {
41 "builtin-echo".to_string()
42 }
43
44 fn contribute(&self, registry: &mut ToolRegistry) -> anyhow::Result<()> {
45 registry.register(Arc::new(EchoTool))
46 }
47}
48
49#[derive(Debug)]
50pub struct EchoTool;
51
52#[async_trait::async_trait]
53impl ToolExecutor for EchoTool {
54 fn spec(&self) -> ToolSpec {
55 ToolSpec {
56 name: "echo".to_string(),
57 description: "Returns the provided text argument unchanged.".to_string(),
58 parameters: json!({
59 "type": "object",
60 "properties": {
61 "text": {
62 "type": "string",
63 "description": "Text to return."
64 }
65 },
66 "required": ["text"]
67 }),
68 }
69 }
70
71 async fn execute(
72 &self,
73 _ctx: ToolExecutionContext,
74 call: ToolCall,
75 ) -> anyhow::Result<ToolResult> {
76 let text = call
77 .arguments
78 .get("text")
79 .and_then(serde_json::Value::as_str)
80 .unwrap_or(&call.raw_arguments)
81 .to_string();
82 Ok(ToolResult {
83 id: call.id,
84 name: call.name,
85 text: text.clone(),
86 data: json!({ "text": text }),
87 is_error: false,
88 })
89 }
90}
91
92#[derive(Clone)]
93pub struct BuiltinCodingToolsContributor {
94 workspace: Workspace,
95 backend: WorkspaceBackendHandle,
96 command_shell: String,
97}
98
99impl BuiltinCodingToolsContributor {
100 pub fn new(workspace: impl Into<PathBuf>) -> anyhow::Result<Self> {
101 Self::new_with_path_scope(workspace, ToolPathScope::default())
102 }
103
104 pub fn new_with_path_scope(
105 workspace: impl Into<PathBuf>,
106 path_scope: ToolPathScope,
107 ) -> anyhow::Result<Self> {
108 Self::new_with_path_scope_and_shell(
109 workspace,
110 path_scope,
111 roder_api::command_shell::default_command_shell(),
112 )
113 }
114
115 pub fn new_with_path_scope_and_shell(
116 workspace: impl Into<PathBuf>,
117 path_scope: ToolPathScope,
118 command_shell: impl Into<String>,
119 ) -> anyhow::Result<Self> {
120 let workspace = Workspace::new_with_scope(workspace.into(), path_scope)?;
121 let backend = Arc::new(LocalWorkspaceBackend::new(workspace.clone()));
122 Ok(Self {
123 workspace,
124 backend,
125 command_shell: command_shell.into(),
126 })
127 }
128
129 #[cfg(test)]
130 fn new_with_backend(
131 workspace: impl Into<PathBuf>,
132 backend: WorkspaceBackendHandle,
133 ) -> anyhow::Result<Self> {
134 Ok(Self {
135 workspace: Workspace::new(workspace.into())?,
136 backend,
137 command_shell: roder_api::command_shell::default_command_shell(),
138 })
139 }
140}
141
142impl ToolContributor for BuiltinCodingToolsContributor {
143 fn id(&self) -> ToolProviderId {
144 "builtin-coding-tools".to_string()
145 }
146
147 fn contribute(&self, registry: &mut ToolRegistry) -> anyhow::Result<()> {
148 files::register(registry, self.workspace.clone(), self.backend.clone())?;
149 search::register(registry, self.workspace.clone(), self.backend.clone())?;
150 shell::register(
151 registry,
152 self.workspace.clone(),
153 self.command_shell.clone(),
154 Some(self.backend.clone()),
155 )?;
156 exec::register(
157 registry,
158 self.workspace.clone(),
159 self.command_shell.clone(),
160 Some(self.backend.clone()),
161 )?;
162 registry.register(Arc::new(patch::ApplyPatchTool {
163 workspace: self.workspace.clone(),
164 backend: self.backend.clone(),
165 }))?;
166 edit::register(registry, self.workspace.clone(), self.backend.clone())?;
167 design::register(registry, self.workspace.clone())?;
168 workflow::register(registry)?;
169 media::register(registry)?;
170 artifacts::register(registry)?;
171 discovery::register(registry)
172 }
173}
174
175pub fn echo_tool_contributor() -> Arc<dyn ToolContributor> {
176 Arc::new(EchoToolContributor)
177}
178
179pub fn builtin_coding_tools_contributor(
180 workspace: impl Into<PathBuf>,
181) -> anyhow::Result<Arc<dyn ToolContributor>> {
182 Ok(Arc::new(BuiltinCodingToolsContributor::new(workspace)?))
183}
184
185pub fn builtin_coding_tools_contributor_with_path_scope(
186 workspace: impl Into<PathBuf>,
187 path_scope: ToolPathScope,
188) -> anyhow::Result<Arc<dyn ToolContributor>> {
189 Ok(Arc::new(
190 BuiltinCodingToolsContributor::new_with_path_scope(workspace, path_scope)?,
191 ))
192}
193
194pub fn builtin_coding_tools_contributor_with_path_scope_and_shell(
195 workspace: impl Into<PathBuf>,
196 path_scope: ToolPathScope,
197 command_shell: impl Into<String>,
198) -> anyhow::Result<Arc<dyn ToolContributor>> {
199 Ok(Arc::new(
200 BuiltinCodingToolsContributor::new_with_path_scope_and_shell(
201 workspace,
202 path_scope,
203 command_shell,
204 )?,
205 ))
206}
207
208#[cfg(test)]
209mod tool_search_catalog_tests {
210 use super::*;
211 use roder_api::inference::ToolSearchConfig;
212 use roder_api::tool_search_catalog::ToolSearchCatalog;
213 use roder_api::tools::ToolRegistry;
214
215 #[test]
216 fn tool_search_catalog_over_builtin_tools_is_stable_and_provider_safe() {
217 let workspace = std::env::temp_dir();
218 let contributor = builtin_coding_tools_contributor(&workspace).expect("contributor");
219 let mut registry = ToolRegistry::default();
220 contributor.contribute(&mut registry).expect("register");
221 let specs = registry.specs();
222 assert!(!specs.is_empty());
223
224 let config = ToolSearchConfig::default();
225 let first = ToolSearchCatalog::build(&specs, &config);
226 let second = ToolSearchCatalog::build(&specs, &config);
227 assert_eq!(first, second, "catalog is stable across runs");
228
229 for item in &first.items {
231 assert!(
232 registry.get(&item.name).is_some(),
233 "{} must map to a canonical executor",
234 item.name
235 );
236 }
237
238 let serialized = serde_json::to_string(&first).unwrap();
240 for needle in ["sk-", "Bearer ", "api_key\":\"", "/Users/", "x-roder-"] {
241 assert!(!serialized.contains(needle), "leaked {needle:?}");
242 }
243 }
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249 #[cfg(unix)]
250 use crate::backend::RunnerWorkspaceBackend;
251 #[cfg(unix)]
252 use roder_api::remote_runner::{RemoteRunnerProvider, RunnerDestination, RunnerManifest};
253 #[cfg(unix)]
254 use roder_ext_runner_unix_local::UnixLocalRunnerProvider;
255 #[cfg(not(windows))]
256 use std::sync::{Mutex, OnceLock};
257
258 #[test]
259 fn echo_contributor_registers_echo_spec() {
260 let mut registry = ToolRegistry::default();
261 EchoToolContributor.contribute(&mut registry).unwrap();
262
263 let specs = registry.specs();
264 assert_eq!(specs.len(), 1);
265 assert_eq!(specs[0].name, "echo");
266 assert!(registry.get("echo").is_some());
267 }
268
269 #[tokio::test]
270 async fn echo_tool_returns_text_argument() {
271 let tool = EchoTool;
272 let result = tool
273 .execute(
274 context(),
275 ToolCall {
276 id: "call-a".to_string(),
277 name: "echo".to_string(),
278 arguments: json!({ "text": "hello harness" }),
279 raw_arguments: "{}".to_string(),
280 thread_id: "thread-a".to_string(),
281 turn_id: "turn-a".to_string(),
282 },
283 )
284 .await
285 .unwrap();
286
287 assert_eq!(result.text, "hello harness");
288 assert_eq!(result.data, json!({ "text": "hello harness" }));
289 assert!(!result.is_error);
290 }
291
292 #[tokio::test]
293 async fn builtin_coding_tools_read_search_and_edit_workspace_files() {
294 let root = test_workspace("coding-tools");
295 std::fs::create_dir_all(root.join("src")).unwrap();
296 let mut registry = ToolRegistry::default();
297 BuiltinCodingToolsContributor::new(root.clone())
298 .unwrap()
299 .contribute(&mut registry)
300 .unwrap();
301
302 let write = run_tool(
303 ®istry,
304 &root,
305 "write_file",
306 json!({ "path": "src/main.rs", "content": "alpha\nneedle\nomega\n" }),
307 )
308 .await;
309 assert_eq!(write.text, "wrote src/main.rs");
310
311 let grep = run_tool(®istry, &root, "grep", json!({ "query": "needle" })).await;
312 assert!(grep.text.contains("src/main.rs:2:needle"));
313
314 let glob = run_tool(®istry, &root, "glob", json!({ "pattern": "src/*.rs" })).await;
315 assert_eq!(glob.text, "src/main.rs");
316
317 let edit = run_tool(
318 ®istry,
319 &root,
320 "edit",
321 json!({ "path": "src/main.rs", "old_string": "needle", "new_string": "NEEDLE" }),
322 )
323 .await;
324 assert_eq!(edit.text, "edited src/main.rs");
325
326 let multi_edit = run_tool(
327 ®istry,
328 &root,
329 "multi_edit",
330 json!({
331 "path": "src/main.rs",
332 "edits": [
333 { "old_string": "alpha", "new_string": "ALPHA" },
334 { "old_string": "omega", "new_string": "OMEGA" }
335 ]
336 }),
337 )
338 .await;
339 assert_eq!(multi_edit.text, "edited src/main.rs (2 replacements)");
340
341 let patch = run_tool(
342 ®istry,
343 &root,
344 "apply_patch",
345 json!({
346 "patch": "*** Begin Patch\n*** Update File: src/main.rs\n@@\n-ALPHA\n+patched\n*** End Patch\n"
347 }),
348 )
349 .await;
350 assert_eq!(patch.text, "Success. Updated src/main.rs");
351
352 let read = run_tool(
353 ®istry,
354 &root,
355 "read_file",
356 json!({ "path": "src/main.rs", "start_line": 2, "limit": 1 }),
357 )
358 .await;
359 assert!(read.text.contains("2: NEEDLE"));
360 assert_eq!(read.data["next_start_line"], 3);
361
362 let relative_read = run_tool(
363 ®istry,
364 &root,
365 "read_file",
366 json!({ "path": "./src/../src/main.rs", "start_line": 1, "limit": 1 }),
367 )
368 .await;
369 assert!(relative_read.text.contains("1: patched"));
370
371 let _ = std::fs::remove_dir_all(root);
372 }
373
374 #[tokio::test]
375 async fn builtin_apply_patch_respects_path_scope() {
376 let root = test_workspace("apply-patch-root");
377 let outside = test_workspace("apply-patch-outside");
378 let target = outside.join("patched.txt");
379
380 let patch = format!(
381 "*** Begin Patch\n*** Add File: {}\n+yes\n*** End Patch\n",
382 target.display()
383 );
384
385 let mut global_registry = ToolRegistry::default();
386 BuiltinCodingToolsContributor::new(root.clone())
387 .unwrap()
388 .contribute(&mut global_registry)
389 .unwrap();
390 let result = run_tool(
391 &global_registry,
392 &root,
393 "apply_patch",
394 json!({ "patch": patch }),
395 )
396 .await;
397 assert!(result.text.contains("Success. Added"));
398 assert_eq!(std::fs::read_to_string(&target).unwrap(), "yes\n");
399
400 let mut workspace_registry = ToolRegistry::default();
401 BuiltinCodingToolsContributor::new_with_path_scope(root.clone(), ToolPathScope::Workspace)
402 .unwrap()
403 .contribute(&mut workspace_registry)
404 .unwrap();
405 let err = registry_apply_patch_error(&workspace_registry, &root, &target).await;
406 assert!(err.contains("outside workspace"));
407
408 let _ = std::fs::remove_dir_all(root);
409 let _ = std::fs::remove_dir_all(outside);
410 }
411
412 #[test]
413 fn schema_snapshots_cover_model_facing_builtin_coding_tools() {
414 let root = test_workspace("schema-snapshots");
415 let mut registry = ToolRegistry::default();
416 BuiltinCodingToolsContributor::new(root.clone())
417 .unwrap()
418 .contribute(&mut registry)
419 .unwrap();
420 let schemas = registry
421 .specs()
422 .into_iter()
423 .filter(|spec| {
424 matches!(
425 spec.name.as_str(),
426 "read_file"
427 | "grep"
428 | "glob"
429 | "edit"
430 | "multi_edit"
431 | "apply_patch"
432 | "shell"
433 | "exec_command"
434 )
435 })
436 .map(|spec| (spec.name, serde_json::to_string(&spec.parameters).unwrap()))
437 .collect::<std::collections::BTreeMap<_, _>>();
438
439 assert!(
440 schemas["read_file"]
441 .starts_with(r#"{"type":"object","required":["path"],"properties":"#)
442 );
443 assert!(
444 schemas["grep"].starts_with(r#"{"type":"object","required":["query"],"properties":"#)
445 );
446 assert!(
447 schemas["glob"].starts_with(r#"{"type":"object","required":["pattern"],"properties":"#)
448 );
449 assert!(schemas["edit"].starts_with(
450 r#"{"type":"object","required":["path","old_string","new_string"],"properties":"#
451 ));
452 assert!(
453 schemas["apply_patch"]
454 .starts_with(r#"{"type":"object","required":["patch"],"properties":"#)
455 );
456 assert!(
457 schemas["shell"]
458 .starts_with(r#"{"type":"object","required":["command"],"properties":"#)
459 );
460 assert!(
461 schemas["exec_command"]
462 .starts_with(r#"{"type":"object","required":["cmd"],"properties":"#)
463 );
464 assert_eq!(
465 schemas["multi_edit"],
466 r#"{"type":"object","required":["path","edits"],"properties":{"edits":{"type":"array","items":{"type":"object","required":["old_string","new_string"],"properties":{"new_string":{"type":"string"},"old_string":{"type":"string"}},"additionalProperties":false}},"path":{"type":"string"}},"additionalProperties":false}"#
467 );
468 assert!(
469 schemas
470 .values()
471 .all(|schema| schema.contains(r#""additionalProperties":false"#))
472 );
473 let _ = std::fs::remove_dir_all(root);
474 }
475
476 #[cfg(unix)]
477 #[tokio::test]
478 async fn builtin_coding_tools_match_direct_local_and_unix_local_runner_backends() {
479 let direct_root = test_workspace("coding-tools-direct");
480 let runner_root = test_workspace("coding-tools-runner");
481 let direct_outputs = run_coding_tool_sequence(
482 BuiltinCodingToolsContributor::new(direct_root.clone()).unwrap(),
483 )
484 .await;
485
486 let guard = Workspace::new(runner_root.clone()).unwrap();
487 let provider = UnixLocalRunnerProvider::default();
488 let session = provider
489 .create_session(RunnerDestination {
490 id: "unix-local".to_string(),
491 provider_id: "unix-local".to_string(),
492 config: serde_json::json!({ "root": runner_root.display().to_string() }),
493 default_manifest: RunnerManifest::default(),
494 })
495 .await
496 .unwrap();
497 let runner_backend = Arc::new(RunnerWorkspaceBackend::new(guard, session));
498 let runner_outputs = run_coding_tool_sequence(
499 BuiltinCodingToolsContributor::new_with_backend(runner_root.clone(), runner_backend)
500 .unwrap(),
501 )
502 .await;
503
504 assert_eq!(runner_outputs, direct_outputs);
505
506 let _ = std::fs::remove_dir_all(direct_root);
507 let _ = std::fs::remove_dir_all(runner_root);
508 }
509
510 #[tokio::test]
511 async fn builtin_coding_tools_paginate_line_outputs() {
512 let root = test_workspace("paging");
513 std::fs::create_dir_all(root.join("src")).unwrap();
514 std::fs::write(root.join("src/a.rs"), "needle a\n").unwrap();
515 std::fs::write(root.join("src/b.rs"), "needle b\n").unwrap();
516 let mut registry = ToolRegistry::default();
517 BuiltinCodingToolsContributor::new(root.clone())
518 .unwrap()
519 .contribute(&mut registry)
520 .unwrap();
521
522 let files = run_tool(
523 ®istry,
524 &root,
525 "list_files",
526 json!({ "path": "src", "limit": 1 }),
527 )
528 .await;
529 assert_eq!(files.text.lines().next(), Some("a.rs"));
530 assert_eq!(files.data["next_offset"], 1);
531
532 let grep = run_tool(
533 ®istry,
534 &root,
535 "grep",
536 json!({ "query": "needle", "path": "src", "limit": 1 }),
537 )
538 .await;
539 assert!(grep.text.contains("src/a.rs:1:needle a"));
540 assert_eq!(grep.data["next_offset"], 1);
541
542 let glob = run_tool(
543 ®istry,
544 &root,
545 "glob",
546 json!({ "pattern": "src/*.rs", "offset": 1, "limit": 1 }),
547 )
548 .await;
549 assert_eq!(glob.text.lines().next(), Some("src/b.rs"));
550 assert_eq!(glob.data["offset"], 1);
551
552 let _ = std::fs::remove_dir_all(root);
553 }
554
555 #[tokio::test]
556 async fn builtin_grep_supports_regex_modes_and_metadata() {
557 let root = test_workspace("grep-search-index");
558 std::fs::create_dir_all(root.join("src")).unwrap();
559 std::fs::write(root.join("src/a.rs"), "ErrorKind\nterror\nerror\n").unwrap();
560 std::fs::write(root.join("src/b.rs"), "nothing\n").unwrap();
561 let mut registry = ToolRegistry::default();
562 BuiltinCodingToolsContributor::new(root.clone())
563 .unwrap()
564 .contribute(&mut registry)
565 .unwrap();
566
567 let grep = run_tool(
568 ®istry,
569 &root,
570 "grep",
571 json!({
572 "query": "error",
573 "path": "src",
574 "regex": true,
575 "case_sensitive": false,
576 "word_boundary": true,
577 "mode": "auto"
578 }),
579 )
580 .await;
581
582 assert!(grep.text.contains("src/a.rs:3:error"));
583 assert!(!grep.text.contains("terror"));
584 assert_eq!(grep.data["engine"], "indexed");
585 assert_eq!(grep.data["candidate_files"], 1);
586 assert_eq!(grep.data["verified_files"], 1);
587 assert_eq!(grep.data["stale"], false);
588 assert_eq!(grep.data["index_version"], "roder-search-v2");
589 assert_eq!(grep.data["retrieval_mode"], "exact_text");
590
591 let scan = run_tool(
592 ®istry,
593 &root,
594 "grep",
595 json!({ "query": "nothing", "path": "src", "mode": "scan" }),
596 )
597 .await;
598 assert!(scan.text.contains("src/b.rs:1:nothing"));
599 assert_eq!(scan.data["engine"], "scan");
600
601 let _ = std::fs::remove_dir_all(root);
602 }
603
604 #[tokio::test]
605 async fn search_tools_advertise_retrieval_metadata() {
606 let root = test_workspace("retrieval-metadata");
607 let mut registry = ToolRegistry::default();
608 BuiltinCodingToolsContributor::new(root.clone())
609 .unwrap()
610 .contribute(&mut registry)
611 .unwrap();
612
613 let grep = registry.get("grep").unwrap().spec();
614 assert_eq!(grep.parameters["x-roder"]["retrievalMode"], "exact_text");
615 let glob = registry.get("glob").unwrap().spec();
616 assert_eq!(glob.parameters["x-roder"]["retrievalMode"], "file_name");
617
618 let _ = std::fs::remove_dir_all(root);
619 }
620
621 #[tokio::test]
622 async fn builtin_grep_refreshes_index_after_workspace_writes() {
623 let root = test_workspace("grep-search-refresh");
624 std::fs::create_dir_all(root.join("src")).unwrap();
625 std::fs::write(root.join("src/a.rs"), "needle a\n").unwrap();
626 let mut registry = ToolRegistry::default();
627 BuiltinCodingToolsContributor::new(root.clone())
628 .unwrap()
629 .contribute(&mut registry)
630 .unwrap();
631
632 let first = run_tool(
633 ®istry,
634 &root,
635 "grep",
636 json!({ "query": "needle", "mode": "indexed" }),
637 )
638 .await;
639 assert_eq!(first.data["engine"], "indexed");
640 assert!(first.text.contains("src/a.rs:1:needle a"));
641
642 let write = run_tool(
643 ®istry,
644 &root,
645 "write_file",
646 json!({ "path": "src/b.rs", "content": "needle b\n" }),
647 )
648 .await;
649 assert_eq!(write.text, "wrote src/b.rs");
650
651 let second = run_tool(
652 ®istry,
653 &root,
654 "grep",
655 json!({ "query": "needle", "mode": "indexed" }),
656 )
657 .await;
658 assert!(second.text.contains("src/a.rs:1:needle a"));
659 assert!(second.text.contains("src/b.rs:1:needle b"));
660
661 let _ = std::fs::remove_dir_all(root);
662 }
663
664 #[tokio::test]
665 async fn artifacts_tools_read_grep_and_tail_current_thread_store() {
666 let mut registry = ToolRegistry::default();
667 artifacts::register(&mut registry).unwrap();
668 let store = Arc::new(FakeArtifactStore);
669 let ctx = context_without_handles().with_context_artifacts(store);
670
671 let read = registry
672 .get("read_artifact")
673 .unwrap()
674 .execute(
675 ctx.clone(),
676 call(
677 "read_artifact",
678 json!({ "artifact_id": "artifact-1", "start_line": 2, "limit": 1 }),
679 ),
680 )
681 .await
682 .unwrap();
683 assert_eq!(read.text, " 2: needle");
684 assert_eq!(read.data["nextStartLine"], 3);
685
686 let grep = registry
687 .get("grep_artifact")
688 .unwrap()
689 .execute(
690 ctx.clone(),
691 call(
692 "grep_artifact",
693 json!({ "artifact_id": "artifact-1", "query": "needle" }),
694 ),
695 )
696 .await
697 .unwrap();
698 assert_eq!(grep.text, "2: needle");
699
700 let tail = registry
701 .get("tail_artifact")
702 .unwrap()
703 .execute(
704 ctx,
705 call(
706 "tail_artifact",
707 json!({ "artifact_id": "artifact-1", "lines": 1 }),
708 ),
709 )
710 .await
711 .unwrap();
712 assert_eq!(tail.text, " 3: omega");
713 }
714
715 #[tokio::test]
716 async fn builtin_coding_tools_allow_paths_outside_workspace_by_default() {
717 let root = test_workspace("path-global-root");
718 let outside = test_workspace("path-global-outside");
719 std::fs::create_dir_all(&root).unwrap();
720 std::fs::create_dir_all(&outside).unwrap();
721 let outside_file = outside.join("outside.txt");
722 let mut registry = ToolRegistry::default();
723 BuiltinCodingToolsContributor::new(root.clone())
724 .unwrap()
725 .contribute(&mut registry)
726 .unwrap();
727
728 let write = run_tool(
729 ®istry,
730 &root,
731 "write_file",
732 json!({ "path": outside_file.display().to_string(), "content": "yes" }),
733 )
734 .await;
735 assert_eq!(
736 write.text,
737 format!("wrote {}", outside_file.display()).replace('\\', "/")
738 );
739
740 let list = run_tool(
741 ®istry,
742 &root,
743 "list_files",
744 json!({ "path": outside.display().to_string() }),
745 )
746 .await;
747 assert_eq!(list.text, "outside.txt");
748
749 let _ = std::fs::remove_dir_all(root);
750 let _ = std::fs::remove_dir_all(outside);
751 }
752
753 #[cfg(not(windows))]
754 #[tokio::test]
755 #[allow(clippy::await_holding_lock)]
756 async fn builtin_coding_tools_list_files_expands_home_directory() {
757 let _guard = env_lock().lock().unwrap();
758 let previous_home = std::env::var_os("HOME");
759 let previous_userprofile = std::env::var_os("USERPROFILE");
760 let root = test_workspace("home-root");
761 let home = test_workspace("home-dir");
762 std::fs::write(home.join("home-file.txt"), "yes").unwrap();
763 let mut registry = ToolRegistry::default();
764 BuiltinCodingToolsContributor::new(root.clone())
765 .unwrap()
766 .contribute(&mut registry)
767 .unwrap();
768
769 unsafe {
771 std::env::set_var("HOME", &home);
772 std::env::set_var("USERPROFILE", &home);
773 }
774 let list = run_tool(®istry, &root, "list_files", json!({ "path": "~/" })).await;
775 restore_home(previous_home);
776 restore_userprofile(previous_userprofile);
777
778 assert_eq!(list.text, "home-file.txt");
779
780 let _ = std::fs::remove_dir_all(root);
781 let _ = std::fs::remove_dir_all(home);
782 }
783
784 #[tokio::test]
785 async fn builtin_coding_tools_can_restrict_paths_to_workspace() {
786 let root = test_workspace("path-safety");
787 std::fs::create_dir_all(&root).unwrap();
788 let mut registry = ToolRegistry::default();
789 BuiltinCodingToolsContributor::new_with_path_scope(root.clone(), ToolPathScope::Workspace)
790 .unwrap()
791 .contribute(&mut registry)
792 .unwrap();
793
794 let err = registry
795 .get("write_file")
796 .unwrap()
797 .execute(
798 context_with_workspace(&root),
799 call(
800 "write_file",
801 json!({ "path": "../outside.txt", "content": "nope" }),
802 ),
803 )
804 .await
805 .unwrap_err()
806 .to_string();
807 assert!(err.contains("outside workspace"));
808
809 let _ = std::fs::remove_dir_all(root);
810 }
811
812 #[tokio::test]
813 async fn workspace_tools_require_scoped_workspace_handle() {
814 let root = test_workspace("missing-workspace-handle");
815 std::fs::write(root.join("note.txt"), "secret\n").unwrap();
816 let mut registry = ToolRegistry::default();
817 BuiltinCodingToolsContributor::new(root.clone())
818 .unwrap()
819 .contribute(&mut registry)
820 .unwrap();
821
822 let err = registry
823 .get("read_file")
824 .unwrap()
825 .execute(
826 context_without_handles(),
827 call("read_file", json!({ "path": "note.txt" })),
828 )
829 .await
830 .unwrap_err()
831 .to_string();
832
833 assert!(err.contains("workspace handle is not available"));
834
835 let _ = std::fs::remove_dir_all(root);
836 }
837
838 #[tokio::test]
839 async fn shell_tool_requires_scoped_process_runner() {
840 let root = test_workspace("missing-process-handle");
841 let mut registry = ToolRegistry::default();
842 BuiltinCodingToolsContributor::new(root.clone())
843 .unwrap()
844 .contribute(&mut registry)
845 .unwrap();
846
847 let err = registry
848 .get("shell")
849 .unwrap()
850 .execute(
851 context_without_handles(),
852 call("shell", json!({ "command": "printf hi" })),
853 )
854 .await
855 .unwrap_err()
856 .to_string();
857
858 assert!(err.contains("process runner is not available"));
859
860 let _ = std::fs::remove_dir_all(root);
861 }
862
863 async fn run_tool(
864 registry: &ToolRegistry,
865 workspace: &std::path::Path,
866 name: &str,
867 arguments: serde_json::Value,
868 ) -> ToolResult {
869 registry
870 .get(name)
871 .unwrap_or_else(|| panic!("missing tool {name}"))
872 .execute(context_with_workspace(workspace), call(name, arguments))
873 .await
874 .unwrap()
875 }
876
877 async fn registry_apply_patch_error(
878 registry: &ToolRegistry,
879 workspace: &std::path::Path,
880 target: &std::path::Path,
881 ) -> String {
882 let result = registry
883 .get("apply_patch")
884 .unwrap()
885 .execute(
886 context_with_workspace(workspace),
887 call(
888 "apply_patch",
889 json!({
890 "patch": format!(
891 "*** Begin Patch\n*** Add File: {}\n+no\n*** End Patch\n",
892 target.display()
893 )
894 }),
895 ),
896 )
897 .await
898 .unwrap();
899 assert!(result.is_error);
900 result.text
901 }
902
903 fn context() -> ToolExecutionContext {
904 context_with_workspace(std::path::Path::new("."))
905 }
906
907 fn context_with_workspace(workspace: &std::path::Path) -> ToolExecutionContext {
908 context_without_handles()
909 .with_workspace_handle(Arc::new(LocalWorkspaceHandle::new(workspace)))
910 .with_process_runner(Arc::new(LocalProcessRunnerHandle))
911 }
912
913 fn context_without_handles() -> ToolExecutionContext {
914 ToolExecutionContext::new(
915 "thread-a",
916 "turn-a",
917 roder_api::policy_mode::PolicyMode::Default,
918 )
919 }
920
921 fn call(name: &str, arguments: serde_json::Value) -> ToolCall {
922 ToolCall {
923 id: format!("call-{name}"),
924 name: name.to_string(),
925 raw_arguments: arguments.to_string(),
926 arguments,
927 thread_id: "thread-a".to_string(),
928 turn_id: "turn-a".to_string(),
929 }
930 }
931
932 fn test_workspace(name: &str) -> PathBuf {
933 let stamp = std::time::SystemTime::now()
934 .duration_since(std::time::UNIX_EPOCH)
935 .unwrap()
936 .as_nanos();
937 let path = std::env::temp_dir().join(format!("roder-tools-{name}-{stamp}"));
938 let _ = std::fs::remove_dir_all(&path);
939 std::fs::create_dir_all(&path).unwrap();
940 path
941 }
942
943 #[cfg(not(windows))]
944 fn env_lock() -> &'static Mutex<()> {
945 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
946 LOCK.get_or_init(|| Mutex::new(()))
947 }
948
949 #[cfg(not(windows))]
950 fn restore_home(previous_home: Option<std::ffi::OsString>) {
951 unsafe {
953 if let Some(previous_home) = previous_home {
954 std::env::set_var("HOME", previous_home);
955 } else {
956 std::env::remove_var("HOME");
957 }
958 }
959 }
960
961 #[cfg(not(windows))]
962 fn restore_userprofile(previous_userprofile: Option<std::ffi::OsString>) {
963 unsafe {
965 if let Some(previous_userprofile) = previous_userprofile {
966 std::env::set_var("USERPROFILE", previous_userprofile);
967 } else {
968 std::env::remove_var("USERPROFILE");
969 }
970 }
971 }
972
973 struct FakeArtifactStore;
974
975 impl roder_api::artifacts::ContextArtifactAccess for FakeArtifactStore {
976 fn create_artifact(
977 &self,
978 request: roder_api::artifacts::CreateArtifactRequest<'_>,
979 ) -> anyhow::Result<roder_api::artifacts::ContextArtifact> {
980 Ok(roder_api::artifacts::ContextArtifact {
981 id: "artifact-1".to_string(),
982 kind: request.kind,
983 thread_id: request.thread_id.to_string(),
984 turn_id: request.turn_id.to_string(),
985 byte_count: request.bytes.len() as u64,
986 line_count: String::from_utf8_lossy(request.bytes).lines().count() as u64,
987 source_tool_id: request.source_tool_id.map(ToString::to_string),
988 label: request.label.map(ToString::to_string),
989 store_path: "/private/artifact-1.txt".to_string(),
990 retention_expires_at: None,
991 created_at: time::OffsetDateTime::UNIX_EPOCH,
992 roder_owned: true,
993 })
994 }
995
996 fn append_artifact(
997 &self,
998 thread_id: &roder_api::events::ThreadId,
999 _artifact_id: &roder_api::artifacts::ContextArtifactId,
1000 _bytes: &[u8],
1001 ) -> anyhow::Result<roder_api::artifacts::ContextArtifact> {
1002 Ok(artifact(thread_id))
1003 }
1004
1005 fn list_artifacts(
1006 &self,
1007 thread_id: &roder_api::events::ThreadId,
1008 ) -> anyhow::Result<Vec<roder_api::artifacts::ContextArtifact>> {
1009 Ok(vec![artifact(thread_id)])
1010 }
1011
1012 fn read_artifact(
1013 &self,
1014 thread_id: &roder_api::events::ThreadId,
1015 _artifact_id: &roder_api::artifacts::ContextArtifactId,
1016 start_line: usize,
1017 _limit: usize,
1018 ) -> anyhow::Result<roder_api::artifacts::ArtifactReadPage> {
1019 let lines = [" 1: alpha", " 2: needle", " 3: omega"];
1020 Ok(roder_api::artifacts::ArtifactReadPage {
1021 artifact: artifact(thread_id).descriptor(),
1022 text: lines[start_line - 1].to_string(),
1023 start_line,
1024 limit: 1,
1025 shown: 1,
1026 total_lines: 3,
1027 next_start_line: Some(start_line + 1).filter(|line| *line <= 3),
1028 truncated: start_line < 3,
1029 })
1030 }
1031
1032 fn grep_artifact(
1033 &self,
1034 thread_id: &roder_api::events::ThreadId,
1035 _artifact_id: &roder_api::artifacts::ContextArtifactId,
1036 query: &str,
1037 _offset: usize,
1038 _limit: usize,
1039 ) -> anyhow::Result<roder_api::artifacts::ArtifactGrepPage> {
1040 Ok(roder_api::artifacts::ArtifactGrepPage {
1041 artifact: artifact(thread_id).descriptor(),
1042 query: query.to_string(),
1043 text: "2: needle".to_string(),
1044 offset: 0,
1045 limit: 200,
1046 shown: 1,
1047 total_matches: 1,
1048 next_offset: None,
1049 truncated: false,
1050 })
1051 }
1052
1053 fn tail_artifact(
1054 &self,
1055 thread_id: &roder_api::events::ThreadId,
1056 _artifact_id: &roder_api::artifacts::ContextArtifactId,
1057 lines: usize,
1058 ) -> anyhow::Result<roder_api::artifacts::ArtifactTailPage> {
1059 Ok(roder_api::artifacts::ArtifactTailPage {
1060 artifact: artifact(thread_id).descriptor(),
1061 text: " 3: omega".to_string(),
1062 start_line: 3,
1063 lines,
1064 shown: 1,
1065 total_lines: 3,
1066 truncated: true,
1067 })
1068 }
1069
1070 fn delete_artifact(
1071 &self,
1072 _thread_id: &roder_api::events::ThreadId,
1073 _artifact_id: &roder_api::artifacts::ContextArtifactId,
1074 ) -> anyhow::Result<bool> {
1075 Ok(true)
1076 }
1077 }
1078
1079 fn artifact(thread_id: &str) -> roder_api::artifacts::ContextArtifact {
1080 roder_api::artifacts::ContextArtifact {
1081 id: "artifact-1".to_string(),
1082 kind: roder_api::artifacts::ContextArtifactKind::ToolOutput,
1083 thread_id: thread_id.to_string(),
1084 turn_id: "turn-a".to_string(),
1085 byte_count: 18,
1086 line_count: 3,
1087 source_tool_id: Some("call-a".to_string()),
1088 label: Some("stdout".to_string()),
1089 store_path: "/private/artifact-1.txt".to_string(),
1090 retention_expires_at: None,
1091 created_at: time::OffsetDateTime::UNIX_EPOCH,
1092 roder_owned: true,
1093 }
1094 }
1095
1096 async fn run_coding_tool_sequence(contributor: BuiltinCodingToolsContributor) -> Vec<String> {
1097 let workspace = contributor.workspace.root().to_path_buf();
1098 let mut registry = ToolRegistry::default();
1099 contributor.contribute(&mut registry).unwrap();
1100 let calls = [
1101 (
1102 "write_file",
1103 json!({ "path": "src/main.rs", "content": "alpha\nneedle\nomega\n" }),
1104 ),
1105 ("list_files", json!({ "path": "src" })),
1106 ("grep", json!({ "query": "needle" })),
1107 ("glob", json!({ "pattern": "src/*.rs" })),
1108 (
1109 "edit",
1110 json!({ "path": "src/main.rs", "old_string": "needle", "new_string": "NEEDLE" }),
1111 ),
1112 (
1113 "multi_edit",
1114 json!({
1115 "path": "src/main.rs",
1116 "edits": [
1117 { "old_string": "alpha", "new_string": "ALPHA" },
1118 { "old_string": "omega", "new_string": "OMEGA" }
1119 ]
1120 }),
1121 ),
1122 (
1123 "apply_patch",
1124 json!({
1125 "patch": "*** Begin Patch\n*** Update File: src/main.rs\n@@\n-ALPHA\n+patched\n*** End Patch\n"
1126 }),
1127 ),
1128 (
1129 "read_file",
1130 json!({ "path": "src/main.rs", "start_line": 1, "limit": 3 }),
1131 ),
1132 ];
1133 let mut outputs = Vec::new();
1134 for (name, args) in calls {
1135 let result = run_tool(®istry, &workspace, name, args).await;
1136 assert!(!result.is_error, "{name}: {}", result.text);
1137 outputs.push(format!("{}\n{}", result.text, redact_volatile(result.data)));
1138 }
1139 outputs
1140 }
1141
1142 fn redact_volatile(mut data: serde_json::Value) -> serde_json::Value {
1147 if let Some(obj) = data.as_object_mut() {
1148 for key in ["engine", "elapsed_ms", "index_build_time_ms", "index_bytes"] {
1149 if obj.contains_key(key) {
1150 obj.insert(key.to_string(), serde_json::Value::Null);
1151 }
1152 }
1153 }
1154 data
1155 }
1156}