use async_trait::async_trait;
use boa_engine::{Context, JsNativeError, JsValue, Source};
use std::time::Instant;
use tracing::warn;
use crate::{
BackendCapabilities, CodeExecutor, ExecutionError, ExecutionIsolation, ExecutionLanguage,
ExecutionPayload, ExecutionRequest, ExecutionResult, ExecutionStatus,
};
const LOOP_ITERATION_LIMIT: u64 = 10_000_000;
const RECURSION_LIMIT: usize = 512;
const STACK_SIZE_LIMIT: usize = 10 * 1024;
const INTERPRETER_STACK_BYTES: usize = 8 * 1024 * 1024;
#[derive(Debug, Default)]
pub struct EmbeddedJsExecutor;
impl EmbeddedJsExecutor {
pub fn new() -> Self {
Self
}
}
#[async_trait]
impl CodeExecutor for EmbeddedJsExecutor {
fn name(&self) -> &str {
"EmbeddedJsExecutor"
}
fn capabilities(&self) -> BackendCapabilities {
BackendCapabilities {
isolation: ExecutionIsolation::InProcess,
enforce_network_policy: true,
enforce_filesystem_policy: true,
enforce_environment_policy: true,
enforce_timeout: true,
supports_structured_output: true,
supports_process_execution: false,
supports_persistent_workspace: false,
supports_interactive_sessions: false,
}
}
fn supports_language(&self, lang: &ExecutionLanguage) -> bool {
matches!(lang, ExecutionLanguage::JavaScript)
}
async fn execute(&self, request: ExecutionRequest) -> Result<ExecutionResult, ExecutionError> {
crate::validate_request(&self.capabilities(), &[ExecutionLanguage::JavaScript], &request)?;
let code = match &request.payload {
ExecutionPayload::Source { code } => code.clone(),
ExecutionPayload::GuestModule { .. } => {
return Err(ExecutionError::InvalidRequest(
"EmbeddedJsExecutor does not support guest modules".to_string(),
));
}
};
if code.trim().is_empty() {
return Err(ExecutionError::InvalidRequest("empty JavaScript source".to_string()));
}
let timeout = request.sandbox.timeout;
let input = request.input.clone();
let start = Instant::now();
let (sender, receiver) = tokio::sync::oneshot::channel();
std::thread::Builder::new()
.name("adk-embedded-js".to_string())
.stack_size(INTERPRETER_STACK_BYTES)
.spawn(move || {
let _ = sender.send(evaluate(&code, input.as_ref()));
})
.map_err(|e| {
ExecutionError::InternalError(format!("failed to spawn JavaScript thread: {e}"))
})?;
match tokio::time::timeout(timeout, receiver).await {
Ok(Ok(result)) => Ok(result),
Ok(Err(_)) => Err(ExecutionError::InternalError("JS thread panicked".to_string())),
Err(_) => {
warn!(
timeout_ms = timeout.as_millis() as u64,
"javascript execution timed out; the interpreter stops at its next runtime limit"
);
Ok(ExecutionResult {
status: ExecutionStatus::Timeout,
stdout: String::new(),
stderr: format!("execution exceeded timeout of {}ms", timeout.as_millis()),
output: None,
exit_code: None,
stdout_truncated: false,
stderr_truncated: false,
duration_ms: start.elapsed().as_millis() as u64,
metadata: None,
})
}
}
}
}
fn evaluate(code: &str, input: Option<&serde_json::Value>) -> ExecutionResult {
let start = Instant::now();
let mut context = Context::default();
let limits = context.runtime_limits_mut();
limits.set_loop_iteration_limit(LOOP_ITERATION_LIMIT);
limits.set_recursion_limit(RECURSION_LIMIT);
limits.set_stack_size_limit(STACK_SIZE_LIMIT);
let failed = |stderr: String, start: Instant| ExecutionResult {
status: ExecutionStatus::Failed,
stdout: String::new(),
stderr,
output: None,
exit_code: None,
stdout_truncated: false,
stderr_truncated: false,
duration_ms: start.elapsed().as_millis() as u64,
metadata: None,
};
let input_str = input
.map(|v| serde_json::to_string(v).unwrap_or_else(|_| "{}".to_string()))
.unwrap_or_else(|| "null".to_string());
let setup = format!("var input = {input_str};");
if let Err(e) = context.eval(Source::from_bytes(&setup)) {
return failed(format!("Failed to inject input: {e:?}"), start);
}
let wrapped = format!("(function() {{ {code} }})()");
match context.eval(Source::from_bytes(&wrapped)) {
Ok(val) => {
let output = js_value_to_json(&val, &mut context);
ExecutionResult {
status: ExecutionStatus::Success,
stdout: String::new(),
stderr: String::new(),
output: Some(output),
exit_code: None,
stdout_truncated: false,
stderr_truncated: false,
duration_ms: start.elapsed().as_millis() as u64,
metadata: None,
}
}
Err(e) if e.as_native().is_some_and(JsNativeError::is_runtime_limit) => {
failed(format!("JavaScript runtime limit exceeded: {e}"), start)
}
Err(e) => failed(format!("JavaScript error: {e:?}"), start),
}
}
fn js_value_to_json(val: &JsValue, context: &mut Context) -> serde_json::Value {
match val {
JsValue::Null | JsValue::Undefined => serde_json::Value::Null,
JsValue::Boolean(b) => serde_json::Value::Bool(*b),
JsValue::Integer(n) => serde_json::json!(*n),
JsValue::Rational(n) => {
if n.is_finite() {
serde_json::json!(*n)
} else {
serde_json::Value::Null
}
}
JsValue::String(s) => serde_json::Value::String(s.to_std_string_escaped()),
JsValue::BigInt(n) => serde_json::Value::String(n.to_string()),
JsValue::Symbol(_) => serde_json::Value::Null,
JsValue::Object(_) => {
let stringify_code = format!(
"JSON.stringify({})",
"__adk_tmp__"
);
let global = context.global_object();
let key = boa_engine::JsString::from("__adk_tmp__");
let _ = global.set(key.clone(), val.clone(), false, context);
let result = context.eval(Source::from_bytes(stringify_code.as_bytes()));
let _ = global.delete_property_or_throw(key, context);
if let Ok(json_val) = result
&& let Some(s) = json_val.as_string()
{
let std_str: String = s.to_std_string_escaped();
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&std_str) {
return parsed;
}
}
serde_json::Value::String("[object]".to_string())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::SandboxPolicy;
use std::time::Duration;
fn js_request(code: &str, timeout: Duration) -> ExecutionRequest {
ExecutionRequest {
language: ExecutionLanguage::JavaScript,
payload: ExecutionPayload::Source { code: code.to_string() },
argv: vec![],
stdin: None,
input: Some(serde_json::json!({ "x": 41 })),
sandbox: SandboxPolicy { timeout, ..SandboxPolicy::strict_js() },
identity: None,
}
}
#[tokio::test]
async fn returns_json_output() {
let result = EmbeddedJsExecutor::new()
.execute(js_request("return { y: input.x + 1 };", Duration::from_secs(5)))
.await
.unwrap();
assert_eq!(result.status, ExecutionStatus::Success);
assert_eq!(result.output, Some(serde_json::json!({ "y": 42 })));
}
#[tokio::test]
async fn infinite_loop_returns_at_the_deadline() {
let timeout = Duration::from_millis(500);
let started = Instant::now();
let result = EmbeddedJsExecutor::new()
.execute(js_request("while (true) {}", timeout))
.await
.unwrap();
assert!(
started.elapsed() < timeout + Duration::from_secs(2),
"took {:?}",
started.elapsed()
);
let stopped_by_limit = result.status == ExecutionStatus::Failed
&& result.stderr.contains("runtime limit exceeded");
assert!(
result.status == ExecutionStatus::Timeout || stopped_by_limit,
"unexpected result: {result:?}"
);
}
#[test]
fn loop_limit_stops_the_interpreter_and_cannot_be_caught() {
let result = evaluate("try { while (true) {} } catch (e) { return 'caught'; }", None);
assert_eq!(result.status, ExecutionStatus::Failed, "{result:?}");
assert!(result.stderr.contains("runtime limit exceeded"), "{}", result.stderr);
assert!(result.stderr.contains("loop iteration"), "{}", result.stderr);
}
#[tokio::test]
async fn work_spread_across_frames_returns_timeout_at_the_deadline() {
let timeout = Duration::from_millis(300);
let code =
"function spin() { for (let i = 0; i < 1000000; i++) {} } while (true) { spin(); }";
let started = Instant::now();
let result = EmbeddedJsExecutor::new().execute(js_request(code, timeout)).await.unwrap();
assert_eq!(result.status, ExecutionStatus::Timeout, "{result:?}");
assert!(
started.elapsed() < timeout + Duration::from_secs(2),
"took {:?}",
started.elapsed()
);
}
#[test]
fn unbounded_recursion_hits_the_recursion_limit() {
let result = evaluate("function f() { return f(); } return f();", None);
assert_eq!(result.status, ExecutionStatus::Failed, "{result:?}");
assert!(result.stderr.contains("runtime limit exceeded"), "{}", result.stderr);
}
#[tokio::test]
async fn recursion_through_native_builtins_hits_the_limit_not_the_native_stack() {
let code = "function f(n) { return [n].map(x => f(x + 1)); } return f(0);";
let result = EmbeddedJsExecutor::new()
.execute(js_request(code, Duration::from_secs(30)))
.await
.unwrap();
assert_eq!(result.status, ExecutionStatus::Failed, "{result:?}");
assert!(result.stderr.contains("runtime limit exceeded"), "{}", result.stderr);
}
}