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