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
parent 87279de434
commit 42e22f3bde
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1611 additions and 5 deletions

View file

@ -1,6 +1,6 @@
[codespell]
# Ref: https://github.com/codespell-project/codespell#using-a-config-file
skip = .git*,vendor,*-lock.yaml,*.lock,.codespellrc,*test.ts,*.jsonl,frame*.txt,*.snap,*.snap.new
skip = .git*,vendor,*-lock.yaml,*.lock,.codespellrc,*test.ts,*.jsonl,frame*.txt,*.snap,*.snap.new,*meriyah.umd.min.js
check-hidden = true
ignore-regex = ^\s*"image/\S+": ".*|\b(afterAll)\b
ignore-words-list = ratatui,ser,iTerm,iterm2,iterm

3
NOTICE
View file

@ -4,3 +4,6 @@ Copyright 2025 OpenAI
This project includes code derived from [Ratatui](https://github.com/ratatui/ratatui), licensed under the MIT license.
Copyright (c) 2016-2022 Florian Dehau
Copyright (c) 2023-2025 The Ratatui Developers
This project includes Meriyah parser assets from [meriyah](https://github.com/meriyah/meriyah), licensed under the ISC license.
Copyright (c) 2019 and later, KFlash and others.

View file

@ -1 +1,3 @@
exports_files([
"node-version.txt",
])

View file

@ -11,7 +11,9 @@ codex_rust_crate(
"Cargo.toml",
],
allow_empty = True,
),
) + [
"//codex-rs:node-version.txt",
],
integration_compile_data_extra = [
"//codex-rs/apply-patch:apply_patch_tool_instructions.md",
"models.json",

View file

@ -221,6 +221,9 @@
"include_apply_patch_tool": {
"type": "boolean"
},
"js_repl": {
"type": "boolean"
},
"memory_tool": {
"type": "boolean"
},
@ -290,6 +293,9 @@
"include_apply_patch_tool": {
"type": "boolean"
},
"js_repl_node_path": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"model": {
"type": "string"
},
@ -1299,6 +1305,9 @@
"include_apply_patch_tool": {
"type": "boolean"
},
"js_repl": {
"type": "boolean"
},
"memory_tool": {
"type": "boolean"
},
@ -1421,6 +1430,14 @@
"description": "System instructions.",
"type": "string"
},
"js_repl_node_path": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "Optional absolute path to the Node runtime used by `js_repl`."
},
"log_dir": {
"allOf": [
{

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),
});

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),

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>,

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",

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]

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."
);
}
}

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;

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

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(())
}
}

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;

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,

View file

@ -249,6 +249,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
cwd: resolved_cwd,
model_provider: model_provider.clone(),
codex_linux_sandbox_exe,
js_repl_node_path: None,
base_instructions: None,
developer_instructions: None,
personality: None,

View file

@ -0,0 +1 @@
24.13.1

71
docs/js_repl.md Normal file
View file

@ -0,0 +1,71 @@
# JavaScript REPL (`js_repl`)
`js_repl` runs JavaScript in a persistent Node-backed kernel with top-level `await`.
## Feature gate
`js_repl` is disabled by default and only appears when:
```toml
[features]
js_repl = true
```
## Node runtime
`js_repl` requires a Node version that meets or exceeds `codex-rs/node-version.txt`.
Runtime resolution order:
1. `CODEX_JS_REPL_NODE_PATH` environment variable
2. `js_repl_node_path` in config/profile
3. `node` discovered on `PATH`
You can configure an explicit runtime path:
```toml
js_repl_node_path = "/absolute/path/to/node"
```
## Usage
- `js_repl` is a freeform tool: send raw JavaScript source text.
- Optional first-line pragma:
- `// codex-js-repl: timeout_ms=15000`
- Top-level bindings persist across calls.
- Top-level static import declarations (for example `import x from "pkg"`) are currently unsupported; use dynamic imports with `await import("pkg")`.
- Use `js_repl_reset` to clear kernel state.
## Vendored parser asset (`meriyah.umd.min.js`)
The kernel embeds a vendored Meriyah bundle at:
- `codex-rs/core/src/tools/js_repl/meriyah.umd.min.js`
Current source is `meriyah@7.0.0` from npm (`dist/meriyah.umd.min.js`).
Licensing is tracked in:
- `third_party/meriyah/LICENSE`
- `NOTICE`
### How this file was sourced
From a clean temp directory:
```sh
tmp="$(mktemp -d)"
cd "$tmp"
npm pack meriyah@7.0.0
tar -xzf meriyah-7.0.0.tgz
cp package/dist/meriyah.umd.min.js /path/to/repo/codex-rs/core/src/tools/js_repl/meriyah.umd.min.js
cp package/LICENSE.md /path/to/repo/third_party/meriyah/LICENSE
```
### How to update to a newer version
1. Replace `7.0.0` in the commands above with the target version.
2. Copy the new `dist/meriyah.umd.min.js` into `codex-rs/core/src/tools/js_repl/meriyah.umd.min.js`.
3. Copy the package license into `third_party/meriyah/LICENSE`.
4. Update the version string in the header comment at the top of `meriyah.umd.min.js`.
5. Update `NOTICE` if the upstream copyright notice changed.
6. Run the relevant `js_repl` tests.

15
third_party/meriyah/LICENSE vendored Normal file
View file

@ -0,0 +1,15 @@
ISC License
Copyright (c) 2019 and later, KFlash and others.
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THIS SOFTWARE.