[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:
parent
57f4e37539
commit
f600453699
8 changed files with 461 additions and 45 deletions
|
|
@ -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": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue