Skip to main content

bamboo_tools/tools/
js_repl.rs

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