1use 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#[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 #[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
58pub 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
72pub 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 pub fn from_spec(spec: ToolSpec, plugin_root: &Path) -> Self {
92 let plugin_root_str = plugin_root.to_string_lossy();
93
94 let args: Vec<String> =
96 spec.args.into_iter().map(|a| a.replace("${PLUGIN_ROOT}", &plugin_root_str)).collect();
97
98 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 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 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}