use async_trait::async_trait;
use bamboo_agent_core::{Tool, ToolError, ToolResult};
use serde::Deserialize;
use serde_json::json;
use std::path::PathBuf;
use std::process::Stdio;
use tokio::process::Command;
use tokio::time::{timeout, Duration};
const DEFAULT_TIMEOUT_MS: u64 = 30_000;
const MAX_TIMEOUT_MS: u64 = 120_000;
const MAX_OUTPUT_BYTES: usize = 256 * 1024;
#[derive(Debug, Deserialize)]
struct JsReplArgs {
code: String,
#[serde(default)]
timeout_ms: Option<u64>,
}
pub struct JsReplTool;
impl JsReplTool {
pub fn new() -> Self {
Self
}
fn effective_timeout(requested: Option<u64>) -> Duration {
let ms = requested
.unwrap_or(DEFAULT_TIMEOUT_MS)
.clamp(1, MAX_TIMEOUT_MS);
Duration::from_millis(ms)
}
fn truncate_output(s: &str) -> (&str, bool) {
if s.len() <= MAX_OUTPUT_BYTES {
(s, false)
} else {
let mut end = MAX_OUTPUT_BYTES;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
(&s[..end], true)
}
}
}
impl Default for JsReplTool {
fn default() -> Self {
Self::new()
}
}
fn resolve_node() -> Option<PathBuf> {
if let Ok(path) = std::env::var("BAMBOO_JS_REPL_NODE_PATH") {
let p = PathBuf::from(&path);
if p.exists() {
return Some(p);
}
}
find_in_path("node")
}
fn find_in_path(name: &str) -> Option<PathBuf> {
let path_var = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path_var) {
let candidate = dir.join(name);
if candidate.is_file() {
return Some(candidate);
}
#[cfg(windows)]
for ext in &["exe", "cmd", "bat"] {
let with_ext = dir.join(format!("{}.{}", name, ext));
if with_ext.is_file() {
return Some(with_ext);
}
}
}
None
}
#[async_trait]
impl Tool for JsReplTool {
fn name(&self) -> &str {
"js_repl"
}
fn description(&self) -> &str {
"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."
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"code": {
"type": "string",
"description": "JavaScript code to execute"
},
"timeout_ms": {
"type": "number",
"description": "Optional timeout in milliseconds (default 30000, max 120000)"
}
},
"required": ["code"],
"additionalProperties": false
})
}
async fn execute(&self, args: serde_json::Value) -> Result<ToolResult, ToolError> {
let parsed: JsReplArgs = serde_json::from_value(args)
.map_err(|e| ToolError::InvalidArguments(format!("Invalid js_repl args: {}", e)))?;
let code = parsed.code.trim();
if code.is_empty() {
return Err(ToolError::InvalidArguments(
"'code' cannot be empty".to_string(),
));
}
let node_path = resolve_node().ok_or_else(|| {
ToolError::Execution(
"Node.js not found. Install Node.js or set BAMBOO_JS_REPL_NODE_PATH.".to_string(),
)
})?;
let effective_timeout = Self::effective_timeout(parsed.timeout_ms);
let wrapper = format!(
r#"(async () => {{
{}
}})().catch(e => {{ console.error(e); process.exit(1); }});"#,
code
);
let child = Command::new(&node_path)
.arg("-e")
.arg(&wrapper)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true)
.spawn()
.map_err(|e| {
ToolError::Execution(format!(
"Failed to start Node.js ({}): {}",
node_path.display(),
e
))
})?;
match timeout(effective_timeout, child.wait_with_output()).await {
Ok(Ok(output)) => {
let stdout_raw = String::from_utf8_lossy(&output.stdout);
let stderr_raw = String::from_utf8_lossy(&output.stderr);
let (stdout, stdout_truncated) = Self::truncate_output(&stdout_raw);
let (stderr, stderr_truncated) = Self::truncate_output(&stderr_raw);
let exit_code = output.status.code();
let success = output.status.success();
Ok(ToolResult {
success,
result: json!({
"exit_code": exit_code,
"stdout": stdout,
"stderr": stderr,
"stdout_truncated": stdout_truncated,
"stderr_truncated": stderr_truncated,
"timed_out": false,
})
.to_string(),
display_preference: Some("Collapsible".to_string()),
})
}
Ok(Err(e)) => Err(ToolError::Execution(format!(
"Node.js process error: {}",
e
))),
Err(_) => {
Ok(ToolResult {
success: false,
result: json!({
"exit_code": null,
"stdout": "",
"stderr": "Execution timed out",
"stdout_truncated": false,
"stderr_truncated": false,
"timed_out": true,
})
.to_string(),
display_preference: Some("Collapsible".to_string()),
})
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tool_name() {
let tool = JsReplTool::new();
assert_eq!(tool.name(), "js_repl");
}
fn has_node() -> bool {
find_in_path("node").is_some()
}
#[test]
fn test_resolve_node_finds_system_node() {
if !has_node() {
return;
}
assert!(resolve_node().is_some());
}
#[tokio::test]
async fn test_execute_simple_expression() {
if !has_node() {
return;
}
let tool = JsReplTool::new();
let result = tool
.execute(json!({ "code": "console.log(2 + 2)" }))
.await
.unwrap();
assert!(result.success);
let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
assert_eq!(payload["timed_out"], false);
assert_eq!(payload["exit_code"], 0);
assert!(payload["stdout"].as_str().unwrap().contains("4"));
}
#[tokio::test]
async fn test_execute_async_await() {
if !has_node() {
return;
}
let tool = JsReplTool::new();
let result = tool
.execute(json!({
"code": "const result = await Promise.resolve(42); console.log(result)"
}))
.await
.unwrap();
assert!(result.success);
let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
assert!(payload["stdout"].as_str().unwrap().contains("42"));
}
#[tokio::test]
async fn test_execute_error_returns_nonzero_exit() {
if !has_node() {
return;
}
let tool = JsReplTool::new();
let result = tool
.execute(json!({ "code": "throw new Error('test error')" }))
.await
.unwrap();
assert!(!result.success);
let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
assert_ne!(payload["exit_code"], 0);
assert!(payload["stderr"].as_str().unwrap().contains("test error"));
}
#[tokio::test]
async fn test_empty_code_rejected() {
let tool = JsReplTool::new();
let err = tool.execute(json!({ "code": " " })).await.unwrap_err();
assert!(matches!(err, ToolError::InvalidArguments(msg) if msg.contains("empty")));
}
#[tokio::test]
async fn test_missing_code_rejected() {
let tool = JsReplTool::new();
let err = tool.execute(json!({})).await.unwrap_err();
assert!(matches!(err, ToolError::InvalidArguments(_)));
}
#[test]
fn test_effective_timeout() {
assert_eq!(
JsReplTool::effective_timeout(None),
Duration::from_millis(30_000)
);
assert_eq!(
JsReplTool::effective_timeout(Some(500_000)),
Duration::from_millis(MAX_TIMEOUT_MS)
);
assert_eq!(
JsReplTool::effective_timeout(Some(5_000)),
Duration::from_millis(5_000)
);
}
#[test]
fn test_truncate_output() {
let short = "hello";
let (out, trunc) = JsReplTool::truncate_output(short);
assert_eq!(out, "hello");
assert!(!trunc);
}
#[tokio::test]
async fn test_multiline_code() {
if !has_node() {
return;
}
let tool = JsReplTool::new();
let result = tool
.execute(json!({
"code": "const a = 10;\nconst b = 20;\nconsole.log(a + b);"
}))
.await
.unwrap();
assert!(result.success);
let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
assert!(payload["stdout"].as_str().unwrap().contains("30"));
}
}