返回 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 //! tempfile-spawn logic isolated for the test pin.
10 //!
11 //! Registration is gated by [`crate::dependencies::resolve_node`]:
12 //! when Node is missing the tool is simply not advertised, so the
13 //! model never sees a runtime it can't actually use. See
14 //! `core::engine::tool_catalog::ensure_advanced_tooling` for the
15 //! catalog-side dispatch.
16
17 use std::ffi::OsString;
18 use std::path::Path;
19 use std::time::Duration;
20
21 use crate::dependencies::ExternalTool;
22 use serde_json::{Value, json};
23
24 use crate::models::Tool;
25 use crate::tools::spec::{ToolError, ToolResult, required_str};
26
27 /// Tool name surfaced to the model. Held alongside `code_execution`
28 /// in the deferred-tool dispatcher.
29 pub const JS_EXECUTION_TOOL_NAME: &str = "js_execution";
30 /// Tool-type tag — uses the same `code_execution_*` family the
31 /// Anthropic message API expects so the wire shape stays stable
32 /// across the two interpreters.
33 const JS_EXECUTION_TOOL_TYPE: &str = "code_execution_20250825";
34 const NODE_USE_ENV_PROXY: &str = "NODE_USE_ENV_PROXY";
35 const NODE_PROXY_PAIRS: &[(&str, &str)] =
36 &[("HTTP_PROXY", "http_proxy"), ("HTTPS_PROXY", "https_proxy")];
37
38 fn first_non_empty_env_from(
39 keys: &[&str],
40 env: &impl Fn(&str) -> Option<OsString>,
41 ) -> Option<OsString> {
42 keys.iter()
43 .filter_map(|key| env(key))
44 .find(|value| !value.is_empty())
45 }
46
47 fn node_proxy_env_overrides_from(
48 env: impl Fn(&str) -> Option<OsString>,
49 ) -> Vec<(&'static str, OsString)> {
50 let all_proxy = first_non_empty_env_from(&["ALL_PROXY", "all_proxy"], &env);
51 let proxy_configured = all_proxy.is_some()
52 || NODE_PROXY_PAIRS
53 .iter()
54 .any(|(upper, lower)| first_non_empty_env_from(&[upper, lower], &env).is_some());
55
56 let mut overrides = Vec::new();
57 if proxy_configured && first_non_empty_env_from(&[NODE_USE_ENV_PROXY], &env).is_none() {
58 overrides.push((NODE_USE_ENV_PROXY, OsString::from("1")));
59 }
60
61 for (upper, lower) in NODE_PROXY_PAIRS {
62 if first_non_empty_env_from(&[upper], &env).is_none()
63 && let Some(value) =
64 first_non_empty_env_from(&[lower], &env).or_else(|| all_proxy.clone())
65 {
66 overrides.push((*upper, value));
67 }
68 }
69
70 if first_non_empty_env_from(&["NO_PROXY"], &env).is_none()
71 && let Some(value) = first_non_empty_env_from(&["no_proxy"], &env)
72 {
73 overrides.push(("NO_PROXY", value));
74 }
75
76 overrides
77 }
78
79 fn node_proxy_env_overrides() -> Vec<(&'static str, OsString)> {
80 node_proxy_env_overrides_from(|key| std::env::var_os(key))
81 }
82
83 fn apply_node_execution_env(cmd: &mut tokio::process::Command) {
84 crate::child_env::apply_to_tokio_command(cmd, node_proxy_env_overrides());
85 }
86
87 /// Build the `Tool` definition the catalog should advertise when
88 /// Node.js is present on the host. Kept as a constructor (rather
89 /// than a `static`) so the input schema can stay declarative
90 /// without a `lazy_static!`-style indirection.
91 #[must_use]
92 pub fn js_execution_tool_definition() -> Tool {
93 Tool {
94 tool_type: Some(JS_EXECUTION_TOOL_TYPE.to_string()),
95 name: JS_EXECUTION_TOOL_NAME.to_string(),
96 description:
97 "Execute JavaScript code with the local Node.js runtime in the workspace and return stdout/stderr/return_code as JSON."
98 .to_string(),
99 input_schema: json!({
100 "type": "object",
101 "properties": {
102 "code": { "type": "string", "description": "JavaScript source code to execute." }
103 },
104 "required": ["code"]
105 }),
106 allowed_callers: Some(vec!["direct".to_string()]),
107 defer_loading: Some(false),
108 input_examples: None,
109 strict: None,
110 cache_control: None,
111 }
112 }
113
114 /// Run the model-provided JavaScript and return the captured
115 /// stdout / stderr / return_code payload. Mirrors
116 /// `execute_code_execution_tool` exactly — same tempfile pattern,
117 /// same 120-second timeout, same error shape — so the surfaces
118 /// stay interchangeable from the model's point of view.
119 ///
120 /// Tempfile lives only for the duration of this execution; `Drop`
121 /// removes it. We use the `.js` extension so any source-map /
122 /// shebang / encoding-sniffer logic in the interpreter behaves
123 /// normally.
124 pub async fn execute_js_execution_tool(
125 input: &Value,
126 workspace: &Path,
127 ) -> Result<ToolResult, ToolError> {
128 let code = required_str(input, "code")?;
129
130 // Resolve the Node runtime via ExternalTool. If it's absent now
131 // tokio_command() returns None and we fail fast with a clear message.
132
133 let temp_dir = tempfile::tempdir()
134 .map_err(|e| ToolError::execution_failed(format!("tempdir failed: {e}")))?;
135 let script_path = temp_dir.path().join("js_execution.js");
136 tokio::fs::write(&script_path, code)
137 .await
138 .map_err(|e| ToolError::execution_failed(format!("tempfile write failed: {e}")))?;
139
140 let mut cmd = crate::dependencies::Node::tokio_command().ok_or_else(|| {
141 ToolError::execution_failed("js_execution: Node.js runtime became unavailable".to_string())
142 })?;
143 // Recent Node releases use this startup env to make fetch/http(s) honor
144 // standard proxy variables; older runtimes ignore it and keep prior behavior.
145 apply_node_execution_env(&mut cmd);
146 cmd.arg(&script_path).current_dir(workspace);
147
148 // #3273: Node's built-in `fetch` (undici) ignores HTTP(S)_PROXY env vars
149 // unless `NODE_USE_ENV_PROXY` is set (Node >= 24). This child already
150 // inherits CodeWhale's proxy environment, so enabling the flag lets
151 // `js_execution`'s `fetch()` reach the network through the same proxy/VPN
152 // as the rest of the app and honor `NO_PROXY`. Only default it on when the
153 // user hasn't chosen a value, so an explicit opt-out (`NODE_USE_ENV_PROXY=0`)
154 // still wins. No-op on Node < 24, which ignores the unknown variable.
155 if std::env::var_os("NODE_USE_ENV_PROXY").is_none() {
156 cmd.env("NODE_USE_ENV_PROXY", "1");
157 }
158
159 let output = tokio::time::timeout(Duration::from_secs(120), cmd.output())
160 .await
161 .map_err(|_| ToolError::Timeout { seconds: 120 })
162 .and_then(|res| res.map_err(|e| ToolError::execution_failed(e.to_string())))?;
163
164 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
165 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
166 let return_code = output.status.code().unwrap_or(-1);
167 let success = output.status.success();
168 let payload = json!({
169 "type": "code_execution_result",
170 "stdout": stdout,
171 "stderr": stderr,
172 "return_code": return_code,
173 "content": [],
174 });
175
176 Ok(ToolResult {
177 content: serde_json::to_string(&payload).unwrap_or_else(|_| payload.to_string()),
178 success,
179 metadata: Some(payload),
180 })
181 }
182
183 #[cfg(test)]
184 mod tests {
185 use super::*;
186 use crate::test_support::{EnvVarGuard, lock_test_env};
187 use std::ffi::OsString;
188 use tempfile::tempdir;
189
190 /// Skip helper — `js_execution` is a no-op on hosts without Node.
191 /// The tool simply isn't advertised in that case, so happy-path
192 /// tests don't fail; they just don't exercise the spawn path.
193 fn node_present() -> bool {
194 crate::dependencies::resolve_node().is_some()
195 }
196
197 fn proxy_env<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<OsString> + 'a {
198 move |key| {
199 pairs
200 .iter()
201 .find_map(|(name, value)| (*name == key).then(|| OsString::from(value)))
202 }
203 }
204
205 #[test]
206 fn tool_definition_advertises_js_execution_name_and_required_code_field() {
207 let tool = js_execution_tool_definition();
208 assert_eq!(tool.name, JS_EXECUTION_TOOL_NAME);
209 assert_eq!(tool.tool_type.as_deref(), Some(JS_EXECUTION_TOOL_TYPE));
210 assert!(tool.description.contains("local Node.js runtime"));
211 assert!(!tool.description.contains("sandbox"));
212 let required = tool
213 .input_schema
214 .get("required")
215 .and_then(|v| v.as_array())
216 .expect("schema must declare a `required` array");
217 assert!(
218 required.iter().any(|v| v.as_str() == Some("code")),
219 "input_schema must require `code`",
220 );
221 }
222
223 #[test]
224 fn node_proxy_overrides_enable_env_proxy_when_proxy_env_is_present() {
225 let overrides =
226 node_proxy_env_overrides_from(proxy_env(&[("HTTPS_PROXY", "http://127.0.0.1:20499")]));
227
228 assert_eq!(
229 overrides,
230 vec![(NODE_USE_ENV_PROXY, OsString::from("1"))],
231 "uppercase proxy vars are inherited by the child; only Node's env-proxy flag is needed"
232 );
233 }
234
235 #[test]
236 fn node_proxy_overrides_mirror_lowercase_proxy_vars() {
237 let overrides = node_proxy_env_overrides_from(proxy_env(&[
238 ("https_proxy", "http://127.0.0.1:20499"),
239 ("no_proxy", "localhost"),
240 ]));
241
242 assert_eq!(
243 overrides,
244 vec![
245 (NODE_USE_ENV_PROXY, OsString::from("1")),
246 ("HTTPS_PROXY", OsString::from("http://127.0.0.1:20499")),
247 ("NO_PROXY", OsString::from("localhost")),
248 ]
249 );
250 }
251
252 #[tokio::test]
253 async fn execute_js_runs_node_and_returns_stdout_payload() {
254 if !node_present() {
255 // Catalog-build skips the tool entirely on hosts without
256 // Node — match that behaviour in the test rather than
257 // failing the suite for users without Node installed.
258 return;
259 }
260 let tmp = tempdir().expect("tempdir");
261 let result = execute_js_execution_tool(
262 &json!({ "code": "process.stdout.write('hello from node')" }),
263 tmp.path(),
264 )
265 .await
266 .expect("execute");
267 assert!(result.success, "successful node run must report success");
268 assert!(
269 result.content.contains("hello from node"),
270 "stdout payload must surface the printed text; got {}",
271 result.content
272 );
273 }
274
275 #[tokio::test]
276 async fn execute_js_surfaces_runtime_error_with_nonzero_exit() {
277 if !node_present() {
278 return;
279 }
280 let tmp = tempdir().expect("tempdir");
281 let result = execute_js_execution_tool(
282 &json!({ "code": "throw new Error('intentional fail')" }),
283 tmp.path(),
284 )
285 .await
286 .expect("execute should not Err — runtime errors land in stderr/exit code");
287 assert!(
288 !result.success,
289 "non-zero exit must report success=false in the result payload"
290 );
291 assert!(
292 result.content.contains("intentional fail"),
293 "stderr payload must surface the error message; got {}",
294 result.content
295 );
296 }
297
298 // The env lock must stay held across the await so no other env-mutating test
299 // races the process env while the child node run reads it.
300 #[allow(clippy::await_holding_lock)]
301 #[tokio::test]
302 async fn execute_js_does_not_inherit_parent_secret_env() {
303 if !node_present() {
304 return;
305 }
306 let _env_lock = lock_test_env();
307 let _secret = EnvVarGuard::set("CODEWHALE_JS_SECRET_LEAK_TEST", "secret-value");
308 let tmp = tempdir().expect("tempdir");
309 let result = execute_js_execution_tool(
310 &json!({
311 "code": "process.stdout.write(process.env.CODEWHALE_JS_SECRET_LEAK_TEST || 'missing')"
312 }),
313 tmp.path(),
314 )
315 .await
316 .expect("execute");
317 assert!(
318 result.success,
319 "node run should succeed: {}",
320 result.content
321 );
322 assert!(
323 result.content.contains("missing"),
324 "sanitized child env must not expose parent secrets; got {}",
325 result.content
326 );
327 assert!(
328 !result.content.contains("secret-value"),
329 "secret value must not appear in js_execution output"
330 );
331 }
332
333 #[tokio::test]
334 async fn execute_js_enables_env_proxy_so_fetch_honors_proxy_vars() {
335 if !node_present() {
336 return;
337 }
338 // The tool defers to an explicit caller choice; only assert the
339 // default-on behavior when the surrounding env hasn't set it.
340 if std::env::var_os("NODE_USE_ENV_PROXY").is_some() {
341 return;
342 }
343 let tmp = tempdir().expect("tempdir");
344 let result = execute_js_execution_tool(
345 &json!({ "code": "process.stdout.write(String(process.env.NODE_USE_ENV_PROXY))" }),
346 tmp.path(),
347 )
348 .await
349 .expect("execute");
350 assert!(
351 result.content.contains("\"stdout\":\"1\""),
352 "#3273: js_execution must default NODE_USE_ENV_PROXY=1 so Node's fetch \
353 routes through HTTP(S)_PROXY; got {}",
354 result.content
355 );
356 }
357
358 #[tokio::test]
359 async fn execute_js_rejects_input_without_code_field() {
360 let tmp = tempdir().expect("tempdir");
361 let err = execute_js_execution_tool(&json!({}), tmp.path())
362 .await
363 .expect_err("missing `code` must reject before any node spawn");
364 let msg = err.to_string();
365 assert!(
366 msg.contains("code"),
367 "error must name the missing `code` field; got {msg}"
368 );
369 }
370 }
371
371 lines RUST