1use crate::program::ProgramCatalog;
4use crate::text::truncate_utf8;
5use crate::tools::types::{Tool, ToolContext, ToolOutput};
6use crate::tools::ToolRegistry;
7use anyhow::{anyhow, Result};
8use async_trait::async_trait;
9use rquickjs::function::{Async, Func};
10use rquickjs::{async_with, AsyncContext, AsyncRuntime, CatchResultExt, Error as JsError, Promise};
11use serde::Deserialize;
12use std::collections::HashSet;
13use std::sync::Arc;
14use std::time::Instant;
15use tokio::sync::Mutex;
16use tokio::time::{timeout, Duration};
17
18const DEFAULT_SCRIPT_TIMEOUT_MS: u64 = 30_000;
19const DEFAULT_SCRIPT_MAX_TOOL_CALLS: usize = 20;
20const DEFAULT_SCRIPT_MAX_OUTPUT_BYTES: usize = 64 * 1024;
21const MAX_SCRIPT_SOURCE_BYTES: usize = 64 * 1024;
22
23pub struct ProgramTool {
24 registry: Arc<ToolRegistry>,
25}
26
27impl ProgramTool {
28 pub fn new(registry: Arc<ToolRegistry>) -> Self {
29 Self { registry }
30 }
31
32 pub fn with_catalog(registry: Arc<ToolRegistry>, _catalog: ProgramCatalog) -> Self {
33 Self { registry }
34 }
35}
36
37#[async_trait]
38impl Tool for ProgramTool {
39 fn name(&self) -> &str {
40 "program"
41 }
42
43 fn description(&self) -> &str {
44 "Run a sandboxed JavaScript PTC script. The script defines async function run(ctx, inputs) and may call only allowed ctx tools."
45 }
46
47 fn parameters(&self) -> serde_json::Value {
48 serde_json::json!({
49 "type": "object",
50 "additionalProperties": false,
51 "properties": {
52 "type": {
53 "type": "string",
54 "description": "Required. Program kind. Only \"script\" is supported.",
55 "enum": ["script"]
56 },
57 "inputs": {
58 "type": "object",
59 "description": "Optional. JSON inputs passed to the script as the second argument."
60 },
61 "language": {
62 "type": "string",
63 "description": "Script language. Only JavaScript is supported.",
64 "enum": ["javascript"]
65 },
66 "source": {
67 "type": "string",
68 "description": "Inline JavaScript source defining async function run(ctx, inputs)."
69 },
70 "path": {
71 "type": "string",
72 "description": "Workspace-relative path to a .js or .mjs script defining async function run(ctx, inputs). Used when source is omitted."
73 },
74 "allowed_tools": {
75 "type": "array",
76 "description": "Tool names the script may call through ctx. Defaults to all registered tools except program.",
77 "items": { "type": "string" }
78 },
79 "limits": {
80 "type": "object",
81 "description": "Optional timeoutMs, maxToolCalls, and maxOutputBytes.",
82 "additionalProperties": false,
83 "properties": {
84 "timeoutMs": { "type": "integer", "minimum": 1 },
85 "maxToolCalls": { "type": "integer", "minimum": 1 },
86 "maxOutputBytes": { "type": "integer", "minimum": 1 }
87 }
88 }
89 },
90 "required": ["type"]
91 })
92 }
93
94 async fn execute(&self, args: &serde_json::Value, ctx: &ToolContext) -> Result<ToolOutput> {
95 let Some(kind) = args.get("type").and_then(|value| value.as_str()) else {
96 return Ok(ToolOutput::error("type parameter is required"));
97 };
98 if kind != "script" {
99 return Ok(ToolOutput::error(format!(
100 "Unsupported program type: {kind}. Only \"script\" is supported."
101 )));
102 }
103 let inputs = args
104 .get("inputs")
105 .cloned()
106 .unwrap_or_else(|| serde_json::json!({}));
107
108 execute_script_program(args, inputs, Arc::clone(&self.registry), ctx).await
109 }
110}
111
112#[derive(Debug, Deserialize)]
113#[serde(rename_all = "camelCase")]
114struct ScriptLimits {
115 timeout_ms: Option<u64>,
116 max_tool_calls: Option<usize>,
117 max_output_bytes: Option<usize>,
118}
119
120#[derive(Debug, Clone)]
121struct ScriptCallRecord {
122 tool_name: String,
123 success: bool,
124 exit_code: i32,
125 output_bytes: usize,
126 metadata: Option<serde_json::Value>,
127}
128
129async fn execute_script_program(
130 args: &serde_json::Value,
131 inputs: serde_json::Value,
132 registry: Arc<ToolRegistry>,
133 ctx: &ToolContext,
134) -> Result<ToolOutput> {
135 let language = args
136 .get("language")
137 .and_then(|value| value.as_str())
138 .unwrap_or("javascript");
139 if language != "javascript" {
140 return Ok(ToolOutput::error(format!(
141 "Unsupported script language: {language}"
142 )));
143 }
144
145 let source = match load_script_source(args, ctx).await {
146 Ok(source) => source,
147 Err(message) => return Ok(ToolOutput::error(message)),
148 };
149 if source.len() > MAX_SCRIPT_SOURCE_BYTES {
150 return Ok(ToolOutput::error(format!(
151 "script source is too large: {} bytes exceeds {} bytes",
152 source.len(),
153 MAX_SCRIPT_SOURCE_BYTES
154 )));
155 }
156 if let Err(message) = validate_script_source(&source) {
157 return Ok(ToolOutput::error(message));
158 }
159
160 let allowed_tools = script_allowed_tools(args, ®istry);
161 let limits = script_limits(args);
162 match run_quickjs_script(
163 &source,
164 inputs,
165 registry,
166 ctx.clone(),
167 allowed_tools,
168 limits,
169 )
170 .await
171 {
172 Ok(output) => Ok(output),
173 Err(err) => Ok(ToolOutput::error(format!("program script failed: {err}"))),
174 }
175}
176
177async fn load_script_source(
178 args: &serde_json::Value,
179 ctx: &ToolContext,
180) -> std::result::Result<String, String> {
181 if let Some(source) = args.get("source").and_then(|value| value.as_str()) {
182 return Ok(source.to_string());
183 }
184
185 let Some(path) = args.get("path").and_then(|value| value.as_str()) else {
186 return Err("program script requires either source or path".to_string());
187 };
188 if !(path.ends_with(".js") || path.ends_with(".mjs")) {
189 return Err("program script path must point to a .js or .mjs file".to_string());
190 }
191
192 let workspace_path = ctx
193 .resolve_workspace_path(path)
194 .map_err(|err| format!("failed to resolve script path: {err}"))?;
195 ctx.workspace_services
196 .fs()
197 .read_text(&workspace_path)
198 .await
199 .map_err(|err| format!("failed to read script path '{}': {err}", path))
200}
201
202fn script_allowed_tools(args: &serde_json::Value, registry: &ToolRegistry) -> HashSet<String> {
203 let mut allowed = args
204 .get("allowed_tools")
205 .and_then(|value| value.as_array())
206 .map(|items| {
207 items
208 .iter()
209 .filter_map(|item| item.as_str())
210 .map(ToString::to_string)
211 .collect::<HashSet<_>>()
212 })
213 .unwrap_or_else(|| registry.list().into_iter().collect());
214
215 allowed.remove("program");
216 allowed
220}
221
222fn script_limits(args: &serde_json::Value) -> ScriptLimits {
223 args.get("limits")
224 .cloned()
225 .and_then(|value| serde_json::from_value(value).ok())
226 .unwrap_or(ScriptLimits {
227 timeout_ms: None,
228 max_tool_calls: None,
229 max_output_bytes: None,
230 })
231}
232
233fn validate_script_source(source: &str) -> std::result::Result<(), String> {
234 let forbidden = [
235 ("import ", "imports are not allowed inside PTC scripts"),
236 (
237 "import(",
238 "dynamic imports are not allowed inside PTC scripts",
239 ),
240 ("eval(", "eval is not allowed inside PTC scripts"),
241 (
242 "Function(",
243 "Function constructor is not allowed inside PTC scripts",
244 ),
245 ("Worker(", "Worker is not allowed inside PTC scripts"),
246 ("WebSocket", "WebSocket is not allowed inside PTC scripts"),
247 (
248 "fetch(",
249 "fetch is not allowed inside PTC scripts; use ctx tools instead",
250 ),
251 ];
252
253 for (needle, message) in forbidden {
254 if source.contains(needle) {
255 return Err(message.to_string());
256 }
257 }
258 Ok(())
259}
260
261async fn run_quickjs_script(
262 source: &str,
263 inputs: serde_json::Value,
264 registry: Arc<ToolRegistry>,
265 ctx: ToolContext,
266 allowed_tools: HashSet<String>,
267 limits: ScriptLimits,
268) -> Result<ToolOutput> {
269 let timeout_ms = limits.timeout_ms.unwrap_or(DEFAULT_SCRIPT_TIMEOUT_MS);
270 let max_tool_calls = limits
271 .max_tool_calls
272 .unwrap_or(DEFAULT_SCRIPT_MAX_TOOL_CALLS);
273 let max_output_bytes = limits
274 .max_output_bytes
275 .unwrap_or(DEFAULT_SCRIPT_MAX_OUTPUT_BYTES);
276 let executable_source = script_source_with_host_entrypoint(source)?;
277 let outer = tokio::runtime::Handle::current();
280 let state = Arc::new(Mutex::new(ScriptVmState {
281 registry,
282 ctx,
283 allowed_tools,
284 max_tool_calls,
285 max_output_bytes,
286 tool_calls: 0,
287 records: Vec::new(),
288 outer,
289 }));
290
291 let vm_state = Arc::clone(&state);
292 let result = timeout(
293 Duration::from_millis(timeout_ms),
294 tokio::task::spawn_blocking(move || {
295 let runtime = tokio::runtime::Builder::new_current_thread()
296 .enable_all()
297 .build()
298 .map_err(|err| anyhow!("failed to create program VM runtime: {err}"))?;
299 runtime.block_on(run_embedded_script(
300 executable_source,
301 inputs,
302 vm_state,
303 timeout_ms,
304 ))
305 }),
306 )
307 .await;
308
309 match result {
310 Ok(Ok(Ok(result))) => {
311 let records = state.lock().await.records.clone();
312 let output = render_script_output(&result, &records, "");
313 Ok(ToolOutput::success(output).with_metadata(serde_json::json!({
314 "program": {
315 "name": "script",
316 "language": "javascript",
317 "runtime": "embedded-quickjs",
318 "success": true,
319 "tool_calls": records.iter().map(script_record_to_value).collect::<Vec<_>>(),
320 },
321 "script_result": result,
322 })))
323 }
324 Ok(Ok(Err(err))) if is_quickjs_timeout(&err) => Ok(ToolOutput::error(format!(
325 "program script timed out after {timeout_ms} ms"
326 ))),
327 Ok(Ok(Err(err))) => Ok(ToolOutput::error(format!("program script error:\n{err}"))),
328 Ok(Err(err)) => Ok(ToolOutput::error(format!(
329 "program VM thread failed: {err}"
330 ))),
331 Err(_) => Ok(ToolOutput::error(format!(
332 "program script timed out after {timeout_ms} ms"
333 ))),
334 }
335}
336
337fn script_source_with_host_entrypoint(source: &str) -> Result<String> {
338 let rewritten = if source.contains("export default async function run") {
339 source.replacen("export default async function run", "async function run", 1)
340 } else if source.contains("export default function run") {
341 source.replacen("export default function run", "function run", 1)
342 } else if source.contains("async function run") || source.contains("function run") {
343 source.to_string()
344 } else {
345 return Err(anyhow!(
346 "PTC script must define async function run(ctx, inputs)"
347 ));
348 };
349
350 Ok(format!(
351 r#"{rewritten}
352
353globalThis.__a3sResultJson = (async () => JSON.stringify(await run(globalThis.__a3sCtx, globalThis.__a3sInputs)))();
354"#
355 ))
356}
357
358async fn run_embedded_script(
359 source: String,
360 inputs: serde_json::Value,
361 state: Arc<Mutex<ScriptVmState>>,
362 timeout_ms: u64,
363) -> Result<serde_json::Value> {
364 let runtime = AsyncRuntime::new()?;
365 let started = Instant::now();
366 runtime
367 .set_interrupt_handler(Some(Box::new(move || {
368 started.elapsed() >= Duration::from_millis(timeout_ms)
369 })))
370 .await;
371 runtime.set_memory_limit(64 * 1024 * 1024).await;
372 runtime.set_max_stack_size(512 * 1024).await;
373
374 let context = AsyncContext::full(&runtime).await?;
375 let inputs_json = serde_json::to_string(&inputs)?;
376 let script = format!("{}\n{}", embedded_script_bootstrap(&inputs_json), source);
377 let result_json = async_with!(context => |ctx| {
378 let state = Arc::clone(&state);
379 let host_tool = move |tool: String, args_json: String| {
380 let state = Arc::clone(&state);
381 async move { execute_host_tool_json(state, tool, args_json).await }
382 };
383 if let Err(err) = ctx.globals().set("__a3sHostTool", Func::from(Async(host_tool))) {
384 return Err(format!("failed to install program host tool: {err}"));
385 }
386 let promise: Promise = match ctx.eval(script) {
387 Ok(promise) => promise,
388 Err(err) => return Err(format!("failed to evaluate program script: {err}")),
389 };
390 promise
391 .into_future::<String>()
392 .await
393 .catch(&ctx)
394 .map_err(|err| err.to_string())
395 })
396 .await
397 .map_err(anyhow::Error::msg)?;
398
399 serde_json::from_str(&result_json)
400 .map_err(|err| anyhow!("program script returned invalid JSON: {err}"))
401}
402
403struct ScriptVmState {
404 registry: Arc<ToolRegistry>,
405 ctx: ToolContext,
406 allowed_tools: HashSet<String>,
407 max_tool_calls: usize,
408 max_output_bytes: usize,
409 tool_calls: usize,
410 records: Vec<ScriptCallRecord>,
411 outer: tokio::runtime::Handle,
415}
416
417fn embedded_script_bootstrap(inputs_json: &str) -> String {
418 format!(
419 r#"
420const __a3sCallTool = async (tool, args = {{}}) => {{
421 const response = await globalThis.__a3sHostTool(String(tool), JSON.stringify(args ?? {{}}));
422 return JSON.parse(response);
423}};
424
425const __a3sCtx = Object.freeze({{
426 tool: __a3sCallTool,
427 readFile: (path) => __a3sCallTool("read", {{ file_path: path }}).then((r) => r.output),
428 read: (path) => __a3sCallTool("read", {{ file_path: path }}),
429 grep: (pattern, options = {{}}) => __a3sCallTool("grep", {{ pattern, ...options }}).then((r) => r.output),
430 glob: (pattern, options = {{}}) => __a3sCallTool("glob", {{ pattern, ...options }}).then((r) => r.output),
431 ls: (path = ".") => __a3sCallTool("ls", {{ path }}).then((r) => r.output),
432 bash: (command) => __a3sCallTool("bash", {{ command }}).then((r) => r.output),
433 git: (args = {{}}) => __a3sCallTool("git", args),
434 webSearch: (params) => __a3sCallTool("web_search", params),
435 verify: (args) => __a3sCallTool("bash", args),
436}});
437
438Object.defineProperty(globalThis, "__a3sCtx", {{ value: __a3sCtx, configurable: false }});
439Object.defineProperty(globalThis, "__a3sInputs", {{ value: {inputs_json}, configurable: false }});
440Object.defineProperty(globalThis, "fetch", {{ value: undefined, configurable: false, writable: false }});
441Object.defineProperty(globalThis, "WebSocket", {{ value: undefined, configurable: false, writable: false }});
442Object.defineProperty(globalThis, "Worker", {{ value: undefined, configurable: false, writable: false }});
443"#
444 )
445}
446
447async fn execute_host_tool_json(
448 state: Arc<Mutex<ScriptVmState>>,
449 tool: String,
450 args_json: String,
451) -> rquickjs::Result<String> {
452 let args = serde_json::from_str(&args_json).map_err(|err| {
453 JsError::new_from_js_message("string", "object", format!("invalid tool args JSON: {err}"))
454 })?;
455 let (registry, ctx, max_output_bytes, outer) = {
456 let mut script = state.lock().await;
457 if !script.allowed_tools.contains(&tool) {
458 return Err(JsError::new_from_js_message(
459 "tool",
460 "allowed tool",
461 format!("tool '{tool}' is not allowed for this PTC script"),
462 ));
463 }
464 script.tool_calls += 1;
465 if script.tool_calls > script.max_tool_calls {
466 return Err(JsError::new_from_js_message(
467 "tool call",
468 "limited tool call",
469 format!("PTC script exceeded maxToolCalls={}", script.max_tool_calls),
470 ));
471 }
472 (
473 Arc::clone(&script.registry),
474 script.ctx.clone(),
475 script.max_output_bytes,
476 script.outer.clone(),
477 )
478 };
479
480 let tool_for_spawn = tool.clone();
484 let result = outer
485 .spawn(async move {
486 registry
487 .execute_with_context(&tool_for_spawn, &args, &ctx)
488 .await
489 })
490 .await
491 .map_err(|err| JsError::new_from_js_message("tool", "spawn", err.to_string()))?
492 .map_err(|err| JsError::new_from_js_message("tool", "result", err.to_string()))?;
493 let mut output = result.output;
494 if output.len() > max_output_bytes {
495 output = truncate_utf8(&output, max_output_bytes).to_string();
496 }
497 let success = result.exit_code == 0;
498 let metadata = result.metadata.clone();
499 let exit_code = result.exit_code;
500 let name = result.name;
501
502 {
503 let mut script = state.lock().await;
504 script.records.push(ScriptCallRecord {
505 tool_name: tool,
506 success,
507 exit_code,
508 output_bytes: output.len(),
509 metadata: metadata.clone(),
510 });
511 }
512
513 serde_json::to_string(&serde_json::json!({
514 "name": name,
515 "output": output,
516 "exitCode": exit_code,
517 "metadata": metadata,
518 }))
519 .map_err(|err| JsError::new_from_js_message("tool result", "json", err.to_string()))
520}
521
522fn is_quickjs_timeout(err: &anyhow::Error) -> bool {
523 let text = err.to_string();
524 text.contains("interrupted") || text.contains("InternalError")
525}
526
527fn script_record_to_value(record: &ScriptCallRecord) -> serde_json::Value {
528 serde_json::json!({
529 "tool_name": record.tool_name,
530 "success": record.success,
531 "exit_code": record.exit_code,
532 "output_bytes": record.output_bytes,
533 "metadata": record.metadata,
534 })
535}
536
537fn render_script_output(
538 result: &serde_json::Value,
539 records: &[ScriptCallRecord],
540 stderr: &str,
541) -> String {
542 let mut output = String::from("Program script completed.");
543 if let Some(summary) = result.get("summary").and_then(|value| value.as_str()) {
544 output.push('\n');
545 output.push_str(summary);
546 }
547
548 output.push_str(&format!("\n\nTool calls: {}", records.len()));
549 for (index, record) in records.iter().enumerate() {
550 output.push_str(&format!(
551 "\n{}. {} ({}, exit_code={}, output_bytes={})",
552 index + 1,
553 record.tool_name,
554 if record.success { "ok" } else { "failed" },
555 record.exit_code,
556 record.output_bytes
557 ));
558 }
559
560 output.push_str("\n\nResult:\n");
561 output.push_str(&serde_json::to_string_pretty(result).unwrap_or_else(|_| result.to_string()));
562
563 if !stderr.is_empty() {
564 output.push_str("\n\nstderr:\n");
565 output.push_str(stderr);
566 }
567
568 output
569}
570
571#[cfg(test)]
572mod tests {
573 use super::*;
574 use async_trait::async_trait;
575 use std::path::PathBuf;
576
577 struct EchoTool;
578
579 #[async_trait]
580 impl Tool for EchoTool {
581 fn name(&self) -> &str {
582 "echo"
583 }
584
585 fn description(&self) -> &str {
586 "Echo test tool"
587 }
588
589 fn parameters(&self) -> serde_json::Value {
590 serde_json::json!({
591 "type": "object",
592 "properties": {
593 "message": { "type": "string" }
594 }
595 })
596 }
597
598 async fn execute(
599 &self,
600 args: &serde_json::Value,
601 _ctx: &ToolContext,
602 ) -> Result<ToolOutput> {
603 let message = args
604 .get("message")
605 .and_then(|value| value.as_str())
606 .unwrap_or("");
607 Ok(ToolOutput::success(format!("echo:{message}")))
608 }
609 }
610
611 #[tokio::test]
612 async fn program_tool_rejects_non_script_type() {
613 let tool = ProgramTool::new(Arc::new(ToolRegistry::new(PathBuf::from("/tmp"))));
614 let output = tool
615 .execute(
616 &serde_json::json!({ "type": "program_code_search" }),
617 &ToolContext::new(PathBuf::from("/tmp")),
618 )
619 .await
620 .unwrap();
621
622 assert!(!output.success);
623 assert!(output.content.contains("Only \"script\" is supported"));
624 }
625
626 #[tokio::test]
627 async fn program_tool_rejects_missing_script_source_and_path() {
628 let tool = ProgramTool::new(Arc::new(ToolRegistry::new(PathBuf::from("/tmp"))));
629 let output = tool
630 .execute(
631 &serde_json::json!({ "type": "script" }),
632 &ToolContext::new(PathBuf::from("/tmp")),
633 )
634 .await
635 .unwrap();
636
637 assert!(!output.success);
638 assert!(output.content.contains("requires either source or path"));
639 }
640
641 #[tokio::test]
642 async fn program_tool_rejects_unsupported_language() {
643 let tool = ProgramTool::new(Arc::new(ToolRegistry::new(PathBuf::from("/tmp"))));
644 let output = tool
645 .execute(
646 &serde_json::json!({
647 "type": "script",
648 "language": "typescript",
649 "source": "async function run() { return {}; }"
650 }),
651 &ToolContext::new(PathBuf::from("/tmp")),
652 )
653 .await
654 .unwrap();
655
656 assert!(!output.success);
657 assert!(output.content.contains("Unsupported script language"));
658 }
659
660 #[tokio::test]
661 async fn program_tool_rejects_unsupported_script_path() {
662 let dir = tempfile::tempdir().unwrap();
663 std::fs::write(dir.path().join("script.txt"), "async function run() {}").unwrap();
664 let tool = ProgramTool::new(Arc::new(ToolRegistry::new(dir.path().to_path_buf())));
665 let output = tool
666 .execute(
667 &serde_json::json!({
668 "type": "script",
669 "path": "script.txt"
670 }),
671 &ToolContext::new(dir.path().to_path_buf()),
672 )
673 .await
674 .unwrap();
675
676 assert!(!output.success);
677 assert!(output.content.contains(".js or .mjs file"));
678 }
679
680 #[test]
681 fn program_tool_default_allowed_tools_include_registry_tools_except_program() {
682 let registry = ToolRegistry::new(PathBuf::from("/tmp"));
683 registry.register(Arc::new(EchoTool));
684 registry.register_builtin(Arc::new(ProgramTool::new(Arc::new(ToolRegistry::new(
685 PathBuf::from("/tmp"),
686 )))));
687
688 let allowed = script_allowed_tools(&serde_json::json!({}), ®istry);
689
690 assert!(allowed.contains("echo"));
691 assert!(!allowed.contains("program"));
692 }
693
694 #[test]
695 fn program_tool_allows_delegation_tools_in_scripts() {
696 let registry = ToolRegistry::new(PathBuf::from("/tmp"));
700 let args = serde_json::json!({
701 "allowed_tools": ["parallel_task", "task", "program", "echo"]
702 });
703 let allowed = script_allowed_tools(&args, ®istry);
704 assert!(allowed.contains("parallel_task"));
705 assert!(allowed.contains("task"));
706 assert!(allowed.contains("echo"));
707 assert!(!allowed.contains("program"));
708 }
709
710 #[tokio::test]
711 async fn program_tool_source_uses_default_all_registered_tools() {
712 let registry = Arc::new(ToolRegistry::new(PathBuf::from("/tmp")));
713 registry.register(Arc::new(EchoTool));
714 let tool = ProgramTool::new(Arc::clone(®istry));
715 let output = tool
716 .execute(
717 &serde_json::json!({
718 "type": "script",
719 "source": r#"
720 async function run(ctx, inputs) {
721 const result = await ctx.tool("echo", { message: inputs.message });
722 return { summary: result.output, result };
723 }
724 "#,
725 "inputs": { "message": "hello" }
726 }),
727 &ToolContext::new(PathBuf::from("/tmp")),
728 )
729 .await
730 .unwrap();
731
732 assert!(output.success, "{}", output.content);
733 assert!(output.content.contains("echo:hello"));
734 let metadata = output.metadata.unwrap();
735 assert_eq!(metadata["program"]["runtime"], "embedded-quickjs");
736 assert_eq!(metadata["script_result"]["summary"], "echo:hello");
737 }
738
739 #[tokio::test]
740 async fn program_tool_explicit_allowed_tools_restrict_default_tools() {
741 let registry = Arc::new(ToolRegistry::new(PathBuf::from("/tmp")));
742 registry.register(Arc::new(EchoTool));
743 let tool = ProgramTool::new(Arc::clone(®istry));
744 let output = tool
745 .execute(
746 &serde_json::json!({
747 "type": "script",
748 "source": r#"
749 async function run(ctx) {
750 await ctx.tool("echo", { message: "blocked" });
751 return {};
752 }
753 "#,
754 "allowed_tools": ["read"]
755 }),
756 &ToolContext::new(PathBuf::from("/tmp")),
757 )
758 .await
759 .unwrap();
760
761 assert!(!output.success);
762 assert!(output.content.contains("tool 'echo' is not allowed"));
763 }
764
765 #[tokio::test]
766 async fn program_tool_enforces_max_tool_calls() {
767 let registry = Arc::new(ToolRegistry::new(PathBuf::from("/tmp")));
768 registry.register(Arc::new(EchoTool));
769 let tool = ProgramTool::new(Arc::clone(®istry));
770 let output = tool
771 .execute(
772 &serde_json::json!({
773 "type": "script",
774 "source": r#"
775 async function run(ctx) {
776 await ctx.tool("echo", { message: "one" });
777 await ctx.tool("echo", { message: "two" });
778 return {};
779 }
780 "#,
781 "limits": { "maxToolCalls": 1 }
782 }),
783 &ToolContext::new(PathBuf::from("/tmp")),
784 )
785 .await
786 .unwrap();
787
788 assert!(!output.success);
789 assert!(output.content.contains("exceeded maxToolCalls=1"));
790 }
791
792 #[test]
793 fn program_tool_rejects_fetch_source_access() {
794 let err =
795 validate_script_source("export default async function run() { return fetch('/'); }")
796 .unwrap_err();
797 assert!(err.contains("fetch is not allowed"));
798 }
799
800 #[test]
801 fn program_tool_accepts_plain_function_run_entrypoint() {
802 let source = script_source_with_host_entrypoint(
803 "async function run(ctx, inputs) { return { summary: inputs.message }; }",
804 )
805 .unwrap();
806
807 assert!(source.contains("globalThis.__a3sResultJson"));
808 assert!(source.contains("async function run"));
809 }
810
811 #[test]
812 fn program_tool_renders_result_summary_and_tool_records() {
813 let output = render_script_output(
814 &serde_json::json!({ "summary": "done", "items": [1] }),
815 &[ScriptCallRecord {
816 tool_name: "echo".to_string(),
817 success: true,
818 exit_code: 0,
819 output_bytes: 8,
820 metadata: Some(serde_json::json!({ "kind": "test" })),
821 }],
822 "",
823 );
824
825 assert!(output.contains("Program script completed."));
826 assert!(output.contains("done"));
827 assert!(output.contains("echo (ok"));
828 assert!(output.contains("\"items\""));
829 }
830}