Add feature-gated freeform js_repl core runtime (#10674)

## Summary

This PR adds an **experimental, feature-gated `js_repl` core runtime**
so models can execute JavaScript in a persistent REPL context across
tool calls.

The implementation integrates with existing feature gating, tool
registration, prompt composition, config/schema docs, and tests.

## What changed

- Added new experimental feature flag: `features.js_repl`.
- Added freeform `js_repl` tool and companion `js_repl_reset` tool.
- Gated tool availability behind `Feature::JsRepl`.
- Added conditional prompt-section injection for JS REPL instructions
via marker-based prompt processing.
- Implemented JS REPL handlers, including freeform parsing and pragma
support (timeout/reset controls).
- Added runtime resolution order for Node:
  1. `CODEX_JS_REPL_NODE_PATH`
  2. `js_repl_node_path` in config
  3. `PATH`
- Added JS runtime assets/version files and updated docs/schema.

## Why

This enables richer agent workflows that require incremental JavaScript
execution with preserved state, while keeping rollout safe behind an
explicit feature flag.

## Testing

Coverage includes:

- Feature-flag gating behavior for tool exposure.
- Freeform parser/pragma handling edge cases.
- Runtime behavior (state persistence across calls and top-level `await`
support).

## Usage

```toml
[features]
js_repl = true
```

Optional runtime override:

- `CODEX_JS_REPL_NODE_PATH`, or
- `js_repl_node_path` in config.

#### [git stack](https://github.com/magus/git-stack-cli)
- 👉 `1` https://github.com/openai/codex/pull/10674
-  `2` https://github.com/openai/codex/pull/10672
-  `3` https://github.com/openai/codex/pull/10671
-  `4` https://github.com/openai/codex/pull/10673
-  `5` https://github.com/openai/codex/pull/10670
This commit is contained in:
Curtis 'Fjord' Hawthorne
2026-02-11 12:05:02 -08:00
committed by GitHub
Unverified
parent 87279de434
commit 42e22f3bde
21 changed files with 1611 additions and 5 deletions
+28 -2
View File
@@ -220,6 +220,7 @@ use crate::tasks::SessionTask;
use crate::tasks::SessionTaskContext;
use crate::tools::ToolRouter;
use crate::tools::context::SharedTurnDiffTracker;
use crate::tools::js_repl::JsReplHandle;
use crate::tools::parallel::ToolCallRuntime;
use crate::tools::sandboxing::ApprovalStore;
use crate::tools::spec::ToolsConfig;
@@ -510,6 +511,7 @@ pub(crate) struct Session {
pending_mcp_server_refresh_config: Mutex<Option<McpServerRefreshConfig>>,
pub(crate) active_turn: Mutex<Option<ActiveTurn>>,
pub(crate) services: SessionServices,
js_repl: Arc<JsReplHandle>,
next_internal_sub_id: AtomicU64,
}
@@ -549,6 +551,7 @@ pub(crate) struct TurnContext {
pub(crate) codex_linux_sandbox_exe: Option<PathBuf>,
pub(crate) tool_call_gate: Arc<ReadinessFlag>,
pub(crate) truncation_policy: TruncationPolicy,
pub(crate) js_repl: Arc<JsReplHandle>,
pub(crate) dynamic_tools: Vec<DynamicToolSpec>,
turn_metadata_header: OnceCell<Option<String>>,
}
@@ -809,6 +812,7 @@ impl Session {
model_info: ModelInfo,
network: Option<NetworkProxy>,
sub_id: String,
js_repl: Arc<JsReplHandle>,
) -> TurnContext {
let reasoning_effort = session_configuration.collaboration_mode.reasoning_effort();
let reasoning_summary = session_configuration.model_reasoning_summary;
@@ -857,6 +861,7 @@ impl Session {
codex_linux_sandbox_exe: per_turn_config.codex_linux_sandbox_exe.clone(),
tool_call_gate: Arc::new(ReadinessFlag::new()),
truncation_policy: model_info.truncation_policy.into(),
js_repl,
dynamic_tools: session_configuration.dynamic_tools.clone(),
turn_metadata_header: OnceCell::new(),
}
@@ -1123,6 +1128,10 @@ impl Session {
Self::build_model_client_beta_features_header(config.as_ref()),
),
};
let js_repl = Arc::new(JsReplHandle::with_node_path(
config.js_repl_node_path.clone(),
config.codex_home.clone(),
));
let prewarm_model_info = models_manager
.get_model_info(session_configuration.collaboration_mode.model(), &config)
@@ -1150,6 +1159,7 @@ impl Session {
pending_mcp_server_refresh_config: Mutex::new(None),
active_turn: Mutex::new(None),
services,
js_repl,
next_internal_sub_id: AtomicU64::new(0),
});
@@ -1598,6 +1608,7 @@ impl Session {
.as_ref()
.map(StartedNetworkProxy::proxy),
sub_id,
Arc::clone(&self.js_repl),
);
if let Some(final_schema) = final_output_json_schema {
@@ -3766,6 +3777,7 @@ async fn spawn_review_thread(
final_output_json_schema: None,
codex_linux_sandbox_exe: parent_turn_context.codex_linux_sandbox_exe.clone(),
tool_call_gate: Arc::new(ReadinessFlag::new()),
js_repl: Arc::clone(&sess.js_repl),
dynamic_tools: parent_turn_context.dynamic_tools.clone(),
truncation_policy: model_info.truncation_policy.into(),
turn_metadata_header: parent_turn_context.turn_metadata_header.clone(),
@@ -4253,11 +4265,13 @@ async fn run_sampling_request(
let model_supports_parallel = turn_context.model_info.supports_parallel_tool_calls;
let tools =
crate::tools::spec::filter_tools_for_model(router.specs(), &turn_context.tools_config);
let base_instructions = sess.get_base_instructions().await;
let prompt = Prompt {
input,
tools: router.specs(),
tools,
parallel_tool_calls: model_supports_parallel,
base_instructions,
personality: turn_context.personality,
@@ -5280,9 +5294,9 @@ mod tests {
];
let (session, _turn_context) = make_session_and_context().await;
let config = test_config();
for test_case in test_cases {
let config = test_config();
let model_info = model_info_for_slug(test_case.slug, &config);
if test_case.expects_apply_patch_instructions {
assert_eq!(
@@ -6298,6 +6312,10 @@ mod tests {
Session::build_model_client_beta_features_header(config.as_ref()),
),
};
let js_repl = Arc::new(JsReplHandle::with_node_path(
config.js_repl_node_path.clone(),
config.codex_home.clone(),
));
let turn_context = Session::make_turn_context(
Some(Arc::clone(&auth_manager)),
@@ -6308,6 +6326,7 @@ mod tests {
model_info,
None,
"turn_id".to_string(),
Arc::clone(&js_repl),
);
let session = Session {
@@ -6319,6 +6338,7 @@ mod tests {
pending_mcp_server_refresh_config: Mutex::new(None),
active_turn: Mutex::new(None),
services,
js_repl,
next_internal_sub_id: AtomicU64::new(0),
};
@@ -6435,6 +6455,10 @@ mod tests {
Session::build_model_client_beta_features_header(config.as_ref()),
),
};
let js_repl = Arc::new(JsReplHandle::with_node_path(
config.js_repl_node_path.clone(),
config.codex_home.clone(),
));
let turn_context = Arc::new(Session::make_turn_context(
Some(Arc::clone(&auth_manager)),
@@ -6445,6 +6469,7 @@ mod tests {
model_info,
None,
"turn_id".to_string(),
Arc::clone(&js_repl),
));
let session = Arc::new(Session {
@@ -6456,6 +6481,7 @@ mod tests {
pending_mcp_server_refresh_config: Mutex::new(None),
active_turn: Mutex::new(None),
services,
js_repl,
next_internal_sub_id: AtomicU64::new(0),
});
+16
View File
@@ -304,6 +304,9 @@ pub struct Config {
/// When this program is invoked, arg0 will be set to `codex-linux-sandbox`.
pub codex_linux_sandbox_exe: Option<PathBuf>,
/// Optional absolute path to the Node runtime used by `js_repl`.
pub js_repl_node_path: Option<PathBuf>,
/// Value to use for `reasoning.effort` when making a request using the
/// Responses API.
pub model_reasoning_effort: Option<ReasoningEffort>,
@@ -939,6 +942,9 @@ pub struct ConfigToml {
/// Token budget applied when storing tool/function outputs in the context manager.
pub tool_output_token_limit: Option<usize>,
/// Optional absolute path to the Node runtime used by `js_repl`.
pub js_repl_node_path: Option<AbsolutePathBuf>,
/// Profile to use from the `profiles` map.
pub profile: Option<String>,
@@ -1282,6 +1288,7 @@ pub struct ConfigOverrides {
pub model_provider: Option<String>,
pub config_profile: Option<String>,
pub codex_linux_sandbox_exe: Option<PathBuf>,
pub js_repl_node_path: Option<PathBuf>,
pub base_instructions: Option<String>,
pub developer_instructions: Option<String>,
pub personality: Option<Personality>,
@@ -1408,6 +1415,7 @@ impl Config {
model_provider,
config_profile: config_profile_key,
codex_linux_sandbox_exe,
js_repl_node_path: js_repl_node_path_override,
base_instructions,
developer_instructions,
personality,
@@ -1641,6 +1649,9 @@ impl Config {
"experimental compact prompt file",
)?;
let compact_prompt = compact_prompt.or(file_compact_prompt);
let js_repl_node_path = js_repl_node_path_override
.or(config_profile.js_repl_node_path.map(Into::into))
.or(cfg.js_repl_node_path.map(Into::into));
let review_model = override_review_model.or(cfg.review_model);
@@ -1757,6 +1768,7 @@ impl Config {
ephemeral: ephemeral.unwrap_or_default(),
file_opener: cfg.file_opener.unwrap_or(UriBasedFileOpener::VsCode),
codex_linux_sandbox_exe,
js_repl_node_path,
hide_agent_reasoning: cfg.hide_agent_reasoning.unwrap_or(false),
show_raw_agent_reasoning: cfg
@@ -4026,6 +4038,7 @@ model_verbosity = "high"
ephemeral: false,
file_opener: UriBasedFileOpener::VsCode,
codex_linux_sandbox_exe: None,
js_repl_node_path: None,
hide_agent_reasoning: false,
show_raw_agent_reasoning: false,
model_reasoning_effort: Some(ReasoningEffort::High),
@@ -4132,6 +4145,7 @@ model_verbosity = "high"
ephemeral: false,
file_opener: UriBasedFileOpener::VsCode,
codex_linux_sandbox_exe: None,
js_repl_node_path: None,
hide_agent_reasoning: false,
show_raw_agent_reasoning: false,
model_reasoning_effort: None,
@@ -4236,6 +4250,7 @@ model_verbosity = "high"
ephemeral: false,
file_opener: UriBasedFileOpener::VsCode,
codex_linux_sandbox_exe: None,
js_repl_node_path: None,
hide_agent_reasoning: false,
show_raw_agent_reasoning: false,
model_reasoning_effort: None,
@@ -4326,6 +4341,7 @@ model_verbosity = "high"
ephemeral: false,
file_opener: UriBasedFileOpener::VsCode,
codex_linux_sandbox_exe: None,
js_repl_node_path: None,
hide_agent_reasoning: false,
show_raw_agent_reasoning: false,
model_reasoning_effort: Some(ReasoningEffort::High),
+1
View File
@@ -30,6 +30,7 @@ pub struct ConfigProfile {
pub chatgpt_base_url: Option<String>,
/// Optional path to a file containing model instructions.
pub model_instructions_file: Option<AbsolutePathBuf>,
pub js_repl_node_path: Option<AbsolutePathBuf>,
/// Deprecated: ignored. Use `model_instructions_file`.
#[schemars(skip)]
pub experimental_instructions_file: Option<AbsolutePathBuf>,
+8
View File
@@ -78,6 +78,8 @@ pub enum Feature {
ShellTool,
// Experimental
/// Enable JavaScript REPL tools backed by a persistent Node kernel.
JsRepl,
/// Use the single unified PTY-backed exec tool.
UnifiedExec,
/// Include the freeform apply_patch tool.
@@ -422,6 +424,12 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::Stable,
default_enabled: true,
},
FeatureSpec {
id: Feature::JsRepl,
key: "js_repl",
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::WebSearchRequest,
key: "web_search_request",
+40
View File
@@ -34,6 +34,25 @@ pub const LOCAL_PROJECT_DOC_FILENAME: &str = "AGENTS.override.md";
/// be concatenated with the following separator.
const PROJECT_DOC_SEPARATOR: &str = "\n\n--- project-doc ---\n\n";
fn render_js_repl_instructions(config: &Config) -> Option<String> {
if !config.features.enabled(Feature::JsRepl) {
return None;
}
let mut section = String::from("## JavaScript REPL (Node)\n");
section.push_str("- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel. `codex.state` persists for the session (best effort) and is cleared by `js_repl_reset`.\n");
section.push_str("- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n");
section.push_str("- Helpers available in `js_repl`: `codex.state` and `codex.tmpDir`.\n");
section.push_str("- Top-level bindings persist across cells. If you hit `SyntaxError: Identifier 'x' has already been declared`, reuse the binding, pick a new name, wrap in `{ ... }` for block scope, or reset the kernel with `js_repl_reset`.\n");
section.push_str("- Top-level static import declarations (for example `import x from \"pkg\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")` instead.\n");
section.push_str(
"- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`.",
);
Some(section)
}
/// Combines `Config::instructions` and `AGENTS.md` (if present) into a single
/// string of instructions.
pub(crate) async fn get_user_instructions(
@@ -61,6 +80,13 @@ pub(crate) async fn get_user_instructions(
}
};
if let Some(js_repl_section) = render_js_repl_instructions(config) {
if !output.is_empty() {
output.push_str("\n\n");
}
output.push_str(&js_repl_section);
}
let skills_section = skills.and_then(render_skills_section);
if let Some(skills_section) = skills_section {
if !output.is_empty() {
@@ -236,6 +262,7 @@ fn candidate_filenames<'a>(config: &'a Config) -> Vec<&'a str> {
mod tests {
use super::*;
use crate::config::ConfigBuilder;
use crate::features::Feature;
use crate::skills::load_skills;
use std::fs;
use std::path::PathBuf;
@@ -364,6 +391,19 @@ mod tests {
);
}
#[tokio::test]
async fn js_repl_instructions_are_appended_when_enabled() {
let tmp = tempfile::tempdir().expect("tempdir");
let mut cfg = make_config(&tmp, 4096, None).await;
cfg.features.enable(Feature::JsRepl);
let res = get_user_instructions(&cfg, None)
.await
.expect("js_repl instructions expected");
let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel. `codex.state` persists for the session (best effort) and is cleared by `js_repl_reset`.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers available in `js_repl`: `codex.state` and `codex.tmpDir`.\n- Top-level bindings persist across cells. If you hit `SyntaxError: Identifier 'x' has already been declared`, reuse the binding, pick a new name, wrap in `{ ... }` for block scope, or reset the kernel with `js_repl_reset`.\n- Top-level static import declarations (for example `import x from \"pkg\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")` instead.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`.";
assert_eq!(res, expected);
}
/// When both system instructions *and* a project doc are present the two
/// should be concatenated with the separator.
#[tokio::test]
+224
View File
@@ -0,0 +1,224 @@
use async_trait::async_trait;
use serde_json::Value as JsonValue;
use std::sync::Arc;
use crate::features::Feature;
use crate::function_tool::FunctionCallError;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolOutput;
use crate::tools::context::ToolPayload;
use crate::tools::handlers::parse_arguments;
use crate::tools::js_repl::JS_REPL_PRAGMA_PREFIX;
use crate::tools::js_repl::JsReplArgs;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
use codex_protocol::models::FunctionCallOutputBody;
pub struct JsReplHandler;
pub struct JsReplResetHandler;
#[async_trait]
impl ToolHandler for JsReplHandler {
fn kind(&self) -> ToolKind {
ToolKind::Function
}
fn matches_kind(&self, payload: &ToolPayload) -> bool {
matches!(
payload,
ToolPayload::Function { .. } | ToolPayload::Custom { .. }
)
}
async fn handle(&self, invocation: ToolInvocation) -> Result<ToolOutput, FunctionCallError> {
let ToolInvocation {
session,
turn,
tracker,
payload,
..
} = invocation;
if !session.features().enabled(Feature::JsRepl) {
return Err(FunctionCallError::RespondToModel(
"js_repl is disabled by feature flag".to_string(),
));
}
let args = match payload {
ToolPayload::Function { arguments } => parse_arguments(&arguments)?,
ToolPayload::Custom { input } => parse_freeform_args(&input)?,
_ => {
return Err(FunctionCallError::RespondToModel(
"js_repl expects custom or function payload".to_string(),
));
}
};
let manager = turn.js_repl.manager().await?;
let result = manager
.execute(Arc::clone(&session), Arc::clone(&turn), tracker, args)
.await?;
Ok(ToolOutput::Function {
body: FunctionCallOutputBody::Text(result.output),
success: Some(true),
})
}
}
#[async_trait]
impl ToolHandler for JsReplResetHandler {
fn kind(&self) -> ToolKind {
ToolKind::Function
}
async fn handle(&self, invocation: ToolInvocation) -> Result<ToolOutput, FunctionCallError> {
if !invocation.session.features().enabled(Feature::JsRepl) {
return Err(FunctionCallError::RespondToModel(
"js_repl is disabled by feature flag".to_string(),
));
}
let manager = invocation.turn.js_repl.manager().await?;
manager.reset().await?;
Ok(ToolOutput::Function {
body: FunctionCallOutputBody::Text("js_repl kernel reset".to_string()),
success: Some(true),
})
}
}
fn parse_freeform_args(input: &str) -> Result<JsReplArgs, FunctionCallError> {
if input.trim().is_empty() {
return Err(FunctionCallError::RespondToModel(
"js_repl expects raw JavaScript tool input (non-empty). Provide JS source text, optionally with first-line `// codex-js-repl: ...`."
.to_string(),
));
}
let mut args = JsReplArgs {
code: input.to_string(),
timeout_ms: None,
};
let mut lines = input.splitn(2, '\n');
let first_line = lines.next().unwrap_or_default();
let rest = lines.next().unwrap_or_default();
let trimmed = first_line.trim_start();
let Some(pragma) = trimmed.strip_prefix(JS_REPL_PRAGMA_PREFIX) else {
reject_json_or_quoted_source(&args.code)?;
return Ok(args);
};
let mut timeout_ms: Option<u64> = None;
let directive = pragma.trim();
if !directive.is_empty() {
for token in directive.split_whitespace() {
let (key, value) = token.split_once('=').ok_or_else(|| {
FunctionCallError::RespondToModel(format!(
"js_repl pragma expects space-separated key=value pairs (supported keys: timeout_ms); got `{token}`"
))
})?;
match key {
"timeout_ms" => {
if timeout_ms.is_some() {
return Err(FunctionCallError::RespondToModel(
"js_repl pragma specifies timeout_ms more than once".to_string(),
));
}
let parsed = value.parse::<u64>().map_err(|_| {
FunctionCallError::RespondToModel(format!(
"js_repl pragma timeout_ms must be an integer; got `{value}`"
))
})?;
timeout_ms = Some(parsed);
}
_ => {
return Err(FunctionCallError::RespondToModel(format!(
"js_repl pragma only supports timeout_ms; got `{key}`"
)));
}
}
}
}
if rest.trim().is_empty() {
return Err(FunctionCallError::RespondToModel(
"js_repl pragma must be followed by JavaScript source on subsequent lines".to_string(),
));
}
reject_json_or_quoted_source(rest)?;
args.code = rest.to_string();
args.timeout_ms = timeout_ms;
Ok(args)
}
fn reject_json_or_quoted_source(code: &str) -> Result<(), FunctionCallError> {
let trimmed = code.trim();
if trimmed.starts_with("```") {
return Err(FunctionCallError::RespondToModel(
"js_repl expects raw JavaScript source, not markdown code fences. Resend plain JS only (optional first line `// codex-js-repl: ...`)."
.to_string(),
));
}
let Ok(value) = serde_json::from_str::<JsonValue>(trimmed) else {
return Ok(());
};
match value {
JsonValue::Object(_) | JsonValue::String(_) => Err(FunctionCallError::RespondToModel(
"js_repl is a freeform tool and expects raw JavaScript source. Resend plain JS only (optional first line `// codex-js-repl: ...`); do not send JSON (`{\"code\":...}`), quoted code, or markdown fences."
.to_string(),
)),
_ => Ok(()),
}
}
#[cfg(test)]
mod tests {
use super::parse_freeform_args;
use pretty_assertions::assert_eq;
#[test]
fn parse_freeform_args_without_pragma() {
let args = parse_freeform_args("console.log('ok');").expect("parse args");
assert_eq!(args.code, "console.log('ok');");
assert_eq!(args.timeout_ms, None);
}
#[test]
fn parse_freeform_args_with_pragma() {
let input = "// codex-js-repl: timeout_ms=15000\nconsole.log('ok');";
let args = parse_freeform_args(input).expect("parse args");
assert_eq!(args.code, "console.log('ok');");
assert_eq!(args.timeout_ms, Some(15_000));
}
#[test]
fn parse_freeform_args_rejects_unknown_key() {
let err = parse_freeform_args("// codex-js-repl: nope=1\nconsole.log('ok');")
.expect_err("expected error");
assert_eq!(
err.to_string(),
"js_repl pragma only supports timeout_ms; got `nope`"
);
}
#[test]
fn parse_freeform_args_rejects_reset_key() {
let err = parse_freeform_args("// codex-js-repl: reset=true\nconsole.log('ok');")
.expect_err("expected error");
assert_eq!(
err.to_string(),
"js_repl pragma only supports timeout_ms; got `reset`"
);
}
#[test]
fn parse_freeform_args_rejects_json_wrapped_code() {
let err = parse_freeform_args(r#"{"code":"await doThing()"}"#).expect_err("expected error");
assert_eq!(
err.to_string(),
"js_repl is a freeform tool and expects raw JavaScript source. Resend plain JS only (optional first line `// codex-js-repl: ...`); do not send JSON (`{\"code\":...}`), quoted code, or markdown fences."
);
}
}
+3
View File
@@ -2,6 +2,7 @@ pub mod apply_patch;
pub(crate) mod collab;
mod dynamic;
mod grep_files;
mod js_repl;
mod list_dir;
mod mcp;
mod mcp_resource;
@@ -22,6 +23,8 @@ pub use apply_patch::ApplyPatchHandler;
pub use collab::CollabHandler;
pub use dynamic::DynamicToolHandler;
pub use grep_files::GrepFilesHandler;
pub use js_repl::JsReplHandler;
pub use js_repl::JsReplResetHandler;
pub use list_dir::ListDirHandler;
pub use mcp::McpHandler;
pub use mcp_resource::McpResourceHandler;
+324
View File
@@ -0,0 +1,324 @@
// Node-based kernel for js_repl.
// Communicates over JSON lines on stdin/stdout.
// Requires Node started with --experimental-vm-modules.
const { builtinModules } = require("node:module");
const { createInterface } = require("node:readline");
const path = require("node:path");
const { pathToFileURL } = require("node:url");
const { inspect } = require("node:util");
const vm = require("node:vm");
const { SourceTextModule, SyntheticModule } = vm;
const meriyahPromise = import("./meriyah.umd.min.js").then((m) => m.default ?? m);
const context = vm.createContext({});
context.globalThis = context;
context.global = context;
context.console = console;
context.setTimeout = setTimeout;
context.clearTimeout = clearTimeout;
context.setInterval = setInterval;
context.clearInterval = clearInterval;
context.queueMicrotask = queueMicrotask;
// Explicit long-lived mutable store exposed as `codex.state`. This is useful
// when callers want shared state without relying on lexical binding carry-over.
const codexState = {};
context.codex = {
state: codexState,
tmpDir: process.env.CODEX_JS_TMP_DIR || process.cwd(),
};
/**
* @typedef {{ name: string, kind: "const"|"let"|"var"|"function"|"class" }} Binding
*/
// REPL state model:
// - Every exec is compiled as a fresh ESM "cell".
// - `previousModule` is the most recently evaluated module namespace.
// - `previousBindings` tracks which top-level names should be carried forward.
// Each new cell imports a synthetic view of the previous namespace and
// redeclares those names so user variables behave like a persistent REPL.
let previousModule = null;
/** @type {Binding[]} */
let previousBindings = [];
let cellCounter = 0;
const builtinModuleSet = new Set([
...builtinModules,
...builtinModules.map((name) => `node:${name}`),
]);
const deniedBuiltinModules = new Set([
"process",
"node:process",
"child_process",
"node:child_process",
"worker_threads",
"node:worker_threads",
]);
function toNodeBuiltinSpecifier(specifier) {
return specifier.startsWith("node:") ? specifier : `node:${specifier}`;
}
function isDeniedBuiltin(specifier) {
const normalized = specifier.startsWith("node:") ? specifier.slice(5) : specifier;
return deniedBuiltinModules.has(specifier) || deniedBuiltinModules.has(normalized);
}
function resolveSpecifier(specifier) {
if (specifier.startsWith("node:") || builtinModuleSet.has(specifier)) {
if (isDeniedBuiltin(specifier)) {
throw new Error(`Importing module "${specifier}" is not allowed in js_repl`);
}
return { kind: "builtin", specifier: toNodeBuiltinSpecifier(specifier) };
}
if (specifier.startsWith("file:")) {
return { kind: "url", url: specifier };
}
if (specifier.startsWith("./") || specifier.startsWith("../") || path.isAbsolute(specifier)) {
return { kind: "path", path: path.resolve(process.cwd(), specifier) };
}
return { kind: "bare", specifier };
}
function importResolved(resolved) {
if (resolved.kind === "builtin") {
return import(resolved.specifier);
}
if (resolved.kind === "url") {
return import(resolved.url);
}
if (resolved.kind === "path") {
return import(pathToFileURL(resolved.path).href);
}
if (resolved.kind === "bare") {
return import(resolved.specifier);
}
throw new Error(`Unsupported module resolution kind: ${resolved.kind}`);
}
function collectPatternNames(pattern, kind, map) {
if (!pattern) return;
switch (pattern.type) {
case "Identifier":
if (!map.has(pattern.name)) map.set(pattern.name, kind);
return;
case "ObjectPattern":
for (const prop of pattern.properties ?? []) {
if (prop.type === "Property") {
collectPatternNames(prop.value, kind, map);
} else if (prop.type === "RestElement") {
collectPatternNames(prop.argument, kind, map);
}
}
return;
case "ArrayPattern":
for (const elem of pattern.elements ?? []) {
if (!elem) continue;
if (elem.type === "RestElement") {
collectPatternNames(elem.argument, kind, map);
} else {
collectPatternNames(elem, kind, map);
}
}
return;
case "AssignmentPattern":
collectPatternNames(pattern.left, kind, map);
return;
case "RestElement":
collectPatternNames(pattern.argument, kind, map);
return;
default:
return;
}
}
function collectBindings(ast) {
const map = new Map();
for (const stmt of ast.body ?? []) {
if (stmt.type === "VariableDeclaration") {
const kind = stmt.kind;
for (const decl of stmt.declarations) {
collectPatternNames(decl.id, kind, map);
}
} else if (stmt.type === "FunctionDeclaration" && stmt.id) {
map.set(stmt.id.name, "function");
} else if (stmt.type === "ClassDeclaration" && stmt.id) {
map.set(stmt.id.name, "class");
} else if (stmt.type === "ForStatement") {
if (stmt.init && stmt.init.type === "VariableDeclaration" && stmt.init.kind === "var") {
for (const decl of stmt.init.declarations) {
collectPatternNames(decl.id, "var", map);
}
}
} else if (stmt.type === "ForInStatement" || stmt.type === "ForOfStatement") {
if (stmt.left && stmt.left.type === "VariableDeclaration" && stmt.left.kind === "var") {
for (const decl of stmt.left.declarations) {
collectPatternNames(decl.id, "var", map);
}
}
}
}
return Array.from(map.entries()).map(([name, kind]) => ({ name, kind }));
}
async function buildModuleSource(code) {
const meriyah = await meriyahPromise;
const ast = meriyah.parseModule(code, {
next: true,
module: true,
ranges: false,
loc: false,
disableWebCompat: true,
});
const currentBindings = collectBindings(ast);
const priorBindings = previousModule ? previousBindings : [];
let prelude = "";
if (previousModule && priorBindings.length) {
// Recreate carried bindings before running user code in this new cell.
prelude += 'import * as __prev from "@prev";\n';
prelude += priorBindings
.map((b) => {
const keyword = b.kind === "var" ? "var" : b.kind === "const" ? "const" : "let";
return `${keyword} ${b.name} = __prev.${b.name};`;
})
.join("\n");
prelude += "\n";
}
const mergedBindings = new Map();
for (const binding of priorBindings) {
mergedBindings.set(binding.name, binding.kind);
}
for (const binding of currentBindings) {
mergedBindings.set(binding.name, binding.kind);
}
// Export the merged binding set so the next cell can import it through @prev.
const exportNames = Array.from(mergedBindings.keys());
const exportStmt = exportNames.length ? `\nexport { ${exportNames.join(", ")} };` : "";
const nextBindings = Array.from(mergedBindings, ([name, kind]) => ({ name, kind }));
return { source: `${prelude}${code}${exportStmt}`, nextBindings };
}
function send(message) {
process.stdout.write(JSON.stringify(message));
process.stdout.write("\n");
}
function formatLog(args) {
return args
.map((arg) => (typeof arg === "string" ? arg : inspect(arg, { depth: 4, colors: false })))
.join(" ");
}
function withCapturedConsole(ctx, fn) {
const logs = [];
const original = ctx.console ?? console;
const captured = {
...original,
log: (...args) => {
logs.push(formatLog(args));
},
info: (...args) => {
logs.push(formatLog(args));
},
warn: (...args) => {
logs.push(formatLog(args));
},
error: (...args) => {
logs.push(formatLog(args));
},
debug: (...args) => {
logs.push(formatLog(args));
},
};
ctx.console = captured;
return fn(logs).finally(() => {
ctx.console = original;
});
}
async function handleExec(message) {
try {
const code = typeof message.code === "string" ? message.code : "";
const { source, nextBindings } = await buildModuleSource(code);
let output = "";
await withCapturedConsole(context, async (logs) => {
const module = new SourceTextModule(source, {
context,
identifier: `cell-${cellCounter++}.mjs`,
initializeImportMeta(meta, mod) {
meta.url = `file://${mod.identifier}`;
},
importModuleDynamically(specifier) {
return importResolved(resolveSpecifier(specifier));
},
});
await module.link(async (specifier) => {
if (specifier === "@prev" && previousModule) {
const exportNames = previousBindings.map((b) => b.name);
// Build a synthetic module snapshot of the prior cell's exports.
// This is the bridge that carries values from cell N to cell N+1.
const synthetic = new SyntheticModule(
exportNames,
function initSynthetic() {
for (const binding of previousBindings) {
this.setExport(binding.name, previousModule.namespace[binding.name]);
}
},
{ context },
);
return synthetic;
}
const resolved = resolveSpecifier(specifier);
return importResolved(resolved);
});
await module.evaluate();
previousModule = module;
previousBindings = nextBindings;
output = logs.join("\n");
});
send({
type: "exec_result",
id: message.id,
ok: true,
output,
error: null,
});
} catch (error) {
send({
type: "exec_result",
id: message.id,
ok: false,
output: "",
error: error && error.message ? error.message : String(error),
});
}
}
let queue = Promise.resolve();
const input = createInterface({ input: process.stdin, crlfDelay: Infinity });
input.on("line", (line) => {
let message;
try {
message = JSON.parse(line);
} catch {
return;
}
if (message.type === "exec") {
queue = queue.then(() => handleExec(message));
}
});
File diff suppressed because one or more lines are too long
+754
View File
@@ -0,0 +1,754 @@
use std::collections::HashMap;
use std::fmt;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use codex_protocol::ThreadId;
use serde::Deserialize;
use serde::Serialize;
use tokio::io::AsyncBufReadExt;
use tokio::io::AsyncWriteExt;
use tokio::io::BufReader;
use tokio::process::Child;
use tokio::process::ChildStdin;
use tokio::sync::Mutex;
use tokio::sync::OnceCell;
use tokio_util::sync::CancellationToken;
use tracing::warn;
use uuid::Uuid;
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::exec::ExecExpiration;
use crate::exec_env::create_env;
use crate::function_tool::FunctionCallError;
use crate::sandboxing::CommandSpec;
use crate::sandboxing::SandboxManager;
use crate::sandboxing::SandboxPermissions;
use crate::tools::context::SharedTurnDiffTracker;
use crate::tools::sandboxing::SandboxablePreference;
pub(crate) const JS_REPL_PRAGMA_PREFIX: &str = "// codex-js-repl:";
const KERNEL_SOURCE: &str = include_str!("kernel.js");
const MERIYAH_UMD: &str = include_str!("meriyah.umd.min.js");
const JS_REPL_MIN_NODE_VERSION: &str = include_str!("../../../../node-version.txt");
/// Per-task js_repl handle stored on the turn context.
pub(crate) struct JsReplHandle {
node_path: Option<PathBuf>,
codex_home: PathBuf,
cell: OnceCell<Arc<JsReplManager>>,
}
impl fmt::Debug for JsReplHandle {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("JsReplHandle").finish_non_exhaustive()
}
}
impl JsReplHandle {
pub(crate) fn with_node_path(node_path: Option<PathBuf>, codex_home: PathBuf) -> Self {
Self {
node_path,
codex_home,
cell: OnceCell::new(),
}
}
pub(crate) async fn manager(&self) -> Result<Arc<JsReplManager>, FunctionCallError> {
self.cell
.get_or_try_init(|| async {
JsReplManager::new(self.node_path.clone(), self.codex_home.clone()).await
})
.await
.cloned()
}
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct JsReplArgs {
pub code: String,
#[serde(default)]
pub timeout_ms: Option<u64>,
}
#[derive(Clone, Debug)]
pub struct JsExecResult {
pub output: String,
}
struct KernelState {
_child: Child,
stdin: Arc<Mutex<ChildStdin>>,
pending_execs: Arc<Mutex<HashMap<String, tokio::sync::oneshot::Sender<ExecResultMessage>>>>,
shutdown: CancellationToken,
}
pub struct JsReplManager {
node_path: Option<PathBuf>,
codex_home: PathBuf,
tmp_dir: tempfile::TempDir,
kernel: Mutex<Option<KernelState>>,
exec_lock: Arc<tokio::sync::Semaphore>,
}
impl JsReplManager {
async fn new(
node_path: Option<PathBuf>,
codex_home: PathBuf,
) -> Result<Arc<Self>, FunctionCallError> {
let tmp_dir = tempfile::tempdir().map_err(|err| {
FunctionCallError::RespondToModel(format!("failed to create js_repl temp dir: {err}"))
})?;
let manager = Arc::new(Self {
node_path,
codex_home,
tmp_dir,
kernel: Mutex::new(None),
exec_lock: Arc::new(tokio::sync::Semaphore::new(1)),
});
Ok(manager)
}
pub async fn reset(&self) -> Result<(), FunctionCallError> {
self.reset_kernel().await;
Ok(())
}
async fn reset_kernel(&self) {
let state = {
let mut guard = self.kernel.lock().await;
guard.take()
};
if let Some(state) = state {
state.shutdown.cancel();
}
}
pub async fn execute(
&self,
session: Arc<Session>,
turn: Arc<TurnContext>,
_tracker: SharedTurnDiffTracker,
args: JsReplArgs,
) -> Result<JsExecResult, FunctionCallError> {
let _permit = self.exec_lock.clone().acquire_owned().await.map_err(|_| {
FunctionCallError::RespondToModel("js_repl execution unavailable".to_string())
})?;
let (stdin, pending_execs) = {
let mut kernel = self.kernel.lock().await;
if kernel.is_none() {
let state = self
.start_kernel(Arc::clone(&turn), Some(session.conversation_id))
.await
.map_err(FunctionCallError::RespondToModel)?;
*kernel = Some(state);
}
let state = match kernel.as_ref() {
Some(state) => state,
None => {
return Err(FunctionCallError::RespondToModel(
"js_repl kernel unavailable".to_string(),
));
}
};
(Arc::clone(&state.stdin), Arc::clone(&state.pending_execs))
};
let (req_id, rx) = {
let req_id = Uuid::new_v4().to_string();
let mut pending = pending_execs.lock().await;
let (tx, rx) = tokio::sync::oneshot::channel();
pending.insert(req_id.clone(), tx);
(req_id, rx)
};
let payload = HostToKernel::Exec {
id: req_id.clone(),
code: args.code,
timeout_ms: args.timeout_ms,
};
Self::write_message(&stdin, &payload).await?;
let timeout_ms = args.timeout_ms.unwrap_or(30_000);
let response = match tokio::time::timeout(Duration::from_millis(timeout_ms), rx).await {
Ok(Ok(msg)) => msg,
Ok(Err(_)) => {
let mut pending = pending_execs.lock().await;
pending.remove(&req_id);
return Err(FunctionCallError::RespondToModel(
"js_repl kernel closed unexpectedly".to_string(),
));
}
Err(_) => {
self.reset().await?;
return Err(FunctionCallError::RespondToModel(
"js_repl execution timed out; kernel reset, rerun your request".to_string(),
));
}
};
match response {
ExecResultMessage::Ok { output } => Ok(JsExecResult { output }),
ExecResultMessage::Err { message } => Err(FunctionCallError::RespondToModel(message)),
}
}
async fn start_kernel(
&self,
turn: Arc<TurnContext>,
thread_id: Option<ThreadId>,
) -> Result<KernelState, String> {
let node_path = resolve_node(self.node_path.as_deref()).ok_or_else(|| {
"Node runtime not found; install Node or set CODEX_JS_REPL_NODE_PATH".to_string()
})?;
ensure_node_version(&node_path).await?;
let kernel_path = self
.write_kernel_script()
.await
.map_err(|err| err.to_string())?;
let mut env = create_env(&turn.shell_environment_policy, thread_id);
env.insert(
"CODEX_JS_TMP_DIR".to_string(),
self.tmp_dir.path().to_string_lossy().to_string(),
);
env.insert(
"CODEX_JS_REPL_HOME".to_string(),
self.codex_home
.join("js_repl")
.to_string_lossy()
.to_string(),
);
let spec = CommandSpec {
program: node_path.to_string_lossy().to_string(),
args: vec![
"--experimental-vm-modules".to_string(),
kernel_path.to_string_lossy().to_string(),
],
cwd: turn.cwd.clone(),
env,
expiration: ExecExpiration::DefaultTimeout,
sandbox_permissions: SandboxPermissions::UseDefault,
justification: None,
};
let sandbox = SandboxManager::new();
let has_managed_network_requirements = turn
.config
.config_layer_stack
.requirements_toml()
.network
.is_some();
let sandbox_type = sandbox.select_initial(
&turn.sandbox_policy,
SandboxablePreference::Auto,
turn.windows_sandbox_level,
has_managed_network_requirements,
);
let exec_env = sandbox
.transform(crate::sandboxing::SandboxTransformRequest {
spec,
policy: &turn.sandbox_policy,
sandbox: sandbox_type,
enforce_managed_network: has_managed_network_requirements,
network: None,
sandbox_policy_cwd: &turn.cwd,
codex_linux_sandbox_exe: turn.codex_linux_sandbox_exe.as_ref(),
use_linux_sandbox_bwrap: turn
.features
.enabled(crate::features::Feature::UseLinuxSandboxBwrap),
windows_sandbox_level: turn.windows_sandbox_level,
})
.map_err(|err| format!("failed to configure sandbox for js_repl: {err}"))?;
let mut cmd =
tokio::process::Command::new(exec_env.command.first().cloned().unwrap_or_default());
if exec_env.command.len() > 1 {
cmd.args(&exec_env.command[1..]);
}
#[cfg(unix)]
cmd.arg0(
exec_env
.arg0
.clone()
.unwrap_or_else(|| exec_env.command.first().cloned().unwrap_or_default()),
);
cmd.current_dir(&exec_env.cwd);
cmd.env_clear();
cmd.envs(exec_env.env);
cmd.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true);
let mut child = cmd
.spawn()
.map_err(|err| format!("failed to start Node runtime: {err}"))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| "js_repl kernel missing stdout".to_string())?;
let stderr = child.stderr.take();
let stdin = child
.stdin
.take()
.ok_or_else(|| "js_repl kernel missing stdin".to_string())?;
let shutdown = CancellationToken::new();
let pending_execs: Arc<
Mutex<HashMap<String, tokio::sync::oneshot::Sender<ExecResultMessage>>>,
> = Arc::new(Mutex::new(HashMap::new()));
let stdin_arc = Arc::new(Mutex::new(stdin));
tokio::spawn(Self::read_stdout(
stdout,
Arc::clone(&pending_execs),
shutdown.clone(),
));
if let Some(stderr) = stderr {
tokio::spawn(Self::read_stderr(stderr, shutdown.clone()));
} else {
warn!("js_repl kernel missing stderr");
}
Ok(KernelState {
_child: child,
stdin: stdin_arc,
pending_execs,
shutdown,
})
}
async fn write_kernel_script(&self) -> Result<PathBuf, std::io::Error> {
let dir = self.tmp_dir.path();
let kernel_path = dir.join("js_repl_kernel.js");
let meriyah_path = dir.join("meriyah.umd.min.js");
tokio::fs::write(&kernel_path, KERNEL_SOURCE).await?;
tokio::fs::write(&meriyah_path, MERIYAH_UMD).await?;
Ok(kernel_path)
}
async fn write_message(
stdin: &Arc<Mutex<ChildStdin>>,
msg: &HostToKernel,
) -> Result<(), FunctionCallError> {
let encoded = serde_json::to_string(msg).map_err(|err| {
FunctionCallError::RespondToModel(format!("failed to serialize kernel message: {err}"))
})?;
let mut guard = stdin.lock().await;
guard.write_all(encoded.as_bytes()).await.map_err(|err| {
FunctionCallError::RespondToModel(format!("failed to write to kernel: {err}"))
})?;
guard.write_all(b"\n").await.map_err(|err| {
FunctionCallError::RespondToModel(format!("failed to flush kernel message: {err}"))
})?;
Ok(())
}
async fn read_stdout(
stdout: tokio::process::ChildStdout,
pending_execs: Arc<Mutex<HashMap<String, tokio::sync::oneshot::Sender<ExecResultMessage>>>>,
shutdown: CancellationToken,
) {
let mut reader = BufReader::new(stdout).lines();
loop {
let line = tokio::select! {
_ = shutdown.cancelled() => break,
res = reader.next_line() => match res {
Ok(Some(line)) => line,
Ok(None) => break,
Err(err) => {
warn!("js_repl kernel stream ended: {err}");
break;
}
},
};
let parsed: Result<KernelToHost, _> = serde_json::from_str(&line);
let msg = match parsed {
Ok(m) => m,
Err(err) => {
warn!("js_repl kernel sent invalid json: {err} (line: {line})");
continue;
}
};
let KernelToHost::ExecResult {
id,
ok,
output,
error,
} = msg;
let mut pending = pending_execs.lock().await;
if let Some(tx) = pending.remove(&id) {
let payload = if ok {
ExecResultMessage::Ok { output }
} else {
ExecResultMessage::Err {
message: error.unwrap_or_else(|| "js_repl execution failed".to_string()),
}
};
let _ = tx.send(payload);
}
}
let mut pending = pending_execs.lock().await;
for (_id, tx) in pending.drain() {
let _ = tx.send(ExecResultMessage::Err {
message: "js_repl kernel exited unexpectedly".to_string(),
});
}
}
async fn read_stderr(stderr: tokio::process::ChildStderr, shutdown: CancellationToken) {
let mut reader = BufReader::new(stderr).lines();
loop {
let line = tokio::select! {
_ = shutdown.cancelled() => break,
res = reader.next_line() => match res {
Ok(Some(line)) => line,
Ok(None) => break,
Err(err) => {
warn!("js_repl kernel stderr ended: {err}");
break;
}
},
};
let trimmed = line.trim();
if !trimmed.is_empty() {
warn!("js_repl stderr: {trimmed}");
}
}
}
}
#[derive(Clone, Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum KernelToHost {
ExecResult {
id: String,
ok: bool,
output: String,
#[serde(default)]
error: Option<String>,
},
}
#[derive(Clone, Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum HostToKernel {
Exec {
id: String,
code: String,
#[serde(default)]
timeout_ms: Option<u64>,
},
}
#[derive(Debug)]
enum ExecResultMessage {
Ok { output: String },
Err { message: String },
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
struct NodeVersion {
major: u64,
minor: u64,
patch: u64,
}
impl fmt::Display for NodeVersion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
}
}
impl NodeVersion {
fn parse(input: &str) -> Result<Self, String> {
let trimmed = input.trim().trim_start_matches('v');
let mut parts = trimmed.split(['.', '-', '+']);
let major = parts
.next()
.ok_or_else(|| "missing major version".to_string())?
.parse::<u64>()
.map_err(|err| format!("invalid major version: {err}"))?;
let minor = parts
.next()
.ok_or_else(|| "missing minor version".to_string())?
.parse::<u64>()
.map_err(|err| format!("invalid minor version: {err}"))?;
let patch = parts
.next()
.ok_or_else(|| "missing patch version".to_string())?
.parse::<u64>()
.map_err(|err| format!("invalid patch version: {err}"))?;
Ok(Self {
major,
minor,
patch,
})
}
}
fn required_node_version() -> Result<NodeVersion, String> {
NodeVersion::parse(JS_REPL_MIN_NODE_VERSION)
}
async fn read_node_version(node_path: &Path) -> Result<NodeVersion, String> {
let output = tokio::process::Command::new(node_path)
.arg("--version")
.output()
.await
.map_err(|err| format!("failed to execute Node: {err}"))?;
if !output.status.success() {
let mut details = String::new();
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = stdout.trim();
let stderr = stderr.trim();
if !stdout.is_empty() {
details.push_str(" stdout: ");
details.push_str(stdout);
}
if !stderr.is_empty() {
details.push_str(" stderr: ");
details.push_str(stderr);
}
let details = if details.is_empty() {
String::new()
} else {
format!(" ({details})")
};
return Err(format!(
"failed to read Node version (status {status}){details}",
status = output.status
));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let stdout = stdout.trim();
NodeVersion::parse(stdout)
.map_err(|err| format!("failed to parse Node version output `{stdout}`: {err}"))
}
async fn ensure_node_version(node_path: &Path) -> Result<(), String> {
let required = required_node_version()?;
let found = read_node_version(node_path).await?;
if found < required {
return Err(format!(
"Node runtime too old for js_repl (resolved {node_path}): found v{found}, requires >= v{required}. Install/update Node or set js_repl_node_path to a newer runtime.",
node_path = node_path.display()
));
}
Ok(())
}
pub(crate) fn resolve_node(config_path: Option<&Path>) -> Option<PathBuf> {
if let Some(path) = std::env::var_os("CODEX_JS_REPL_NODE_PATH") {
let p = PathBuf::from(path);
if p.exists() {
return Some(p);
}
}
if let Some(path) = config_path
&& path.exists()
{
return Some(path.to_path_buf());
}
if let Ok(path) = which::which("node") {
return Some(path);
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::codex::make_session_and_context;
use crate::turn_diff_tracker::TurnDiffTracker;
use pretty_assertions::assert_eq;
#[test]
fn node_version_parses_v_prefix_and_suffix() {
let version = NodeVersion::parse("v25.1.0-nightly.2024").unwrap();
assert_eq!(
version,
NodeVersion {
major: 25,
minor: 1,
patch: 0,
}
);
}
async fn can_run_js_repl_runtime_tests() -> bool {
if std::env::var_os("CODEX_SANDBOX").is_some() {
return false;
}
let Some(node_path) = resolve_node(None) else {
return false;
};
let required = match required_node_version() {
Ok(v) => v,
Err(_) => return false,
};
let found = match read_node_version(&node_path).await {
Ok(v) => v,
Err(_) => return false,
};
found >= required
}
#[tokio::test]
async fn js_repl_persists_top_level_bindings_and_supports_tla() -> anyhow::Result<()> {
if !can_run_js_repl_runtime_tests().await {
return Ok(());
}
let (session, turn) = make_session_and_context().await;
let session = Arc::new(session);
let turn = Arc::new(turn);
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default()));
let manager = turn.js_repl.manager().await?;
let first = manager
.execute(
Arc::clone(&session),
Arc::clone(&turn),
Arc::clone(&tracker),
JsReplArgs {
code: "let x = await Promise.resolve(41); console.log(x);".to_string(),
timeout_ms: Some(10_000),
},
)
.await?;
assert!(first.output.contains("41"));
let second = manager
.execute(
Arc::clone(&session),
Arc::clone(&turn),
Arc::clone(&tracker),
JsReplArgs {
code: "console.log(x + 1);".to_string(),
timeout_ms: Some(10_000),
},
)
.await?;
assert!(second.output.contains("42"));
Ok(())
}
#[tokio::test]
async fn js_repl_timeout_does_not_deadlock() -> anyhow::Result<()> {
if !can_run_js_repl_runtime_tests().await {
return Ok(());
}
let (session, turn) = make_session_and_context().await;
let session = Arc::new(session);
let turn = Arc::new(turn);
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default()));
let manager = turn.js_repl.manager().await?;
let result = tokio::time::timeout(
Duration::from_secs(3),
manager.execute(
session,
turn,
tracker,
JsReplArgs {
code: "while (true) {}".to_string(),
timeout_ms: Some(50),
},
),
)
.await
.expect("execute should return, not deadlock")
.expect_err("expected timeout error");
assert_eq!(
result.to_string(),
"js_repl execution timed out; kernel reset, rerun your request"
);
Ok(())
}
#[tokio::test]
async fn js_repl_does_not_expose_process_global() -> anyhow::Result<()> {
if !can_run_js_repl_runtime_tests().await {
return Ok(());
}
let (session, turn) = make_session_and_context().await;
let session = Arc::new(session);
let turn = Arc::new(turn);
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default()));
let manager = turn.js_repl.manager().await?;
let result = manager
.execute(
session,
turn,
tracker,
JsReplArgs {
code: "console.log(typeof process);".to_string(),
timeout_ms: Some(10_000),
},
)
.await?;
assert!(result.output.contains("undefined"));
Ok(())
}
#[tokio::test]
async fn js_repl_blocks_sensitive_builtin_imports() -> anyhow::Result<()> {
if !can_run_js_repl_runtime_tests().await {
return Ok(());
}
let (session, turn) = make_session_and_context().await;
let session = Arc::new(session);
let turn = Arc::new(turn);
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default()));
let manager = turn.js_repl.manager().await?;
let err = manager
.execute(
session,
turn,
tracker,
JsReplArgs {
code: "await import(\"node:process\");".to_string(),
timeout_ms: Some(10_000),
},
)
.await
.expect_err("node:process import should be blocked");
assert!(
err.to_string()
.contains("Importing module \"node:process\" is not allowed in js_repl")
);
Ok(())
}
}
+1
View File
@@ -1,6 +1,7 @@
pub mod context;
pub mod events;
pub(crate) mod handlers;
pub mod js_repl;
pub mod orchestrator;
pub mod parallel;
pub mod registry;
+91
View File
@@ -1,4 +1,6 @@
use crate::agent::AgentRole;
use crate::client_common::tools::FreeformTool;
use crate::client_common::tools::FreeformToolFormat;
use crate::client_common::tools::ResponsesApiTool;
use crate::client_common::tools::ToolSpec;
use crate::features::Feature;
@@ -31,6 +33,7 @@ pub(crate) struct ToolsConfig {
pub apply_patch_tool_type: Option<ApplyPatchToolType>,
pub web_search_mode: Option<WebSearchMode>,
pub search_tool: bool,
pub js_repl_enabled: bool,
pub collab_tools: bool,
pub collaboration_modes_tools: bool,
pub request_rule_enabled: bool,
@@ -51,6 +54,7 @@ impl ToolsConfig {
web_search_mode,
} = params;
let include_apply_patch_tool = features.enabled(Feature::ApplyPatchFreeform);
let include_js_repl = features.enabled(Feature::JsRepl);
let include_collab_tools = features.enabled(Feature::Collab);
let include_collaboration_modes_tools = features.enabled(Feature::CollaborationModes);
let request_rule_enabled = features.enabled(Feature::RequestRule);
@@ -86,6 +90,7 @@ impl ToolsConfig {
apply_patch_tool_type,
web_search_mode: *web_search_mode,
search_tool: include_search_tool,
js_repl_enabled: include_js_repl,
collab_tools: include_collab_tools,
collaboration_modes_tools: include_collaboration_modes_tools,
request_rule_enabled,
@@ -94,6 +99,10 @@ impl ToolsConfig {
}
}
pub(crate) fn filter_tools_for_model(tools: Vec<ToolSpec>, _config: &ToolsConfig) -> Vec<ToolSpec> {
tools
}
/// Generic JSONSchema subset needed for our tool definitions
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "lowercase")]
@@ -1039,6 +1048,36 @@ fn create_list_dir_tool() -> ToolSpec {
})
}
fn create_js_repl_tool() -> ToolSpec {
const JS_REPL_FREEFORM_GRAMMAR: &str = r#"start: /[\s\S]*/"#;
ToolSpec::Freeform(FreeformTool {
name: "js_repl".to_string(),
description: "Runs JavaScript in a persistent Node kernel with top-level await. This is a freeform tool: send raw JavaScript source text, optionally with a first-line pragma like `// codex-js-repl: timeout_ms=15000`; do not send JSON/quotes/markdown fences."
.to_string(),
format: FreeformToolFormat {
r#type: "grammar".to_string(),
syntax: "lark".to_string(),
definition: JS_REPL_FREEFORM_GRAMMAR.to_string(),
},
})
}
fn create_js_repl_reset_tool() -> ToolSpec {
ToolSpec::Function(ResponsesApiTool {
name: "js_repl_reset".to_string(),
description:
"Restarts the js_repl kernel for this run and clears persisted top-level bindings."
.to_string(),
strict: false,
parameters: JsonSchema::Object {
properties: BTreeMap::new(),
required: None,
additional_properties: Some(false.into()),
},
})
}
fn create_list_mcp_resources_tool() -> ToolSpec {
let properties = BTreeMap::from([
(
@@ -1346,6 +1385,8 @@ pub(crate) fn build_specs(
use crate::tools::handlers::CollabHandler;
use crate::tools::handlers::DynamicToolHandler;
use crate::tools::handlers::GrepFilesHandler;
use crate::tools::handlers::JsReplHandler;
use crate::tools::handlers::JsReplResetHandler;
use crate::tools::handlers::ListDirHandler;
use crate::tools::handlers::McpHandler;
use crate::tools::handlers::McpResourceHandler;
@@ -1373,6 +1414,8 @@ pub(crate) fn build_specs(
let shell_command_handler = Arc::new(ShellCommandHandler);
let request_user_input_handler = Arc::new(RequestUserInputHandler);
let search_tool_handler = Arc::new(SearchToolBm25Handler);
let js_repl_handler = Arc::new(JsReplHandler);
let js_repl_reset_handler = Arc::new(JsReplResetHandler);
match &config.shell_type {
ConfigShellToolType::Default => {
@@ -1422,6 +1465,13 @@ pub(crate) fn build_specs(
builder.push_spec(PLAN_TOOL.clone());
builder.register_handler("update_plan", plan_handler);
if config.js_repl_enabled {
builder.push_spec(create_js_repl_tool());
builder.push_spec(create_js_repl_reset_tool());
builder.register_handler("js_repl", js_repl_handler);
builder.register_handler("js_repl_reset", js_repl_reset_handler);
}
if config.collaboration_modes_tools {
builder.push_spec(create_request_user_input_tool());
builder.register_handler("request_user_input", request_user_input_handler);
@@ -1817,6 +1867,47 @@ mod tests {
assert_contains_tool_names(&tools, &["request_user_input"]);
}
#[test]
fn js_repl_requires_feature_flag() {
let config = test_config();
let model_info =
ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
let features = Features::with_defaults();
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
});
let (tools, _) = build_specs(&tools_config, None, &[]).build();
assert!(
!tools.iter().any(|tool| tool.spec.name() == "js_repl"),
"js_repl should be disabled when the feature is off"
);
assert!(
!tools.iter().any(|tool| tool.spec.name() == "js_repl_reset"),
"js_repl_reset should be disabled when the feature is off"
);
}
#[test]
fn js_repl_enabled_adds_tools() {
let config = test_config();
let model_info =
ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
let mut features = Features::with_defaults();
features.enable(Feature::JsRepl);
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
});
let (tools, _) = build_specs(&tools_config, None, &[]).build();
assert_contains_tool_names(&tools, &["js_repl", "js_repl_reset"]);
}
fn assert_model_tools(
model_slug: &str,
features: &Features,