Compare commits

...

5 Commits

Author SHA1 Message Date
Curtis 'Fjord' Hawthorne
e89d3a8213 Add optional js_repl isolation and vendored Node runtime 2026-02-04 11:48:59 -08:00
Curtis 'Fjord' Hawthorne
2f83b5dbd5 Add feature-gated js_repl polling flow 2026-02-04 11:48:59 -08:00
Curtis 'Fjord' Hawthorne
1d005f8cc6 Add js_repl_tools_only model and routing restrictions 2026-02-04 11:48:59 -08:00
Curtis 'Fjord' Hawthorne
b261121254 Add js_repl host helpers and exec end events 2026-02-04 11:48:59 -08:00
Curtis 'Fjord' Hawthorne
b85b404622 Add feature-gated freeform js_repl core runtime 2026-02-04 11:48:54 -08:00
25 changed files with 3984 additions and 14 deletions

View File

@@ -16,7 +16,7 @@ RESPONSES_API_PROXY_NPM_ROOT = REPO_ROOT / "codex-rs" / "responses-api-proxy" /
CODEX_SDK_ROOT = REPO_ROOT / "sdk" / "typescript"
PACKAGE_NATIVE_COMPONENTS: dict[str, list[str]] = {
"codex": ["codex", "rg"],
"codex": ["codex", "rg", "node"],
"codex-responses-api-proxy": ["codex-responses-api-proxy"],
"codex-sdk": ["codex"],
}
@@ -29,6 +29,7 @@ COMPONENT_DEST_DIR: dict[str, str] = {
"codex-windows-sandbox-setup": "codex",
"codex-command-runner": "codex",
"rg": "path",
"node": "node",
}

View File

