1use super::{ScriptedExecutionTrace, ScriptedTool, ToolDefExtension, extension::InvocationLog};
4use crate::tool_def::usage_from_schema;
5use async_trait::async_trait;
6use bashkit::Bash;
7use bashkit::tool::timeout_response;
8use bashkit::tool::{
9 Tool, ToolError, ToolExecution, ToolOutputChunk, ToolRequest, ToolResponse, ToolStatus,
10 VERSION, localized, tool_output_from_response, tool_request_from_value, tool_request_schema,
11 tool_response_schema,
12};
13use bashkit::{Builtin, BuiltinContext as Context, ExecutionPlan};
14use bashkit::{Error, ExecResult};
15use std::collections::VecDeque;
16use std::sync::{Arc, Mutex};
17use std::time::Duration;
18
19struct ScriptedBuiltin {
25 inner: Arc<dyn Builtin>,
26 sanitize_errors: bool,
27}
28
29impl ScriptedBuiltin {
30 fn sanitize<T>(&self, result: bashkit::Result<T>) -> bashkit::Result<T> {
31 match result {
32 Err(err)
33 if self.sanitize_errors
34 && !matches!(err, Error::Cancelled | Error::ResourceLimit(_)) =>
35 {
36 Err(Error::Execution("callback failed".into()))
37 }
38 result => result,
39 }
40 }
41}
42
43#[async_trait]
44impl Builtin for ScriptedBuiltin {
45 async fn execute(&self, ctx: Context<'_>) -> bashkit::Result<ExecResult> {
46 let result = self.inner.execute(ctx).await;
47 self.sanitize(result)
48 }
49
50 async fn execution_plan(&self, ctx: &Context<'_>) -> bashkit::Result<Option<ExecutionPlan>> {
51 let result = self.inner.execution_plan(ctx).await;
52 self.sanitize(result)
53 }
54
55 fn llm_hint(&self) -> Option<&'static str> {
56 self.inner.llm_hint()
57 }
58
59 fn reset_session_state(&self) {
60 self.inner.reset_session_state();
61 }
62}
63
64impl ScriptedTool {
69 fn create_bash(&self, log: InvocationLog) -> Bash {
71 let mut builder = Bash::builder();
72 if let Some(ref profile) = self.profile {
73 builder = builder.profile(profile.clone());
74 }
75 builder = super::logic_only::apply(builder);
76
77 if let Some(ref limits) = self.limits {
78 builder = builder.limits(limits.clone());
79 }
80 for (key, value) in &self.env_vars {
81 builder = builder.env(key, value);
82 }
83 for (name, builtin) in &self.builtins {
88 builder = builder.builtin(
89 name.clone(),
90 Box::new(ScriptedBuiltin {
91 inner: Arc::clone(builtin),
92 sanitize_errors: self.sanitize_errors,
93 }),
94 );
95 }
96 builder = builder.extension(
97 ToolDefExtension::from_registered_tools(self.tools.clone())
98 .sanitize_errors(self.sanitize_errors)
99 .with_invocation_log(log),
100 );
101
102 builder.build()
103 }
104
105 fn build_help(&self) -> String {
106 let mut doc = format!(
107 "# {}\n\n{}\n\n**Version:** {}\n**Name:** `{}`\n**Locale:** `{}`\n\n## Parameters\n\n| Name | Type | Required | Default | Description |\n|------|------|----------|---------|-------------|\n| `commands` | string | yes | — | Bash script that may call the registered tool commands |\n| `timeout_ms` | integer | no | — | Per-call timeout in milliseconds |\n\n## Tool Commands\n\n| Name | Description | Usage |\n|------|-------------|-------|\n",
108 self.display_name, self.description, VERSION, self.name, self.locale
109 );
110
111 for t in &self.tools {
112 let usage = usage_from_schema(&t.def.input_schema)
113 .map(|u| format!("`{} {}`", t.def.name, u))
114 .unwrap_or_else(|| format!("`{}`", t.def.name));
115 doc.push_str(&format!(
116 "| `{}` | {} | {} |\n",
117 t.def.name, t.def.description, usage
118 ));
119 }
120
121 doc.push_str(
122 "\n## Result\n\n| Field | Type | Description |\n|------|------|-------------|\n| `stdout` | string | Combined standard output |\n| `stderr` | string | Tool or bash errors |\n| `exit_code` | integer | Shell exit code |\n| `error` | string | Error category when execution fails |\n\n## Examples\n\n```json\n{\"commands\":\"get_user --id 42\"}\n```\n\n```json\n{\"commands\":\"user=$(get_user --id 42)\\necho \\\"$user\\\" | jq -r '.name'\"}\n```\n\n## Notes\n\n- Pass arguments as `--key value` or `--key=value`.\n- Shell logic, variables, loops, conditionals, pipes, heredocs, here-strings, and stdin-based transforms are available.\n- Filesystem primitives are unavailable: file commands, path script execution, file redirection, and process substitution are rejected.\n- Use `help <tool> --json` inside the tool for runtime schema inspection.\n",
123 );
124
125 doc
126 }
127
128 fn build_system_prompt(&self) -> String {
129 let mut parts = vec![format!(
130 "{}: {}.",
131 self.name,
132 localized(
133 self.locale.as_str(),
134 "run bash scripts that orchestrate registered tool commands",
135 "виконує bash-скрипти для оркестрації зареєстрованих команд",
136 )
137 )];
138
139 let tools = self
140 .tools
141 .iter()
142 .map(|tool| {
143 if self.compact_prompt {
144 format!("{} ({})", tool.def.name, tool.def.description)
145 } else if let Some(usage) = usage_from_schema(&tool.def.input_schema) {
146 format!("{} [{}]", tool.def.name, usage)
147 } else {
148 tool.def.name.clone()
149 }
150 })
151 .collect::<Vec<_>>()
152 .join(", ");
153 parts.push(format!(
154 "{}: {}.",
155 localized(self.locale.as_str(), "Commands", "Команди"),
156 tools
157 ));
158 parts.push(localized(
159 self.locale.as_str(),
160 "Pass args as --key value or --key=value. Shell logic and stdin pipelines are available; filesystem primitives are unavailable. Use help/discover builtins for runtime details.",
161 "Передавайте аргументи як --key value або --key=value. Доступні логіка shell і stdin-конвеєри; файлові примітиви недоступні. Використовуйте help/discover для деталей.",
162 ).to_string());
163
164 parts.join(" ")
165 }
166
167 async fn run_request_with_stream(
168 &self,
169 req: ToolRequest,
170 stream_sender: Option<tokio::sync::mpsc::UnboundedSender<ToolOutputChunk>>,
171 ) -> ToolResponse {
172 if req.commands.is_empty() {
173 self.store_last_execution_trace(ScriptedExecutionTrace::default());
174 return ToolResponse {
175 stdout: String::new(),
176 stderr: String::new(),
177 exit_code: 0,
178 error: None,
179 ..Default::default()
180 };
181 }
182
183 let timeout_ms = req.timeout_ms;
184 let commands = req.commands;
185 let log: InvocationLog = Arc::new(Mutex::new(VecDeque::new()));
186 let mut bash = self.create_bash(Arc::clone(&log));
187
188 let fut = async {
189 let result = if let Some(sender) = stream_sender {
190 let output_cb = Box::new(
191 move |stdout_chunk: &bashkit::StreamData,
192 stderr_chunk: &bashkit::StreamData| {
193 if !stdout_chunk.is_empty() {
194 let _ = sender.send(ToolOutputChunk {
195 data: serde_json::json!(stdout_chunk),
196 kind: "stdout".to_string(),
197 });
198 }
199 if !stderr_chunk.is_empty() {
200 let _ = sender.send(ToolOutputChunk {
201 data: serde_json::json!(stderr_chunk),
202 kind: "stderr".to_string(),
203 });
204 }
205 },
206 );
207 bash.exec_streaming(&commands, output_cb).await
208 } else {
209 bash.exec(&commands).await
210 };
211
212 match result {
213 Ok(result) => result.into(),
214 Err(err) => ToolResponse {
215 stdout: String::new(),
216 stderr: err.to_string(),
217 exit_code: 1,
218 error: Some(err.to_string()),
219 ..Default::default()
220 },
221 }
222 };
223
224 let response = if let Some(ms) = timeout_ms {
227 let duration = Duration::from_millis(ms);
228 match bashkit::time::timeout(duration, fut).await {
229 Ok(response) => response,
230 Err(_) => timeout_response(duration),
231 }
232 } else {
233 fut.await
234 };
235
236 let invocations: Vec<_> = log
237 .lock()
238 .expect("scripted invocation log poisoned")
239 .iter()
240 .cloned()
241 .collect();
242 self.store_last_execution_trace(ScriptedExecutionTrace { invocations });
243 response
244 }
245}
246
247#[async_trait]
252impl Tool for ScriptedTool {
253 fn name(&self) -> &str {
254 &self.name
255 }
256
257 fn display_name(&self) -> &str {
258 &self.display_name
259 }
260
261 fn short_description(&self) -> &str {
262 &self.short_desc
263 }
264
265 fn description(&self) -> &str {
266 &self.description
267 }
268
269 fn help(&self) -> String {
270 self.build_help()
271 }
272
273 fn system_prompt(&self) -> String {
274 self.build_system_prompt()
275 }
276
277 fn locale(&self) -> &str {
278 &self.locale
279 }
280
281 fn input_schema(&self) -> serde_json::Value {
282 tool_request_schema()
283 }
284
285 fn output_schema(&self) -> serde_json::Value {
286 tool_response_schema()
287 }
288
289 fn version(&self) -> &str {
290 VERSION
291 }
292
293 fn execution(&self, args: serde_json::Value) -> std::result::Result<ToolExecution, ToolError> {
294 let req = tool_request_from_value(self.locale(), args)?;
295 let tool = self.clone();
296 Ok(ToolExecution::new(move |stream_sender| async move {
297 let start = bashkit::time::Instant::now();
298 let response = tool.run_request_with_stream(req, stream_sender).await;
299 tool_output_from_response(response, start.elapsed())
300 }))
301 }
302
303 async fn execute(&self, req: ToolRequest) -> ToolResponse {
304 self.run_request_with_stream(req, None).await
305 }
306
307 async fn execute_with_status(
308 &self,
309 req: ToolRequest,
310 mut status_callback: Box<dyn FnMut(ToolStatus) + Send>,
311 ) -> ToolResponse {
312 status_callback(ToolStatus::new("validate").with_percent(0.0));
313
314 if req.commands.is_empty() {
315 status_callback(ToolStatus::new("complete").with_percent(100.0));
316 return ToolResponse {
317 stdout: String::new(),
318 stderr: String::new(),
319 exit_code: 0,
320 error: None,
321 ..Default::default()
322 };
323 }
324
325 status_callback(ToolStatus::new("parse").with_percent(10.0));
326 status_callback(ToolStatus::new("execute").with_percent(20.0));
327 let response = self.run_request_with_stream(req, None).await;
328
329 status_callback(ToolStatus::new("complete").with_percent(100.0));
330 response
331 }
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337 use crate::ToolArgs;
338 use crate::ToolDef;
339 use crate::tool_def::parse_flags;
340 use bashkit::ExecutionLimits;
341
342 #[test]
343 fn test_parse_flags_key_value() {
344 let schema = serde_json::json!({
345 "type": "object",
346 "properties": {
347 "id": {"type": "integer"},
348 "name": {"type": "string"}
349 }
350 });
351 let args = vec!["--id".into(), "42".into(), "--name".into(), "Alice".into()];
352 let result = parse_flags(&args, &schema).expect("parse_flags should succeed");
353 assert_eq!(result["id"], 42);
354 assert_eq!(result["name"], "Alice");
355 }
356
357 #[test]
358 fn test_parse_flags_equals_syntax() {
359 let schema = serde_json::json!({
360 "type": "object",
361 "properties": { "id": {"type": "integer"} }
362 });
363 let args = vec!["--id=99".into()];
364 let result = parse_flags(&args, &schema).expect("parse_flags should succeed");
365 assert_eq!(result["id"], 99);
366 }
367
368 #[test]
369 fn test_parse_flags_boolean() {
370 let schema = serde_json::json!({
371 "type": "object",
372 "properties": {
373 "verbose": {"type": "boolean"},
374 "query": {"type": "string"}
375 }
376 });
377 let args = vec!["--verbose".into(), "--query".into(), "hello".into()];
378 let result = parse_flags(&args, &schema).expect("parse_flags should succeed");
379 assert_eq!(result["verbose"], true);
380 assert_eq!(result["query"], "hello");
381 }
382
383 #[test]
384 fn test_parse_flags_no_schema() {
385 let schema = serde_json::json!({});
386 let args = vec!["--name".into(), "Bob".into()];
387 let result = parse_flags(&args, &schema).expect("parse_flags should succeed");
388 assert_eq!(result["name"], "Bob");
389 }
390
391 #[test]
392 fn test_parse_flags_empty() {
393 let schema = serde_json::json!({});
394 let result = parse_flags(&[], &schema).expect("parse_flags should succeed");
395 assert_eq!(result, serde_json::json!({}));
396 }
397
398 #[test]
399 fn test_parse_flags_rejects_positional() {
400 let schema = serde_json::json!({});
401 let result = parse_flags(&["42".into()], &schema);
402 assert!(result.is_err());
403 assert!(
404 result
405 .expect_err("should reject positional")
406 .contains("expected --flag")
407 );
408 }
409
410 #[test]
411 fn test_usage_from_schema() {
412 let schema = serde_json::json!({
413 "type": "object",
414 "properties": {
415 "id": {"type": "integer"},
416 "name": {"type": "string"}
417 }
418 });
419 let usage = usage_from_schema(&schema).expect("should produce usage string");
420 assert!(usage.contains("--id <integer>"));
421 assert!(usage.contains("--name <string>"));
422 }
423
424 #[test]
425 fn test_usage_from_empty_schema() {
426 assert!(usage_from_schema(&serde_json::json!({})).is_none());
427 assert!(
428 usage_from_schema(&serde_json::json!({"type": "object", "properties": {}})).is_none()
429 );
430 }
431
432 fn build_help_test_tool() -> ScriptedTool {
435 ScriptedTool::builder("test_api")
436 .short_description("Test API")
437 .tool_fn(
438 ToolDef::new("get_user", "Fetch user by ID").with_schema(serde_json::json!({
439 "type": "object",
440 "properties": {
441 "id": {"type": "integer"}
442 }
443 })),
444 |_args: &ToolArgs| Ok("{\"id\":1}\n".to_string()),
445 )
446 .tool_fn(
447 ToolDef::new("list_orders", "List orders for user").with_schema(
448 serde_json::json!({
449 "type": "object",
450 "properties": {
451 "user_id": {"type": "integer"},
452 "limit": {"type": "integer"}
453 }
454 }),
455 ),
456 |_args: &ToolArgs| Ok("[]\n".to_string()),
457 )
458 .build()
459 }
460
461 #[tokio::test]
462 async fn test_scripted_tool_execute_honors_timeout_ms() {
463 let tool = ScriptedTool::builder("timeout_test")
464 .async_tool_fn(
465 ToolDef::new("slow", "Slow async tool"),
466 |_args: ToolArgs| async move {
467 tokio::time::sleep(Duration::from_secs(10)).await;
468 Ok("done\n".to_string())
469 },
470 )
471 .build();
472 let start = bashkit::time::Instant::now();
473
474 let resp = tool
475 .execute(ToolRequest {
476 commands: "slow".to_string(),
477 timeout_ms: Some(50),
478 })
479 .await;
480
481 assert_eq!(resp.exit_code, 124);
482 assert_eq!(resp.error, Some("timeout".to_string()));
483 assert!(resp.stderr.contains("timed out"));
484 assert!(
485 start.elapsed() < Duration::from_secs(2),
486 "timeout_ms should abort before sleep completes"
487 );
488 }
489
490 #[tokio::test]
491 async fn test_scripted_tool_forwards_limits_and_output_caps() {
492 let tool = ScriptedTool::builder("bounded")
493 .limits(ExecutionLimits::new().max_commands(10).max_stdout_bytes(3))
494 .tool_fn(ToolDef::new("emit", "Emit output"), |_args| {
495 Ok("12345".to_string())
496 })
497 .build();
498
499 let response = tool.execute(ToolRequest::new("emit")).await;
500 assert_eq!(response.stdout, "123");
501 assert!(response.stdout_truncated);
502 }
503
504 #[tokio::test]
505 async fn test_scripted_tool_execute_with_status_honors_timeout_ms() {
506 let tool = ScriptedTool::builder("timeout_status_test")
507 .async_tool_fn(
508 ToolDef::new("slow", "Slow async tool"),
509 |_args: ToolArgs| async move {
510 tokio::time::sleep(Duration::from_secs(10)).await;
511 Ok("done\n".to_string())
512 },
513 )
514 .build();
515 let start = bashkit::time::Instant::now();
516
517 let resp = tool
518 .execute_with_status(
519 ToolRequest {
520 commands: "slow".to_string(),
521 timeout_ms: Some(50),
522 },
523 Box::new(|_| {}),
524 )
525 .await;
526
527 assert_eq!(resp.exit_code, 124);
528 assert_eq!(resp.error, Some("timeout".to_string()));
529 assert!(resp.stderr.contains("timed out"));
530 assert!(
531 start.elapsed() < Duration::from_secs(2),
532 "timeout_ms should abort before sleep completes"
533 );
534 }
535
536 #[tokio::test]
537 async fn test_help_list() {
538 let tool = build_help_test_tool();
539 let resp = tool
540 .execute(ToolRequest {
541 commands: "help --list".to_string(),
542 timeout_ms: None,
543 })
544 .await;
545 assert_eq!(resp.exit_code, 0);
546 assert!(resp.stdout.contains("get_user"));
547 assert!(resp.stdout.contains("Fetch user by ID"));
548 assert!(resp.stdout.contains("list_orders"));
549 }
550
551 #[tokio::test]
552 async fn test_help_tool_human_readable() {
553 let tool = build_help_test_tool();
554 let resp = tool
555 .execute(ToolRequest {
556 commands: "help get_user".to_string(),
557 timeout_ms: None,
558 })
559 .await;
560 assert_eq!(resp.exit_code, 0);
561 assert!(resp.stdout.contains("get_user - Fetch user by ID"));
562 assert!(resp.stdout.contains("--id <integer>"));
563 }
564
565 #[tokio::test]
566 async fn test_help_tool_json() {
567 let tool = build_help_test_tool();
568 let resp = tool
569 .execute(ToolRequest {
570 commands: "help get_user --json".to_string(),
571 timeout_ms: None,
572 })
573 .await;
574 assert_eq!(resp.exit_code, 0);
575 let parsed: serde_json::Value =
576 serde_json::from_str(resp.stdout.trim()).expect("should be valid JSON");
577 assert_eq!(parsed["name"], "get_user");
578 assert_eq!(parsed["description"], "Fetch user by ID");
579 assert!(parsed["input_schema"]["properties"]["id"].is_object());
580 }
581
582 #[tokio::test]
583 async fn test_help_unknown_tool() {
584 let tool = build_help_test_tool();
585 let resp = tool
586 .execute(ToolRequest {
587 commands: "help nonexistent".to_string(),
588 timeout_ms: None,
589 })
590 .await;
591 assert_ne!(resp.exit_code, 0);
592 assert!(resp.stderr.contains("unknown tool"));
593 }
594
595 #[tokio::test]
596 async fn test_help_no_args_lists_all() {
597 let tool = build_help_test_tool();
598 let resp = tool
599 .execute(ToolRequest {
600 commands: "help".to_string(),
601 timeout_ms: None,
602 })
603 .await;
604 assert_eq!(resp.exit_code, 0);
605 assert!(resp.stdout.contains("get_user"));
606 assert!(resp.stdout.contains("list_orders"));
607 }
608
609 #[tokio::test]
610 async fn test_help_json_pipe_jq() {
611 let tool = build_help_test_tool();
612 let resp = tool
613 .execute(ToolRequest {
614 commands: "help get_user --json | jq -r '.name'".to_string(),
615 timeout_ms: None,
616 })
617 .await;
618 assert_eq!(resp.exit_code, 0);
619 assert_eq!(resp.stdout.trim(), "get_user");
620 }
621
622 #[tokio::test]
623 async fn test_compact_prompt_omits_usage() {
624 let tool = ScriptedTool::builder("compact_test")
625 .compact_prompt(true)
626 .tool_fn(
627 ToolDef::new("get_user", "Fetch user").with_schema(serde_json::json!({
628 "type": "object",
629 "properties": { "id": {"type": "integer"} }
630 })),
631 |_args: &ToolArgs| Ok("ok\n".to_string()),
632 )
633 .build();
634 let sp = tool.system_prompt();
635 assert!(sp.contains("help/discover"));
636 assert!(!sp.contains("Usage:"));
637 }
638
639 #[tokio::test]
640 async fn test_non_compact_prompt_has_usage() {
641 let tool = ScriptedTool::builder("full_test")
642 .tool_fn(
643 ToolDef::new("get_user", "Fetch user").with_schema(serde_json::json!({
644 "type": "object",
645 "properties": { "id": {"type": "integer"} }
646 })),
647 |_args: &ToolArgs| Ok("ok\n".to_string()),
648 )
649 .build();
650 let sp = tool.system_prompt();
651 assert!(sp.contains("--id <integer>"));
652 }
653
654 #[tokio::test]
655 async fn test_error_uses_display_not_debug() {
656 use super::ScriptedTool;
657 use crate::ToolDef;
658 use bashkit::tool::Tool;
659
660 let tool = ScriptedTool::builder("test")
661 .short_description("test")
662 .tool_fn(ToolDef::new("fail", "Always fails"), |_args: &ToolArgs| {
663 Err("service error".to_string())
664 })
665 .build();
666 let req = ToolRequest {
667 commands: "fail".into(),
668 timeout_ms: None,
669 };
670 let resp = tool.execute(req).await;
671 if let Some(ref err) = resp.error {
673 assert!(
674 !err.contains("Execution("),
675 "error should use Display not Debug: {err}",
676 );
677 }
678 }
679
680 fn build_discover_test_tool() -> ScriptedTool {
683 ScriptedTool::builder("big_api")
684 .short_description("Big API")
685 .tool_fn(
686 ToolDef::new("create_charge", "Create a payment charge")
687 .with_category("payments")
688 .with_tags(&["billing", "write"]),
689 |_args: &ToolArgs| Ok("ok\n".to_string()),
690 )
691 .tool_fn(
692 ToolDef::new("refund", "Issue a refund")
693 .with_category("payments")
694 .with_tags(&["billing", "write"]),
695 |_args: &ToolArgs| Ok("ok\n".to_string()),
696 )
697 .tool_fn(
698 ToolDef::new("get_user", "Fetch user by ID")
699 .with_category("users")
700 .with_tags(&["read"]),
701 |_args: &ToolArgs| Ok("ok\n".to_string()),
702 )
703 .tool_fn(
704 ToolDef::new("delete_user", "Delete a user account")
705 .with_category("users")
706 .with_tags(&["admin", "write"]),
707 |_args: &ToolArgs| Ok("ok\n".to_string()),
708 )
709 .tool_fn(
710 ToolDef::new("get_inventory", "Check inventory levels").with_category("inventory"),
711 |_args: &ToolArgs| Ok("ok\n".to_string()),
712 )
713 .build()
714 }
715
716 #[tokio::test]
717 async fn test_discover_categories() {
718 let tool = build_discover_test_tool();
719 let resp = tool
720 .execute(ToolRequest {
721 commands: "discover --categories".to_string(),
722 timeout_ms: None,
723 })
724 .await;
725 assert_eq!(resp.exit_code, 0);
726 assert!(resp.stdout.contains("payments (2 tools)"));
727 assert!(resp.stdout.contains("users (2 tools)"));
728 assert!(resp.stdout.contains("inventory (1 tool)"));
729 }
730
731 #[tokio::test]
732 async fn test_discover_category_filter() {
733 let tool = build_discover_test_tool();
734 let resp = tool
735 .execute(ToolRequest {
736 commands: "discover --category payments".to_string(),
737 timeout_ms: None,
738 })
739 .await;
740 assert_eq!(resp.exit_code, 0);
741 assert!(resp.stdout.contains("create_charge"));
742 assert!(resp.stdout.contains("refund"));
743 assert!(!resp.stdout.contains("get_user"));
744 }
745
746 #[tokio::test]
747 async fn test_discover_tag_filter() {
748 let tool = build_discover_test_tool();
749 let resp = tool
750 .execute(ToolRequest {
751 commands: "discover --tag admin".to_string(),
752 timeout_ms: None,
753 })
754 .await;
755 assert_eq!(resp.exit_code, 0);
756 assert!(resp.stdout.contains("delete_user"));
757 assert!(!resp.stdout.contains("create_charge"));
758 }
759
760 #[tokio::test]
761 async fn test_discover_search() {
762 let tool = build_discover_test_tool();
763 let resp = tool
764 .execute(ToolRequest {
765 commands: "discover --search user".to_string(),
766 timeout_ms: None,
767 })
768 .await;
769 assert_eq!(resp.exit_code, 0);
770 assert!(resp.stdout.contains("get_user"));
771 assert!(resp.stdout.contains("delete_user"));
772 assert!(!resp.stdout.contains("create_charge"));
773 }
774
775 #[tokio::test]
776 async fn test_discover_search_case_insensitive() {
777 let tool = build_discover_test_tool();
778 let resp = tool
779 .execute(ToolRequest {
780 commands: "discover --search REFUND".to_string(),
781 timeout_ms: None,
782 })
783 .await;
784 assert_eq!(resp.exit_code, 0);
785 assert!(resp.stdout.contains("refund"));
786 }
787
788 #[tokio::test]
789 async fn test_discover_categories_json() {
790 let tool = build_discover_test_tool();
791 let resp = tool
792 .execute(ToolRequest {
793 commands: "discover --categories --json".to_string(),
794 timeout_ms: None,
795 })
796 .await;
797 assert_eq!(resp.exit_code, 0);
798 let arr: Vec<serde_json::Value> =
799 serde_json::from_str(resp.stdout.trim()).expect("valid JSON");
800 assert!(
801 arr.iter()
802 .any(|v| v["category"] == "payments" && v["count"] == 2)
803 );
804 }
805
806 #[tokio::test]
807 async fn test_discover_category_json() {
808 let tool = build_discover_test_tool();
809 let resp = tool
810 .execute(ToolRequest {
811 commands: "discover --category payments --json".to_string(),
812 timeout_ms: None,
813 })
814 .await;
815 assert_eq!(resp.exit_code, 0);
816 let arr: Vec<serde_json::Value> =
817 serde_json::from_str(resp.stdout.trim()).expect("valid JSON");
818 assert_eq!(arr.len(), 2);
819 assert!(arr.iter().any(|v| v["name"] == "create_charge"));
820 }
821
822 #[tokio::test]
823 async fn test_discover_no_args_shows_usage() {
824 let tool = build_discover_test_tool();
825 let resp = tool
826 .execute(ToolRequest {
827 commands: "discover".to_string(),
828 timeout_ms: None,
829 })
830 .await;
831 assert_ne!(resp.exit_code, 0);
832 assert!(resp.stderr.contains("usage:"));
833 }
834
835 #[tokio::test]
836 async fn test_discover_tag_json() {
837 let tool = build_discover_test_tool();
838 let resp = tool
839 .execute(ToolRequest {
840 commands: "discover --tag billing --json".to_string(),
841 timeout_ms: None,
842 })
843 .await;
844 assert_eq!(resp.exit_code, 0);
845 let arr: Vec<serde_json::Value> =
846 serde_json::from_str(resp.stdout.trim()).expect("valid JSON");
847 assert_eq!(arr.len(), 2);
848 assert!(arr.iter().all(|v| {
849 v["tags"]
850 .as_array()
851 .expect("tags array")
852 .contains(&serde_json::json!("billing"))
853 }));
854 }
855
856 #[tokio::test]
857 async fn test_tooldef_with_tags_and_category() {
858 let def = ToolDef::new("test", "A test tool")
859 .with_tags(&["admin", "billing"])
860 .with_category("payments");
861 assert_eq!(def.tags, vec!["admin", "billing"]);
862 assert_eq!(def.category.as_deref(), Some("payments"));
863 }
864
865 #[tokio::test]
868 async fn test_callback_error_sanitized_by_default() {
869 let tool = ScriptedTool::builder("api")
870 .tool_fn(ToolDef::new("fail", "Always fails"), |_args: &ToolArgs| {
871 Err("connection failed: postgres://admin:secret@internal-db:5432/prod".into())
872 })
873 .build();
874 let resp = tool
875 .execute(ToolRequest {
876 commands: "fail".to_string(),
877 timeout_ms: None,
878 })
879 .await;
880 assert_ne!(resp.exit_code, 0);
881 assert!(
883 !resp.stderr.contains("postgres://"),
884 "internal details leaked: {}",
885 resp.stderr
886 );
887 assert!(resp.stderr.contains("callback failed"));
888 }
889
890 #[tokio::test]
891 async fn test_callback_error_unsanitized_when_disabled() {
892 let tool = ScriptedTool::builder("api")
893 .sanitize_errors(false)
894 .tool_fn(ToolDef::new("fail", "Always fails"), |_args: &ToolArgs| {
895 Err("connection failed: postgres://admin:secret@internal-db:5432/prod".into())
896 })
897 .build();
898 let resp = tool
899 .execute(ToolRequest {
900 commands: "fail".to_string(),
901 timeout_ms: None,
902 })
903 .await;
904 assert_ne!(resp.exit_code, 0);
905 assert!(resp.stderr.contains("postgres://"));
907 }
908}
909
910#[cfg(test)]
911mod custom_builtin_tests {
912 use crate::{ScriptedTool, ToolArgs, ToolDef};
913 use async_trait::async_trait;
914 use bashkit::{BashkitContext, ClapBuiltin, Error, ExecResult, Tool};
915 use bashkit::{Builtin, BuiltinContext as Context};
916 use clap::Parser;
917
918 struct RawArgv;
920
921 #[async_trait]
922 impl Builtin for RawArgv {
923 async fn execute(&self, ctx: Context<'_>) -> bashkit::Result<ExecResult> {
924 Ok(ExecResult::ok(format!("argv:[{}]\n", ctx.args.join("|"))))
925 }
926 }
927
928 struct ReadsFile;
930
931 #[async_trait]
932 impl Builtin for ReadsFile {
933 async fn execute(&self, ctx: Context<'_>) -> bashkit::Result<ExecResult> {
934 match ctx.fs.read_file(std::path::Path::new("/etc/passwd")).await {
935 Ok(_) => Ok(ExecResult::ok("read ok\n")),
936 Err(e) => Ok(ExecResult::err(format!("denied: {e}\n"), 1)),
937 }
938 }
939 }
940
941 struct LeakyBuiltin;
942
943 #[async_trait]
944 impl Builtin for LeakyBuiltin {
945 async fn execute(&self, _ctx: Context<'_>) -> bashkit::Result<ExecResult> {
946 Err(Error::Network(
947 "postgres://admin:secret@internal-db/prod".into(),
948 ))
949 }
950 }
951
952 #[derive(Parser)]
953 #[command(name = "leaky_clap")]
954 struct LeakyArgs {}
955
956 struct LeakyClapBuiltin;
957
958 #[async_trait]
959 impl ClapBuiltin for LeakyClapBuiltin {
960 type Args = LeakyArgs;
961
962 async fn execute_clap(
963 &self,
964 _args: Self::Args,
965 _ctx: &mut BashkitContext<'_>,
966 ) -> bashkit::Result<()> {
967 Err(Error::Execution(
968 "SDK diagnostic: postgres://admin:secret@internal-db/prod".into(),
969 ))
970 }
971 }
972
973 async fn run(tool: &ScriptedTool, script: &str) -> (String, String, i64) {
974 let output = tool
975 .execution(serde_json::json!({ "commands": script }))
976 .expect("execution")
977 .execute()
978 .await
979 .expect("execute");
980 (
981 output.result["stdout"].as_str().unwrap_or_default().into(),
982 output.result["stderr"].as_str().unwrap_or_default().into(),
983 output.result["exit_code"].as_i64().unwrap_or_default(),
984 )
985 }
986
987 fn tool_with_raw_argv() -> ScriptedTool {
988 ScriptedTool::builder("api")
989 .builtin("raw", Box::new(RawArgv))
990 .tool_fn(
991 ToolDef::new("greet", "Greet").with_schema(serde_json::json!({
992 "type": "object",
993 "properties": { "name": {"type": "string"} }
994 })),
995 |args: &ToolArgs| Ok(format!("hi {}\n", args.param_str("name").unwrap_or("?"))),
996 )
997 .build()
998 }
999
1000 #[tokio::test]
1001 async fn custom_builtin_receives_raw_argv() {
1002 let tool = tool_with_raw_argv();
1003 let (stdout, _, code) = run(&tool, "raw pos1 -s --long=v --").await;
1005 assert_eq!(code, 0);
1006 assert_eq!(stdout, "argv:[pos1|-s|--long=v|--]\n");
1007 }
1008
1009 #[tokio::test]
1010 async fn custom_builtin_coexists_with_tool_commands() {
1011 let tool = tool_with_raw_argv();
1012 let (stdout, _, code) = run(&tool, "raw a; greet --name bob").await;
1013 assert_eq!(code, 0);
1014 assert_eq!(stdout, "argv:[a]\nhi bob\n");
1015 }
1016
1017 #[tokio::test]
1018 async fn custom_builtin_reused_across_executions() {
1019 let tool = tool_with_raw_argv();
1020 for _ in 0..3 {
1021 let (stdout, _, code) = run(&tool, "raw x").await;
1022 assert_eq!(code, 0);
1023 assert_eq!(stdout, "argv:[x]\n");
1024 }
1025 }
1026
1027 #[tokio::test]
1028 async fn custom_builtin_cannot_shadow_a_tool_command() {
1029 let tool = ScriptedTool::builder("api")
1031 .builtin("greet", Box::new(RawArgv))
1032 .tool_fn(
1033 ToolDef::new("greet", "Greet").with_schema(serde_json::json!({
1034 "type": "object",
1035 "properties": { "name": {"type": "string"} }
1036 })),
1037 |args: &ToolArgs| Ok(format!("hi {}\n", args.param_str("name").unwrap_or("?"))),
1038 )
1039 .build();
1040 let (stdout, _, _) = run(&tool, "greet --name bob").await;
1041 assert_eq!(stdout, "hi bob\n");
1042 }
1043
1044 #[tokio::test]
1045 async fn custom_builtin_cannot_shadow_help_or_discover() {
1046 let tool = ScriptedTool::builder("api")
1047 .builtin("help", Box::new(RawArgv))
1048 .builtin("discover", Box::new(RawArgv))
1049 .tool_fn(
1050 ToolDef::new("greet", "Greet").with_schema(serde_json::json!({})),
1051 |_: &ToolArgs| Ok("hi\n".to_string()),
1052 )
1053 .build();
1054 let (stdout, _, _) = run(&tool, "help --list").await;
1055 assert!(!stdout.starts_with("argv:"), "help was shadowed: {stdout}");
1056 assert!(stdout.contains("greet"), "got: {stdout}");
1057 }
1058
1059 #[tokio::test]
1060 async fn custom_builtin_still_has_no_filesystem() {
1061 let tool = ScriptedTool::builder("api")
1063 .builtin("readfile", Box::new(ReadsFile))
1064 .build();
1065 let (stdout, _, code) = run(&tool, "readfile").await;
1066 assert_ne!(code, 0, "filesystem must stay disabled, got: {stdout}");
1067 }
1068
1069 #[tokio::test]
1070 async fn custom_builtin_errors_are_sanitized_by_default() {
1071 for (name, builtin) in [
1072 ("leaky", Box::new(LeakyBuiltin) as Box<dyn Builtin>),
1073 ("leaky_clap", Box::new(LeakyClapBuiltin) as Box<dyn Builtin>),
1074 ] {
1075 let tool = ScriptedTool::builder("api").builtin(name, builtin).build();
1076 let (_, stderr, code) = run(&tool, name).await;
1077
1078 assert_ne!(code, 0);
1079 assert!(!stderr.contains("postgres://"), "secret leaked: {stderr}");
1080 assert!(stderr.contains("callback failed"), "got: {stderr}");
1081 }
1082 }
1083
1084 #[tokio::test]
1085 async fn custom_builtin_errors_can_be_unsanitized_explicitly() {
1086 for (name, builtin) in [
1087 ("leaky", Box::new(LeakyBuiltin) as Box<dyn Builtin>),
1088 ("leaky_clap", Box::new(LeakyClapBuiltin) as Box<dyn Builtin>),
1089 ] {
1090 let tool = ScriptedTool::builder("api")
1091 .sanitize_errors(false)
1092 .builtin(name, builtin)
1093 .build();
1094 let (_, stderr, code) = run(&tool, name).await;
1095
1096 assert_ne!(code, 0);
1097 assert!(stderr.contains("postgres://"), "got: {stderr}");
1098 }
1099 }
1100
1101 #[tokio::test]
1102 async fn closed_schema_rejects_typo_through_the_shell() {
1103 let tool = ScriptedTool::builder("api")
1104 .tool_fn(
1105 ToolDef::new("list", "List").with_schema(serde_json::json!({
1106 "type": "object",
1107 "properties": { "limit": {"type": "integer"} },
1108 "additionalProperties": false
1109 })),
1110 |args: &ToolArgs| Ok(format!("limit={}\n", args.param_i64("limit").unwrap_or(0))),
1111 )
1112 .build();
1113
1114 let (_, stderr, code) = run(&tool, "list --limti 5").await;
1115 assert_ne!(code, 0);
1116 assert!(stderr.contains("unknown flag: --limti"), "got: {stderr}");
1117
1118 let (stdout, _, code) = run(&tool, "list --limit 5").await;
1119 assert_eq!(code, 0);
1120 assert_eq!(stdout, "limit=5\n");
1121 }
1122}