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
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
23/// JavaScript REPL tool — executes JavaScript code using Node.js.
24///
25/// Uses a fresh Node.js subprocess per invocation.  The code is piped to
26/// `node --input-type=module` on stdin so multi-line programs and `await`
27/// at the top level are supported.
28///
29/// Inspired by Codex's `js_repl` tool but uses a simpler sub-process model
30/// rather than a persistent kernel.
31pub 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
64/// Resolve a Node.js binary, checking `BAMBOO_JS_REPL_NODE_PATH` env var
65/// first, then falling back to a PATH lookup for "node".
66fn 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
76/// Simple cross-platform PATH lookup (avoids the `which` crate dependency).
77fn 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        // On Windows, try common extensions
85        #[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        // Wrap code in an async IIFE to support top-level await.
152        let wrapper = format!(
153            r#"(async () => {{
154{}
155}})().catch(e => {{ console.error(e); process.exit(1); }});"#,
156            code
157        );
158
159        // #217: run in the session's workspace (falls back to the process cwd
160        // when no workspace is tracked — see `workspace_state::workspace_or_
161        // process_cwd`) instead of silently inheriting whatever directory the
162        // host process happens to be in. This is the same shared cwd
163        // resolution `Bash`/`Glob`/`Grep`/`Workspace`/`slash_command` already
164        // use, so relative paths in REPL code (`fs.writeFileSync('out.txt',
165        // ...)`) land in the same place a Bash command run in this session
166        // would land them.
167        let cwd = workspace_state::workspace_or_process_cwd(ctx.session_id());
168
169        // `kill_on_drop(true)` ensures the child is killed when it goes out of
170        // scope (i.e. on timeout). `wait_with_output()` takes ownership, so on
171        // timeout the future is dropped and the child is killed automatically.
172        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                // Timeout — child is killed on drop via kill_on_drop(true)
219                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    /// Helper: returns true when Node.js is available in PATH.
257    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    /// Issue #217: `js_repl` must run in the session's tracked workspace, not
285    /// silently inherit the host process's cwd — the same shared cwd
286    /// resolution Bash/Glob/Grep/Workspace already use.
287    #[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}