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