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(&self, args: serde_json::Value, _ctx: ToolCtx) -> Result<ToolOutcome, ToolError> {
127        let parsed: JsReplArgs = serde_json::from_value(args)
128            .map_err(|e| ToolError::InvalidArguments(format!("Invalid js_repl args: {}", e)))?;
129
130        let code = parsed.code.trim();
131        if code.is_empty() {
132            return Err(ToolError::InvalidArguments(
133                "'code' cannot be empty".to_string(),
134            ));
135        }
136
137        let node_path = resolve_node().ok_or_else(|| {
138            ToolError::Execution(
139                "Node.js not found. Install Node.js or set BAMBOO_JS_REPL_NODE_PATH.".to_string(),
140            )
141        })?;
142
143        let effective_timeout = Self::effective_timeout(parsed.timeout_ms);
144
145        // Wrap code in an async IIFE to support top-level await.
146        let wrapper = format!(
147            r#"(async () => {{
148{}
149}})().catch(e => {{ console.error(e); process.exit(1); }});"#,
150            code
151        );
152
153        // `kill_on_drop(true)` ensures the child is killed when it goes out of
154        // scope (i.e. on timeout). `wait_with_output()` takes ownership, so on
155        // timeout the future is dropped and the child is killed automatically.
156        let child = Command::new(&node_path)
157            .arg("-e")
158            .arg(&wrapper)
159            .stdin(Stdio::null())
160            .stdout(Stdio::piped())
161            .stderr(Stdio::piped())
162            .kill_on_drop(true)
163            .spawn()
164            .map_err(|e| {
165                ToolError::Execution(format!(
166                    "Failed to start Node.js ({}): {}",
167                    node_path.display(),
168                    e
169                ))
170            })?;
171
172        match timeout(effective_timeout, child.wait_with_output()).await {
173            Ok(Ok(output)) => {
174                let stdout_raw = String::from_utf8_lossy(&output.stdout);
175                let stderr_raw = String::from_utf8_lossy(&output.stderr);
176                let (stdout, stdout_truncated) = Self::truncate_output(&stdout_raw);
177                let (stderr, stderr_truncated) = Self::truncate_output(&stderr_raw);
178                let exit_code = output.status.code();
179                let success = output.status.success();
180
181                Ok(ToolOutcome::Completed(ToolResult {
182                    success,
183                    result: json!({
184                        "exit_code": exit_code,
185                        "stdout": stdout,
186                        "stderr": stderr,
187                        "stdout_truncated": stdout_truncated,
188                        "stderr_truncated": stderr_truncated,
189                        "timed_out": false,
190                    })
191                    .to_string(),
192                    display_preference: Some("Collapsible".to_string()),
193                    images: Vec::new(),
194                }))
195            }
196            Ok(Err(e)) => Err(ToolError::Execution(format!(
197                "Node.js process error: {}",
198                e
199            ))),
200            Err(_) => {
201                // Timeout — child is killed on drop via kill_on_drop(true)
202                Ok(ToolOutcome::Completed(ToolResult {
203                    success: false,
204                    result: json!({
205                        "exit_code": null,
206                        "stdout": "",
207                        "stderr": "Execution timed out",
208                        "stdout_truncated": false,
209                        "stderr_truncated": false,
210                        "timed_out": true,
211                    })
212                    .to_string(),
213                    display_preference: Some("Collapsible".to_string()),
214                    images: Vec::new(),
215                }))
216            }
217        }
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    async fn run(tool: &JsReplTool, args: serde_json::Value) -> ToolResult {
226        let out = tool.invoke(args, ToolCtx::none("t")).await.unwrap();
227        let ToolOutcome::Completed(r) = out else {
228            panic!("expected Completed")
229        };
230        r
231    }
232
233    #[test]
234    fn test_tool_name() {
235        let tool = JsReplTool::new();
236        assert_eq!(tool.name(), "js_repl");
237    }
238
239    /// Helper: returns true when Node.js is available in PATH.
240    fn has_node() -> bool {
241        find_in_path("node").is_some()
242    }
243
244    #[test]
245    fn test_resolve_node_finds_system_node() {
246        if !has_node() {
247            return;
248        }
249        assert!(resolve_node().is_some());
250    }
251
252    #[tokio::test]
253    async fn test_execute_simple_expression() {
254        if !has_node() {
255            return;
256        }
257        let tool = JsReplTool::new();
258        let result = run(&tool, json!({ "code": "console.log(2 + 2)" })).await;
259
260        assert!(result.success);
261        let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
262        assert_eq!(payload["timed_out"], false);
263        assert_eq!(payload["exit_code"], 0);
264        assert!(payload["stdout"].as_str().unwrap().contains("4"));
265    }
266
267    #[tokio::test]
268    async fn test_execute_async_await() {
269        if !has_node() {
270            return;
271        }
272        let tool = JsReplTool::new();
273        let result = run(
274            &tool,
275            json!({
276                "code": "const result = await Promise.resolve(42); console.log(result)"
277            }),
278        )
279        .await;
280
281        assert!(result.success);
282        let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
283        assert!(payload["stdout"].as_str().unwrap().contains("42"));
284    }
285
286    #[tokio::test]
287    async fn test_execute_error_returns_nonzero_exit() {
288        if !has_node() {
289            return;
290        }
291        let tool = JsReplTool::new();
292        let result = run(&tool, json!({ "code": "throw new Error('test error')" })).await;
293
294        assert!(!result.success);
295        let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
296        assert_ne!(payload["exit_code"], 0);
297        assert!(payload["stderr"].as_str().unwrap().contains("test error"));
298    }
299
300    #[tokio::test]
301    async fn test_empty_code_rejected() {
302        let tool = JsReplTool::new();
303        let err = tool
304            .invoke(json!({ "code": "  " }), ToolCtx::none("t"))
305            .await
306            .unwrap_err();
307        assert!(matches!(err, ToolError::InvalidArguments(msg) if msg.contains("empty")));
308    }
309
310    #[tokio::test]
311    async fn test_missing_code_rejected() {
312        let tool = JsReplTool::new();
313        let err = tool
314            .invoke(json!({}), ToolCtx::none("t"))
315            .await
316            .unwrap_err();
317        assert!(matches!(err, ToolError::InvalidArguments(_)));
318    }
319
320    #[test]
321    fn test_effective_timeout() {
322        assert_eq!(
323            JsReplTool::effective_timeout(None),
324            Duration::from_millis(30_000)
325        );
326        assert_eq!(
327            JsReplTool::effective_timeout(Some(500_000)),
328            Duration::from_millis(MAX_TIMEOUT_MS)
329        );
330        assert_eq!(
331            JsReplTool::effective_timeout(Some(5_000)),
332            Duration::from_millis(5_000)
333        );
334    }
335
336    #[test]
337    fn test_truncate_output() {
338        let short = "hello";
339        let (out, trunc) = JsReplTool::truncate_output(short);
340        assert_eq!(out, "hello");
341        assert!(!trunc);
342    }
343
344    #[tokio::test]
345    async fn test_multiline_code() {
346        if !has_node() {
347            return;
348        }
349        let tool = JsReplTool::new();
350        let result = run(
351            &tool,
352            json!({
353                "code": "const a = 10;\nconst b = 20;\nconsole.log(a + b);"
354            }),
355        )
356        .await;
357
358        assert!(result.success);
359        let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
360        assert!(payload["stdout"].as_str().unwrap().contains("30"));
361    }
362}