[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/",
]
```
This commit is contained in:
aaronl-openai 2026-02-17 23:29:49 -08:00 committed by GitHub
parent 57f4e37539
commit f600453699
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 461 additions and 45 deletions

View file

@ -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": [
{

View file

@ -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(

View file

@ -336,6 +336,10 @@ pub struct Config {
/// Optional absolute path to the Node runtime used by `js_repl`.
pub js_repl_node_path: Option<PathBuf>,
/// Ordered list of directories to search for Node modules in `js_repl`.
pub js_repl_node_module_dirs: Vec<PathBuf>,
/// Optional absolute path to patched zsh used by zsh-exec-bridge-backed shell execution.
pub zsh_path: Option<PathBuf>,
@ -977,6 +981,10 @@ pub struct ConfigToml {
/// Optional absolute path to the Node runtime used by `js_repl`.
pub js_repl_node_path: Option<AbsolutePathBuf>,
/// Ordered list of directories to search for Node modules in `js_repl`.
pub js_repl_node_module_dirs: Option<Vec<AbsolutePathBuf>>,
/// Optional absolute path to patched zsh used by zsh-exec-bridge-backed shell execution.
pub zsh_path: Option<AbsolutePathBuf>,
@ -1357,6 +1365,7 @@ pub struct ConfigOverrides {
pub config_profile: Option<String>,
pub codex_linux_sandbox_exe: Option<PathBuf>,
pub js_repl_node_path: Option<PathBuf>,
pub js_repl_node_module_dirs: Option<Vec<PathBuf>>,
pub zsh_path: Option<PathBuf>,
pub base_instructions: Option<String>,
pub developer_instructions: Option<String>,
@ -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::<Vec<PathBuf>>())
})
.or_else(|| {
cfg.js_repl_node_module_dirs
.map(|dirs| dirs.into_iter().map(Into::into).collect::<Vec<PathBuf>>())
})
.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,

View file

@ -30,9 +30,11 @@ 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>,
/// Ordered list of directories to search for Node modules in `js_repl`.
pub js_repl_node_module_dirs: Option<Vec<AbsolutePathBuf>>,
/// Optional absolute path to patched zsh used by zsh-exec-bridge-backed shell execution.
pub zsh_path: Option<AbsolutePathBuf>,
pub js_repl_node_path: Option<AbsolutePathBuf>,
/// Deprecated: ignored. Use `model_instructions_file`.
#[schemars(skip)]
pub experimental_instructions_file: Option<AbsolutePathBuf>,

View file

@ -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<string, (msg: any) => 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 },

View file

@ -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<PathBuf>,
codex_home: PathBuf,
node_module_dirs: Vec<PathBuf>,
cell: OnceCell<Arc<JsReplManager>>,
}
@ -63,10 +63,13 @@ impl fmt::Debug for JsReplHandle {
}
impl JsReplHandle {
pub(crate) fn with_node_path(node_path: Option<PathBuf>, codex_home: PathBuf) -> Self {
pub(crate) fn with_node_path(
node_path: Option<PathBuf>,
node_module_dirs: Vec<PathBuf>,
) -> 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<Arc<JsReplManager>, 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<PathBuf>,
codex_home: PathBuf,
node_module_dirs: Vec<PathBuf>,
tmp_dir: tempfile::TempDir,
kernel: Mutex<Option<KernelState>>,
exec_lock: Arc<tokio::sync::Semaphore>,
@ -274,7 +277,7 @@ pub struct JsReplManager {
impl JsReplManager {
async fn new(
node_path: Option<PathBuf>,
codex_home: PathBuf,
node_module_dirs: Vec<PathBuf>,
) -> Result<Arc<Self>, 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(())
}
}

View file

@ -252,6 +252,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> 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,

View file

@ -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.