bamboo_tools/tools/
js_repl.rs1use async_trait::async_trait;
2use bamboo_agent_core::{Tool, ToolClass, ToolCtx, ToolError, ToolOutcome, ToolResult};
3use serde::Deserialize;
4use serde_json::json;
5use std::path::PathBuf;
6use std::process::Stdio;
7use tokio::process::Command;
8use tokio::time::{timeout, Duration};
9
10const DEFAULT_TIMEOUT_MS: u64 = 30_000;
11const MAX_TIMEOUT_MS: u64 = 120_000;
12const MAX_OUTPUT_BYTES: usize = 256 * 1024;
13
14#[derive(Debug, Deserialize)]
15struct JsReplArgs {
16 code: String,
17 #[serde(default)]
18 timeout_ms: Option<u64>,
19}
20
21pub struct JsReplTool;
30
31impl JsReplTool {
32 pub fn new() -> Self {
33 Self
34 }
35
36 fn effective_timeout(requested: Option<u64>) -> Duration {
37 let ms = requested
38 .unwrap_or(DEFAULT_TIMEOUT_MS)
39 .clamp(1, MAX_TIMEOUT_MS);
40 Duration::from_millis(ms)
41 }
42
43 fn truncate_output(s: &str) -> (&str, bool) {
44 if s.len() <= MAX_OUTPUT_BYTES {
45 (s, false)
46 } else {
47 let mut end = MAX_OUTPUT_BYTES;
48 while end > 0 && !s.is_char_boundary(end) {
49 end -= 1;
50 }
51 (&s[..end], true)
52 }
53 }
54}
55
56impl Default for JsReplTool {
57 fn default() -> Self {
58 Self::new()
59 }
60}
61
62fn resolve_node() -> Option<PathBuf> {
65 if let Ok(path) = std::env::var("BAMBOO_JS_REPL_NODE_PATH") {
66 let p = PathBuf::from(&path);
67 if p.exists() {
68 return Some(p);
69 }
70 }
71 find_in_path("node")
72}
73
74fn find_in_path(name: &str) -> Option<PathBuf> {
76 let path_var = std::env::var_os("PATH")?;
77 for dir in std::env::split_paths(&path_var) {
78 let candidate = dir.join(name);
79 if candidate.is_file() {
80 return Some(candidate);
81 }
82 #[cfg(windows)]
84 for ext in &["exe", "cmd", "bat"] {
85 let with_ext = dir.join(format!("{}.{}", name, ext));
86 if with_ext.is_file() {
87 return Some(with_ext);
88 }
89 }
90 }
91 None
92}
93
94#[async_trait]
95impl Tool for JsReplTool {
96 fn name(&self) -> &str {
97 "js_repl"
98 }
99
100 fn description(&self) -> &str {
101 "Execute JavaScript code using Node.js. Supports top-level await and ES modules. The code is run in a fresh process each time; use js_repl_reset is not needed since state is not shared between calls."
102 }
103
104 fn classify(&self, _args: &serde_json::Value) -> ToolClass {
105 ToolClass::MUTATING_SERIAL.promotable()
106 }
107
108 fn parameters_schema(&self) -> serde_json::Value {
109 json!({
110 "type": "object",
111 "properties": {
112 "code": {
113 "type": "string",
114 "description": "JavaScript code to execute"
115 },
116 "timeout_ms": {
117 "type": "number",
118 "description": "Optional timeout in milliseconds (default 30000, max 120000)"
119 }
120 },
121 "required": ["code"],
122 "additionalProperties": false
123 })
124 }
125
126 async fn invoke(
127 &self,
128 args: serde_json::Value,
129 _ctx: ToolCtx,
130 ) -> Result<ToolOutcome, ToolError> {
131 let parsed: JsReplArgs = serde_json::from_value(args)
132 .map_err(|e| ToolError::InvalidArguments(format!("Invalid js_repl args: {}", e)))?;
133
134 let code = parsed.code.trim();
135 if code.is_empty() {
136 return Err(ToolError::InvalidArguments(
137 "'code' cannot be empty".to_string(),
138 ));
139 }
140
141 let node_path = resolve_node().ok_or_else(|| {
142 ToolError::Execution(
143 "Node.js not found. Install Node.js or set BAMBOO_JS_REPL_NODE_PATH.".to_string(),
144 )
145 })?;
146
147 let effective_timeout = Self::effective_timeout(parsed.timeout_ms);
148
149 let wrapper = format!(
151 r#"(async () => {{
152{}
153}})().catch(e => {{ console.error(e); process.exit(1); }});"#,
154 code
155 );
156
157 let child = Command::new(&node_path)
161 .arg("-e")
162 .arg(&wrapper)
163 .stdin(Stdio::null())
164 .stdout(Stdio::piped())
165 .stderr(Stdio::piped())
166 .kill_on_drop(true)
167 .spawn()
168 .map_err(|e| {
169 ToolError::Execution(format!(
170 "Failed to start Node.js ({}): {}",
171 node_path.display(),
172 e
173 ))
174 })?;
175
176 match timeout(effective_timeout, child.wait_with_output()).await {
177 Ok(Ok(output)) => {
178 let stdout_raw = String::from_utf8_lossy(&output.stdout);
179 let stderr_raw = String::from_utf8_lossy(&output.stderr);
180 let (stdout, stdout_truncated) = Self::truncate_output(&stdout_raw);
181 let (stderr, stderr_truncated) = Self::truncate_output(&stderr_raw);
182 let exit_code = output.status.code();
183 let success = output.status.success();
184
185 Ok(ToolOutcome::Completed(ToolResult {
186 success,
187 result: json!({
188 "exit_code": exit_code,
189 "stdout": stdout,
190 "stderr": stderr,
191 "stdout_truncated": stdout_truncated,
192 "stderr_truncated": stderr_truncated,
193 "timed_out": false,
194 })
195 .to_string(),
196 display_preference: Some("Collapsible".to_string()),
197 images: Vec::new(),
198 }))
199 }
200 Ok(Err(e)) => Err(ToolError::Execution(format!(
201 "Node.js process error: {}",
202 e
203 ))),
204 Err(_) => {
205 Ok(ToolOutcome::Completed(ToolResult {
207 success: false,
208 result: json!({
209 "exit_code": null,
210 "stdout": "",
211 "stderr": "Execution timed out",
212 "stdout_truncated": false,
213 "stderr_truncated": false,
214 "timed_out": true,
215 })
216 .to_string(),
217 display_preference: Some("Collapsible".to_string()),
218 images: Vec::new(),
219 }))
220 }
221 }
222 }
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228
229 async fn run(tool: &JsReplTool, args: serde_json::Value) -> ToolResult {
230 let out = tool.invoke(args, ToolCtx::none("t")).await.unwrap();
231 let ToolOutcome::Completed(r) = out else {
232 panic!("expected Completed")
233 };
234 r
235 }
236
237 #[test]
238 fn test_tool_name() {
239 let tool = JsReplTool::new();
240 assert_eq!(tool.name(), "js_repl");
241 }
242
243 fn has_node() -> bool {
245 find_in_path("node").is_some()
246 }
247
248 #[test]
249 fn test_resolve_node_finds_system_node() {
250 if !has_node() {
251 return;
252 }
253 assert!(resolve_node().is_some());
254 }
255
256 #[tokio::test]
257 async fn test_execute_simple_expression() {
258 if !has_node() {
259 return;
260 }
261 let tool = JsReplTool::new();
262 let result = run(&tool, json!({ "code": "console.log(2 + 2)" })).await;
263
264 assert!(result.success);
265 let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
266 assert_eq!(payload["timed_out"], false);
267 assert_eq!(payload["exit_code"], 0);
268 assert!(payload["stdout"].as_str().unwrap().contains("4"));
269 }
270
271 #[tokio::test]
272 async fn test_execute_async_await() {
273 if !has_node() {
274 return;
275 }
276 let tool = JsReplTool::new();
277 let result = run(
278 &tool,
279 json!({
280 "code": "const result = await Promise.resolve(42); console.log(result)"
281 }),
282 )
283 .await;
284
285 assert!(result.success);
286 let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
287 assert!(payload["stdout"].as_str().unwrap().contains("42"));
288 }
289
290 #[tokio::test]
291 async fn test_execute_error_returns_nonzero_exit() {
292 if !has_node() {
293 return;
294 }
295 let tool = JsReplTool::new();
296 let result = run(&tool, json!({ "code": "throw new Error('test error')" })).await;
297
298 assert!(!result.success);
299 let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
300 assert_ne!(payload["exit_code"], 0);
301 assert!(payload["stderr"].as_str().unwrap().contains("test error"));
302 }
303
304 #[tokio::test]
305 async fn test_empty_code_rejected() {
306 let tool = JsReplTool::new();
307 let err = tool
308 .invoke(json!({ "code": " " }), ToolCtx::none("t"))
309 .await
310 .unwrap_err();
311 assert!(matches!(err, ToolError::InvalidArguments(msg) if msg.contains("empty")));
312 }
313
314 #[tokio::test]
315 async fn test_missing_code_rejected() {
316 let tool = JsReplTool::new();
317 let err = tool
318 .invoke(json!({}), ToolCtx::none("t"))
319 .await
320 .unwrap_err();
321 assert!(matches!(err, ToolError::InvalidArguments(_)));
322 }
323
324 #[test]
325 fn test_effective_timeout() {
326 assert_eq!(
327 JsReplTool::effective_timeout(None),
328 Duration::from_millis(30_000)
329 );
330 assert_eq!(
331 JsReplTool::effective_timeout(Some(500_000)),
332 Duration::from_millis(MAX_TIMEOUT_MS)
333 );
334 assert_eq!(
335 JsReplTool::effective_timeout(Some(5_000)),
336 Duration::from_millis(5_000)
337 );
338 }
339
340 #[test]
341 fn test_truncate_output() {
342 let short = "hello";
343 let (out, trunc) = JsReplTool::truncate_output(short);
344 assert_eq!(out, "hello");
345 assert!(!trunc);
346 }
347
348 #[tokio::test]
349 async fn test_multiline_code() {
350 if !has_node() {
351 return;
352 }
353 let tool = JsReplTool::new();
354 let result = run(
355 &tool,
356 json!({
357 "code": "const a = 10;\nconst b = 20;\nconsole.log(a + b);"
358 }),
359 )
360 .await;
361
362 assert!(result.success);
363 let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
364 assert!(payload["stdout"].as_str().unwrap().contains("30"));
365 }
366}