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:
parent
87279de434
commit
42e22f3bde
21 changed files with 1611 additions and 5 deletions
|
|
@ -1,6 +1,6 @@
|
|||
[codespell]
|
||||
# Ref: https://github.com/codespell-project/codespell#using-a-config-file
|
||||
skip = .git*,vendor,*-lock.yaml,*.lock,.codespellrc,*test.ts,*.jsonl,frame*.txt,*.snap,*.snap.new
|
||||
skip = .git*,vendor,*-lock.yaml,*.lock,.codespellrc,*test.ts,*.jsonl,frame*.txt,*.snap,*.snap.new,*meriyah.umd.min.js
|
||||
check-hidden = true
|
||||
ignore-regex = ^\s*"image/\S+": ".*|\b(afterAll)\b
|
||||
ignore-words-list = ratatui,ser,iTerm,iterm2,iterm
|
||||
|
|
|
|||
3
NOTICE
3
NOTICE
|
|
@ -4,3 +4,6 @@ Copyright 2025 OpenAI
|
|||
This project includes code derived from [Ratatui](https://github.com/ratatui/ratatui), licensed under the MIT license.
|
||||
Copyright (c) 2016-2022 Florian Dehau
|
||||
Copyright (c) 2023-2025 The Ratatui Developers
|
||||
|
||||
This project includes Meriyah parser assets from [meriyah](https://github.com/meriyah/meriyah), licensed under the ISC license.
|
||||
Copyright (c) 2019 and later, KFlash and others.
|
||||
|
|
|
|||
|
|
@ -1 +1,3 @@
|
|||
|
||||
exports_files([
|
||||
"node-version.txt",
|
||||
])
|
||||
|
|
|
|||
|
|
@ -11,7 +11,9 @@ codex_rust_crate(
|
|||
"Cargo.toml",
|
||||
],
|
||||
allow_empty = True,
|
||||
),
|
||||
) + [
|
||||
"//codex-rs:node-version.txt",
|
||||
],
|
||||
integration_compile_data_extra = [
|
||||
"//codex-rs/apply-patch:apply_patch_tool_instructions.md",
|
||||
"models.json",
|
||||
|
|
|
|||
|
|
@ -221,6 +221,9 @@
|
|||
"include_apply_patch_tool": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"js_repl": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"memory_tool": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
|
@ -290,6 +293,9 @@
|
|||
"include_apply_patch_tool": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"js_repl_node_path": {
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
|
|
@ -1299,6 +1305,9 @@
|
|||
"include_apply_patch_tool": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"js_repl": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"memory_tool": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
|
@ -1421,6 +1430,14 @@
|
|||
"description": "System instructions.",
|
||||
"type": "string"
|
||||
},
|
||||
"js_repl_node_path": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
}
|
||||
],
|
||||
"description": "Optional absolute path to the Node runtime used by `js_repl`."
|
||||
},
|
||||
"log_dir": {
|
||||
"allOf": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -220,6 +220,7 @@ use crate::tasks::SessionTask;
|
|||
use crate::tasks::SessionTaskContext;
|
||||
use crate::tools::ToolRouter;
|
||||
use crate::tools::context::SharedTurnDiffTracker;
|
||||
use crate::tools::js_repl::JsReplHandle;
|
||||
use crate::tools::parallel::ToolCallRuntime;
|
||||
use crate::tools::sandboxing::ApprovalStore;
|
||||
use crate::tools::spec::ToolsConfig;
|
||||
|
|
@ -510,6 +511,7 @@ pub(crate) struct Session {
|
|||
pending_mcp_server_refresh_config: Mutex<Option<McpServerRefreshConfig>>,
|
||||
pub(crate) active_turn: Mutex<Option<ActiveTurn>>,
|
||||
pub(crate) services: SessionServices,
|
||||
js_repl: Arc<JsReplHandle>,
|
||||
next_internal_sub_id: AtomicU64,
|
||||
}
|
||||
|
||||
|
|
@ -549,6 +551,7 @@ pub(crate) struct TurnContext {
|
|||
pub(crate) codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
pub(crate) tool_call_gate: Arc<ReadinessFlag>,
|
||||
pub(crate) truncation_policy: TruncationPolicy,
|
||||
pub(crate) js_repl: Arc<JsReplHandle>,
|
||||
pub(crate) dynamic_tools: Vec<DynamicToolSpec>,
|
||||
turn_metadata_header: OnceCell<Option<String>>,
|
||||
}
|
||||
|
|
@ -809,6 +812,7 @@ impl Session {
|
|||
model_info: ModelInfo,
|
||||
network: Option<NetworkProxy>,
|
||||
sub_id: String,
|
||||
js_repl: Arc<JsReplHandle>,
|
||||
) -> TurnContext {
|
||||
let reasoning_effort = session_configuration.collaboration_mode.reasoning_effort();
|
||||
let reasoning_summary = session_configuration.model_reasoning_summary;
|
||||
|
|
@ -857,6 +861,7 @@ impl Session {
|
|||
codex_linux_sandbox_exe: per_turn_config.codex_linux_sandbox_exe.clone(),
|
||||
tool_call_gate: Arc::new(ReadinessFlag::new()),
|
||||
truncation_policy: model_info.truncation_policy.into(),
|
||||
js_repl,
|
||||
dynamic_tools: session_configuration.dynamic_tools.clone(),
|
||||
turn_metadata_header: OnceCell::new(),
|
||||
}
|
||||
|
|
@ -1123,6 +1128,10 @@ impl Session {
|
|||
Self::build_model_client_beta_features_header(config.as_ref()),
|
||||
),
|
||||
};
|
||||
let js_repl = Arc::new(JsReplHandle::with_node_path(
|
||||
config.js_repl_node_path.clone(),
|
||||
config.codex_home.clone(),
|
||||
));
|
||||
|
||||
let prewarm_model_info = models_manager
|
||||
.get_model_info(session_configuration.collaboration_mode.model(), &config)
|
||||
|
|
@ -1150,6 +1159,7 @@ impl Session {
|
|||
pending_mcp_server_refresh_config: Mutex::new(None),
|
||||
active_turn: Mutex::new(None),
|
||||
services,
|
||||
js_repl,
|
||||
next_internal_sub_id: AtomicU64::new(0),
|
||||
});
|
||||
|
||||
|
|
@ -1598,6 +1608,7 @@ impl Session {
|
|||
.as_ref()
|
||||
.map(StartedNetworkProxy::proxy),
|
||||
sub_id,
|
||||
Arc::clone(&self.js_repl),
|
||||
);
|
||||
|
||||
if let Some(final_schema) = final_output_json_schema {
|
||||
|
|
@ -3766,6 +3777,7 @@ async fn spawn_review_thread(
|
|||
final_output_json_schema: None,
|
||||
codex_linux_sandbox_exe: parent_turn_context.codex_linux_sandbox_exe.clone(),
|
||||
tool_call_gate: Arc::new(ReadinessFlag::new()),
|
||||
js_repl: Arc::clone(&sess.js_repl),
|
||||
dynamic_tools: parent_turn_context.dynamic_tools.clone(),
|
||||
truncation_policy: model_info.truncation_policy.into(),
|
||||
turn_metadata_header: parent_turn_context.turn_metadata_header.clone(),
|
||||
|
|
@ -4253,11 +4265,13 @@ async fn run_sampling_request(
|
|||
|
||||
let model_supports_parallel = turn_context.model_info.supports_parallel_tool_calls;
|
||||
|
||||
let tools =
|
||||
crate::tools::spec::filter_tools_for_model(router.specs(), &turn_context.tools_config);
|
||||
let base_instructions = sess.get_base_instructions().await;
|
||||
|
||||
let prompt = Prompt {
|
||||
input,
|
||||
tools: router.specs(),
|
||||
tools,
|
||||
parallel_tool_calls: model_supports_parallel,
|
||||
base_instructions,
|
||||
personality: turn_context.personality,
|
||||
|
|
@ -5280,9 +5294,9 @@ mod tests {
|
|||
];
|
||||
|
||||
let (session, _turn_context) = make_session_and_context().await;
|
||||
let config = test_config();
|
||||
|
||||
for test_case in test_cases {
|
||||
let config = test_config();
|
||||
let model_info = model_info_for_slug(test_case.slug, &config);
|
||||
if test_case.expects_apply_patch_instructions {
|
||||
assert_eq!(
|
||||
|
|
@ -6298,6 +6312,10 @@ mod tests {
|
|||
Session::build_model_client_beta_features_header(config.as_ref()),
|
||||
),
|
||||
};
|
||||
let js_repl = Arc::new(JsReplHandle::with_node_path(
|
||||
config.js_repl_node_path.clone(),
|
||||
config.codex_home.clone(),
|
||||
));
|
||||
|
||||
let turn_context = Session::make_turn_context(
|
||||
Some(Arc::clone(&auth_manager)),
|
||||
|
|
@ -6308,6 +6326,7 @@ mod tests {
|
|||
model_info,
|
||||
None,
|
||||
"turn_id".to_string(),
|
||||
Arc::clone(&js_repl),
|
||||
);
|
||||
|
||||
let session = Session {
|
||||
|
|
@ -6319,6 +6338,7 @@ mod tests {
|
|||
pending_mcp_server_refresh_config: Mutex::new(None),
|
||||
active_turn: Mutex::new(None),
|
||||
services,
|
||||
js_repl,
|
||||
next_internal_sub_id: AtomicU64::new(0),
|
||||
};
|
||||
|
||||
|
|
@ -6435,6 +6455,10 @@ mod tests {
|
|||
Session::build_model_client_beta_features_header(config.as_ref()),
|
||||
),
|
||||
};
|
||||
let js_repl = Arc::new(JsReplHandle::with_node_path(
|
||||
config.js_repl_node_path.clone(),
|
||||
config.codex_home.clone(),
|
||||
));
|
||||
|
||||
let turn_context = Arc::new(Session::make_turn_context(
|
||||
Some(Arc::clone(&auth_manager)),
|
||||
|
|
@ -6445,6 +6469,7 @@ mod tests {
|
|||
model_info,
|
||||
None,
|
||||
"turn_id".to_string(),
|
||||
Arc::clone(&js_repl),
|
||||
));
|
||||
|
||||
let session = Arc::new(Session {
|
||||
|
|
@ -6456,6 +6481,7 @@ mod tests {
|
|||
pending_mcp_server_refresh_config: Mutex::new(None),
|
||||
active_turn: Mutex::new(None),
|
||||
services,
|
||||
js_repl,
|
||||
next_internal_sub_id: AtomicU64::new(0),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -304,6 +304,9 @@ pub struct Config {
|
|||
/// When this program is invoked, arg0 will be set to `codex-linux-sandbox`.
|
||||
pub codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
|
||||
/// Optional absolute path to the Node runtime used by `js_repl`.
|
||||
pub js_repl_node_path: Option<PathBuf>,
|
||||
|
||||
/// Value to use for `reasoning.effort` when making a request using the
|
||||
/// Responses API.
|
||||
pub model_reasoning_effort: Option<ReasoningEffort>,
|
||||
|
|
@ -939,6 +942,9 @@ pub struct ConfigToml {
|
|||
/// Token budget applied when storing tool/function outputs in the context manager.
|
||||
pub tool_output_token_limit: Option<usize>,
|
||||
|
||||
/// Optional absolute path to the Node runtime used by `js_repl`.
|
||||
pub js_repl_node_path: Option<AbsolutePathBuf>,
|
||||
|
||||
/// Profile to use from the `profiles` map.
|
||||
pub profile: Option<String>,
|
||||
|
||||
|
|
@ -1282,6 +1288,7 @@ pub struct ConfigOverrides {
|
|||
pub model_provider: Option<String>,
|
||||
pub config_profile: Option<String>,
|
||||
pub codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
pub js_repl_node_path: Option<PathBuf>,
|
||||
pub base_instructions: Option<String>,
|
||||
pub developer_instructions: Option<String>,
|
||||
pub personality: Option<Personality>,
|
||||
|
|
@ -1408,6 +1415,7 @@ impl Config {
|
|||
model_provider,
|
||||
config_profile: config_profile_key,
|
||||
codex_linux_sandbox_exe,
|
||||
js_repl_node_path: js_repl_node_path_override,
|
||||
base_instructions,
|
||||
developer_instructions,
|
||||
personality,
|
||||
|
|
@ -1641,6 +1649,9 @@ impl Config {
|
|||
"experimental compact prompt file",
|
||||
)?;
|
||||
let compact_prompt = compact_prompt.or(file_compact_prompt);
|
||||
let js_repl_node_path = js_repl_node_path_override
|
||||
.or(config_profile.js_repl_node_path.map(Into::into))
|
||||
.or(cfg.js_repl_node_path.map(Into::into));
|
||||
|
||||
let review_model = override_review_model.or(cfg.review_model);
|
||||
|
||||
|
|
@ -1757,6 +1768,7 @@ impl Config {
|
|||
ephemeral: ephemeral.unwrap_or_default(),
|
||||
file_opener: cfg.file_opener.unwrap_or(UriBasedFileOpener::VsCode),
|
||||
codex_linux_sandbox_exe,
|
||||
js_repl_node_path,
|
||||
|
||||
hide_agent_reasoning: cfg.hide_agent_reasoning.unwrap_or(false),
|
||||
show_raw_agent_reasoning: cfg
|
||||
|
|
@ -4026,6 +4038,7 @@ model_verbosity = "high"
|
|||
ephemeral: false,
|
||||
file_opener: UriBasedFileOpener::VsCode,
|
||||
codex_linux_sandbox_exe: None,
|
||||
js_repl_node_path: None,
|
||||
hide_agent_reasoning: false,
|
||||
show_raw_agent_reasoning: false,
|
||||
model_reasoning_effort: Some(ReasoningEffort::High),
|
||||
|
|
@ -4132,6 +4145,7 @@ model_verbosity = "high"
|
|||
ephemeral: false,
|
||||
file_opener: UriBasedFileOpener::VsCode,
|
||||
codex_linux_sandbox_exe: None,
|
||||
js_repl_node_path: None,
|
||||
hide_agent_reasoning: false,
|
||||
show_raw_agent_reasoning: false,
|
||||
model_reasoning_effort: None,
|
||||
|
|
@ -4236,6 +4250,7 @@ model_verbosity = "high"
|
|||
ephemeral: false,
|
||||
file_opener: UriBasedFileOpener::VsCode,
|
||||
codex_linux_sandbox_exe: None,
|
||||
js_repl_node_path: None,
|
||||
hide_agent_reasoning: false,
|
||||
show_raw_agent_reasoning: false,
|
||||
model_reasoning_effort: None,
|
||||
|
|
@ -4326,6 +4341,7 @@ model_verbosity = "high"
|
|||
ephemeral: false,
|
||||
file_opener: UriBasedFileOpener::VsCode,
|
||||
codex_linux_sandbox_exe: None,
|
||||
js_repl_node_path: None,
|
||||
hide_agent_reasoning: false,
|
||||
show_raw_agent_reasoning: false,
|
||||
model_reasoning_effort: Some(ReasoningEffort::High),
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ pub struct ConfigProfile {
|
|||
pub chatgpt_base_url: Option<String>,
|
||||
/// Optional path to a file containing model instructions.
|
||||
pub model_instructions_file: Option<AbsolutePathBuf>,
|
||||
pub js_repl_node_path: Option<AbsolutePathBuf>,
|
||||
/// Deprecated: ignored. Use `model_instructions_file`.
|
||||
#[schemars(skip)]
|
||||
pub experimental_instructions_file: Option<AbsolutePathBuf>,
|
||||
|
|
|
|||
|
|
@ -78,6 +78,8 @@ pub enum Feature {
|
|||
ShellTool,
|
||||
|
||||
// Experimental
|
||||
/// Enable JavaScript REPL tools backed by a persistent Node kernel.
|
||||
JsRepl,
|
||||
/// Use the single unified PTY-backed exec tool.
|
||||
UnifiedExec,
|
||||
/// Include the freeform apply_patch tool.
|
||||
|
|
@ -422,6 +424,12 @@ pub const FEATURES: &[FeatureSpec] = &[
|
|||
stage: Stage::Stable,
|
||||
default_enabled: true,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::JsRepl,
|
||||
key: "js_repl",
|
||||
stage: Stage::UnderDevelopment,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::WebSearchRequest,
|
||||
key: "web_search_request",
|
||||
|
|
|
|||
|
|
@ -34,6 +34,25 @@ pub const LOCAL_PROJECT_DOC_FILENAME: &str = "AGENTS.override.md";
|
|||
/// be concatenated with the following separator.
|
||||
const PROJECT_DOC_SEPARATOR: &str = "\n\n--- project-doc ---\n\n";
|
||||
|
||||
fn render_js_repl_instructions(config: &Config) -> Option<String> {
|
||||
if !config.features.enabled(Feature::JsRepl) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut section = String::from("## JavaScript REPL (Node)\n");
|
||||
section.push_str("- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel. `codex.state` persists for the session (best effort) and is cleared by `js_repl_reset`.\n");
|
||||
section.push_str("- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n");
|
||||
section.push_str("- Helpers available in `js_repl`: `codex.state` and `codex.tmpDir`.\n");
|
||||
section.push_str("- Top-level bindings persist across cells. If you hit `SyntaxError: Identifier 'x' has already been declared`, reuse the binding, pick a new name, wrap in `{ ... }` for block scope, or reset the kernel with `js_repl_reset`.\n");
|
||||
section.push_str("- Top-level static import declarations (for example `import x from \"pkg\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")` instead.\n");
|
||||
|
||||
section.push_str(
|
||||
"- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`.",
|
||||
);
|
||||
|
||||
Some(section)
|
||||
}
|
||||
|
||||
/// Combines `Config::instructions` and `AGENTS.md` (if present) into a single
|
||||
/// string of instructions.
|
||||
pub(crate) async fn get_user_instructions(
|
||||
|
|
@ -61,6 +80,13 @@ pub(crate) async fn get_user_instructions(
|
|||
}
|
||||
};
|
||||
|
||||
if let Some(js_repl_section) = render_js_repl_instructions(config) {
|
||||
if !output.is_empty() {
|
||||
output.push_str("\n\n");
|
||||
}
|
||||
output.push_str(&js_repl_section);
|
||||
}
|
||||
|
||||
let skills_section = skills.and_then(render_skills_section);
|
||||
if let Some(skills_section) = skills_section {
|
||||
if !output.is_empty() {
|
||||
|
|
@ -236,6 +262,7 @@ fn candidate_filenames<'a>(config: &'a Config) -> Vec<&'a str> {
|
|||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::ConfigBuilder;
|
||||
use crate::features::Feature;
|
||||
use crate::skills::load_skills;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
|
@ -364,6 +391,19 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn js_repl_instructions_are_appended_when_enabled() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let mut cfg = make_config(&tmp, 4096, None).await;
|
||||
cfg.features.enable(Feature::JsRepl);
|
||||
|
||||
let res = get_user_instructions(&cfg, None)
|
||||
.await
|
||||
.expect("js_repl instructions expected");
|
||||
let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel. `codex.state` persists for the session (best effort) and is cleared by `js_repl_reset`.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers available in `js_repl`: `codex.state` and `codex.tmpDir`.\n- Top-level bindings persist across cells. If you hit `SyntaxError: Identifier 'x' has already been declared`, reuse the binding, pick a new name, wrap in `{ ... }` for block scope, or reset the kernel with `js_repl_reset`.\n- Top-level static import declarations (for example `import x from \"pkg\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")` instead.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`.";
|
||||
assert_eq!(res, expected);
|
||||
}
|
||||
|
||||
/// When both system instructions *and* a project doc are present the two
|
||||
/// should be concatenated with the separator.
|
||||
#[tokio::test]
|
||||
|
|
|
|||
224
codex-rs/core/src/tools/handlers/js_repl.rs
Normal file
224
codex-rs/core/src/tools/handlers/js_repl.rs
Normal 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."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
codex-rs/core/src/tools/js_repl/kernel.js
Normal file
324
codex-rs/core/src/tools/js_repl/kernel.js
Normal 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));
|
||||
}
|
||||
});
|
||||
6
codex-rs/core/src/tools/js_repl/meriyah.umd.min.js
vendored
Normal file
6
codex-rs/core/src/tools/js_repl/meriyah.umd.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
754
codex-rs/core/src/tools/js_repl/mod.rs
Normal file
754
codex-rs/core/src/tools/js_repl/mod.rs
Normal 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,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;
|
||||
|
|
|
|||
|
|
@ -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 JSON‑Schema 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,
|
||||
|
|
|
|||
|
|
@ -249,6 +249,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
|||
cwd: resolved_cwd,
|
||||
model_provider: model_provider.clone(),
|
||||
codex_linux_sandbox_exe,
|
||||
js_repl_node_path: None,
|
||||
base_instructions: None,
|
||||
developer_instructions: None,
|
||||
personality: None,
|
||||
|
|
|
|||
1
codex-rs/node-version.txt
Normal file
1
codex-rs/node-version.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
24.13.1
|
||||
71
docs/js_repl.md
Normal file
71
docs/js_repl.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# JavaScript REPL (`js_repl`)
|
||||
|
||||
`js_repl` runs JavaScript in a persistent Node-backed kernel with top-level `await`.
|
||||
|
||||
## Feature gate
|
||||
|
||||
`js_repl` is disabled by default and only appears when:
|
||||
|
||||
```toml
|
||||
[features]
|
||||
js_repl = true
|
||||
```
|
||||
|
||||
## Node runtime
|
||||
|
||||
`js_repl` requires a Node version that meets or exceeds `codex-rs/node-version.txt`.
|
||||
|
||||
Runtime resolution order:
|
||||
|
||||
1. `CODEX_JS_REPL_NODE_PATH` environment variable
|
||||
2. `js_repl_node_path` in config/profile
|
||||
3. `node` discovered on `PATH`
|
||||
|
||||
You can configure an explicit runtime path:
|
||||
|
||||
```toml
|
||||
js_repl_node_path = "/absolute/path/to/node"
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
- `js_repl` is a freeform tool: send raw JavaScript source text.
|
||||
- Optional first-line pragma:
|
||||
- `// codex-js-repl: timeout_ms=15000`
|
||||
- Top-level bindings persist across calls.
|
||||
- Top-level static import declarations (for example `import x from "pkg"`) are currently unsupported; use dynamic imports with `await import("pkg")`.
|
||||
- Use `js_repl_reset` to clear kernel state.
|
||||
|
||||
## Vendored parser asset (`meriyah.umd.min.js`)
|
||||
|
||||
The kernel embeds a vendored Meriyah bundle at:
|
||||
|
||||
- `codex-rs/core/src/tools/js_repl/meriyah.umd.min.js`
|
||||
|
||||
Current source is `meriyah@7.0.0` from npm (`dist/meriyah.umd.min.js`).
|
||||
Licensing is tracked in:
|
||||
|
||||
- `third_party/meriyah/LICENSE`
|
||||
- `NOTICE`
|
||||
|
||||
### How this file was sourced
|
||||
|
||||
From a clean temp directory:
|
||||
|
||||
```sh
|
||||
tmp="$(mktemp -d)"
|
||||
cd "$tmp"
|
||||
npm pack meriyah@7.0.0
|
||||
tar -xzf meriyah-7.0.0.tgz
|
||||
cp package/dist/meriyah.umd.min.js /path/to/repo/codex-rs/core/src/tools/js_repl/meriyah.umd.min.js
|
||||
cp package/LICENSE.md /path/to/repo/third_party/meriyah/LICENSE
|
||||
```
|
||||
|
||||
### How to update to a newer version
|
||||
|
||||
1. Replace `7.0.0` in the commands above with the target version.
|
||||
2. Copy the new `dist/meriyah.umd.min.js` into `codex-rs/core/src/tools/js_repl/meriyah.umd.min.js`.
|
||||
3. Copy the package license into `third_party/meriyah/LICENSE`.
|
||||
4. Update the version string in the header comment at the top of `meriyah.umd.min.js`.
|
||||
5. Update `NOTICE` if the upstream copyright notice changed.
|
||||
6. Run the relevant `js_repl` tests.
|
||||
15
third_party/meriyah/LICENSE
vendored
Normal file
15
third_party/meriyah/LICENSE
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
ISC License
|
||||
|
||||
Copyright (c) 2019 and later, KFlash and others.
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted, provided that the above
|
||||
copyright notice and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
PERFORMANCE OF THIS SOFTWARE.
|
||||
Loading…
Add table
Reference in a new issue