Skip to main content

roder_tools/
lib.rs

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        // Every catalog item resolves back to a registered executor.
230        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        // Nothing credential-like or process-local leaves through payloads.
239        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            &registry,
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(&registry, &root, "grep", json!({ "query": "needle" })).await;
312        assert!(grep.text.contains("src/main.rs:2:needle"));
313
314        let glob = run_tool(&registry, &root, "glob", json!({ "pattern": "src/*.rs" })).await;
315        assert_eq!(glob.text, "src/main.rs");
316
317        let edit = run_tool(
318            &registry,
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            &registry,
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            &registry,
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            &registry,
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            &registry,
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            &registry,
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            &registry,
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            &registry,
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            &registry,
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            &registry,
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            &registry,
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            &registry,
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            &registry,
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            &registry,
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            &registry,
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        // SAFETY: this test holds a process-wide mutex while mutating HOME.
770        unsafe {
771            std::env::set_var("HOME", &home);
772            std::env::set_var("USERPROFILE", &home);
773        }
774        let list = run_tool(&registry, &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        // SAFETY: callers hold env_lock while restoring HOME.
952        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        // SAFETY: callers hold env_lock while restoring USERPROFILE.
964        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(&registry, &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    /// Strip search-index metadata that legitimately varies between backends
1143    /// (the local backend can build an on-disk index; the runner backend falls
1144    /// back to scanning) or between runs (timings), so the comparison checks the
1145    /// tool results themselves rather than these implementation details.
1146    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}