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
21 changed files with 1611 additions and 5 deletions
+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,