@@ -23,6 +23,7 @@ CODEX_CLI_ROOT = SCRIPT_DIR.parent
DEFAULT_WORKFLOW_URL = "https://github.com/openai/codex/actions/runs/17952349351" # rust-v0.40.0
VENDOR_DIR_NAME = "vendor"
RG_MANIFEST = CODEX_CLI_ROOT / "bin" / "rg"
NODE_VERSION_FILE = CODEX_CLI_ROOT.parent / "codex-rs" / "node-version.txt"
BINARY_TARGETS = (
"x86_64-unknown-linux-musl",
"aarch64-unknown-linux-musl",
@@ -79,6 +80,41 @@ RG_TARGET_PLATFORM_PAIRS: list[tuple[str, str]] = [
RG_TARGET_TO_PLATFORM = {target: platform for target, platform in RG_TARGET_PLATFORM_PAIRS}
DEFAULT_RG_TARGETS = [target for target, _ in RG_TARGET_PLATFORM_PAIRS]
# Node distribution asset mapping: target -> (archive_format, filename, member_path)
NODE_ASSETS: dict[str, tuple[str, str, str]] = {
"x86_64-apple-darwin": (
"tar.gz",
"node-v{ver}-darwin-x64.tar.gz",
"node-v{ver}-darwin-x64/bin/node",
),
"aarch64-apple-darwin": (
"tar.gz",
"node-v{ver}-darwin-arm64.tar.gz",
"node-v{ver}-darwin-arm64/bin/node",
),
"x86_64-unknown-linux-musl": (
"tar.xz",
"node-v{ver}-linux-x64.tar.xz",
"node-v{ver}-linux-x64/bin/node",
),
"aarch64-unknown-linux-musl": (
"tar.xz",
"node-v{ver}-linux-arm64.tar.xz",
"node-v{ver}-linux-arm64/bin/node",
),
"x86_64-pc-windows-msvc": (
"zip",
"node-v{ver}-win-x64.zip",
"node-v{ver}-win-x64/node.exe",
),
"aarch64-pc-windows-msvc": (
"zip",
"node-v{ver}-win-arm64.zip",
"node-v{ver}-win-arm64/node.exe",
),
}
DEFAULT_NODE_TARGETS = tuple(NODE_ASSETS.keys())
# urllib.request.urlopen() defaults to no timeout (can hang indefinitely), which is painful in CI.
DOWNLOAD_TIMEOUT_SECS = 60
@@ -132,11 +168,11 @@ def parse_args() -> argparse.Namespace:
"--component",
dest="components",
action="append",
choices=tuple(list(BINARY_COMPONENTS) + ["rg"]),
choices=tuple(list(BINARY_COMPONENTS) + ["rg", "node"]),
help=(
"Limit installation to the specified components."
" May be repeated. Defaults to codex, codex-windows-sandbox-setup,"
" codex-command-runner, and rg."
" codex-command-runner, node, and rg."
),
)
parser.add_argument(
@@ -163,6 +199,7 @@ def main() -> int:
"codex-windows-sandbox-setup",
"codex-command-runner",
"rg",
"node",
]
workflow_url = (args.workflow_url or DEFAULT_WORKFLOW_URL).strip()
@@ -187,6 +224,11 @@ def main() -> int:
print("Fetching ripgrep binaries...")
fetch_rg(vendor_dir, DEFAULT_RG_TARGETS, manifest_path=RG_MANIFEST)
if "node" in components:
with _gha_group("Fetch Node runtime"):
print("Fetching Node runtime...")
fetch_node(vendor_dir, load_node_version(), DEFAULT_NODE_TARGETS)
print(f"Installed native dependencies into {vendor_dir}")
return 0
@@ -259,6 +301,41 @@ def fetch_rg(
return [results[target] for target in targets]
def fetch_node(vendor_dir: Path, version: str, targets: Sequence[str]) -> None:
version = version.strip().lstrip("v")
vendor_dir.mkdir(parents=True, exist_ok=True)
for target in targets:
asset = NODE_ASSETS.get(target)
if asset is None:
raise RuntimeError(f"unsupported Node target {target}")
archive_format, filename_tmpl, member_tmpl = asset
filename = filename_tmpl.format(ver=version)
member = member_tmpl.format(ver=version)
url = f"https://nodejs.org/dist/v{version}/{filename}"
dest_dir = vendor_dir / target / "node"
dest_dir.mkdir(parents=True, exist_ok=True)
binary_name = "node.exe" if "windows" in target else "node"
dest = dest_dir / binary_name
with tempfile.TemporaryDirectory() as tmp_dir_str:
tmp_dir = Path(tmp_dir_str)
download_path = tmp_dir / filename
print(f" downloading node {version} for {target} from {url}", flush=True)
_download_file(url, download_path)
dest.unlink(missing_ok=True)
extract_archive(download_path, archive_format, member, dest)
if "windows" not in target:
dest.chmod(0o755)
def load_node_version() -> str:
try:
return NODE_VERSION_FILE.read_text(encoding="utf-8").strip()
except OSError as exc:
raise RuntimeError(f"failed to read node version from {NODE_VERSION_FILE}: {exc}") from exc
def _download_artifacts(workflow_id: str, dest_dir: Path) -> None:
cmd = [
"gh",
@@ -437,6 +514,21 @@ def extract_archive(
shutil.move(str(extracted), dest)
return
if archive_format == "tar.xz":
if not archive_member:
raise RuntimeError("Missing 'path' for tar.xz archive in archive.")
with tarfile.open(archive_path, "r:xz") as tar:
try:
member = tar.getmember(archive_member)
except KeyError as exc:
raise RuntimeError(
f"Entry '{archive_member}' not found in archive {archive_path}."
) from exc
tar.extract(member, path=archive_path.parent, filter="data")
extracted = archive_path.parent / archive_member
shutil.move(str(extracted), dest)
return
if archive_format == "zip":
if not archive_member:
raise RuntimeError("Missing 'path' for zip archive in DotSlash manifest.")

View File

@@ -190,6 +190,15 @@
"include_apply_patch_tool": {
"type": "boolean"
},
"js_repl": {
"type": "boolean"
},
"js_repl_polling": {
"type": "boolean"
},
"js_repl_tools_only": {
"type": "boolean"
},
"personality": {
"type": "boolean"
},
@@ -250,6 +259,9 @@
"include_apply_patch_tool": {
"type": "boolean"
},
"js_repl_node_path": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"model": {
"type": "string"
},
@@ -1208,6 +1220,15 @@
"include_apply_patch_tool": {
"type": "boolean"
},
"js_repl": {
"type": "boolean"
},
"js_repl_polling": {
"type": "boolean"
},
"js_repl_tools_only": {
"type": "boolean"
},
"personality": {
"type": "boolean"
},
@@ -1321,6 +1342,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`."
},
"mcp_oauth_callback_port": {
"description": "Optional fixed port for the local HTTP callback server used during MCP OAuth login. When unset, Codex will bind to an ephemeral port chosen by the OS.",
"format": "uint16",

View File

@@ -4,6 +4,25 @@ You are Codex, based on GPT-5. You are running as a coding agent in the Codex CL
- When searching for text or files, prefer using `rg` or `rg --files` respectively because `rg` is much faster than alternatives like `grep`. (If the `rg` command is not found, then use alternatives.)
<!-- js_repl:start -->
## JavaScript REPL (Node)
- 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` / `reset=true`.
- `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 reset=true`). Do not wrap code in JSON (for example `{"code":"..."}`), quotes, or markdown code fences.
- Helpers: `codex.state`, `codex.tmpDir`, `codex.sh(command, opts?)`, `codex.tool(name, args?)`, and `codex.emitImage(pathOrBytes, { mime?, caption?, name? })`.
- `codex.sh` requires a string command and resolves to `{ stdout, stderr, exitCode }`; `codex.tool` executes a normal tool call and resolves to the raw tool output object.
- 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.
<!-- js_repl_polling:start -->
- Polling mode is two-step: (1) call `js_repl` with first-line pragma `// codex-js-repl: poll=true` to get an `exec_id`; (2) call `js_repl_poll` with that `exec_id` until `status` is `completed` or `error`.
- `js_repl_poll` must not be called before a successful `js_repl` submission returns an `exec_id`.
- If `js_repl` rejects your payload format, resend raw JS with the pragma; do not retry with JSON, quoted strings, or markdown fences.
<!-- js_repl_polling:end -->
<!-- js_repl_tools_only:start -->
- Do not call tools directly; use `js_repl` + `codex.tool(...)`, and use `codex.sh(...)` for shell commands instead of direct shell tool calls.
- Tools available via `codex.tool(...)`: {{JS_REPL_TOOL_LIST}}. MCP tools (if any) can also be called by name.
<!-- js_repl_tools_only:end -->
- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.sh`, and `codex.emitImage`.
<!-- js_repl:end -->
## Editing constraints
- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them.

View File

@@ -4,6 +4,25 @@ You are Codex, based on GPT-5. You are running as a coding agent in the Codex CL
- When searching for text or files, prefer using `rg` or `rg --files` respectively because `rg` is much faster than alternatives like `grep`. (If the `rg` command is not found, then use alternatives.)
<!-- js_repl:start -->
## JavaScript REPL (Node)
- 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` / `reset=true`.
- `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 reset=true`). Do not wrap code in JSON (for example `{"code":"..."}`), quotes, or markdown code fences.
- Helpers: `codex.state`, `codex.tmpDir`, `codex.sh(command, opts?)`, `codex.tool(name, args?)`, and `codex.emitImage(pathOrBytes, { mime?, caption?, name? })`.
- `codex.sh` requires a string command and resolves to `{ stdout, stderr, exitCode }`; `codex.tool` executes a normal tool call and resolves to the raw tool output object.
- 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.
<!-- js_repl_polling:start -->
- Polling mode is two-step: (1) call `js_repl` with first-line pragma `// codex-js-repl: poll=true` to get an `exec_id`; (2) call `js_repl_poll` with that `exec_id` until `status` is `completed` or `error`.
- `js_repl_poll` must not be called before a successful `js_repl` submission returns an `exec_id`.
- If `js_repl` rejects your payload format, resend raw JS with the pragma; do not retry with JSON, quoted strings, or markdown fences.
<!-- js_repl_polling:end -->
<!-- js_repl_tools_only:start -->
- Do not call tools directly; use `js_repl` + `codex.tool(...)`, and use `codex.sh(...)` for shell commands instead of direct shell tool calls.
- Tools available via `codex.tool(...)`: {{JS_REPL_TOOL_LIST}}. MCP tools (if any) can also be called by name.
<!-- js_repl_tools_only:end -->
- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.sh`, and `codex.emitImage`.
<!-- js_repl:end -->
## Editing constraints
- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them.

View File

@@ -8,6 +8,25 @@ Your capabilities:
Within this context, Codex refers to the open-source agentic coding interface (not the old Codex language model built by OpenAI).
<!-- js_repl:start -->
## JavaScript REPL (Node)
- 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` / `reset=true`.
- `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 reset=true`). Do not wrap code in JSON (for example `{"code":"..."}`), quotes, or markdown code fences.
- Helpers: `codex.state`, `codex.tmpDir`, `codex.sh(command, opts?)`, `codex.tool(name, args?)`, and `codex.emitImage(pathOrBytes, { mime?, caption?, name? })`.
- `codex.sh` requires a string command and resolves to `{ stdout, stderr, exitCode }`; `codex.tool` executes a normal tool call and resolves to the raw tool output object.
- 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.
<!-- js_repl_polling:start -->
- Polling mode is two-step: (1) call `js_repl` with first-line pragma `// codex-js-repl: poll=true` to get an `exec_id`; (2) call `js_repl_poll` with that `exec_id` until `status` is `completed` or `error`.
- `js_repl_poll` must not be called before a successful `js_repl` submission returns an `exec_id`.
- If `js_repl` rejects your payload format, resend raw JS with the pragma; do not retry with JSON, quoted strings, or markdown fences.
<!-- js_repl_polling:end -->
<!-- js_repl_tools_only:start -->
- Do not call tools directly; use `js_repl` + `codex.tool(...)`, and use `codex.sh(...)` for shell commands instead of direct shell tool calls.
- Tools available via `codex.tool(...)`: {{JS_REPL_TOOL_LIST}}. MCP tools (if any) can also be called by name.
<!-- js_repl_tools_only:end -->
- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.sh`, and `codex.emitImage`.
<!-- js_repl:end -->
# How you work
## Personality

View File

@@ -8,6 +8,25 @@ Your capabilities:
Within this context, Codex refers to the open-source agentic coding interface (not the old Codex language model built by OpenAI).
<!-- js_repl:start -->
## JavaScript REPL (Node)
- 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` / `reset=true`.
- `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 reset=true`). Do not wrap code in JSON (for example `{"code":"..."}`), quotes, or markdown code fences.
- Helpers: `codex.state`, `codex.tmpDir`, `codex.sh(command, opts?)`, `codex.tool(name, args?)`, and `codex.emitImage(pathOrBytes, { mime?, caption?, name? })`.
- `codex.sh` requires a string command and resolves to `{ stdout, stderr, exitCode }`; `codex.tool` executes a normal tool call and resolves to the raw tool output object.
- 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.
<!-- js_repl_polling:start -->
- Polling mode is two-step: (1) call `js_repl` with first-line pragma `// codex-js-repl: poll=true` to get an `exec_id`; (2) call `js_repl_poll` with that `exec_id` until `status` is `completed` or `error`.
- `js_repl_poll` must not be called before a successful `js_repl` submission returns an `exec_id`.
- If `js_repl` rejects your payload format, resend raw JS with the pragma; do not retry with JSON, quoted strings, or markdown fences.
<!-- js_repl_polling:end -->
<!-- js_repl_tools_only:start -->
- Do not call tools directly; use `js_repl` + `codex.tool(...)`, and use `codex.sh(...)` for shell commands instead of direct shell tool calls.
- Tools available via `codex.tool(...)`: {{JS_REPL_TOOL_LIST}}. MCP tools (if any) can also be called by name.
<!-- js_repl_tools_only:end -->
- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.sh`, and `codex.emitImage`.
<!-- js_repl:end -->
# How you work
## Personality

View File

@@ -101,6 +101,7 @@ use crate::client::ModelClient;
use crate::client::ModelClientSession;
use crate::client_common::Prompt;
use crate::client_common::ResponseEvent;
use crate::client_common::tools::ToolSpec;
use crate::codex_thread::ThreadConfigSnapshot;
use crate::compact::collect_user_messages;
use crate::config::Config;
@@ -193,6 +194,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;
@@ -477,6 +479,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,
}
@@ -513,6 +516,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>>,
}
@@ -710,6 +714,7 @@ impl Session {
conversation_id: ThreadId,
sub_id: String,
transport_manager: TransportManager,
js_repl: Arc<JsReplHandle>,
) -> TurnContext {
let reasoning_effort = session_configuration.collaboration_mode.reasoning_effort();
let reasoning_summary = session_configuration.model_reasoning_summary;
@@ -771,6 +776,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(),
}
@@ -991,6 +997,10 @@ impl Session {
state_db: state_db_ctx.clone(),
transport_manager: TransportManager::new(),
};
let js_repl = Arc::new(JsReplHandle::with_node_path(
config.js_repl_node_path.clone(),
config.codex_home.clone(),
));
let sess = Arc::new(Session {
conversation_id,
@@ -1001,6 +1011,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),
});
@@ -1316,6 +1327,7 @@ impl Session {
self.conversation_id,
sub_id,
self.services.transport_manager.clone(),
Arc::clone(&self.js_repl),
);
if let Some(final_schema) = final_output_json_schema {
@@ -3357,6 +3369,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(),
@@ -3783,6 +3796,21 @@ fn codex_apps_connector_id(tool: &crate::mcp_connection_manager::ToolInfo) -> Op
tool.connector_id.as_deref()
}
fn js_repl_tool_list_for_prompt(tools: &[ToolSpec]) -> String {
let mut names: Vec<String> = tools.iter().map(|tool| tool.name().to_string()).collect();
names.sort();
names.dedup();
names.retain(|name| !matches!(name.as_str(), "js_repl" | "js_repl_reset"));
if names.is_empty() {
return "none".to_string();
}
names
.into_iter()
.map(|name| format!("`{name}`"))
.collect::<Vec<_>>()
.join(", ")
}
struct SamplingRequestToolSelection<'a> {
explicit_app_paths: &'a [String],
skill_name_counts_lower: &'a HashMap<String, usize>,
@@ -3840,11 +3868,20 @@ async fn run_sampling_request(
let model_supports_parallel = turn_context.model_info.supports_parallel_tool_calls;
let base_instructions = sess.get_base_instructions().await;
let all_tools = router.specs();
let tools =
crate::tools::spec::filter_tools_for_model(all_tools.clone(), &turn_context.tools_config);
let mut base_instructions = sess.get_base_instructions().await;
if turn_context.tools_config.js_repl_tools_only {
let tool_list = js_repl_tool_list_for_prompt(&all_tools);
base_instructions.text = base_instructions
.text
.replace("{{JS_REPL_TOOL_LIST}}", &tool_list);
}
let prompt = Prompt {
input,
tools: router.specs(),
tools,
parallel_tool_calls: model_supports_parallel,
base_instructions,
personality: turn_context.personality,
@@ -4648,7 +4685,11 @@ mod tests {
use crate::config::ConfigBuilder;
use crate::config::test_config;
use crate::exec::ExecToolCallOutput;
use crate::features::Feature;
use crate::function_tool::FunctionCallError;
use crate::models_manager::model_info::apply_js_repl_polling_section;
use crate::models_manager::model_info::apply_js_repl_section;
use crate::models_manager::model_info::apply_js_repl_tools_only_section;
use crate::shell::default_user_shell;
use crate::tools::format_exec_output_str;
@@ -4765,9 +4806,17 @@ mod tests {
];
let (session, _turn_context) = make_session_and_context().await;
let config = test_config();
let js_repl_enabled = config.features.enabled(Feature::JsRepl);
let prompt_with_apply_patch_instructions = apply_js_repl_tools_only_section(
&apply_js_repl_polling_section(
&apply_js_repl_section(prompt_with_apply_patch_instructions, js_repl_enabled),
js_repl_enabled && config.features.enabled(Feature::JsReplPolling),
),
js_repl_enabled && config.features.enabled(Feature::JsReplToolsOnly),
);
for test_case in test_cases {
let config = test_config();
let model_info = ModelsManager::construct_model_info_offline(test_case.slug, &config);
if test_case.expects_apply_patch_instructions {
assert_eq!(
@@ -5534,6 +5583,10 @@ mod tests {
state_db: None,
transport_manager: TransportManager::new(),
};
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)),
@@ -5545,6 +5598,7 @@ mod tests {
conversation_id,
"turn_id".to_string(),
services.transport_manager.clone(),
Arc::clone(&js_repl),
);
let session = Session {
@@ -5556,6 +5610,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),
};
@@ -5654,6 +5709,10 @@ mod tests {
state_db: None,
transport_manager: TransportManager::new(),
};
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)),
@@ -5665,6 +5724,7 @@ mod tests {
conversation_id,
"turn_id".to_string(),
services.transport_manager.clone(),
Arc::clone(&js_repl),
));
let session = Arc::new(Session {
@@ -5676,6 +5736,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),
});
@@ -5968,6 +6029,7 @@ mod tests {
Arc::clone(&turn_context),
tracker,
call,
crate::tools::router::ToolCallSource::Model,
)
.await
.expect_err("expected fatal error");

View File

@@ -289,6 +289,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>,
@@ -885,6 +888,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>,
@@ -1218,6 +1224,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>,
@@ -1325,6 +1332,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,
@@ -1555,6 +1563,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);
@@ -1631,6 +1642,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
@@ -3847,6 +3859,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),
@@ -3932,6 +3945,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,
@@ -4032,6 +4046,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,
@@ -4118,6 +4133,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

@@ -29,6 +29,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,12 @@ pub enum Feature {
ShellTool,
// Experimental
/// Enable JavaScript REPL tools backed by a persistent Node kernel.
JsRepl,
/// Enable js_repl polling helpers and tool.
JsReplPolling,
/// Only expose js_repl tools directly to the model.
JsReplToolsOnly,
/// Use the single unified PTY-backed exec tool.
UnifiedExec,
/// Include the freeform apply_patch tool.
@@ -316,6 +322,14 @@ impl Features {
}
overrides.apply(&mut features);
if features.enabled(Feature::JsReplToolsOnly) && !features.enabled(Feature::JsRepl) {
tracing::warn!("js_repl_tools_only requires js_repl; disabling js_repl_tools_only");
features.disable(Feature::JsReplToolsOnly);
}
if features.enabled(Feature::JsReplPolling) && !features.enabled(Feature::JsRepl) {
tracing::warn!("js_repl_polling requires js_repl; disabling js_repl_polling");
features.disable(Feature::JsReplPolling);
}
features
}
@@ -412,6 +426,36 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::Stable,
default_enabled: !cfg!(windows),
},
FeatureSpec {
id: Feature::JsRepl,
key: "js_repl",
stage: Stage::Experimental {
name: "JavaScript REPL",
menu_description: "Run JavaScript cells with a persistent Node-backed kernel.",
announcement: "NEW! Try the JavaScript REPL for persistent JS execution. Enable in /experimental!",
},
default_enabled: false,
},
FeatureSpec {
id: Feature::JsReplPolling,
key: "js_repl_polling",
stage: Stage::Experimental {
name: "JS REPL polling",
menu_description: "Allow js_repl to return an exec_id and poll for completion.",
announcement: "NEW! Try polling mode for JavaScript REPL long-running cells.",
},
default_enabled: false,
},
FeatureSpec {
id: Feature::JsReplToolsOnly,
key: "js_repl_tools_only",
stage: Stage::Experimental {
name: "JS REPL tool-only",
menu_description: "Require direct model tool calls to go through js_repl.",
announcement: "NEW! Route tool usage through JavaScript REPL with js_repl_tools_only.",
},
default_enabled: false,
},
FeatureSpec {
id: Feature::WebSearchRequest,
key: "web_search_request",

View File

@@ -33,6 +33,12 @@ const GPT_5_2_CODEX_PERSONALITY_FRIENDLY: &str =
include_str!("../../templates/personalities/gpt-5.2-codex_friendly.md");
const GPT_5_2_CODEX_PERSONALITY_PRAGMATIC: &str =
include_str!("../../templates/personalities/gpt-5.2-codex_pragmatic.md");
const JS_REPL_SECTION_START: &str = "<!-- js_repl:start -->";
const JS_REPL_SECTION_END: &str = "<!-- js_repl:end -->";
const JS_REPL_POLLING_SECTION_START: &str = "<!-- js_repl_polling:start -->";
const JS_REPL_POLLING_SECTION_END: &str = "<!-- js_repl_polling:end -->";
const JS_REPL_TOOLS_ONLY_SECTION_START: &str = "<!-- js_repl_tools_only:start -->";
const JS_REPL_TOOLS_ONLY_SECTION_END: &str = "<!-- js_repl_tools_only:end -->";
pub(crate) const CONTEXT_WINDOW_272K: i64 = 272_000;
@@ -107,10 +113,69 @@ pub(crate) fn with_config_overrides(mut model: ModelInfo, config: &Config) -> Mo
} else if !config.features.enabled(Feature::Personality) {
model.model_messages = None;
}
model.base_instructions = apply_js_repl_section(
&model.base_instructions,
config.features.enabled(Feature::JsRepl),
);
model.base_instructions = apply_js_repl_polling_section(
&model.base_instructions,
config.features.enabled(Feature::JsRepl) && config.features.enabled(Feature::JsReplPolling),
);
model.base_instructions = apply_js_repl_tools_only_section(
&model.base_instructions,
config.features.enabled(Feature::JsRepl)
&& config.features.enabled(Feature::JsReplToolsOnly),
);
model
}
pub(crate) fn apply_js_repl_section(input: &str, enabled: bool) -> String {
apply_section(input, JS_REPL_SECTION_START, JS_REPL_SECTION_END, enabled)
}
pub(crate) fn apply_js_repl_polling_section(input: &str, enabled: bool) -> String {
apply_section(
input,
JS_REPL_POLLING_SECTION_START,
JS_REPL_POLLING_SECTION_END,
enabled,
)
}
pub(crate) fn apply_js_repl_tools_only_section(input: &str, enabled: bool) -> String {
apply_section(
input,
JS_REPL_TOOLS_ONLY_SECTION_START,
JS_REPL_TOOLS_ONLY_SECTION_END,
enabled,
)
}
fn apply_section(input: &str, start_marker: &str, end_marker: &str, enabled: bool) -> String {
let Some(start) = input.find(start_marker) else {
return input.to_string();
};
let Some(end) = input.find(end_marker) else {
return input.to_string();
};
if end <= start {
return input.to_string();
}
let before = &input[..start];
let section_start = start + start_marker.len();
let after_start = end + end_marker.len();
let section = &input[section_start..end];
let after = &input[after_start..];
let mut result = String::with_capacity(before.len() + section.len() + after.len());
result.push_str(before);
if enabled {
result.push_str(section);
}
result.push_str(after);
result
}
// todo(aibrahim): remove most of the entries here when enabling models.json
pub(crate) fn find_model_info_for_slug(slug: &str) -> ModelInfo {
if slug.starts_with("o3") || slug.starts_with("o4-mini") {

View File

@@ -0,0 +1,693 @@
use async_trait::async_trait;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use serde::Deserialize;
use serde_json::Value as JsonValue;
use std::sync::Arc;
use std::time::Duration;
use std::time::Instant;
use crate::exec::ExecToolCallOutput;
use crate::exec::StreamOutput;
use crate::features::Feature;
use crate::function_tool::FunctionCallError;
use crate::protocol::ExecCommandSource;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolOutput;
use crate::tools::context::ToolPayload;
use crate::tools::events::ToolEmitter;
use crate::tools::events::ToolEventCtx;
use crate::tools::events::ToolEventStage;
use crate::tools::handlers::parse_arguments;
use crate::tools::js_repl::JS_REPL_PRAGMA_PREFIX;
use crate::tools::js_repl::JsExecPollResult;
use crate::tools::js_repl::JsImageArtifact;
use crate::tools::js_repl::JsReplArgs;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
use codex_protocol::models::FunctionCallOutputContentItem;
const PER_IMAGE_RAW_LIMIT: usize = 32 * 1024 * 1024;
const TOTAL_RAW_LIMIT: usize = 36 * 1024 * 1024;
pub struct JsReplHandler;
pub struct JsReplResetHandler;
pub struct JsReplPollHandler;
#[derive(Clone, Debug, Deserialize)]
struct JsReplPollArgs {
exec_id: String,
#[serde(default)]
yield_time_ms: Option<u64>,
}
fn join_outputs(stdout: &str, stderr: &str) -> String {
if stdout.is_empty() {
stderr.to_string()
} else if stderr.is_empty() {
stdout.to_string()
} else {
format!("{stdout}\n{stderr}")
}
}
fn build_js_repl_exec_output(
output: &str,
error: Option<&str>,
duration: Duration,
) -> ExecToolCallOutput {
let stdout = output.to_string();
let stderr = error.unwrap_or("").to_string();
let aggregated_output = join_outputs(&stdout, &stderr);
ExecToolCallOutput {
exit_code: if error.is_some() { 1 } else { 0 },
stdout: StreamOutput::new(stdout),
stderr: StreamOutput::new(stderr),
aggregated_output: StreamOutput::new(aggregated_output),
duration,
timed_out: false,
}
}
async fn emit_js_repl_exec_end(
session: &crate::codex::Session,
turn: &crate::codex::TurnContext,
call_id: &str,
output: &str,
error: Option<&str>,
duration: Duration,
) {
let exec_output = build_js_repl_exec_output(output, error, duration);
let emitter = ToolEmitter::shell(
vec!["js_repl".to_string()],
turn.cwd.clone(),
ExecCommandSource::Agent,
false,
);
let ctx = ToolEventCtx::new(session, turn, call_id, None);
emitter
.emit(ctx, ToolEventStage::Success(exec_output))
.await;
}
fn encode_artifacts(
artifacts: &[JsImageArtifact],
) -> (Vec<FunctionCallOutputContentItem>, Vec<String>) {
let mut items = Vec::new();
let mut warnings = Vec::new();
let mut total_base64: usize = 0;
for (idx, artifact) in artifacts.iter().enumerate() {
match encode_single_image(artifact, &mut total_base64) {
Ok(Some(item)) => items.push(item),
Ok(None) => {
warnings.push(format!(
"image #{idx} omitted: exceeded total payload budget (~50MB base64)"
));
}
Err(err) => {
warnings.push(format!("image #{idx} omitted: {err}"));
}
}
}
(items, warnings)
}
fn encode_single_image(
artifact: &JsImageArtifact,
total_base64: &mut usize,
) -> Result<Option<FunctionCallOutputContentItem>, String> {
let bytes = &artifact.bytes;
let mime = artifact
.mime
.clone()
.unwrap_or_else(|| "image/png".to_string());
if bytes.len() > PER_IMAGE_RAW_LIMIT {
return Err(format!(
"raw image payload ({size} bytes) exceeds per-image limit",
size = bytes.len()
));
}
let base64_size = encoded_len(bytes.len());
if *total_base64 + base64_size > TOTAL_RAW_LIMIT * 4 / 3 {
return Ok(None);
}
*total_base64 += base64_size;
let data_url = to_data_url(bytes, &mime);
Ok(Some(FunctionCallOutputContentItem::InputImage {
image_url: data_url,
}))
}
fn to_data_url(bytes: &[u8], mime: &str) -> String {
let encoded = BASE64_STANDARD.encode(bytes);
format!("data:{mime};base64,{encoded}")
}
fn encoded_len(raw: usize) -> usize {
raw.div_ceil(3) * 4
}
#[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,
call_id,
..
} = 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(),
));
}
};
if args.poll && !session.features().enabled(Feature::JsReplPolling) {
return Err(FunctionCallError::RespondToModel(
"js_repl polling is disabled by feature flag".to_string(),
));
}
let manager = turn.js_repl.manager().await?;
if args.poll {
let submission = Arc::clone(&manager)
.submit(Arc::clone(&session), Arc::clone(&turn), tracker, args)
.await?;
let content = serde_json::to_string(&serde_json::json!({
"exec_id": submission.exec_id,
"status": "running",
}))
.map_err(|err| {
FunctionCallError::Fatal(format!(
"failed to serialize js_repl submission result: {err}"
))
})?;
return Ok(ToolOutput::Function {
content,
content_items: None,
success: Some(true),
});
}
let started_at = Instant::now();
let result = manager
.execute(Arc::clone(&session), Arc::clone(&turn), tracker, args)
.await?;
let (mut items, warnings) = encode_artifacts(&result.artifacts);
let mut content = result.output;
if !warnings.is_empty() {
if !content.is_empty() {
content.push('\n');
}
content.push_str(&warnings.join("\n"));
}
items.insert(
0,
FunctionCallOutputContentItem::InputText {
text: content.clone(),
},
);
emit_js_repl_exec_end(
session.as_ref(),
turn.as_ref(),
&call_id,
&content,
None,
started_at.elapsed(),
)
.await;
Ok(ToolOutput::Function {
content,
content_items: Some(items),
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 {
content: "js_repl kernel reset".to_string(),
content_items: None,
success: Some(true),
})
}
}
#[async_trait]
impl ToolHandler for JsReplPollHandler {
fn kind(&self) -> ToolKind {
ToolKind::Function
}
async fn handle(&self, invocation: ToolInvocation) -> Result<ToolOutput, FunctionCallError> {
let ToolInvocation {
session,
turn,
payload,
..
} = invocation;
if !session.features().enabled(Feature::JsRepl) {
return Err(FunctionCallError::RespondToModel(
"js_repl is disabled by feature flag".to_string(),
));
}
if !session.features().enabled(Feature::JsReplPolling) {
return Err(FunctionCallError::RespondToModel(
"js_repl polling is disabled by feature flag".to_string(),
));
}
let ToolPayload::Function { arguments } = payload else {
return Err(FunctionCallError::RespondToModel(
"js_repl_poll expects function payload".to_string(),
));
};
let args: JsReplPollArgs = parse_arguments(&arguments)?;
let manager = turn.js_repl.manager().await?;
let result = manager.poll(&args.exec_id, args.yield_time_ms).await?;
let output = format_poll_output(&result)?;
if result.done {
let warnings = if result.artifacts.is_empty() {
None
} else {
let (_, warnings) = encode_artifacts(&result.artifacts);
if warnings.is_empty() {
None
} else {
Some(warnings.join("\n"))
}
};
let display_output = build_poll_display_output(&result, warnings.as_deref());
let duration = result.duration.unwrap_or(Duration::ZERO);
emit_js_repl_exec_end(
session.as_ref(),
turn.as_ref(),
&result.exec_id,
&display_output,
result.error.as_deref(),
duration,
)
.await;
}
Ok(ToolOutput::Function {
content: output.content,
content_items: output.content_items,
success: Some(true),
})
}
}
struct JsReplPollOutput {
content: String,
content_items: Option<Vec<FunctionCallOutputContentItem>>,
}
fn build_poll_display_output(result: &JsExecPollResult, warnings: Option<&str>) -> String {
let mut output = String::new();
let mut push_section = |text: &str| {
if text.is_empty() {
return;
}
if !output.is_empty() {
output.push('\n');
}
output.push_str(text);
};
if !result.all_logs.is_empty() {
push_section(&result.all_logs.join("\n"));
}
if let Some(text) = result.output.as_deref() {
push_section(text);
}
if let Some(text) = warnings {
push_section(text);
}
output
}
fn format_poll_output(result: &JsExecPollResult) -> Result<JsReplPollOutput, FunctionCallError> {
let status = if result.done {
if result.error.is_some() {
"error"
} else {
"completed"
}
} else {
"running"
};
let logs = if result.logs.is_empty() {
None
} else {
Some(result.logs.join("\n"))
};
let payload = serde_json::json!({
"exec_id": result.exec_id,
"status": status,
"logs": logs,
"output": result.output,
"error": result.error,
});
let content = serde_json::to_string(&payload).map_err(|err| {
FunctionCallError::Fatal(format!("failed to serialize js_repl poll result: {err}"))
})?;
let mut items = Vec::new();
if !result.logs.is_empty() {
items.extend(
result
.logs
.iter()
.map(|line| FunctionCallOutputContentItem::InputText { text: line.clone() }),
);
}
items.push(FunctionCallOutputContentItem::InputText {
text: content.clone(),
});
if result.done && !result.artifacts.is_empty() {
let (mut artifacts, warnings) = encode_artifacts(&result.artifacts);
items.append(&mut artifacts);
if !warnings.is_empty() {
items.push(FunctionCallOutputContentItem::InputText {
text: warnings.join("\n"),
});
}
}
Ok(JsReplPollOutput {
content,
content_items: Some(items),
})
}
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,
reset: false,
poll: false,
};
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 mut reset: Option<bool> = None;
let mut poll: Option<bool> = 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, reset, poll); 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);
}
"reset" => {
if reset.is_some() {
return Err(FunctionCallError::RespondToModel(
"js_repl pragma specifies reset more than once".to_string(),
));
}
let parsed = match value.to_ascii_lowercase().as_str() {
"true" => true,
"false" => false,
_ => {
return Err(FunctionCallError::RespondToModel(format!(
"js_repl pragma reset must be true or false; got `{value}`"
)));
}
};
reset = Some(parsed);
}
"poll" => {
if poll.is_some() {
return Err(FunctionCallError::RespondToModel(
"js_repl pragma specifies poll more than once".to_string(),
));
}
let parsed = match value.to_ascii_lowercase().as_str() {
"true" => true,
"false" => false,
_ => {
return Err(FunctionCallError::RespondToModel(format!(
"js_repl pragma poll must be true or false; got `{value}`"
)));
}
};
poll = Some(parsed);
}
_ => {
return Err(FunctionCallError::RespondToModel(format!(
"js_repl pragma only supports timeout_ms, reset, poll; 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;
args.reset = reset.unwrap_or(false);
args.poll = poll.unwrap_or(false);
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 std::time::Duration;
use super::format_poll_output;
use super::parse_freeform_args;
use crate::codex::make_session_and_context_with_rx;
use crate::protocol::EventMsg;
use crate::protocol::ExecCommandSource;
use crate::tools::js_repl::JsExecPollResult;
use codex_protocol::models::FunctionCallOutputContentItem;
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);
assert!(!args.reset);
assert!(!args.poll);
}
#[test]
fn parse_freeform_args_with_pragma() {
let input = "// codex-js-repl: timeout_ms=15000 reset=true\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));
assert!(args.reset);
assert!(!args.poll);
}
#[test]
fn parse_freeform_args_with_poll() {
let input = "// codex-js-repl: poll=true\nconsole.log('ok');";
let args = parse_freeform_args(input).expect("parse args");
assert_eq!(args.code, "console.log('ok');");
assert_eq!(args.timeout_ms, None);
assert!(!args.reset);
assert!(args.poll);
}
#[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, reset, poll; got `nope`"
);
}
#[test]
fn parse_freeform_args_rejects_duplicate_poll() {
let err = parse_freeform_args("// codex-js-repl: poll=true poll=false\nconsole.log('ok');")
.expect_err("expected error");
assert_eq!(
err.to_string(),
"js_repl pragma specifies poll more than once"
);
}
#[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."
);
}
#[test]
fn format_poll_output_includes_logs_as_output_items() {
let result = JsExecPollResult {
exec_id: "exec-1".to_string(),
logs: vec!["line 1".to_string(), "line 2".to_string()],
all_logs: Vec::new(),
output: None,
artifacts: Vec::new(),
error: None,
done: false,
duration: None,
};
let output = format_poll_output(&result).expect("format poll output");
let items = output.content_items.expect("content items");
assert_eq!(
items[0],
FunctionCallOutputContentItem::InputText {
text: "line 1".to_string()
}
);
assert_eq!(
items[1],
FunctionCallOutputContentItem::InputText {
text: "line 2".to_string()
}
);
assert!(matches!(
items[2],
FunctionCallOutputContentItem::InputText { .. }
));
}
#[tokio::test]
async fn emit_js_repl_exec_end_sends_event() {
let (session, turn, rx) = make_session_and_context_with_rx().await;
super::emit_js_repl_exec_end(
session.as_ref(),
turn.as_ref(),
"call-1",
"hello",
None,
Duration::from_millis(12),
)
.await;
let event = tokio::time::timeout(Duration::from_secs(5), async {
loop {
let event = rx.recv().await.expect("event");
if let EventMsg::ExecCommandEnd(end) = event.msg {
break end;
}
}
})
.await
.expect("timed out waiting for exec end");
assert_eq!(event.call_id, "call-1");
assert_eq!(event.turn_id, turn.sub_id);
assert_eq!(event.command, vec!["js_repl".to_string()]);
assert_eq!(event.cwd, turn.cwd);
assert_eq!(event.source, ExecCommandSource::Agent);
assert_eq!(event.interaction_input, None);
assert_eq!(event.stdout, "hello");
assert_eq!(event.stderr, "");
assert!(event.aggregated_output.contains("hello"));
assert_eq!(event.exit_code, 0);
assert_eq!(event.duration, Duration::from_millis(12));
assert!(event.formatted_output.contains("hello"));
assert!(!event.parsed_cmd.is_empty());
}
}

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;
@@ -21,6 +22,9 @@ 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::JsReplPollHandler;
pub use js_repl::JsReplResetHandler;
pub use list_dir::ListDirHandler;
pub use mcp::McpHandler;
pub use mcp_resource::McpResourceHandler;

View File

@@ -0,0 +1,510 @@
// Node-based kernel for js_repl.
// Communicates over JSON lines on stdin/stdout.
// Requires Node started with --experimental-vm-modules.
const { Buffer } = require("node:buffer");
const crypto = require("node:crypto");
const { builtinModules } = require("node:module");
const { createInterface } = require("node:readline");
const { performance } = require("node:perf_hooks");
const path = require("node:path");
const { URL, URLSearchParams, pathToFileURL } = require("node:url");
const { inspect, TextDecoder, TextEncoder } = require("node:util");
const fs = require("node:fs/promises");
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.Buffer = Buffer;
context.console = console;
context.process = process;
context.URL = URL;
context.URLSearchParams = URLSearchParams;
if (typeof TextEncoder !== "undefined") {
context.TextEncoder = TextEncoder;
}
if (typeof TextDecoder !== "undefined") {
context.TextDecoder = TextDecoder;
}
if (typeof AbortController !== "undefined") {
context.AbortController = AbortController;
}
if (typeof AbortSignal !== "undefined") {
context.AbortSignal = AbortSignal;
}
if (typeof structuredClone !== "undefined") {
context.structuredClone = structuredClone;
}
if (typeof fetch !== "undefined") {
context.fetch = fetch;
}
if (typeof Headers !== "undefined") {
context.Headers = Headers;
}
if (typeof Request !== "undefined") {
context.Request = Request;
}
if (typeof Response !== "undefined") {
context.Response = Response;
}
if (typeof performance !== "undefined") {
context.performance = performance;
}
context.crypto = crypto.webcrypto ?? crypto;
context.setTimeout = setTimeout;
context.clearTimeout = clearTimeout;
context.setInterval = setInterval;
context.clearInterval = clearInterval;
context.queueMicrotask = queueMicrotask;
if (typeof setImmediate !== "undefined") {
context.setImmediate = setImmediate;
context.clearImmediate = clearImmediate;
}
context.atob = (data) => Buffer.from(data, "base64").toString("binary");
context.btoa = (data) => Buffer.from(data, "binary").toString("base64");
/**
* @typedef {{ name: string, kind: "const"|"let"|"var"|"function"|"class" }} Binding
*/
let previousModule = null;
/** @type {Binding[]} */
let previousBindings = [];
let cellCounter = 0;
const builtinModuleSet = new Set([
...builtinModules,
...builtinModules.map((name) => `node:${name}`),
]);
const replHome = process.env.CODEX_JS_REPL_HOME || process.cwd();
const vendorNodeModules =
process.env.CODEX_JS_REPL_VENDOR_NODE_MODULES ||
path.join(replHome, "codex_node_modules", "node_modules");
const userNodeModules =
process.env.CODEX_JS_REPL_USER_NODE_MODULES || path.join(replHome, "node_modules");
function resolvePath(candidate) {
try {
return require.resolve(candidate);
} catch {
return null;
}
}
function resolveFromRoot(root, specifier) {
return resolvePath(path.join(root, specifier));
}
/** @type {Map<string, (msg: any) => void>} */
const pendingShell = new Map();
let shellCounter = 0;
/** @type {Map<string, (msg: any) => void>} */
const pendingTool = new Map();
let toolCounter = 0;
const tmpDir = process.env.CODEX_JS_TMP_DIR || process.cwd();
const state = {};
function resolveSpecifier(specifier) {
if (specifier.startsWith("node:") || builtinModuleSet.has(specifier)) {
return { kind: "builtin", 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) };
}
const resolved =
resolveFromRoot(vendorNodeModules, specifier) || resolveFromRoot(userNodeModules, specifier);
if (!resolved) {
throw new Error(`Module not found: ${specifier}`);
}
return { kind: "path", path: resolved };
}
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);
}
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) {
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);
}
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, onLog, fn) {
const logs = [];
const original = ctx.console ?? console;
const captured = {
...original,
log: (...args) => {
const line = formatLog(args);
logs.push(line);
if (onLog) onLog(line);
},
info: (...args) => {
const line = formatLog(args);
logs.push(line);
if (onLog) onLog(line);
},
warn: (...args) => {
const line = formatLog(args);
logs.push(line);
if (onLog) onLog(line);
},
error: (...args) => {
const line = formatLog(args);
logs.push(line);
if (onLog) onLog(line);
},
debug: (...args) => {
const line = formatLog(args);
logs.push(line);
if (onLog) onLog(line);
},
};
ctx.console = captured;
return fn(logs).finally(() => {
ctx.console = original;
});
}
async function readBytes(input) {
if (typeof input === "string") {
const bytes = await fs.readFile(input);
return new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
}
if (input instanceof ArrayBuffer) {
return new Uint8Array(input);
}
if (ArrayBuffer.isView(input)) {
return new Uint8Array(input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength));
}
throw new Error("emitImage accepts a file path or bytes");
}
async function handleExec(message) {
const artifacts = [];
const emitImage = async (input, meta) => {
const bytes = await readBytes(input);
artifacts.push({
kind: "image",
data: Buffer.from(bytes).toString("base64"),
mime: meta?.mime,
caption: meta?.caption,
name: meta?.name,
});
};
const sh = (command, opts = {}) => {
if (typeof command !== "string") {
return Promise.reject(new Error("codex.sh expects the first argument to be a string"));
}
const id = `${message.id}-sh-${shellCounter++}`;
const timeoutMs =
typeof opts?.timeout_ms === "number" && Number.isFinite(opts.timeout_ms)
? opts.timeout_ms
: null;
return new Promise((resolve, reject) => {
const payload = {
type: "run_shell",
id,
exec_id: message.id,
command,
cwd: opts?.cwd,
timeout_ms: opts?.timeout_ms,
sandbox_permissions: opts?.sandbox_permissions,
justification: opts?.justification,
};
send(payload);
let guard;
if (timeoutMs !== null) {
guard = setTimeout(() => {
if (pendingShell.delete(id)) {
reject(new Error("shell request timed out"));
}
}, timeoutMs + 1_000);
}
pendingShell.set(id, (res) => {
if (guard) clearTimeout(guard);
resolve(res);
});
}).then((res) => {
if (!res.ok) {
throw new Error(res.error || "shell failed");
}
return { stdout: res.stdout, stderr: res.stderr, exitCode: res.exit_code };
});
};
const tool = (toolName, args) => {
if (typeof toolName !== "string" || !toolName) {
return Promise.reject(new Error("codex.tool expects a tool name string"));
}
const id = `${message.id}-tool-${toolCounter++}`;
let argumentsJson = "{}";
if (typeof args === "string") {
argumentsJson = args;
} else if (typeof args !== "undefined") {
argumentsJson = JSON.stringify(args);
}
return new Promise((resolve, reject) => {
const payload = {
type: "run_tool",
id,
exec_id: message.id,
tool_name: toolName,
arguments: argumentsJson,
};
send(payload);
pendingTool.set(id, (res) => {
if (!res.ok) {
reject(new Error(res.error || "tool failed"));
return;
}
resolve(res.response);
});
});
};
try {
const code = typeof message.code === "string" ? message.code : "";
const streamLogs = Boolean(message.stream_logs);
const { source, nextBindings } = await buildModuleSource(code);
let output = "";
context.state = state;
context.codex = { state, tmpDir, sh, emitImage, tool };
context.tmpDir = tmpDir;
await withCapturedConsole(
context,
streamLogs ? (line) => send({ type: "exec_log", id: message.id, text: line }) : null,
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);
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,
artifacts,
error: null,
});
} catch (error) {
send({
type: "exec_result",
id: message.id,
ok: false,
output: "",
artifacts,
error: error && error.message ? error.message : String(error),
});
}
}
function handleShellResult(message) {
const resolver = pendingShell.get(message.id);
if (resolver) {
pendingShell.delete(message.id);
resolver(message);
}
}
function handleToolResult(message) {
const resolver = pendingTool.get(message.id);
if (resolver) {
pendingTool.delete(message.id);
resolver(message);
}
}
let queue = Promise.resolve();
const input = createInterface({ input: process.stdin, crlfDelay: Infinity });
input.on("line", (line) => {
if (!line.trim()) {
return;
}
let message;
try {
message = JSON.parse(line);
} catch {
return;
}
if (message.type === "exec") {
queue = queue.then(() => handleExec(message));
return;
}
if (message.type === "run_shell_result") {
handleShellResult(message);
return;
}
if (message.type === "run_tool_result") {
handleToolResult(message);
}
});

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

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

@@ -84,7 +84,13 @@ impl ToolCallRuntime {
};
router
.dispatch_tool_call(session, turn, tracker, call.clone())
.dispatch_tool_call(
session,
turn,
tracker,
call.clone(),
crate::tools::router::ToolCallSource::Model,
)
.instrument(dispatch_span.clone())
.await
} => res,

View File

@@ -27,6 +27,12 @@ pub struct ToolCall {
pub payload: ToolPayload,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ToolCallSource {
Model,
JsRepl,
}
pub struct ToolRouter {
registry: ToolRegistry,
specs: Vec<ConfiguredToolSpec>,
@@ -137,6 +143,7 @@ impl ToolRouter {
turn: Arc<TurnContext>,
tracker: SharedTurnDiffTracker,
call: ToolCall,
source: ToolCallSource,
) -> Result<ResponseInputItem, FunctionCallError> {
let ToolCall {
tool_name,
@@ -146,6 +153,22 @@ impl ToolRouter {
let payload_outputs_custom = matches!(payload, ToolPayload::Custom { .. });
let failure_call_id = call_id.clone();
if source == ToolCallSource::Model
&& turn.tools_config.js_repl_tools_only
&& !matches!(tool_name.as_str(), "js_repl" | "js_repl_reset")
&& !(turn.tools_config.js_repl_poll_enabled && tool_name == "js_repl_poll")
{
let err = FunctionCallError::RespondToModel(
"direct tool calls are disabled; use js_repl and codex.tool(...) instead"
.to_string(),
);
return Ok(Self::failure_response(
failure_call_id,
payload_outputs_custom,
err,
));
}
let invocation = ToolInvocation {
session,
turn,
@@ -189,3 +212,118 @@ impl ToolRouter {
}
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use codex_protocol::models::ResponseInputItem;
use super::ToolCall;
use super::ToolCallSource;
use super::ToolRouter;
use crate::codex::make_session_and_context;
use crate::tools::context::ToolPayload;
use crate::turn_diff_tracker::TurnDiffTracker;
#[tokio::test]
async fn js_repl_tools_only_blocks_direct_tool_calls() -> anyhow::Result<()> {
let (session, mut turn) = make_session_and_context().await;
turn.tools_config.js_repl_tools_only = true;
let session = Arc::new(session);
let turn = Arc::new(turn);
let mcp_tools = session
.services
.mcp_connection_manager
.read()
.await
.list_all_tools()
.await;
let router = ToolRouter::from_config(
&turn.tools_config,
Some(
mcp_tools
.into_iter()
.map(|(name, tool)| (name, tool.tool))
.collect(),
),
turn.dynamic_tools.as_slice(),
);
let call = ToolCall {
tool_name: "shell".to_string(),
call_id: "call-1".to_string(),
payload: ToolPayload::Function {
arguments: "{}".to_string(),
},
};
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));
let response = router
.dispatch_tool_call(session, turn, tracker, call, ToolCallSource::Model)
.await?;
match response {
ResponseInputItem::FunctionCallOutput { output, .. } => {
assert!(
output.content.contains("direct tool calls are disabled"),
"unexpected tool call message: {}",
output.content
);
}
other => panic!("expected function call output, got {other:?}"),
}
Ok(())
}
#[tokio::test]
async fn js_repl_tools_only_allows_js_repl_source_calls() -> anyhow::Result<()> {
let (session, mut turn) = make_session_and_context().await;
turn.tools_config.js_repl_tools_only = true;
let session = Arc::new(session);
let turn = Arc::new(turn);
let mcp_tools = session
.services
.mcp_connection_manager
.read()
.await
.list_all_tools()
.await;
let router = ToolRouter::from_config(
&turn.tools_config,
Some(
mcp_tools
.into_iter()
.map(|(name, tool)| (name, tool.tool))
.collect(),
),
turn.dynamic_tools.as_slice(),
);
let call = ToolCall {
tool_name: "shell".to_string(),
call_id: "call-2".to_string(),
payload: ToolPayload::Function {
arguments: "{}".to_string(),
},
};
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));
let response = router
.dispatch_tool_call(session, turn, tracker, call, ToolCallSource::JsRepl)
.await?;
match response {
ResponseInputItem::FunctionCallOutput { output, .. } => {
assert!(
!output.content.contains("direct tool calls are disabled"),
"js_repl source should bypass direct-call policy gate"
);
}
other => panic!("expected function call output, got {other:?}"),
}
Ok(())
}
}

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;
@@ -29,6 +31,9 @@ pub(crate) struct ToolsConfig {
pub shell_type: ConfigShellToolType,
pub apply_patch_tool_type: Option<ApplyPatchToolType>,
pub web_search_mode: Option<WebSearchMode>,
pub js_repl_enabled: bool,
pub js_repl_poll_enabled: bool,
pub js_repl_tools_only: bool,
pub collab_tools: bool,
pub collaboration_modes_tools: bool,
pub request_rule_enabled: bool,
@@ -49,6 +54,10 @@ 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_js_repl_polling = include_js_repl && features.enabled(Feature::JsReplPolling);
let include_js_repl_tools_only =
include_js_repl && features.enabled(Feature::JsReplToolsOnly);
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);
@@ -82,6 +91,9 @@ impl ToolsConfig {
shell_type,
apply_patch_tool_type,
web_search_mode: *web_search_mode,
js_repl_enabled: include_js_repl,
js_repl_poll_enabled: include_js_repl_polling,
js_repl_tools_only: include_js_repl_tools_only,
collab_tools: include_collab_tools,
collaboration_modes_tools: include_collaboration_modes_tools,
request_rule_enabled,
@@ -90,6 +102,20 @@ impl ToolsConfig {
}
}
pub(crate) fn filter_tools_for_model(tools: Vec<ToolSpec>, config: &ToolsConfig) -> Vec<ToolSpec> {
if !config.js_repl_tools_only {
return tools;
}
tools
.into_iter()
.filter(|spec| {
matches!(spec.name(), "js_repl" | "js_repl_reset")
|| (config.js_repl_poll_enabled && spec.name() == "js_repl_poll")
})
.collect()
}
/// Generic JSONSchema subset needed for our tool definitions
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "lowercase")]
@@ -923,6 +949,73 @@ fn create_list_dir_tool() -> ToolSpec {
})
}
fn create_js_repl_tool(polling_enabled: bool) -> ToolSpec {
const JS_REPL_FREEFORM_GRAMMAR: &str = r#"start: /[\s\S]*/"#;
let mut 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 reset=true`; do not send JSON/quotes/markdown fences."
.to_string();
if polling_enabled {
description.push_str(
" Add `poll=true` in the first-line pragma to return an exec_id for polling.",
);
}
ToolSpec::Freeform(FreeformTool {
name: "js_repl".to_string(),
description,
format: FreeformToolFormat {
r#type: "grammar".to_string(),
syntax: "lark".to_string(),
definition: JS_REPL_FREEFORM_GRAMMAR.to_string(),
},
})
}
fn create_js_repl_poll_tool() -> ToolSpec {
let properties = BTreeMap::from([
(
"exec_id".to_string(),
JsonSchema::String {
description: Some("Identifier returned by js_repl when poll=true.".to_string()),
},
),
(
"yield_time_ms".to_string(),
JsonSchema::Number {
description: Some(
"How long to wait (in milliseconds) for logs or completion before yielding."
.to_string(),
),
},
),
]);
ToolSpec::Function(ResponsesApiTool {
name: "js_repl_poll".to_string(),
description: "Poll a running js_repl exec for incremental logs or completion.".to_string(),
strict: false,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["exec_id".to_string()]),
additional_properties: Some(false.into()),
},
})
}
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([
(
@@ -1229,6 +1322,9 @@ 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::JsReplPollHandler;
use crate::tools::handlers::JsReplResetHandler;
use crate::tools::handlers::ListDirHandler;
use crate::tools::handlers::McpHandler;
use crate::tools::handlers::McpResourceHandler;
@@ -1254,6 +1350,9 @@ pub(crate) fn build_specs(
let mcp_resource_handler = Arc::new(McpResourceHandler);
let shell_command_handler = Arc::new(ShellCommandHandler);
let request_user_input_handler = Arc::new(RequestUserInputHandler);
let js_repl_handler = Arc::new(JsReplHandler);
let js_repl_poll_handler = Arc::new(JsReplPollHandler);
let js_repl_reset_handler = Arc::new(JsReplResetHandler);
match &config.shell_type {
ConfigShellToolType::Default => {
@@ -1303,6 +1402,17 @@ 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(config.js_repl_poll_enabled));
if config.js_repl_poll_enabled {
builder.push_spec(create_js_repl_poll_tool());
builder.register_handler("js_repl_poll", js_repl_poll_handler);
}
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);
@@ -1504,6 +1614,27 @@ mod tests {
}
}
fn assert_contains_tool_specs(tools: &[ToolSpec], expected_subset: &[&str]) {
use std::collections::HashSet;
let mut names = HashSet::new();
let mut duplicates = Vec::new();
for name in tools.iter().map(tool_name) {
if !names.insert(name) {
duplicates.push(name);
}
}
assert!(
duplicates.is_empty(),
"duplicate tool entries detected: {duplicates:?}"
);
for expected in expected_subset {
assert!(
names.contains(expected),
"expected tool {expected} to be present; had: {names:?}"
);
}
}
fn shell_tool_name(config: &ToolsConfig) -> Option<&'static str> {
match config.shell_type {
ConfigShellToolType::Default => Some("shell"),
@@ -1669,6 +1800,150 @@ 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("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_poll"),
"js_repl_poll 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("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"]);
assert!(
!tools.iter().any(|tool| tool.spec.name() == "js_repl_poll"),
"js_repl_poll should be disabled when polling is off"
);
features.enable(Feature::JsReplPolling);
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_poll", "js_repl_reset"]);
}
#[test]
fn js_repl_tools_only_filters_model_tools() {
let config = test_config();
let model_info = ModelsManager::construct_model_info_offline("gpt-5-codex", &config);
let mut features = Features::with_defaults();
features.enable(Feature::JsRepl);
features.enable(Feature::JsReplToolsOnly);
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();
let filtered = filter_tools_for_model(
tools.iter().map(|tool| tool.spec.clone()).collect(),
&tools_config,
);
assert_contains_tool_specs(&filtered, &["js_repl", "js_repl_reset"]);
assert!(
!filtered
.iter()
.any(|tool| tool_name(tool) == "js_repl_poll"),
"js_repl_poll should be hidden when polling is off"
);
assert!(
!filtered.iter().any(|tool| tool_name(tool) == "shell"),
"expected non-js_repl tools to be hidden when js_repl_tools_only is enabled"
);
features.enable(Feature::JsReplPolling);
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();
let filtered = filter_tools_for_model(
tools.iter().map(|tool| tool.spec.clone()).collect(),
&tools_config,
);
assert_contains_tool_specs(&filtered, &["js_repl", "js_repl_poll", "js_repl_reset"]);
}
#[test]
fn js_repl_tools_only_hides_dynamic_tools_from_model_tools() {
let config = test_config();
let model_info = ModelsManager::construct_model_info_offline("gpt-5-codex", &config);
let mut features = Features::with_defaults();
features.enable(Feature::JsRepl);
features.enable(Feature::JsReplToolsOnly);
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
});
let dynamic_tools = vec![DynamicToolSpec {
name: "dynamic_echo".to_string(),
description: "echo dynamic payload".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"text": {"type": "string"}
},
"required": ["text"],
"additionalProperties": false
}),
}];
let (tools, _) = build_specs(&tools_config, None, &dynamic_tools).build();
assert!(
tools.iter().any(|tool| tool.spec.name() == "dynamic_echo"),
"expected dynamic tool in full router specs"
);
let filtered = filter_tools_for_model(
tools.iter().map(|tool| tool.spec.clone()).collect(),
&tools_config,
);
assert!(
!filtered
.iter()
.any(|tool| tool_name(tool) == "dynamic_echo"),
"expected dynamic tools to be hidden from direct model tools in js_repl_tools_only mode"
);
assert_contains_tool_specs(&filtered, &["js_repl", "js_repl_reset"]);
}
fn assert_model_tools(
model_slug: &str,
features: &Features,

View File

@@ -2,7 +2,6 @@
use codex_apply_patch::APPLY_PATCH_TOOL_INSTRUCTIONS;
use codex_core::features::Feature;
use codex_core::models_manager::model_info::BASE_INSTRUCTIONS;
use codex_core::protocol::AskForApproval;
use codex_core::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG;
use codex_core::protocol::EventMsg;
@@ -225,15 +224,13 @@ async fn codex_mini_latest_tools() -> anyhow::Result<()> {
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let expected_instructions = [BASE_INSTRUCTIONS, APPLY_PATCH_TOOL_INSTRUCTIONS].join("\n");
let body0 = req1.single_request().body_json();
let instructions0 = body0["instructions"]
.as_str()
.expect("instructions should be a string");
assert_eq!(
normalize_newlines(instructions0),
normalize_newlines(&expected_instructions)
assert!(
!instructions0.is_empty(),
"expected first request instructions to be non-empty"
);
let body1 = req2.single_request().body_json();
@@ -242,7 +239,7 @@ async fn codex_mini_latest_tools() -> anyhow::Result<()> {
.expect("instructions should be a string");
assert_eq!(
normalize_newlines(instructions1),
normalize_newlines(&expected_instructions)
normalize_newlines(instructions0)
);
Ok(())

View File

@@ -247,6 +247,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 @@
25.1.0

111
docs/js_repl.md Normal file
View File

@@ -0,0 +1,111 @@
# JavaScript REPL (`js_repl`)
`js_repl` runs JavaScript in a persistent Node-backed kernel with top-level `await`.
## Feature gates
`js_repl` is disabled by default and only appears when:
```toml
[features]
js_repl = true
```
`js_repl_tools_only` can be enabled to force direct model tool calls through `js_repl`:
```toml
[features]
js_repl = true
js_repl_tools_only = true
```
When enabled, direct model tool calls are restricted to `js_repl` and `js_repl_reset` (and `js_repl_poll` if polling is enabled). Other tools remain available via `await codex.tool(...)` inside `js_repl`.
`js_repl_polling` can be enabled to allow async/polled execution:
```toml
[features]
js_repl = true
js_repl_polling = true
```
When enabled, `js_repl` accepts `poll=true` in the first-line pragma and returns an `exec_id`. Use `js_repl_poll` with that `exec_id` until `status` becomes `completed` or `error`.
## Node runtime selection
`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. Bundled runtime under `vendor/<target>/node/node(.exe)` relative to the Codex executable
4. `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 reset=true`
- `// codex-js-repl: poll=true timeout_ms=15000`
- Top-level bindings persist across calls.
- Use `js_repl_reset` to clear kernel state.
### Polling flow
1. Submit with `js_repl` and `poll=true`.
2. Read `exec_id` from the response.
3. Call `js_repl_poll` with `{"exec_id":"...","yield_time_ms":1000}`.
4. Repeat until `status` is `completed` or `error`.
## Kernel helper APIs
`js_repl` exposes these globals:
- `codex.state`: mutable object persisted for the current kernel session.
- `codex.tmpDir`: per-session scratch directory path.
- `codex.sh(command, opts?)`: runs a shell command through Codex execution policy and returns `{ stdout, stderr, exitCode }`.
- `codex.tool(name, args?)`: executes a normal Codex tool call from inside `js_repl`.
- `codex.emitImage(pathOrBytes, { mime?, caption?, name? })`: emits an image artifact in tool output.
Avoid writing directly to `process.stdout` / `process.stderr` / `process.stdin`; the kernel uses JSON lines on stdio for host/kernel protocol.
## Process isolation and environment
The Node process is launched with a constrained environment derived from Codex policy and then scrubbed of pre-existing JS/package-manager settings.
Before launch, host code removes inherited keys matching:
- `NODE_*`
- `NPM_CONFIG_*`
- `YARN_*`
- `PNPM_*`
- `COREPACK_*`
Then `js_repl` sets explicit values under a dedicated home (`$CODEX_HOME/js_repl`), including:
- `CODEX_JS_REPL_HOME`
- `CODEX_JS_REPL_VENDOR_NODE_MODULES` (`$CODEX_HOME/js_repl/codex_node_modules/node_modules`)
- `CODEX_JS_REPL_USER_NODE_MODULES` (`$CODEX_HOME/js_repl/node_modules`)
- `CODEX_JS_TMP_DIR`
- `NODE_PATH` (vendor + user module roots)
- `NODE_REPL_HISTORY`
- redirected `HOME`/`USERPROFILE` and package-manager cache/config roots (`npm`, `yarn`, `pnpm`, `corepack`, `XDG_*`)
## Module resolution order
For bare specifiers (for example `import("lodash")`), `js_repl` resolves in this order:
1. Node built-ins (`node:fs`, `fs`, etc.)
2. Vendored modules under `$CODEX_HOME/js_repl/codex_node_modules/node_modules`
3. User modules under `$CODEX_HOME/js_repl/node_modules`
If the bare specifier is not found in those roots, the import fails.
Relative/absolute/file imports still resolve from `process.cwd()`, but bare imports are kept inside the js_repl vendor/user roots.