Skip to main content

telos_agent/integrations/plugin/
tool_loader.rs

1//! CommandTool — declarative JSON-defined tools executed as subprocesses.
2//!
3//! Plugin tools are defined as JSON files specifying a command, optional args,
4//! timeout, and permission level. At runtime, arguments are piped as JSON to
5//! stdin; stdout JSON is the tool result.
6
7use async_trait::async_trait;
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10use std::collections::HashMap;
11use std::path::Path;
12use tokio::io::AsyncWriteExt;
13use tokio::process::Command as TokioCommand;
14
15use crate::error::AgentError;
16use crate::integrations::plugin::PluginError;
17use crate::tools::api::{
18    InterruptBehavior, PermissionDecision, Tool, ToolContext, ToolDefinition, ToolOutput,
19};
20
21/// Declarative JSON spec for a plugin tool (e.g. `tools/my-tool.json`).
22#[derive(Debug, Clone, Serialize, Deserialize)]
23#[serde(rename_all = "camelCase")]
24pub struct ToolSpec {
25    pub name: String,
26    pub description: String,
27    pub input_schema: Value,
28    pub command: String,
29    #[serde(default, skip_serializing_if = "Vec::is_empty")]
30    pub args: Vec<String>,
31    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
32    pub env: HashMap<String, String>,
33    #[serde(default = "default_tool_timeout_ms")]
34    pub timeout_ms: u64,
35    #[serde(default)]
36    pub is_concurrency_safe: bool,
37    /// Default permission decision when no rule matches.
38    #[serde(default = "default_permission")]
39    pub permission: ToolPermission,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43#[serde(rename_all = "lowercase")]
44pub enum ToolPermission {
45    Allow,
46    Ask,
47    Deny,
48}
49
50fn default_tool_timeout_ms() -> u64 {
51    60_000
52}
53
54fn default_permission() -> ToolPermission {
55    ToolPermission::Ask
56}
57
58/// Load a tool spec from a JSON file.
59pub fn load_tool_spec(path: &Path) -> Result<ToolSpec, PluginError> {
60    let content = std::fs::read_to_string(path).map_err(|e| PluginError::ManifestParse {
61        path: path.to_path_buf(),
62        reason: format!("failed to read tool spec: {e}"),
63    })?;
64    let spec: ToolSpec =
65        serde_json::from_str(&content).map_err(|e| PluginError::ManifestParse {
66            path: path.to_path_buf(),
67            reason: format!("invalid JSON: {e}"),
68        })?;
69    Ok(spec)
70}
71
72/// A `Tool` implementation backed by a subprocess command.
73///
74/// Arguments are serialized to JSON and piped to the command's stdin.
75/// The command must write a JSON value to stdout before exiting.
76pub struct CommandTool {
77    definition: ToolDefinition,
78    command: String,
79    args: Vec<String>,
80    env: HashMap<String, String>,
81    timeout: std::time::Duration,
82    is_concurrency_safe: bool,
83    default_permission: PermissionDecision,
84}
85
86impl CommandTool {
87    /// Build a CommandTool from a parsed ToolSpec.
88    ///
89    /// `plugin_root` is prepended to relative command paths and substituted
90    /// for `${PLUGIN_ROOT}` in args and env values.
91    pub fn from_spec(spec: ToolSpec, plugin_root: &Path) -> Self {
92        let plugin_root_str = plugin_root.to_string_lossy();
93
94        // Substitute ${PLUGIN_ROOT} in args
95        let args: Vec<String> =
96            spec.args.into_iter().map(|a| a.replace("${PLUGIN_ROOT}", &plugin_root_str)).collect();
97
98        // Substitute ${PLUGIN_ROOT} in env values
99        let env: HashMap<String, String> = spec
100            .env
101            .into_iter()
102            .map(|(k, v)| (k, v.replace("${PLUGIN_ROOT}", &plugin_root_str)))
103            .collect();
104
105        let command = resolve_plugin_command(
106            &spec.command.replace("${PLUGIN_ROOT}", &plugin_root_str),
107            plugin_root,
108        );
109
110        let definition = ToolDefinition {
111            name: spec.name,
112            description: spec.description,
113            input_schema: spec.input_schema,
114        };
115
116        let default_permission = match spec.permission {
117            ToolPermission::Allow => PermissionDecision::Allow,
118            ToolPermission::Ask => {
119                PermissionDecision::Ask { reason: "plugin tool requires approval".into() }
120            }
121            ToolPermission::Deny => PermissionDecision::Deny {
122                reason: "plugin tool is configured to deny by default".into(),
123            },
124        };
125
126        Self {
127            definition,
128            command,
129            args,
130            env,
131            timeout: std::time::Duration::from_millis(spec.timeout_ms),
132            is_concurrency_safe: spec.is_concurrency_safe,
133            default_permission,
134        }
135    }
136
137    /// Create a CommandTool directly (for programmatic construction).
138    pub fn new(
139        definition: ToolDefinition,
140        command: String,
141        args: Vec<String>,
142        env: HashMap<String, String>,
143        timeout: std::time::Duration,
144        is_concurrency_safe: bool,
145        default_permission: PermissionDecision,
146    ) -> Self {
147        Self { definition, command, args, env, timeout, is_concurrency_safe, default_permission }
148    }
149}
150
151#[async_trait]
152impl Tool for CommandTool {
153    fn definition(&self) -> ToolDefinition {
154        self.definition.clone()
155    }
156
157    fn is_concurrency_safe(&self, _arguments: &Value) -> bool {
158        self.is_concurrency_safe
159    }
160
161    fn interrupt_behavior(&self) -> InterruptBehavior {
162        InterruptBehavior::Cancel
163    }
164
165    async fn check_permission(
166        &self,
167        _arguments: &Value,
168        _context: &ToolContext,
169    ) -> Result<PermissionDecision, AgentError> {
170        Ok(self.default_permission.clone())
171    }
172
173    async fn invoke(
174        &self,
175        arguments: Value,
176        context: ToolContext,
177    ) -> Result<ToolOutput, AgentError> {
178        let args_json = serde_json::to_vec(&arguments)
179            .map_err(|e| AgentError::Validation(format!("failed to serialize arguments: {e}")))?;
180
181        let mut command = TokioCommand::new(&self.command);
182        command
183            .args(&self.args)
184            .current_dir(&context.cwd)
185            .env_clear()
186            .envs(context.env.iter())
187            .envs(&self.env)
188            .stdin(std::process::Stdio::piped())
189            .stdout(std::process::Stdio::piped())
190            .stderr(std::process::Stdio::piped())
191            .kill_on_drop(true);
192        hide_console_window(&mut command);
193        let mut child = command.spawn().map_err(|e| AgentError::ToolExecution {
194            tool: self.definition.name.clone(),
195            message: format!("failed to spawn command '{}': {e}", self.command),
196        })?;
197
198        // Write JSON arguments to stdin
199        let mut stdin = child.stdin.take().ok_or_else(|| AgentError::ToolExecution {
200            tool: self.definition.name.clone(),
201            message: "failed to open stdin".into(),
202        })?;
203
204        let output = tokio::time::timeout(self.timeout, async {
205            match stdin.write_all(&args_json).await {
206                Ok(()) => {}
207                Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => {}
208                Err(e) => return Err(e),
209            }
210            drop(stdin);
211            child.wait_with_output().await
212        })
213        .await
214        .map_err(|_| AgentError::ToolExecution {
215            tool: self.definition.name.clone(),
216            message: format!("tool timed out after {}ms", self.timeout.as_millis()),
217        })?
218        .map_err(|e| AgentError::ToolExecution {
219            tool: self.definition.name.clone(),
220            message: format!("I/O error: {e}"),
221        })?;
222
223        if !output.status.success() {
224            let stderr = String::from_utf8_lossy(&output.stderr);
225            return Err(AgentError::ToolExecution {
226                tool: self.definition.name.clone(),
227                message: format!("tool exited with status {}: {}", output.status, stderr.trim()),
228            });
229        }
230
231        let value: Value =
232            serde_json::from_slice(&output.stdout).map_err(|e| AgentError::ToolExecution {
233                tool: self.definition.name.clone(),
234                message: format!("invalid JSON output: {e}"),
235            })?;
236
237        Ok(ToolOutput::json(value))
238    }
239}
240
241fn resolve_plugin_command(command: &str, plugin_root: &Path) -> String {
242    let path = Path::new(command);
243    if path.is_absolute() || is_bare_executable(command) {
244        return command.to_string();
245    }
246    plugin_root.join(path).to_string_lossy().into_owned()
247}
248
249fn is_bare_executable(command: &str) -> bool {
250    !command.starts_with('.') && !command.contains('/') && !command.contains('\\')
251}
252
253#[cfg_attr(not(windows), allow(unused_variables))]
254fn hide_console_window(command: &mut TokioCommand) {
255    #[cfg(windows)]
256    {
257        const CREATE_NO_WINDOW: u32 = 0x0800_0000;
258        command.creation_flags(CREATE_NO_WINDOW);
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use serde_json::json;
266    use tempfile::TempDir;
267
268    #[cfg(windows)]
269    fn powershell_test_executable() -> String {
270        for candidate in ["pwsh", "powershell"] {
271            if std::process::Command::new(candidate)
272                .args(["-NoProfile", "-NonInteractive", "-Command", "$PSVersionTable.PSVersion"])
273                .output()
274                .is_ok()
275            {
276                return candidate.into();
277            }
278        }
279        "powershell".into()
280    }
281
282    fn json_echo_command() -> (String, Vec<String>) {
283        #[cfg(windows)]
284        {
285            (
286                powershell_test_executable(),
287                vec![
288                    "-NoProfile".into(),
289                    "-NonInteractive".into(),
290                    "-Command".into(),
291                    "[Console]::Out.Write([Console]::In.ReadToEnd())".into(),
292                ],
293            )
294        }
295        #[cfg(not(windows))]
296        {
297            ("cat".into(), vec![])
298        }
299    }
300
301    fn env_probe_command() -> (String, Vec<String>) {
302        #[cfg(windows)]
303        {
304            (
305                powershell_test_executable(),
306                vec![
307                    "-NoProfile".into(),
308                    "-NonInteractive".into(),
309                    "-Command".into(),
310                    concat!(
311                        "$null = [Console]::In.ReadToEnd(); ",
312                        "$secret = if ($null -eq $env:TELOS_PLUGIN_SECRET) { '' } else { $env:TELOS_PLUGIN_SECRET }; ",
313                        "$context = if ($null -eq $env:TELOS_CONTEXT_VISIBLE) { '' } else { $env:TELOS_CONTEXT_VISIBLE }; ",
314                        "$configured = if ($null -eq $env:PLUGIN_VISIBLE) { '' } else { $env:PLUGIN_VISIBLE }; ",
315                        "[Console]::Out.Write((@{secret=$secret; context=$context; configured=$configured} | ConvertTo-Json -Compress))"
316                    )
317                    .into(),
318                ],
319            )
320        }
321        #[cfg(not(windows))]
322        {
323            (
324                "/bin/sh".into(),
325                vec![
326                    "-c".into(),
327                    "cat >/dev/null; printf '{\"secret\":\"%s\",\"context\":\"%s\",\"configured\":\"%s\"}' \"$TELOS_PLUGIN_SECRET\" \"$TELOS_CONTEXT_VISIBLE\" \"$PLUGIN_VISIBLE\"".into(),
328                ],
329            )
330        }
331    }
332
333    fn cwd_probe_command() -> (String, Vec<String>) {
334        #[cfg(windows)]
335        {
336            (
337                powershell_test_executable(),
338                vec![
339                    "-NoProfile".into(),
340                    "-NonInteractive".into(),
341                    "-Command".into(),
342                    "$null = [Console]::In.ReadToEnd(); [Console]::Out.Write((@{cwd=(Get-Location).Path} | ConvertTo-Json -Compress))".into(),
343                ],
344            )
345        }
346        #[cfg(not(windows))]
347        {
348            (
349                "/bin/sh".into(),
350                vec!["-c".into(), "cat >/dev/null; printf '{\"cwd\":\"%s\"}' \"$PWD\"".into()],
351            )
352        }
353    }
354
355    fn failing_command() -> (String, Vec<String>) {
356        #[cfg(windows)]
357        {
358            (
359                powershell_test_executable(),
360                vec![
361                    "-NoProfile".into(),
362                    "-NonInteractive".into(),
363                    "-Command".into(),
364                    "exit 1".into(),
365                ],
366            )
367        }
368        #[cfg(not(windows))]
369        {
370            ("/bin/sh".into(), vec!["-c".into(), "exit 1".into()])
371        }
372    }
373
374    #[test]
375    fn parse_tool_spec_minimal() {
376        let json = json!({
377            "name": "my_tool",
378            "description": "A test",
379            "inputSchema": {"type": "object"},
380            "command": "echo"
381        });
382        let spec: ToolSpec = serde_json::from_value(json).unwrap();
383        assert_eq!(spec.name, "my_tool");
384        assert_eq!(spec.command, "echo");
385        assert!(spec.args.is_empty());
386        assert_eq!(spec.timeout_ms, 60_000);
387        assert!(!spec.is_concurrency_safe);
388    }
389
390    #[test]
391    fn parse_tool_spec_full() {
392        let json = json!({
393            "name": "full_tool",
394            "description": "Full spec",
395            "inputSchema": {"type": "object", "properties": {"text": {"type": "string"}}},
396            "command": "python3",
397            "args": ["-u", "${PLUGIN_ROOT}/scripts/tool.py"],
398            "env": {"PYTHONUNBUFFERED": "1"},
399            "timeoutMs": 10000,
400            "isConcurrencySafe": true,
401            "permission": "allow"
402        });
403        let spec: ToolSpec = serde_json::from_value(json).unwrap();
404        assert_eq!(spec.name, "full_tool");
405        assert_eq!(spec.args.len(), 2);
406        assert_eq!(spec.timeout_ms, 10_000);
407        assert!(spec.is_concurrency_safe);
408        assert!(matches!(spec.permission, ToolPermission::Allow));
409    }
410
411    #[test]
412    fn command_tool_from_spec_substitutes_plugin_root() {
413        let spec = ToolSpec {
414            name: "test".into(),
415            description: "test".into(),
416            input_schema: json!({}),
417            command: "${PLUGIN_ROOT}/bin/tool".into(),
418            args: vec!["--config".into(), "${PLUGIN_ROOT}/config.json".into()],
419            env: HashMap::from([("TOOL_HOME".into(), "${PLUGIN_ROOT}/home".into())]),
420            timeout_ms: 5000,
421            is_concurrency_safe: false,
422            permission: ToolPermission::Ask,
423        };
424
425        let tool = CommandTool::from_spec(spec, Path::new("/opt/plugin"));
426        assert_eq!(tool.command, "/opt/plugin/bin/tool");
427        assert_eq!(tool.args, vec!["--config", "/opt/plugin/config.json"]);
428        assert_eq!(tool.env.get("TOOL_HOME").unwrap(), "/opt/plugin/home");
429    }
430
431    #[test]
432    fn command_tool_from_spec_resolves_relative_plugin_command() {
433        let temp = TempDir::new().unwrap();
434        let relative_command = if cfg!(windows) { r"bin\tool.exe" } else { "bin/tool" };
435        let spec = ToolSpec {
436            name: "test".into(),
437            description: "test".into(),
438            input_schema: json!({}),
439            command: relative_command.into(),
440            args: vec![],
441            env: HashMap::new(),
442            timeout_ms: 5000,
443            is_concurrency_safe: false,
444            permission: ToolPermission::Ask,
445        };
446
447        let tool = CommandTool::from_spec(spec, temp.path());
448
449        assert_eq!(
450            std::path::PathBuf::from(&tool.command),
451            temp.path().join("bin").join(if cfg!(windows) { "tool.exe" } else { "tool" })
452        );
453    }
454
455    #[tokio::test]
456    async fn command_tool_invoke_echo() {
457        let definition = ToolDefinition {
458            name: "echo_test".into(),
459            description: "Echo test".into(),
460            input_schema: json!({"type": "object"}),
461        };
462        let (command, args) = json_echo_command();
463
464        let tool = CommandTool::new(
465            definition,
466            command,
467            args,
468            HashMap::new(),
469            std::time::Duration::from_secs(5),
470            true,
471            PermissionDecision::Allow,
472        );
473
474        let result = tool.invoke(json!({"hello": "world"}), ToolContext::dummy()).await.unwrap();
475
476        let content = result.content;
477        assert_eq!(content["hello"], "world");
478    }
479
480    #[tokio::test]
481    async fn command_tool_invoke_failure() {
482        let definition = ToolDefinition {
483            name: "fail_test".into(),
484            description: "Fail test".into(),
485            input_schema: json!({"type": "object"}),
486        };
487        let (command, args) = failing_command();
488
489        let tool = CommandTool::new(
490            definition,
491            command,
492            args,
493            HashMap::new(),
494            std::time::Duration::from_secs(5),
495            false,
496            PermissionDecision::Allow,
497        );
498
499        let result = tool.invoke(json!({}), ToolContext::dummy()).await;
500        let err = result.unwrap_err().to_string();
501        assert!(err.contains("tool exited with status"), "{err}");
502    }
503
504    #[tokio::test]
505    async fn command_tool_runs_in_context_cwd() {
506        let temp = TempDir::new().unwrap();
507        let cwd = temp.path().join("workspace");
508        std::fs::create_dir(&cwd).unwrap();
509        let definition = ToolDefinition {
510            name: "cwd_test".into(),
511            description: "Cwd test".into(),
512            input_schema: json!({"type": "object"}),
513        };
514        let (command, args) = cwd_probe_command();
515
516        let tool = CommandTool::new(
517            definition,
518            command,
519            args,
520            HashMap::new(),
521            std::time::Duration::from_secs(5),
522            true,
523            PermissionDecision::Allow,
524        );
525        let mut context = ToolContext::dummy();
526        context.cwd = cwd.clone();
527
528        let result = tool.invoke(json!({}), context).await.unwrap();
529
530        assert_eq!(
531            std::fs::canonicalize(result.content["cwd"].as_str().unwrap()).unwrap(),
532            std::fs::canonicalize(&cwd).unwrap()
533        );
534    }
535
536    #[tokio::test]
537    async fn command_tool_runs_with_clean_environment() {
538        unsafe { std::env::set_var("TELOS_PLUGIN_SECRET", "leaked") };
539        let definition = ToolDefinition {
540            name: "env_test".into(),
541            description: "Env test".into(),
542            input_schema: json!({"type": "object"}),
543        };
544        let (command, args) = env_probe_command();
545
546        let tool = CommandTool::new(
547            definition,
548            command,
549            args,
550            HashMap::from([("PLUGIN_VISIBLE".into(), "yes".into())]),
551            std::time::Duration::from_secs(5),
552            true,
553            PermissionDecision::Allow,
554        );
555        let mut context = ToolContext::dummy();
556        context.env.insert("TELOS_CONTEXT_VISIBLE".into(), "context".into());
557
558        let result = tool.invoke(json!({}), context).await.unwrap();
559        unsafe { std::env::remove_var("TELOS_PLUGIN_SECRET") };
560
561        assert_eq!(result.content["secret"], "");
562        assert_eq!(result.content["context"], "context");
563        assert_eq!(result.content["configured"], "yes");
564    }
565
566    #[test]
567    fn load_tool_spec_from_file() {
568        let tmp = TempDir::new().unwrap();
569        let path = tmp.path().join("tool.json");
570        std::fs::write(
571            &path,
572            serde_json::to_string(&json!({
573                "name": "file_tool",
574                "description": "Loaded from file",
575                "inputSchema": {"type": "object"},
576                "command": "echo",
577                "permission": "allow"
578            }))
579            .unwrap(),
580        )
581        .unwrap();
582
583        let spec = load_tool_spec(&path).unwrap();
584        assert_eq!(spec.name, "file_tool");
585    }
586}