Skip to main content

bamboo_tools/tools/
js_repl.rs

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
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
21/// JavaScript REPL tool — executes JavaScript code using Node.js.
22///
23/// Uses a fresh Node.js subprocess per invocation.  The code is piped to
24/// `node --input-type=module` on stdin so multi-line programs and `await`
25/// at the top level are supported.
26///
27/// Inspired by Codex's `js_repl` tool but uses a simpler sub-process model
28/// rather than a persistent kernel.
29pub 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
62/// Resolve a Node.js binary, checking `BAMBOO_JS_REPL_NODE_PATH` env var
63/// first, then falling back to a PATH lookup for "node".
64fn 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
74/// Simple cross-platform PATH lookup (avoids the `which` crate dependency).
75fn 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        // On Windows, try common extensions
83        #[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        // Wrap code in an async IIFE to support top-level await.
150        let wrapper = format!(
151            r#"(async () => {{
152{}
153}})().catch(e => {{ console.error(e); process.exit(1); }});"#,
154            code
155        );
156
157        // `kill_on_drop(true)` ensures the child is killed when it goes out of
158        // scope (i.e. on timeout). `wait_with_output()` takes ownership, so on
159        // timeout the future is dropped and the child is killed automatically.
160        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                // Timeout — child is killed on drop via kill_on_drop(true)
206                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    /// Helper: returns true when Node.js is available in PATH.
244    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}