返回 CodeWhale
js_execution.rs
根目录 / crates / tui / src / tools / js_execution.rs
1 //! `js_execution` tool — execute model-provided JavaScript via a local
2 //! Node.js runtime, returning stdout / stderr / exit code as JSON.
3 //!
4 //! Mirrors the shape of `code_execution` (Python) so the model sees a
5 //! single consistent surface for "run this snippet locally and tell me
6 //! what it printed." The split into a dedicated module (rather than
7 //! living inline in `core::engine::tool_catalog` next to
8 //! `execute_code_execution_tool`) keeps the dependency-probe and
9 //! code-delivery logic isolated.
10 //!
11 //! Both interpreter tools reuse the shell's permission-aware launcher. Native
12 //! isolation depends on its available backend; unsupported read-only execution
13 //! and local execution with an external backend are refused. This is not a
14 //! persistent REPL; source is read from stdin rather than a host temporary file.
15 //!
16 //! Registration is gated by [`crate::dependencies::resolve_node`]:
17 //! when Node is missing the tool is simply not advertised, so the
18 //! model never sees a runtime it can't actually use. See
19 //! `core::engine::tool_catalog::ensure_advanced_tooling` for the
20 //! catalog-side dispatch.
21
22 use std::ffi::OsString;
23 use std::path::Path;
24 use std::time::Duration;
25
26 use crate::dependencies::ExternalTool;
27 use serde_json::{Value, json};
28
29 use crate::tools::spec::{ToolContext, ToolError, ToolResult, required_str};
30 use codewhale_models::Tool;
31
32 /// Tool name surfaced to the model. Held alongside `code_execution`
33 /// in the deferred-tool dispatcher.
34 pub const JS_EXECUTION_TOOL_NAME: &str = "js_execution";
35 /// Tool-type tag — uses the same `code_execution_*` family the
36 /// Anthropic message API expects so the wire shape stays stable
37 /// across the two interpreters.
38 const JS_EXECUTION_TOOL_TYPE: &str = "code_execution_20250825";
39 const NODE_USE_ENV_PROXY: &str = "NODE_USE_ENV_PROXY";
40 const NODE_PROXY_PAIRS: &[(&str, &str)] =
41 &[("HTTP_PROXY", "http_proxy"), ("HTTPS_PROXY", "https_proxy")];
42
43 fn first_non_empty_env_from(
44 keys: &[&str],
45 env: &impl Fn(&str) -> Option<OsString>,
46 ) -> Option<OsString> {
47 keys.iter()
48 .filter_map(|key| env(key))
49 .find(|value| !value.is_empty())
50 }
51
52 fn node_proxy_env_overrides_from(
53 env: impl Fn(&str) -> Option<OsString>,
54 ) -> Vec<(&'static str, OsString)> {
55 let all_proxy = first_non_empty_env_from(&["ALL_PROXY", "all_proxy"], &env);
56 let proxy_configured = all_proxy.is_some()
57 || NODE_PROXY_PAIRS
58 .iter()
59 .any(|(upper, lower)| first_non_empty_env_from(&[upper, lower], &env).is_some());
60
61 let mut overrides = Vec::new();
62 if proxy_configured && first_non_empty_env_from(&[NODE_USE_ENV_PROXY], &env).is_none() {
63 overrides.push((NODE_USE_ENV_PROXY, OsString::from("1")));
64 }
65
66 for (upper, lower) in NODE_PROXY_PAIRS {
67 if first_non_empty_env_from(&[upper], &env).is_none()
68 && let Some(value) =
69 first_non_empty_env_from(&[lower], &env).or_else(|| all_proxy.clone())
70 {
71 overrides.push((*upper, value));
72 }
73 }
74
75 if first_non_empty_env_from(&["NO_PROXY"], &env).is_none()
76 && let Some(value) = first_non_empty_env_from(&["no_proxy"], &env)
77 {
78 overrides.push(("NO_PROXY", value));
79 }
80
81 overrides
82 }
83
84 fn node_proxy_env_overrides() -> Vec<(&'static str, OsString)> {
85 node_proxy_env_overrides_from(|key| std::env::var_os(key))
86 }
87
88 fn apply_node_execution_env(cmd: &mut tokio::process::Command) {
89 // The shared launcher already scrubbed the environment and supplied
90 // sandbox markers. Append Node overrides without clearing that environment.
91 cmd.envs(node_proxy_env_overrides());
92 }
93
94 /// Build the `Tool` definition the catalog should advertise when
95 /// Node.js is present on the host. Kept as a constructor (rather
96 /// than a `static`) so the input schema can stay declarative
97 /// without a `lazy_static!`-style indirection.
98 #[must_use]
99 pub fn js_execution_tool_definition() -> Tool {
100 Tool {
101 tool_type: Some(JS_EXECUTION_TOOL_TYPE.to_string()),
102 name: JS_EXECUTION_TOOL_NAME.to_string(),
103 description:
104 "Execute JavaScript code with the local Node.js runtime using this call's execution policy and return stdout/stderr/return_code as JSON."
105 .to_string(),
106 input_schema: json!({
107 "type": "object",
108 "properties": {
109 "code": { "type": "string", "description": "JavaScript source code to execute." },
110 "sandbox_permissions": {
111 "type": "string",
112 "enum": ["workspace-write", "danger-full-access"],
113 "description": "Request a wider policy for this exact execution after a sandbox denial; requires justification and explicit user approval."
114 },
115 "justification": {
116 "type": "string",
117 "description": "Required with sandbox_permissions: why this exact code needs wider access."
118 }
119 },
120 "required": ["code"]
121 }),
122 allowed_callers: Some(vec!["direct".to_string()]),
123 defer_loading: Some(false),
124 input_examples: None,
125 strict: None,
126 cache_control: None,
127 }
128 }
129
130 /// Wall-clock budget for one `js_execution` call. Real scripts call slow
131 /// APIs, wait on local services, and legitimately run for minutes, so the
132 /// budget is deliberately generous — and when it does fire, the contained
133 /// interpreter and everything it started are killed, not left orphaned.
134 fn js_execution_timeout() -> Duration {
135 if cfg!(test) {
136 // Short enough that the timeout-kill test finishes quickly, long
137 // enough that the happy-path tests never approach it.
138 Duration::from_secs(5)
139 } else {
140 Duration::from_secs(600)
141 }
142 }
143
144 /// Run JavaScript under the effective per-call policy, with the existing
145 /// 600-second budget and process-tree cleanup. Stdin avoids host temporary-file
146 /// visibility problems in a sandbox; CommonJS matches the former .js script.
147 pub async fn execute_js_execution_tool(
148 input: &Value,
149 workspace: &Path,
150 context: &ToolContext,
151 ) -> Result<ToolResult, ToolError> {
152 let code = required_str(input, "code")?;
153 let node = crate::dependencies::Node::resolve().ok_or_else(|| {
154 ToolError::execution_failed("js_execution: Node.js runtime became unavailable")
155 })?;
156 let budget = js_execution_timeout();
157 let mut cmd = crate::tools::shell::sandboxed_runner_command(
158 context,
159 &node,
160 vec!["--input-type=commonjs".to_string(), "-".to_string()],
161 workspace,
162 budget,
163 )?;
164 apply_node_execution_env(&mut cmd);
165 if std::env::var_os("NODE_USE_ENV_PROXY").is_none() {
166 cmd.env("NODE_USE_ENV_PROXY", "1");
167 }
168 let output = tokio::time::timeout(
169 budget,
170 crate::process_tree::contained_output_with_input(&mut cmd, code.as_bytes().to_vec()),
171 )
172 .await
173 .map_err(|_| ToolError::Timeout {
174 seconds: budget.as_secs(),
175 })
176 .and_then(|res| res.map_err(|e| ToolError::execution_failed(e.to_string())))?;
177
178 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
179 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
180 let return_code = output.status.code().unwrap_or(-1);
181 let success = output.status.success();
182 let payload = json!({
183 "type": "code_execution_result",
184 "stdout": stdout,
185 "stderr": stderr,
186 "return_code": return_code,
187 "content": [],
188 });
189
190 Ok(ToolResult {
191 content: serde_json::to_string(&payload).unwrap_or_else(|_| payload.to_string()),
192 success,
193 metadata: Some(payload),
194 })
195 }
196
197 #[cfg(test)]
198 mod tests {
199 use super::*;
200 use crate::test_support::{EnvVarGuard, lock_test_env};
201 use std::ffi::OsString;
202 use tempfile::tempdir;
203
204 /// Skip helper — `js_execution` is a no-op on hosts without Node.
205 /// The tool simply isn't advertised in that case, so happy-path
206 /// tests don't fail; they just don't exercise the spawn path.
207 fn node_present() -> bool {
208 crate::dependencies::resolve_node().is_some()
209 }
210
211 fn proxy_env<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<OsString> + 'a {
212 move |key| {
213 pairs
214 .iter()
215 .find_map(|(name, value)| (*name == key).then(|| OsString::from(value)))
216 }
217 }
218
219 #[test]
220 fn tool_definition_advertises_js_execution_name_and_required_code_field() {
221 let tool = js_execution_tool_definition();
222 assert_eq!(tool.name, JS_EXECUTION_TOOL_NAME);
223 assert_eq!(tool.tool_type.as_deref(), Some(JS_EXECUTION_TOOL_TYPE));
224 assert!(tool.description.contains("local Node.js runtime"));
225 assert!(!tool.description.contains("sandbox"));
226 let required = tool
227 .input_schema
228 .get("required")
229 .and_then(|v| v.as_array())
230 .expect("schema must declare a `required` array");
231 assert!(
232 required.iter().any(|v| v.as_str() == Some("code")),
233 "input_schema must require `code`",
234 );
235 }
236
237 #[test]
238 fn node_proxy_overrides_enable_env_proxy_when_proxy_env_is_present() {
239 let overrides =
240 node_proxy_env_overrides_from(proxy_env(&[("HTTPS_PROXY", "http://127.0.0.1:20499")]));
241
242 assert_eq!(
243 overrides,
244 vec![(NODE_USE_ENV_PROXY, OsString::from("1"))],
245 "uppercase proxy vars are inherited by the child; only Node's env-proxy flag is needed"
246 );
247 }
248
249 #[test]
250 fn node_proxy_overrides_mirror_lowercase_proxy_vars() {
251 let overrides = node_proxy_env_overrides_from(proxy_env(&[
252 ("https_proxy", "http://127.0.0.1:20499"),
253 ("no_proxy", "localhost"),
254 ]));
255
256 assert_eq!(
257 overrides,
258 vec![
259 (NODE_USE_ENV_PROXY, OsString::from("1")),
260 ("HTTPS_PROXY", OsString::from("http://127.0.0.1:20499")),
261 ("NO_PROXY", OsString::from("localhost")),
262 ]
263 );
264 }
265
266 #[tokio::test]
267 async fn execute_js_runs_node_and_returns_stdout_payload() {
268 if !node_present() {
269 // Catalog-build skips the tool entirely on hosts without
270 // Node — match that behaviour in the test rather than
271 // failing the suite for users without Node installed.
272 return;
273 }
274 let tmp = tempdir().expect("tempdir");
275 let result = execute_js_execution_tool(
276 &json!({ "code": "process.stdout.write('hello from node')" }),
277 tmp.path(),
278 &ToolContext::new(tmp.path()),
279 )
280 .await
281 .expect("execute");
282 assert!(result.success, "successful node run must report success");
283 assert!(
284 result.content.contains("hello from node"),
285 "stdout payload must surface the printed text; got {}",
286 result.content
287 );
288 }
289
290 /// A timed-out or cancelled call drops the future; the interpreter and
291 /// what it started must end with it instead of running on.
292 #[cfg(unix)]
293 #[tokio::test]
294 async fn dropped_js_execution_kills_the_interpreter_tree() {
295 if !node_present() {
296 return;
297 }
298 let tmp = tempdir().expect("tempdir");
299 let code = "const { spawn } = require('child_process');\n\
300 const child = spawn('sleep', ['300'], { stdio: 'ignore' });\n\
301 require('fs').writeFileSync('grandchild.pid', String(child.pid));\n\
302 setInterval(() => {}, 1000);";
303 let input = json!({ "code": code });
304 let grandchild = crate::process_tree::drop_once_pid_written(
305 execute_js_execution_tool(&input, tmp.path(), &ToolContext::new(tmp.path())),
306 &tmp.path().join("grandchild.pid"),
307 )
308 .await;
309 assert!(
310 crate::process_tree::wait_for_pid_exit(grandchild, Duration::from_secs(5)),
311 "a process started by the dropped script is still running"
312 );
313 }
314
315 #[tokio::test]
316 async fn execute_js_surfaces_runtime_error_with_nonzero_exit() {
317 if !node_present() {
318 return;
319 }
320 let tmp = tempdir().expect("tempdir");
321 let result = execute_js_execution_tool(
322 &json!({ "code": "throw new Error('intentional fail')" }),
323 tmp.path(),
324 &ToolContext::new(tmp.path()),
325 )
326 .await
327 .expect("execute should not Err — runtime errors land in stderr/exit code");
328 assert!(
329 !result.success,
330 "non-zero exit must report success=false in the result payload"
331 );
332 assert!(
333 result.content.contains("intentional fail"),
334 "stderr payload must surface the error message; got {}",
335 result.content
336 );
337 }
338
339 // The env lock must stay held across the await so no other env-mutating test
340 // races the process env while the child node run reads it.
341 #[allow(clippy::await_holding_lock)]
342 #[tokio::test]
343 async fn execute_js_does_not_inherit_parent_secret_env() {
344 if !node_present() {
345 return;
346 }
347 let _env_lock = lock_test_env();
348 let _secret = EnvVarGuard::set("CODEWHALE_JS_SECRET_LEAK_TEST", "secret-value");
349 let tmp = tempdir().expect("tempdir");
350 let result = execute_js_execution_tool(
351 &json!({
352 "code": "process.stdout.write(process.env.CODEWHALE_JS_SECRET_LEAK_TEST || 'missing')"
353 }),
354 tmp.path(),
355 &ToolContext::new(tmp.path()),
356 )
357 .await
358 .expect("execute");
359 assert!(
360 result.success,
361 "node run should succeed: {}",
362 result.content
363 );
364 assert!(
365 result.content.contains("missing"),
366 "sanitized child env must not expose parent secrets; got {}",
367 result.content
368 );
369 assert!(
370 !result.content.contains("secret-value"),
371 "secret value must not appear in js_execution output"
372 );
373 }
374
375 #[tokio::test]
376 async fn execute_js_enables_env_proxy_so_fetch_honors_proxy_vars() {
377 if !node_present() {
378 return;
379 }
380 // The tool defers to an explicit caller choice; only assert the
381 // default-on behavior when the surrounding env hasn't set it.
382 if std::env::var_os("NODE_USE_ENV_PROXY").is_some() {
383 return;
384 }
385 let tmp = tempdir().expect("tempdir");
386 let result = execute_js_execution_tool(
387 &json!({ "code": "process.stdout.write(String(process.env.NODE_USE_ENV_PROXY))" }),
388 tmp.path(),
389 &ToolContext::new(tmp.path()),
390 )
391 .await
392 .expect("execute");
393 assert!(
394 result.content.contains("\"stdout\":\"1\""),
395 "#3273: js_execution must default NODE_USE_ENV_PROXY=1 so Node's fetch \
396 routes through HTTP(S)_PROXY; got {}",
397 result.content
398 );
399 }
400
401 #[tokio::test]
402 async fn execute_js_rejects_input_without_code_field() {
403 let tmp = tempdir().expect("tempdir");
404 let err = execute_js_execution_tool(&json!({}), tmp.path(), &ToolContext::new(tmp.path()))
405 .await
406 .expect_err("missing `code` must reject before any node spawn");
407 let msg = err.to_string();
408 assert!(
409 msg.contains("code"),
410 "error must name the missing `code` field; got {msg}"
411 );
412 }
413
414 #[cfg(unix)]
415 #[tokio::test]
416 async fn timeout_kills_the_node_child_instead_of_orphaning_it() {
417 if !node_present() {
418 return;
419 }
420 let workspace = tempdir().expect("workspace tempdir");
421 let pid_file = workspace.path().join("child_pid");
422 let code = format!(
423 "const fs = require('fs'); \
424 fs.writeFileSync({}, String(process.pid)); \
425 setTimeout(() => {{}}, 60000);",
426 serde_json::json!(pid_file.to_string_lossy())
427 );
428
429 let err = execute_js_execution_tool(
430 &serde_json::json!({ "code": code }),
431 workspace.path(),
432 &ToolContext::new(workspace.path()),
433 )
434 .await
435 .expect_err("a 60s sleep must hit the execution timeout");
436 assert!(
437 matches!(err, ToolError::Timeout { .. }),
438 "expected a timeout error; got {err:?}"
439 );
440
441 // The child reported its pid before sleeping; the timeout must have
442 // killed it, not left it running. The dropped child is reaped by the
443 // runtime's orphan queue when its driver parks, so the poll yields
444 // (a blocking sleep here would keep a killed zombie visible to
445 // `kill(pid, 0)` for the whole wait).
446 let pid: i32 = std::fs::read_to_string(&pid_file)
447 .expect("child must have written its pid")
448 .trim()
449 .parse()
450 .expect("pid file must contain an integer");
451 let mut attempts = 0;
452 while unsafe { libc::kill(pid, 0) } == 0 {
453 assert!(
454 attempts < 50,
455 "node child {pid} is still alive after the timeout kill"
456 );
457 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
458 attempts += 1;
459 }
460 }
461
462 #[cfg(unix)]
463 #[tokio::test]
464 async fn timeout_returns_promptly_even_when_a_grandchild_holds_the_pipes() {
465 if !node_present() {
466 return;
467 }
468 let workspace = tempdir().expect("workspace tempdir");
469 // The child spawns a grandchild that inherits stdout/stderr (so the
470 // pipe write ends outlive the child) and then blocks far past the
471 // execution timeout. The budget bounds the whole call: the timeout
472 // fires and the process-group kill takes the grandchild down with
473 // the interpreter instead of the call hanging on pipe EOF.
474 let code = "const { spawn } = require('child_process'); \
475 const g = spawn('sleep', ['30'], { stdio: ['ignore', 'inherit', 'inherit'] }); \
476 console.log('grandchild ' + g.pid); \
477 setTimeout(() => {}, 60000);";
478
479 let started = std::time::Instant::now();
480 let err = execute_js_execution_tool(
481 &serde_json::json!({ "code": code }),
482 workspace.path(),
483 &ToolContext::new(workspace.path()),
484 )
485 .await
486 .expect_err("a 60s sleep must hit the execution timeout");
487 let elapsed = started.elapsed();
488 assert!(
489 matches!(err, ToolError::Timeout { .. }),
490 "expected a timeout error; got {err:?}"
491 );
492 assert!(
493 elapsed < std::time::Duration::from_secs(15),
494 "timeout must return promptly even with a grandchild holding the pipes; took {elapsed:?}"
495 );
496 }
497 }
498
498 lines RUST