From f600453699d9995ea8e21a39a768e7d09152d34f Mon Sep 17 00:00:00 2001 From: aaronl-openai Date: Tue, 17 Feb 2026 23:29:49 -0800 Subject: [PATCH] [js_repl] paths for node module resolution can be specified for js_repl (#11944) # External (non-OpenAI) Pull Request Requirements In `js_repl` mode, module resolution currently starts from `js_repl_kernel.js`, which is written to a per-kernel temp dir. This effectively means that bare imports will not resolve. This PR adds a new config option, `js_repl_node_module_dirs`, which is a list of dirs that are used (in order) to resolve a bare import. If none of those work, the current working directory of the thread is used. For example: ```toml js_repl_node_module_dirs = [ "/path/to/node_modules/", "/other/path/to/node_modules/", ] ``` --- codex-rs/core/config.schema.json | 14 ++ codex-rs/core/src/codex.rs | 6 +- codex-rs/core/src/config/mod.rs | 26 +++ codex-rs/core/src/config/profile.rs | 4 +- codex-rs/core/src/tools/js_repl/kernel.js | 183 +++++++++++++-- codex-rs/core/src/tools/js_repl/mod.rs | 259 ++++++++++++++++++++-- codex-rs/exec/src/lib.rs | 1 + docs/js_repl.md | 13 ++ 8 files changed, 461 insertions(+), 45 deletions(-) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index a2d974073..9d7257a39 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -331,6 +331,13 @@ "include_apply_patch_tool": { "type": "boolean" }, + "js_repl_node_module_dirs": { + "description": "Ordered list of directories to search for Node modules in `js_repl`.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + }, "js_repl_node_path": { "$ref": "#/definitions/AbsolutePathBuf" }, @@ -1518,6 +1525,13 @@ "description": "System instructions.", "type": "string" }, + "js_repl_node_module_dirs": { + "description": "Ordered list of directories to search for Node modules in `js_repl`.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + }, "js_repl_node_path": { "allOf": [ { diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index b054423b6..8f4660a6e 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1327,7 +1327,7 @@ impl Session { }; let js_repl = Arc::new(JsReplHandle::with_node_path( config.js_repl_node_path.clone(), - config.codex_home.clone(), + config.js_repl_node_module_dirs.clone(), )); let prewarm_model_info = models_manager @@ -7413,7 +7413,7 @@ mod tests { }; let js_repl = Arc::new(JsReplHandle::with_node_path( config.js_repl_node_path.clone(), - config.codex_home.clone(), + config.js_repl_node_module_dirs.clone(), )); let turn_context = Session::make_turn_context( @@ -7562,7 +7562,7 @@ mod tests { }; let js_repl = Arc::new(JsReplHandle::with_node_path( config.js_repl_node_path.clone(), - config.codex_home.clone(), + config.js_repl_node_module_dirs.clone(), )); let turn_context = Arc::new(Session::make_turn_context( diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 7b1e754df..3bd47ad16 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -336,6 +336,10 @@ pub struct Config { /// Optional absolute path to the Node runtime used by `js_repl`. pub js_repl_node_path: Option, + + /// Ordered list of directories to search for Node modules in `js_repl`. + pub js_repl_node_module_dirs: Vec, + /// Optional absolute path to patched zsh used by zsh-exec-bridge-backed shell execution. pub zsh_path: Option, @@ -977,6 +981,10 @@ pub struct ConfigToml { /// Optional absolute path to the Node runtime used by `js_repl`. pub js_repl_node_path: Option, + + /// Ordered list of directories to search for Node modules in `js_repl`. + pub js_repl_node_module_dirs: Option>, + /// Optional absolute path to patched zsh used by zsh-exec-bridge-backed shell execution. pub zsh_path: Option, @@ -1357,6 +1365,7 @@ pub struct ConfigOverrides { pub config_profile: Option, pub codex_linux_sandbox_exe: Option, pub js_repl_node_path: Option, + pub js_repl_node_module_dirs: Option>, pub zsh_path: Option, pub base_instructions: Option, pub developer_instructions: Option, @@ -1485,6 +1494,7 @@ impl Config { config_profile: config_profile_key, codex_linux_sandbox_exe, js_repl_node_path: js_repl_node_path_override, + js_repl_node_module_dirs: js_repl_node_module_dirs_override, zsh_path: zsh_path_override, base_instructions, developer_instructions, @@ -1746,6 +1756,17 @@ impl Config { 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 js_repl_node_module_dirs = js_repl_node_module_dirs_override + .or_else(|| { + config_profile + .js_repl_node_module_dirs + .map(|dirs| dirs.into_iter().map(Into::into).collect::>()) + }) + .or_else(|| { + cfg.js_repl_node_module_dirs + .map(|dirs| dirs.into_iter().map(Into::into).collect::>()) + }) + .unwrap_or_default(); let zsh_path = zsh_path_override .or(config_profile.zsh_path.map(Into::into)) .or(cfg.zsh_path.map(Into::into)); @@ -1873,6 +1894,7 @@ impl Config { file_opener: cfg.file_opener.unwrap_or(UriBasedFileOpener::VsCode), codex_linux_sandbox_exe, js_repl_node_path, + js_repl_node_module_dirs, zsh_path, hide_agent_reasoning: cfg.hide_agent_reasoning.unwrap_or(false), @@ -4202,6 +4224,7 @@ model_verbosity = "high" file_opener: UriBasedFileOpener::VsCode, codex_linux_sandbox_exe: None, js_repl_node_path: None, + js_repl_node_module_dirs: Vec::new(), zsh_path: None, hide_agent_reasoning: false, show_raw_agent_reasoning: false, @@ -4315,6 +4338,7 @@ model_verbosity = "high" file_opener: UriBasedFileOpener::VsCode, codex_linux_sandbox_exe: None, js_repl_node_path: None, + js_repl_node_module_dirs: Vec::new(), zsh_path: None, hide_agent_reasoning: false, show_raw_agent_reasoning: false, @@ -4426,6 +4450,7 @@ model_verbosity = "high" file_opener: UriBasedFileOpener::VsCode, codex_linux_sandbox_exe: None, js_repl_node_path: None, + js_repl_node_module_dirs: Vec::new(), zsh_path: None, hide_agent_reasoning: false, show_raw_agent_reasoning: false, @@ -4523,6 +4548,7 @@ model_verbosity = "high" file_opener: UriBasedFileOpener::VsCode, codex_linux_sandbox_exe: None, js_repl_node_path: None, + js_repl_node_module_dirs: Vec::new(), zsh_path: None, hide_agent_reasoning: false, show_raw_agent_reasoning: false, diff --git a/codex-rs/core/src/config/profile.rs b/codex-rs/core/src/config/profile.rs index 952331337..d8b7ab1ba 100644 --- a/codex-rs/core/src/config/profile.rs +++ b/codex-rs/core/src/config/profile.rs @@ -30,9 +30,11 @@ pub struct ConfigProfile { pub chatgpt_base_url: Option, /// Optional path to a file containing model instructions. pub model_instructions_file: Option, + pub js_repl_node_path: Option, + /// Ordered list of directories to search for Node modules in `js_repl`. + pub js_repl_node_module_dirs: Option>, /// Optional absolute path to patched zsh used by zsh-exec-bridge-backed shell execution. pub zsh_path: Option, - pub js_repl_node_path: Option, /// Deprecated: ignored. Use `model_instructions_file`. #[schemars(skip)] pub experimental_instructions_file: Option, diff --git a/codex-rs/core/src/tools/js_repl/kernel.js b/codex-rs/core/src/tools/js_repl/kernel.js index 9beadc80c..bcc9d1e36 100644 --- a/codex-rs/core/src/tools/js_repl/kernel.js +++ b/codex-rs/core/src/tools/js_repl/kernel.js @@ -4,7 +4,7 @@ const { Buffer } = require("node:buffer"); const crypto = require("node:crypto"); -const { builtinModules } = require("node:module"); +const { builtinModules, createRequire } = require("node:module"); const { createInterface } = require("node:readline"); const { performance } = require("node:perf_hooks"); const path = require("node:path"); @@ -13,7 +13,9 @@ const { inspect, TextDecoder, TextEncoder } = 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 meriyahPromise = import("./meriyah.umd.min.js").then( + (m) => m.default ?? m, +); // vm contexts start with very few globals. Populate common Node/web globals // so snippets and dependencies behave like a normal modern JS runtime. @@ -100,8 +102,12 @@ function toNodeBuiltinSpecifier(specifier) { } function isDeniedBuiltin(specifier) { - const normalized = specifier.startsWith("node:") ? specifier.slice(5) : specifier; - return deniedBuiltinModules.has(specifier) || deniedBuiltinModules.has(normalized); + const normalized = specifier.startsWith("node:") + ? specifier.slice(5) + : specifier; + return ( + deniedBuiltinModules.has(specifier) || deniedBuiltinModules.has(normalized) + ); } /** @type {Map void>} */ @@ -111,39 +117,146 @@ const tmpDir = process.env.CODEX_JS_TMP_DIR || process.cwd(); // 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 state = {}; +const nodeModuleDirEnv = process.env.CODEX_JS_REPL_NODE_MODULE_DIRS ?? ""; +const moduleSearchBases = (() => { + const bases = []; + const seen = new Set(); + for (const entry of nodeModuleDirEnv.split(path.delimiter)) { + const trimmed = entry.trim(); + if (!trimmed) { + continue; + } + const resolved = path.isAbsolute(trimmed) + ? trimmed + : path.resolve(process.cwd(), trimmed); + const base = + path.basename(resolved) === "node_modules" + ? path.dirname(resolved) + : resolved; + if (seen.has(base)) { + continue; + } + seen.add(base); + bases.push(base); + } + const cwd = process.cwd(); + if (!seen.has(cwd)) { + bases.push(cwd); + } + return bases; +})(); + +const importResolveConditions = new Set(["node", "import"]); +const requireByBase = new Map(); + +function getRequireForBase(base) { + let req = requireByBase.get(base); + if (!req) { + req = createRequire(path.join(base, "__codex_js_repl__.cjs")); + requireByBase.set(base, req); + } + return req; +} + +function isModuleNotFoundError(err) { + return ( + err?.code === "MODULE_NOT_FOUND" || err?.code === "ERR_MODULE_NOT_FOUND" + ); +} + +function isWithinBaseNodeModules(base, resolvedPath) { + const nodeModulesRoot = path.resolve(base, "node_modules"); + const relative = path.relative(nodeModulesRoot, resolvedPath); + return ( + relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative) + ); +} + +function isBarePackageSpecifier(specifier) { + if ( + typeof specifier !== "string" || + !specifier || + specifier.trim() !== specifier + ) { + return false; + } + if (specifier.startsWith("./") || specifier.startsWith("../")) { + return false; + } + if (specifier.startsWith("/") || specifier.startsWith("\\")) { + return false; + } + if (path.isAbsolute(specifier)) { + return false; + } + if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(specifier)) { + return false; + } + if (specifier.includes("\\")) { + return false; + } + return true; +} + +function resolveBareSpecifier(specifier) { + let firstResolutionError = null; + + for (const base of moduleSearchBases) { + try { + const resolved = getRequireForBase(base).resolve(specifier, { + conditions: importResolveConditions, + }); + if (isWithinBaseNodeModules(base, resolved)) { + return resolved; + } + // Ignore resolutions that escape this base via parent node_modules lookup. + } catch (err) { + if (isModuleNotFoundError(err)) { + continue; + } + if (!firstResolutionError) { + firstResolutionError = err; + } + } + } + + if (firstResolutionError) { + throw firstResolutionError; + } + return null; +} 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`); + 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 (!isBarePackageSpecifier(specifier)) { + throw new Error( + `Unsupported import specifier "${specifier}" in js_repl. Use a package name like "lodash" or "@scope/pkg".`, + ); } - if (specifier.startsWith("./") || specifier.startsWith("../") || path.isAbsolute(specifier)) { - return { kind: "path", path: path.resolve(process.cwd(), specifier) }; + const resolvedBare = resolveBareSpecifier(specifier); + if (!resolvedBare) { + throw new Error(`Module not found: ${specifier}`); } - return { kind: "bare", specifier }; + return { kind: "path", path: resolvedBare }; } 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}`); } @@ -196,13 +309,24 @@ function collectBindings(ast) { } 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") { + 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") { + } 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); } @@ -230,7 +354,8 @@ async function buildModuleSource(code) { prelude += 'import * as __prev from "@prev";\n'; prelude += priorBindings .map((b) => { - const keyword = b.kind === "var" ? "var" : b.kind === "const" ? "const" : "let"; + const keyword = + b.kind === "var" ? "var" : b.kind === "const" ? "const" : "let"; return `${keyword} ${b.name} = __prev.${b.name};`; }) .join("\n"); @@ -246,9 +371,14 @@ async function buildModuleSource(code) { } // 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 exportStmt = exportNames.length + ? `\nexport { ${exportNames.join(", ")} };` + : ""; - const nextBindings = Array.from(mergedBindings, ([name, kind]) => ({ name, kind })); + const nextBindings = Array.from(mergedBindings, ([name, kind]) => ({ + name, + kind, + })); return { source: `${prelude}${code}${exportStmt}`, nextBindings }; } @@ -259,7 +389,9 @@ function send(message) { function formatLog(args) { return args - .map((arg) => (typeof arg === "string" ? arg : inspect(arg, { depth: 4, colors: false }))) + .map((arg) => + typeof arg === "string" ? arg : inspect(arg, { depth: 4, colors: false }), + ) .join(" "); } @@ -352,7 +484,10 @@ async function handleExec(message) { exportNames, function initSynthetic() { for (const binding of previousBindings) { - this.setExport(binding.name, previousModule.namespace[binding.name]); + this.setExport( + binding.name, + previousModule.namespace[binding.name], + ); } }, { context }, diff --git a/codex-rs/core/src/tools/js_repl/mod.rs b/codex-rs/core/src/tools/js_repl/mod.rs index ef0cdad00..5b7b539bb 100644 --- a/codex-rs/core/src/tools/js_repl/mod.rs +++ b/codex-rs/core/src/tools/js_repl/mod.rs @@ -52,7 +52,7 @@ const JS_REPL_MODEL_DIAG_ERROR_MAX_BYTES: usize = 256; /// Per-task js_repl handle stored on the turn context. pub(crate) struct JsReplHandle { node_path: Option, - codex_home: PathBuf, + node_module_dirs: Vec, cell: OnceCell>, } @@ -63,10 +63,13 @@ impl fmt::Debug for JsReplHandle { } impl JsReplHandle { - pub(crate) fn with_node_path(node_path: Option, codex_home: PathBuf) -> Self { + pub(crate) fn with_node_path( + node_path: Option, + node_module_dirs: Vec, + ) -> Self { Self { node_path, - codex_home, + node_module_dirs, cell: OnceCell::new(), } } @@ -74,7 +77,7 @@ impl JsReplHandle { pub(crate) async fn manager(&self) -> Result, FunctionCallError> { self.cell .get_or_try_init(|| async { - JsReplManager::new(self.node_path.clone(), self.codex_home.clone()).await + JsReplManager::new(self.node_path.clone(), self.node_module_dirs.clone()).await }) .await .cloned() @@ -264,7 +267,7 @@ fn with_model_kernel_failure_message( pub struct JsReplManager { node_path: Option, - codex_home: PathBuf, + node_module_dirs: Vec, tmp_dir: tempfile::TempDir, kernel: Mutex>, exec_lock: Arc, @@ -274,7 +277,7 @@ pub struct JsReplManager { impl JsReplManager { async fn new( node_path: Option, - codex_home: PathBuf, + node_module_dirs: Vec, ) -> Result, FunctionCallError> { let tmp_dir = tempfile::tempdir().map_err(|err| { FunctionCallError::RespondToModel(format!("failed to create js_repl temp dir: {err}")) @@ -282,7 +285,7 @@ impl JsReplManager { let manager = Arc::new(Self { node_path, - codex_home, + node_module_dirs, tmp_dir, kernel: Mutex::new(None), exec_lock: Arc::new(tokio::sync::Semaphore::new(1)), @@ -565,13 +568,15 @@ impl JsReplManager { "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 node_module_dirs_key = "CODEX_JS_REPL_NODE_MODULE_DIRS"; + if !self.node_module_dirs.is_empty() && !env.contains_key(node_module_dirs_key) { + let joined = std::env::join_paths(&self.node_module_dirs) + .map_err(|err| format!("failed to join js_repl_node_module_dirs: {err}"))?; + env.insert( + node_module_dirs_key.to_string(), + joined.to_string_lossy().to_string(), + ); + } let spec = CommandSpec { program: node_path.to_string_lossy().to_string(), @@ -1290,6 +1295,9 @@ mod tests { use codex_protocol::models::ResponseInputItem; use codex_protocol::openai_models::InputModality; use pretty_assertions::assert_eq; + use std::fs; + use std::path::Path; + use tempfile::tempdir; #[test] fn node_version_parses_v_prefix_and_suffix() { @@ -1463,7 +1471,7 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn reset_waits_for_exec_lock_before_clearing_exec_tool_calls() { - let manager = JsReplManager::new(None, PathBuf::from(".")) + let manager = JsReplManager::new(None, Vec::new()) .await .expect("manager should initialize"); let permit = manager @@ -1503,7 +1511,7 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn reset_clears_inflight_exec_tool_calls_without_waiting() { - let manager = JsReplManager::new(None, std::env::temp_dir()) + let manager = JsReplManager::new(None, Vec::new()) .await .expect("manager should initialize"); let exec_id = Uuid::new_v4().to_string(); @@ -1536,7 +1544,7 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn reset_aborts_inflight_exec_tool_tasks() { - let manager = JsReplManager::new(None, std::env::temp_dir()) + let manager = JsReplManager::new(None, Vec::new()) .await .expect("manager should initialize"); let exec_id = Uuid::new_v4().to_string(); @@ -1582,6 +1590,22 @@ mod tests { found >= required } + fn write_js_repl_test_package(base: &Path, name: &str, value: &str) -> anyhow::Result<()> { + let pkg_dir = base.join("node_modules").join(name); + fs::create_dir_all(&pkg_dir)?; + fs::write( + pkg_dir.join("package.json"), + format!( + "{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"type\": \"module\",\n \"exports\": {{\n \"import\": \"./index.js\"\n }}\n}}\n" + ), + )?; + fs::write( + pkg_dir.join("index.js"), + format!("export const value = \"{value}\";\n"), + )?; + Ok(()) + } + #[tokio::test] async fn js_repl_persists_top_level_bindings_and_supports_tla() -> anyhow::Result<()> { if !can_run_js_repl_runtime_tests().await { @@ -2028,4 +2052,205 @@ console.log(out.output?.body?.text ?? ""); ); Ok(()) } + + #[tokio::test] + async fn js_repl_prefers_env_node_module_dirs_over_config() -> anyhow::Result<()> { + if !can_run_js_repl_runtime_tests().await { + return Ok(()); + } + + let env_base = tempdir()?; + write_js_repl_test_package(env_base.path(), "repl_probe", "env")?; + + let config_base = tempdir()?; + let cwd_dir = tempdir()?; + + let (session, mut turn) = make_session_and_context().await; + turn.shell_environment_policy.r#set.insert( + "CODEX_JS_REPL_NODE_MODULE_DIRS".to_string(), + env_base.path().to_string_lossy().to_string(), + ); + turn.cwd = cwd_dir.path().to_path_buf(); + turn.js_repl = Arc::new(JsReplHandle::with_node_path( + turn.config.js_repl_node_path.clone(), + vec![config_base.path().to_path_buf()], + )); + + 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: "const mod = await import(\"repl_probe\"); console.log(mod.value);" + .to_string(), + timeout_ms: Some(10_000), + }, + ) + .await?; + assert!(result.output.contains("env")); + Ok(()) + } + + #[tokio::test] + async fn js_repl_resolves_from_first_config_dir() -> anyhow::Result<()> { + if !can_run_js_repl_runtime_tests().await { + return Ok(()); + } + + let first_base = tempdir()?; + let second_base = tempdir()?; + write_js_repl_test_package(first_base.path(), "repl_probe", "first")?; + write_js_repl_test_package(second_base.path(), "repl_probe", "second")?; + + let cwd_dir = tempdir()?; + + let (session, mut turn) = make_session_and_context().await; + turn.shell_environment_policy + .r#set + .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); + turn.cwd = cwd_dir.path().to_path_buf(); + turn.js_repl = Arc::new(JsReplHandle::with_node_path( + turn.config.js_repl_node_path.clone(), + vec![ + first_base.path().to_path_buf(), + second_base.path().to_path_buf(), + ], + )); + + 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: "const mod = await import(\"repl_probe\"); console.log(mod.value);" + .to_string(), + timeout_ms: Some(10_000), + }, + ) + .await?; + assert!(result.output.contains("first")); + Ok(()) + } + + #[tokio::test] + async fn js_repl_falls_back_to_cwd_node_modules() -> anyhow::Result<()> { + if !can_run_js_repl_runtime_tests().await { + return Ok(()); + } + + let config_base = tempdir()?; + let cwd_dir = tempdir()?; + write_js_repl_test_package(cwd_dir.path(), "repl_probe", "cwd")?; + + let (session, mut turn) = make_session_and_context().await; + turn.shell_environment_policy + .r#set + .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); + turn.cwd = cwd_dir.path().to_path_buf(); + turn.js_repl = Arc::new(JsReplHandle::with_node_path( + turn.config.js_repl_node_path.clone(), + vec![config_base.path().to_path_buf()], + )); + + 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: "const mod = await import(\"repl_probe\"); console.log(mod.value);" + .to_string(), + timeout_ms: Some(10_000), + }, + ) + .await?; + assert!(result.output.contains("cwd")); + Ok(()) + } + + #[tokio::test] + async fn js_repl_accepts_node_modules_dir_entries() -> anyhow::Result<()> { + if !can_run_js_repl_runtime_tests().await { + return Ok(()); + } + + let base_dir = tempdir()?; + let cwd_dir = tempdir()?; + write_js_repl_test_package(base_dir.path(), "repl_probe", "normalized")?; + + let (session, mut turn) = make_session_and_context().await; + turn.shell_environment_policy + .r#set + .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); + turn.cwd = cwd_dir.path().to_path_buf(); + turn.js_repl = Arc::new(JsReplHandle::with_node_path( + turn.config.js_repl_node_path.clone(), + vec![base_dir.path().join("node_modules")], + )); + + 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: "const mod = await import(\"repl_probe\"); console.log(mod.value);" + .to_string(), + timeout_ms: Some(10_000), + }, + ) + .await?; + assert!(result.output.contains("normalized")); + Ok(()) + } + + #[tokio::test] + async fn js_repl_rejects_path_specifiers() -> 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(\"./local.js\");".to_string(), + timeout_ms: Some(10_000), + }, + ) + .await + .expect_err("expected path specifier to be rejected"); + assert!(err.to_string().contains("Unsupported import specifier")); + Ok(()) + } } diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 1533e7e63..c4964cc80 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -252,6 +252,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any model_provider: model_provider.clone(), codex_linux_sandbox_exe, js_repl_node_path: None, + js_repl_node_module_dirs: None, zsh_path: None, base_instructions: None, developer_instructions: None, diff --git a/docs/js_repl.md b/docs/js_repl.md index 1b1974074..a9b8b7228 100644 --- a/docs/js_repl.md +++ b/docs/js_repl.md @@ -37,6 +37,19 @@ You can configure an explicit runtime path: js_repl_node_path = "/absolute/path/to/node" ``` +## Module resolution + +`js_repl` resolves **bare** specifiers (for example `await import("pkg")`) using an ordered +search path. Path-style specifiers (`./`, `../`, absolute paths, `file:` URLs) are rejected. + +Module resolution proceeds in the following order: + +1. `CODEX_JS_REPL_NODE_MODULE_DIRS` (PATH-delimited list) +2. `js_repl_node_module_dirs` in config/profile (array of absolute paths) +3. Thread working directory (cwd, always included as the last fallback) + +For `CODEX_JS_REPL_NODE_MODULE_DIRS` and `js_repl_node_module_dirs`, module resolution is attempted in the order provided with earlier entries taking precedence. + ## Usage - `js_repl` is a freeform tool: send raw JavaScript source text.