mirror of
https://github.com/openai/codex.git
synced 2026-03-05 13:35:28 +03:00
[js_repl] Support local ESM file imports (#13437)
## Summary
- add `js_repl` support for dynamic imports of relative and absolute
local ESM `.js` / `.mjs` files
- keep bare package imports on the native Node path and resolved from
REPL-global search roots (`CODEX_JS_REPL_NODE_MODULE_DIRS`, then `cwd`),
even when they originate from imported local files
- restrict static imports inside imported local files to other local
relative/absolute `.js` / `.mjs` files, and surface a clear error for
unsupported top-level static imports in the REPL cell
- run imported local files inside the REPL VM context so they can access
`codex.tmpDir`, `codex.tool`, captured `console`, and Node-like
`import.meta` helpers
- reload local files between execs so later `await import("./file.js")`
calls pick up edits and fixed failures, while preserving package/builtin
caching and persistent top-level REPL bindings
- make `import.meta.resolve()` self-consistent by allowing the returned
`file://...` URLs to round-trip through `await import(...)`
- update both public and injected `js_repl` docs to clarify the narrowed
contract, including global bare-import resolution behavior for local
absolute files
## Testing
- `cargo test -p codex-core js_repl_`
- built codex binary and verified behavior
---------
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -65,7 +65,7 @@ fn render_js_repl_instructions(config: &Config) -> Option<String> {
|
|||||||
section.push_str("- When generating or converting images for `view_image` in `js_repl`, prefer JPEG at 85% quality unless lossless quality is strictly required; other formats can be used if the user requests them. This keeps uploads smaller and reduces the chance of hitting image size caps.\n");
|
section.push_str("- When generating or converting images for `view_image` in `js_repl`, prefer JPEG at 85% quality unless lossless quality is strictly required; other formats can be used if the user requests them. This keeps uploads smaller and reduces the chance of hitting image size caps.\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 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("- Top-level static import declarations (for example `import x from \"./file.js\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")`, `await import(\"./file.js\")`, or `await import(\"/abs/path/file.mjs\")` instead. Imported local files must be ESM `.js`/`.mjs` files and run in the same REPL VM context. Bare package imports always resolve from REPL-global search roots (`CODEX_JS_REPL_NODE_MODULE_DIRS`, then cwd), not relative to the imported file location. Local files may statically import only other local relative/absolute/`file://` `.js`/`.mjs` files; package and builtin imports from local files must stay dynamic. `import.meta.resolve()` returns importable strings such as `file://...`, bare package names, and `node:...` specifiers. Local file modules reload between execs, while top-level bindings persist until `js_repl_reset`.\n");
|
||||||
|
|
||||||
if config.features.enabled(Feature::JsReplToolsOnly) {
|
if config.features.enabled(Feature::JsReplToolsOnly) {
|
||||||
section.push_str("- Do not call tools directly; use `js_repl` + `codex.tool(...)` for all tool calls, including shell commands.\n");
|
section.push_str("- Do not call tools directly; use `js_repl` + `codex.tool(...)` for all tool calls, including shell commands.\n");
|
||||||
@@ -492,7 +492,7 @@ mod tests {
|
|||||||
let res = get_user_instructions(&cfg, None, None)
|
let res = get_user_instructions(&cfg, None, None)
|
||||||
.await
|
.await
|
||||||
.expect("js_repl instructions expected");
|
.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.\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: `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n- `codex.emitImage(...)` adds exactly one image to the outer `js_repl` function output. It accepts a direct image URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\" })`.\n- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\" }))`.\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`, `codex.tool(...)`, and `codex.emitImage(...)`.";
|
let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\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: `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n- `codex.emitImage(...)` adds exactly one image to the outer `js_repl` function output. It accepts a direct image URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\" })`.\n- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\" }))`.\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 \"./file.js\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")`, `await import(\"./file.js\")`, or `await import(\"/abs/path/file.mjs\")` instead. Imported local files must be ESM `.js`/`.mjs` files and run in the same REPL VM context. Bare package imports always resolve from REPL-global search roots (`CODEX_JS_REPL_NODE_MODULE_DIRS`, then cwd), not relative to the imported file location. Local files may statically import only other local relative/absolute/`file://` `.js`/`.mjs` files; package and builtin imports from local files must stay dynamic. `import.meta.resolve()` returns importable strings such as `file://...`, bare package names, and `node:...` specifiers. Local file modules reload between execs, while top-level bindings persist until `js_repl_reset`.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.tool(...)`, and `codex.emitImage(...)`.";
|
||||||
assert_eq!(res, expected);
|
assert_eq!(res, expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -511,7 +511,7 @@ mod tests {
|
|||||||
let res = get_user_instructions(&cfg, None, None)
|
let res = get_user_instructions(&cfg, None, None)
|
||||||
.await
|
.await
|
||||||
.expect("js_repl instructions expected");
|
.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.\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: `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n- `codex.emitImage(...)` adds exactly one image to the outer `js_repl` function output. It accepts a direct image URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\" })`.\n- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\" }))`.\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- Do not call tools directly; use `js_repl` + `codex.tool(...)` for all tool calls, including shell commands.\n- MCP tools (if any) can also be called by name via `codex.tool(...)`.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.tool(...)`, and `codex.emitImage(...)`.";
|
let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\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: `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n- `codex.emitImage(...)` adds exactly one image to the outer `js_repl` function output. It accepts a direct image URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\" })`.\n- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\" }))`.\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 \"./file.js\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")`, `await import(\"./file.js\")`, or `await import(\"/abs/path/file.mjs\")` instead. Imported local files must be ESM `.js`/`.mjs` files and run in the same REPL VM context. Bare package imports always resolve from REPL-global search roots (`CODEX_JS_REPL_NODE_MODULE_DIRS`, then cwd), not relative to the imported file location. Local files may statically import only other local relative/absolute/`file://` `.js`/`.mjs` files; package and builtin imports from local files must stay dynamic. `import.meta.resolve()` returns importable strings such as `file://...`, bare package names, and `node:...` specifiers. Local file modules reload between execs, while top-level bindings persist until `js_repl_reset`.\n- Do not call tools directly; use `js_repl` + `codex.tool(...)` for all tool calls, including shell commands.\n- MCP tools (if any) can also be called by name via `codex.tool(...)`.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.tool(...)`, and `codex.emitImage(...)`.";
|
||||||
assert_eq!(res, expected);
|
assert_eq!(res, expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -530,7 +530,7 @@ mod tests {
|
|||||||
let res = get_user_instructions(&cfg, None, None)
|
let res = get_user_instructions(&cfg, None, None)
|
||||||
.await
|
.await
|
||||||
.expect("js_repl instructions expected");
|
.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.\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: `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n- `codex.emitImage(...)` adds exactly one image to the outer `js_repl` function output. It accepts a direct image URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\" })`.\n- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\" }))`.\n- When generating or converting images for `view_image` in `js_repl`, prefer JPEG at 85% quality unless lossless quality is strictly required; other formats can be used if the user requests them. This keeps uploads smaller and reduces the chance of hitting image size caps.\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`, `codex.tool(...)`, and `codex.emitImage(...)`.";
|
let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\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: `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n- `codex.emitImage(...)` adds exactly one image to the outer `js_repl` function output. It accepts a direct image URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\" })`.\n- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\" }))`.\n- When generating or converting images for `view_image` in `js_repl`, prefer JPEG at 85% quality unless lossless quality is strictly required; other formats can be used if the user requests them. This keeps uploads smaller and reduces the chance of hitting image size caps.\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 \"./file.js\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")`, `await import(\"./file.js\")`, or `await import(\"/abs/path/file.mjs\")` instead. Imported local files must be ESM `.js`/`.mjs` files and run in the same REPL VM context. Bare package imports always resolve from REPL-global search roots (`CODEX_JS_REPL_NODE_MODULE_DIRS`, then cwd), not relative to the imported file location. Local files may statically import only other local relative/absolute/`file://` `.js`/`.mjs` files; package and builtin imports from local files must stay dynamic. `import.meta.resolve()` returns importable strings such as `file://...`, bare package names, and `node:...` specifiers. Local file modules reload between execs, while top-level bindings persist until `js_repl_reset`.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.tool(...)`, and `codex.emitImage(...)`.";
|
||||||
assert_eq!(res, expected);
|
assert_eq!(res, expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ const { builtinModules, createRequire } = require("node:module");
|
|||||||
const { createInterface } = require("node:readline");
|
const { createInterface } = require("node:readline");
|
||||||
const { performance } = require("node:perf_hooks");
|
const { performance } = require("node:perf_hooks");
|
||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
const { URL, URLSearchParams, pathToFileURL } = require("node:url");
|
const { URL, URLSearchParams, fileURLToPath, pathToFileURL } = require(
|
||||||
|
"node:url",
|
||||||
|
);
|
||||||
const { inspect, TextDecoder, TextEncoder } = require("node:util");
|
const { inspect, TextDecoder, TextEncoder } = require("node:util");
|
||||||
const vm = require("node:vm");
|
const vm = require("node:vm");
|
||||||
|
|
||||||
@@ -151,6 +153,14 @@ const moduleSearchBases = (() => {
|
|||||||
|
|
||||||
const importResolveConditions = new Set(["node", "import"]);
|
const importResolveConditions = new Set(["node", "import"]);
|
||||||
const requireByBase = new Map();
|
const requireByBase = new Map();
|
||||||
|
const linkedFileModules = new Map();
|
||||||
|
const linkedNativeModules = new Map();
|
||||||
|
const linkedModuleEvaluations = new Map();
|
||||||
|
|
||||||
|
function clearLocalFileModuleCaches() {
|
||||||
|
linkedFileModules.clear();
|
||||||
|
linkedModuleEvaluations.clear();
|
||||||
|
}
|
||||||
|
|
||||||
function canonicalizePath(value) {
|
function canonicalizePath(value) {
|
||||||
try {
|
try {
|
||||||
@@ -160,6 +170,28 @@ function canonicalizePath(value) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveResultToUrl(resolved) {
|
||||||
|
if (resolved.kind === "builtin") {
|
||||||
|
return resolved.specifier;
|
||||||
|
}
|
||||||
|
if (resolved.kind === "file") {
|
||||||
|
return pathToFileURL(resolved.path).href;
|
||||||
|
}
|
||||||
|
if (resolved.kind === "package") {
|
||||||
|
return resolved.specifier;
|
||||||
|
}
|
||||||
|
throw new Error(`Unsupported module resolution kind: ${resolved.kind}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setImportMeta(meta, mod, isMain = false) {
|
||||||
|
meta.url = pathToFileURL(mod.identifier).href;
|
||||||
|
meta.filename = mod.identifier;
|
||||||
|
meta.dirname = path.dirname(mod.identifier);
|
||||||
|
meta.main = isMain;
|
||||||
|
meta.resolve = (specifier) =>
|
||||||
|
resolveResultToUrl(resolveSpecifier(specifier, mod.identifier));
|
||||||
|
}
|
||||||
|
|
||||||
function getRequireForBase(base) {
|
function getRequireForBase(base) {
|
||||||
let req = requireByBase.get(base);
|
let req = requireByBase.get(base);
|
||||||
if (!req) {
|
if (!req) {
|
||||||
@@ -185,6 +217,41 @@ function isWithinBaseNodeModules(base, resolvedPath) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isExplicitRelativePathSpecifier(specifier) {
|
||||||
|
return (
|
||||||
|
specifier.startsWith("./") ||
|
||||||
|
specifier.startsWith("../") ||
|
||||||
|
specifier.startsWith(".\\") ||
|
||||||
|
specifier.startsWith("..\\")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFileUrlSpecifier(specifier) {
|
||||||
|
if (typeof specifier !== "string" || !specifier.startsWith("file:")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return new URL(specifier).protocol === "file:";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPathSpecifier(specifier) {
|
||||||
|
if (
|
||||||
|
typeof specifier !== "string" ||
|
||||||
|
!specifier ||
|
||||||
|
specifier.trim() !== specifier
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
isExplicitRelativePathSpecifier(specifier) ||
|
||||||
|
path.isAbsolute(specifier) ||
|
||||||
|
isFileUrlSpecifier(specifier)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function isBarePackageSpecifier(specifier) {
|
function isBarePackageSpecifier(specifier) {
|
||||||
if (
|
if (
|
||||||
typeof specifier !== "string" ||
|
typeof specifier !== "string" ||
|
||||||
@@ -239,7 +306,61 @@ function resolveBareSpecifier(specifier) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveSpecifier(specifier) {
|
function resolvePathSpecifier(specifier, referrerIdentifier = null) {
|
||||||
|
let candidate;
|
||||||
|
if (isFileUrlSpecifier(specifier)) {
|
||||||
|
try {
|
||||||
|
candidate = fileURLToPath(new URL(specifier));
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`Failed to resolve module "${specifier}": ${err.message}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const baseDir =
|
||||||
|
referrerIdentifier && path.isAbsolute(referrerIdentifier)
|
||||||
|
? path.dirname(referrerIdentifier)
|
||||||
|
: process.cwd();
|
||||||
|
candidate = path.isAbsolute(specifier)
|
||||||
|
? specifier
|
||||||
|
: path.resolve(baseDir, specifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolvedPath;
|
||||||
|
try {
|
||||||
|
resolvedPath = fs.realpathSync.native(candidate);
|
||||||
|
} catch (err) {
|
||||||
|
if (err?.code === "ENOENT") {
|
||||||
|
throw new Error(`Module not found: ${specifier}`);
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to resolve module "${specifier}": ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let stats;
|
||||||
|
try {
|
||||||
|
stats = fs.statSync(resolvedPath);
|
||||||
|
} catch (err) {
|
||||||
|
if (err?.code === "ENOENT") {
|
||||||
|
throw new Error(`Module not found: ${specifier}`);
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to inspect module "${specifier}": ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stats.isFile()) {
|
||||||
|
throw new Error(
|
||||||
|
`Unsupported import specifier "${specifier}" in js_repl. Directory imports are not supported.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const extension = path.extname(resolvedPath).toLowerCase();
|
||||||
|
if (extension !== ".js" && extension !== ".mjs") {
|
||||||
|
throw new Error(
|
||||||
|
`Unsupported import specifier "${specifier}" in js_repl. Only .js and .mjs files are supported.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { kind: "file", path: resolvedPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSpecifier(specifier, referrerIdentifier = null) {
|
||||||
if (specifier.startsWith("node:") || builtinModuleSet.has(specifier)) {
|
if (specifier.startsWith("node:") || builtinModuleSet.has(specifier)) {
|
||||||
if (isDeniedBuiltin(specifier)) {
|
if (isDeniedBuiltin(specifier)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -249,9 +370,13 @@ function resolveSpecifier(specifier) {
|
|||||||
return { kind: "builtin", specifier: toNodeBuiltinSpecifier(specifier) };
|
return { kind: "builtin", specifier: toNodeBuiltinSpecifier(specifier) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isPathSpecifier(specifier)) {
|
||||||
|
return resolvePathSpecifier(specifier, referrerIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
if (!isBarePackageSpecifier(specifier)) {
|
if (!isBarePackageSpecifier(specifier)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Unsupported import specifier "${specifier}" in js_repl. Use a package name like "lodash" or "@scope/pkg".`,
|
`Unsupported import specifier "${specifier}" in js_repl. Use a package name like "lodash" or "@scope/pkg", or a relative/absolute/file:// .js/.mjs path.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,19 +385,98 @@ function resolveSpecifier(specifier) {
|
|||||||
throw new Error(`Module not found: ${specifier}`);
|
throw new Error(`Module not found: ${specifier}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { kind: "path", path: resolvedBare };
|
return { kind: "package", path: resolvedBare, specifier };
|
||||||
}
|
}
|
||||||
|
|
||||||
function importResolved(resolved) {
|
function importNativeResolved(resolved) {
|
||||||
if (resolved.kind === "builtin") {
|
if (resolved.kind === "builtin") {
|
||||||
return import(resolved.specifier);
|
return import(resolved.specifier);
|
||||||
}
|
}
|
||||||
if (resolved.kind === "path") {
|
if (resolved.kind === "package") {
|
||||||
return import(pathToFileURL(resolved.path).href);
|
return import(pathToFileURL(resolved.path).href);
|
||||||
}
|
}
|
||||||
throw new Error(`Unsupported module resolution kind: ${resolved.kind}`);
|
throw new Error(`Unsupported module resolution kind: ${resolved.kind}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadLinkedNativeModule(resolved) {
|
||||||
|
const key =
|
||||||
|
resolved.kind === "builtin"
|
||||||
|
? `builtin:${resolved.specifier}`
|
||||||
|
: `package:${resolved.path}`;
|
||||||
|
let modulePromise = linkedNativeModules.get(key);
|
||||||
|
if (!modulePromise) {
|
||||||
|
modulePromise = (async () => {
|
||||||
|
const namespace = await importNativeResolved(resolved);
|
||||||
|
const exportNames = Object.getOwnPropertyNames(namespace);
|
||||||
|
return new SyntheticModule(
|
||||||
|
exportNames,
|
||||||
|
function initSyntheticModule() {
|
||||||
|
for (const name of exportNames) {
|
||||||
|
this.setExport(name, namespace[name]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ context },
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
linkedNativeModules.set(key, modulePromise);
|
||||||
|
}
|
||||||
|
return modulePromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLinkedFileModule(modulePath) {
|
||||||
|
let module = linkedFileModules.get(modulePath);
|
||||||
|
if (!module) {
|
||||||
|
const source = fs.readFileSync(modulePath, "utf8");
|
||||||
|
module = new SourceTextModule(source, {
|
||||||
|
context,
|
||||||
|
identifier: modulePath,
|
||||||
|
initializeImportMeta(meta, mod) {
|
||||||
|
setImportMeta(meta, mod, false);
|
||||||
|
},
|
||||||
|
importModuleDynamically(specifier, referrer) {
|
||||||
|
return importResolved(resolveSpecifier(specifier, referrer?.identifier));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
linkedFileModules.set(modulePath, module);
|
||||||
|
}
|
||||||
|
if (module.status === "unlinked") {
|
||||||
|
await module.link(async (specifier, referencingModule) => {
|
||||||
|
const resolved = resolveSpecifier(specifier, referencingModule?.identifier);
|
||||||
|
if (resolved.kind !== "file") {
|
||||||
|
throw new Error(
|
||||||
|
`Static import "${specifier}" is not supported from js_repl local files. Use await import("${specifier}") instead.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return loadLinkedFileModule(resolved.path);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return module;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLinkedModule(resolved) {
|
||||||
|
if (resolved.kind === "file") {
|
||||||
|
return loadLinkedFileModule(resolved.path);
|
||||||
|
}
|
||||||
|
if (resolved.kind === "builtin" || resolved.kind === "package") {
|
||||||
|
return loadLinkedNativeModule(resolved);
|
||||||
|
}
|
||||||
|
throw new Error(`Unsupported module resolution kind: ${resolved.kind}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importResolved(resolved) {
|
||||||
|
if (resolved.kind === "file") {
|
||||||
|
const module = await loadLinkedFileModule(resolved.path);
|
||||||
|
let evaluation = linkedModuleEvaluations.get(resolved.path);
|
||||||
|
if (!evaluation) {
|
||||||
|
evaluation = module.evaluate();
|
||||||
|
linkedModuleEvaluations.set(resolved.path, evaluation);
|
||||||
|
}
|
||||||
|
await evaluation;
|
||||||
|
return module.namespace;
|
||||||
|
}
|
||||||
|
return importNativeResolved(resolved);
|
||||||
|
}
|
||||||
|
|
||||||
function collectPatternNames(pattern, kind, map) {
|
function collectPatternNames(pattern, kind, map) {
|
||||||
if (!pattern) return;
|
if (!pattern) return;
|
||||||
switch (pattern.type) {
|
switch (pattern.type) {
|
||||||
@@ -730,6 +934,7 @@ function normalizeEmitImageValue(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleExec(message) {
|
async function handleExec(message) {
|
||||||
|
clearLocalFileModuleCaches();
|
||||||
activeExecId = message.id;
|
activeExecId = message.id;
|
||||||
const pendingBackgroundTasks = new Set();
|
const pendingBackgroundTasks = new Set();
|
||||||
const tool = (toolName, args) => {
|
const tool = (toolName, args) => {
|
||||||
@@ -816,14 +1021,18 @@ async function handleExec(message) {
|
|||||||
context.tmpDir = tmpDir;
|
context.tmpDir = tmpDir;
|
||||||
|
|
||||||
await withCapturedConsole(context, async (logs) => {
|
await withCapturedConsole(context, async (logs) => {
|
||||||
|
const cellIdentifier = path.join(
|
||||||
|
process.cwd(),
|
||||||
|
`.codex_js_repl_cell_${cellCounter++}.mjs`,
|
||||||
|
);
|
||||||
const module = new SourceTextModule(source, {
|
const module = new SourceTextModule(source, {
|
||||||
context,
|
context,
|
||||||
identifier: `cell-${cellCounter++}.mjs`,
|
identifier: cellIdentifier,
|
||||||
initializeImportMeta(meta, mod) {
|
initializeImportMeta(meta, mod) {
|
||||||
meta.url = `file://${mod.identifier}`;
|
setImportMeta(meta, mod, true);
|
||||||
},
|
},
|
||||||
importModuleDynamically(specifier) {
|
importModuleDynamically(specifier, referrer) {
|
||||||
return importResolved(resolveSpecifier(specifier));
|
return importResolved(resolveSpecifier(specifier, referrer?.identifier));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -846,9 +1055,9 @@ async function handleExec(message) {
|
|||||||
);
|
);
|
||||||
return synthetic;
|
return synthetic;
|
||||||
}
|
}
|
||||||
|
throw new Error(
|
||||||
const resolved = resolveSpecifier(specifier);
|
`Top-level static import "${specifier}" is not supported in js_repl. Use await import("${specifier}") instead.`,
|
||||||
return importResolved(resolved);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
await module.evaluate();
|
await module.evaluate();
|
||||||
|
|||||||
@@ -2140,6 +2140,19 @@ mod tests {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn write_js_repl_test_module(
|
||||||
|
base: &Path,
|
||||||
|
relative: &str,
|
||||||
|
contents: &str,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let module_path = base.join(relative);
|
||||||
|
if let Some(parent) = module_path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
fs::write(module_path, contents)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn js_repl_timeout_does_not_deadlock() -> anyhow::Result<()> {
|
async fn js_repl_timeout_does_not_deadlock() -> anyhow::Result<()> {
|
||||||
if !can_run_js_repl_runtime_tests().await {
|
if !can_run_js_repl_runtime_tests().await {
|
||||||
@@ -3186,7 +3199,338 @@ await codex.emitImage(out);
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn js_repl_rejects_path_specifiers() -> anyhow::Result<()> {
|
async fn js_repl_supports_relative_file_imports() -> anyhow::Result<()> {
|
||||||
|
if !can_run_js_repl_runtime_tests().await {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let cwd_dir = tempdir()?;
|
||||||
|
write_js_repl_test_module(
|
||||||
|
cwd_dir.path(),
|
||||||
|
"child.js",
|
||||||
|
"export const value = \"child\";\n",
|
||||||
|
)?;
|
||||||
|
write_js_repl_test_module(
|
||||||
|
cwd_dir.path(),
|
||||||
|
"parent.js",
|
||||||
|
"import { value as childValue } from \"./child.js\";\nexport const value = `${childValue}-parent`;\n",
|
||||||
|
)?;
|
||||||
|
write_js_repl_test_module(
|
||||||
|
cwd_dir.path(),
|
||||||
|
"local.mjs",
|
||||||
|
"export const value = \"mjs\";\n",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
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::new(),
|
||||||
|
));
|
||||||
|
|
||||||
|
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 parent = await import(\"./parent.js\"); const other = await import(\"./local.mjs\"); console.log(parent.value); console.log(other.value);".to_string(),
|
||||||
|
timeout_ms: Some(10_000),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert!(result.output.contains("child-parent"));
|
||||||
|
assert!(result.output.contains("mjs"));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn js_repl_supports_absolute_file_imports() -> anyhow::Result<()> {
|
||||||
|
if !can_run_js_repl_runtime_tests().await {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let module_dir = tempdir()?;
|
||||||
|
let cwd_dir = tempdir()?;
|
||||||
|
write_js_repl_test_module(
|
||||||
|
module_dir.path(),
|
||||||
|
"absolute.js",
|
||||||
|
"export const value = \"absolute\";\n",
|
||||||
|
)?;
|
||||||
|
let absolute_path_json =
|
||||||
|
serde_json::to_string(&module_dir.path().join("absolute.js").display().to_string())?;
|
||||||
|
|
||||||
|
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::new(),
|
||||||
|
));
|
||||||
|
|
||||||
|
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: format!(
|
||||||
|
"const mod = await import({absolute_path_json}); console.log(mod.value);"
|
||||||
|
),
|
||||||
|
timeout_ms: Some(10_000),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert!(result.output.contains("absolute"));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn js_repl_imported_local_files_can_access_repl_globals() -> anyhow::Result<()> {
|
||||||
|
if !can_run_js_repl_runtime_tests().await {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let cwd_dir = tempdir()?;
|
||||||
|
write_js_repl_test_module(
|
||||||
|
cwd_dir.path(),
|
||||||
|
"globals.js",
|
||||||
|
"console.log(codex.tmpDir === tmpDir);\nconsole.log(typeof codex.tool);\nconsole.log(\"local-file-console-ok\");\n",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
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::new(),
|
||||||
|
));
|
||||||
|
|
||||||
|
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: "await import(\"./globals.js\");".to_string(),
|
||||||
|
timeout_ms: Some(10_000),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert!(result.output.contains("true"));
|
||||||
|
assert!(result.output.contains("function"));
|
||||||
|
assert!(result.output.contains("local-file-console-ok"));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn js_repl_reimports_local_files_after_edit() -> anyhow::Result<()> {
|
||||||
|
if !can_run_js_repl_runtime_tests().await {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let cwd_dir = tempdir()?;
|
||||||
|
let helper_path = cwd_dir.path().join("helper.js");
|
||||||
|
fs::write(&helper_path, "export const value = \"v1\";\n")?;
|
||||||
|
|
||||||
|
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::new(),
|
||||||
|
));
|
||||||
|
|
||||||
|
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: "const { value: firstValue } = await import(\"./helper.js\");\nconsole.log(firstValue);".to_string(),
|
||||||
|
timeout_ms: Some(10_000),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert!(first.output.contains("v1"));
|
||||||
|
|
||||||
|
fs::write(&helper_path, "export const value = \"v2\";\n")?;
|
||||||
|
|
||||||
|
let second = manager
|
||||||
|
.execute(
|
||||||
|
session,
|
||||||
|
turn,
|
||||||
|
tracker,
|
||||||
|
JsReplArgs {
|
||||||
|
code: "console.log(firstValue);\nconst { value: secondValue } = await import(\"./helper.js\");\nconsole.log(secondValue);".to_string(),
|
||||||
|
timeout_ms: Some(10_000),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert!(second.output.contains("v1"));
|
||||||
|
assert!(second.output.contains("v2"));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn js_repl_reimports_local_files_after_fixing_failure() -> anyhow::Result<()> {
|
||||||
|
if !can_run_js_repl_runtime_tests().await {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let cwd_dir = tempdir()?;
|
||||||
|
let helper_path = cwd_dir.path().join("broken.js");
|
||||||
|
fs::write(&helper_path, "throw new Error(\"boom\");\n")?;
|
||||||
|
|
||||||
|
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::new(),
|
||||||
|
));
|
||||||
|
|
||||||
|
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(
|
||||||
|
Arc::clone(&session),
|
||||||
|
Arc::clone(&turn),
|
||||||
|
Arc::clone(&tracker),
|
||||||
|
JsReplArgs {
|
||||||
|
code: "await import(\"./broken.js\");".to_string(),
|
||||||
|
timeout_ms: Some(10_000),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect_err("expected broken module import to fail");
|
||||||
|
assert!(err.to_string().contains("boom"));
|
||||||
|
|
||||||
|
fs::write(&helper_path, "export const value = \"fixed\";\n")?;
|
||||||
|
|
||||||
|
let result = manager
|
||||||
|
.execute(
|
||||||
|
session,
|
||||||
|
turn,
|
||||||
|
tracker,
|
||||||
|
JsReplArgs {
|
||||||
|
code: "console.log((await import(\"./broken.js\")).value);".to_string(),
|
||||||
|
timeout_ms: Some(10_000),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert!(result.output.contains("fixed"));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn js_repl_local_files_expose_node_like_import_meta() -> anyhow::Result<()> {
|
||||||
|
if !can_run_js_repl_runtime_tests().await {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let cwd_dir = tempdir()?;
|
||||||
|
let pkg_dir = cwd_dir.path().join("node_modules").join("repl_meta_pkg");
|
||||||
|
fs::create_dir_all(&pkg_dir)?;
|
||||||
|
fs::write(
|
||||||
|
pkg_dir.join("package.json"),
|
||||||
|
"{\n \"name\": \"repl_meta_pkg\",\n \"version\": \"1.0.0\",\n \"type\": \"module\",\n \"exports\": {\n \"import\": \"./index.js\"\n }\n}\n",
|
||||||
|
)?;
|
||||||
|
fs::write(
|
||||||
|
pkg_dir.join("index.js"),
|
||||||
|
"import { sep } from \"node:path\";\nexport const value = `pkg:${typeof sep}`;\n",
|
||||||
|
)?;
|
||||||
|
write_js_repl_test_module(
|
||||||
|
cwd_dir.path(),
|
||||||
|
"child.js",
|
||||||
|
"export const value = \"child-export\";\n",
|
||||||
|
)?;
|
||||||
|
write_js_repl_test_module(
|
||||||
|
cwd_dir.path(),
|
||||||
|
"meta.js",
|
||||||
|
"console.log(import.meta.url);\nconsole.log(import.meta.filename);\nconsole.log(import.meta.dirname);\nconsole.log(import.meta.main);\nconsole.log(import.meta.resolve(\"./child.js\"));\nconsole.log(import.meta.resolve(\"repl_meta_pkg\"));\nconsole.log(import.meta.resolve(\"node:fs\"));\nconsole.log((await import(import.meta.resolve(\"./child.js\"))).value);\nconsole.log((await import(import.meta.resolve(\"repl_meta_pkg\"))).value);\n",
|
||||||
|
)?;
|
||||||
|
let child_path = fs::canonicalize(cwd_dir.path().join("child.js"))?;
|
||||||
|
let child_url = url::Url::from_file_path(&child_path)
|
||||||
|
.expect("child path should convert to file URL")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
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::new(),
|
||||||
|
));
|
||||||
|
|
||||||
|
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: "await import(\"./meta.js\");".to_string(),
|
||||||
|
timeout_ms: Some(10_000),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let cwd_display = cwd_dir.path().display().to_string();
|
||||||
|
let meta_path_display = cwd_dir.path().join("meta.js").display().to_string();
|
||||||
|
assert!(result.output.contains("file://"));
|
||||||
|
assert!(result.output.contains(&meta_path_display));
|
||||||
|
assert!(result.output.contains(&cwd_display));
|
||||||
|
assert!(result.output.contains("false"));
|
||||||
|
assert!(result.output.contains(&child_url));
|
||||||
|
assert!(result.output.contains("repl_meta_pkg"));
|
||||||
|
assert!(result.output.contains("node:fs"));
|
||||||
|
assert!(result.output.contains("child-export"));
|
||||||
|
assert!(result.output.contains("pkg:string"));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn js_repl_rejects_top_level_static_imports_with_clear_error() -> anyhow::Result<()> {
|
||||||
if !can_run_js_repl_runtime_tests().await {
|
if !can_run_js_repl_runtime_tests().await {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -3203,13 +3547,259 @@ await codex.emitImage(out);
|
|||||||
turn,
|
turn,
|
||||||
tracker,
|
tracker,
|
||||||
JsReplArgs {
|
JsReplArgs {
|
||||||
code: "await import(\"./local.js\");".to_string(),
|
code: "import \"./local.js\";".to_string(),
|
||||||
timeout_ms: Some(10_000),
|
timeout_ms: Some(10_000),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect_err("expected path specifier to be rejected");
|
.expect_err("expected top-level static import to be rejected");
|
||||||
assert!(err.to_string().contains("Unsupported import specifier"));
|
assert!(
|
||||||
|
err.to_string()
|
||||||
|
.contains("Top-level static import \"./local.js\" is not supported in js_repl")
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn js_repl_local_files_reject_static_bare_imports() -> anyhow::Result<()> {
|
||||||
|
if !can_run_js_repl_runtime_tests().await {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let cwd_dir = tempdir()?;
|
||||||
|
write_js_repl_test_package(cwd_dir.path(), "repl_counter", "pkg")?;
|
||||||
|
write_js_repl_test_module(
|
||||||
|
cwd_dir.path(),
|
||||||
|
"entry.js",
|
||||||
|
"import { value } from \"repl_counter\";\nconsole.log(value);\n",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
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::new(),
|
||||||
|
));
|
||||||
|
|
||||||
|
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(\"./entry.js\");".to_string(),
|
||||||
|
timeout_ms: Some(10_000),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect_err("expected static bare import to be rejected");
|
||||||
|
assert!(
|
||||||
|
err.to_string().contains(
|
||||||
|
"Static import \"repl_counter\" is not supported from js_repl local files"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn js_repl_rejects_unsupported_file_specifiers() -> anyhow::Result<()> {
|
||||||
|
if !can_run_js_repl_runtime_tests().await {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let cwd_dir = tempdir()?;
|
||||||
|
write_js_repl_test_module(cwd_dir.path(), "local.ts", "export const value = \"ts\";\n")?;
|
||||||
|
write_js_repl_test_module(cwd_dir.path(), "local", "export const value = \"noext\";\n")?;
|
||||||
|
fs::create_dir_all(cwd_dir.path().join("dir"))?;
|
||||||
|
|
||||||
|
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::new(),
|
||||||
|
));
|
||||||
|
|
||||||
|
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 unsupported_extension = manager
|
||||||
|
.execute(
|
||||||
|
Arc::clone(&session),
|
||||||
|
Arc::clone(&turn),
|
||||||
|
Arc::clone(&tracker),
|
||||||
|
JsReplArgs {
|
||||||
|
code: "await import(\"./local.ts\");".to_string(),
|
||||||
|
timeout_ms: Some(10_000),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect_err("expected unsupported extension to be rejected");
|
||||||
|
assert!(
|
||||||
|
unsupported_extension
|
||||||
|
.to_string()
|
||||||
|
.contains("Only .js and .mjs files are supported")
|
||||||
|
);
|
||||||
|
|
||||||
|
let extensionless = manager
|
||||||
|
.execute(
|
||||||
|
Arc::clone(&session),
|
||||||
|
Arc::clone(&turn),
|
||||||
|
Arc::clone(&tracker),
|
||||||
|
JsReplArgs {
|
||||||
|
code: "await import(\"./local\");".to_string(),
|
||||||
|
timeout_ms: Some(10_000),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect_err("expected extensionless import to be rejected");
|
||||||
|
assert!(
|
||||||
|
extensionless
|
||||||
|
.to_string()
|
||||||
|
.contains("Only .js and .mjs files are supported")
|
||||||
|
);
|
||||||
|
|
||||||
|
let directory = manager
|
||||||
|
.execute(
|
||||||
|
Arc::clone(&session),
|
||||||
|
Arc::clone(&turn),
|
||||||
|
Arc::clone(&tracker),
|
||||||
|
JsReplArgs {
|
||||||
|
code: "await import(\"./dir\");".to_string(),
|
||||||
|
timeout_ms: Some(10_000),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect_err("expected directory import to be rejected");
|
||||||
|
assert!(
|
||||||
|
directory
|
||||||
|
.to_string()
|
||||||
|
.contains("Directory imports are not supported")
|
||||||
|
);
|
||||||
|
|
||||||
|
let unsupported_url = manager
|
||||||
|
.execute(
|
||||||
|
session,
|
||||||
|
turn,
|
||||||
|
tracker,
|
||||||
|
JsReplArgs {
|
||||||
|
code: "await import(\"https://example.com/test.js\");".to_string(),
|
||||||
|
timeout_ms: Some(10_000),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect_err("expected unsupported url import to be rejected");
|
||||||
|
assert!(
|
||||||
|
unsupported_url
|
||||||
|
.to_string()
|
||||||
|
.contains("Unsupported import specifier")
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn js_repl_blocks_sensitive_builtin_imports_from_local_files() -> anyhow::Result<()> {
|
||||||
|
if !can_run_js_repl_runtime_tests().await {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let cwd_dir = tempdir()?;
|
||||||
|
write_js_repl_test_module(
|
||||||
|
cwd_dir.path(),
|
||||||
|
"blocked.js",
|
||||||
|
"import process from \"node:process\";\nconsole.log(process.pid);\n",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
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::new(),
|
||||||
|
));
|
||||||
|
|
||||||
|
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(\"./blocked.js\");".to_string(),
|
||||||
|
timeout_ms: Some(10_000),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect_err("expected blocked builtin import to be rejected");
|
||||||
|
assert!(
|
||||||
|
err.to_string()
|
||||||
|
.contains("Importing module \"node:process\" is not allowed in js_repl")
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn js_repl_local_files_do_not_escape_node_module_search_roots() -> anyhow::Result<()> {
|
||||||
|
if !can_run_js_repl_runtime_tests().await {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let parent_dir = tempdir()?;
|
||||||
|
write_js_repl_test_package(parent_dir.path(), "repl_probe", "parent")?;
|
||||||
|
let cwd_dir = parent_dir.path().join("workspace");
|
||||||
|
fs::create_dir_all(&cwd_dir)?;
|
||||||
|
write_js_repl_test_module(
|
||||||
|
&cwd_dir,
|
||||||
|
"entry.js",
|
||||||
|
"const { value } = await import(\"repl_probe\");\nconsole.log(value);\n",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
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.clone();
|
||||||
|
turn.js_repl = Arc::new(JsReplHandle::with_node_path(
|
||||||
|
turn.config.js_repl_node_path.clone(),
|
||||||
|
Vec::new(),
|
||||||
|
));
|
||||||
|
|
||||||
|
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(\"./entry.js\");".to_string(),
|
||||||
|
timeout_ms: Some(10_000),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect_err("expected parent node_modules lookup to be rejected");
|
||||||
|
assert!(err.to_string().contains("repl_probe"));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ js_repl_node_path = "/absolute/path/to/node"
|
|||||||
## Module resolution
|
## Module resolution
|
||||||
|
|
||||||
`js_repl` resolves **bare** specifiers (for example `await import("pkg")`) using an ordered
|
`js_repl` resolves **bare** specifiers (for example `await import("pkg")`) using an ordered
|
||||||
search path. Path-style specifiers (`./`, `../`, absolute paths, `file:` URLs) are rejected.
|
search path. Local file imports are also supported for relative paths, absolute paths, and
|
||||||
|
`file://` URLs that point to ESM `.js` / `.mjs` files.
|
||||||
|
|
||||||
Module resolution proceeds in the following order:
|
Module resolution proceeds in the following order:
|
||||||
|
|
||||||
@@ -50,6 +51,9 @@ Module resolution proceeds in the following order:
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
Bare package imports always use this REPL-wide search path, even when they originate from an
|
||||||
|
imported local file. They are not resolved relative to the imported file's location.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
- `js_repl` is a freeform tool: send raw JavaScript source text.
|
- `js_repl` is a freeform tool: send raw JavaScript source text.
|
||||||
@@ -57,6 +61,10 @@ For `CODEX_JS_REPL_NODE_MODULE_DIRS` and `js_repl_node_module_dirs`, module reso
|
|||||||
- `// codex-js-repl: timeout_ms=15000`
|
- `// codex-js-repl: timeout_ms=15000`
|
||||||
- Top-level bindings persist across calls.
|
- 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")`.
|
- Top-level static import declarations (for example `import x from "pkg"`) are currently unsupported; use dynamic imports with `await import("pkg")`.
|
||||||
|
- Imported local files must be ESM `.js` / `.mjs` files and run in the same REPL VM context as the calling cell.
|
||||||
|
- Static imports inside imported local files may only target other local `.js` / `.mjs` files via relative paths, absolute paths, or `file://` URLs. Bare package and builtin imports from local files must stay dynamic via `await import(...)`.
|
||||||
|
- `import.meta.resolve()` returns importable strings such as `file://...`, bare package names, and `node:fs`; the returned value can be passed back to `await import(...)`.
|
||||||
|
- Local file modules reload between execs, so a later `await import("./file.js")` picks up edits and fixed failures. Top-level bindings you already created still persist until `js_repl_reset`.
|
||||||
- Use `js_repl_reset` to clear the kernel state.
|
- Use `js_repl_reset` to clear the kernel state.
|
||||||
|
|
||||||
## Helper APIs inside the kernel
|
## Helper APIs inside the kernel
|
||||||
@@ -66,6 +74,7 @@ For `CODEX_JS_REPL_NODE_MODULE_DIRS` and `js_repl_node_module_dirs`, module reso
|
|||||||
- `codex.tmpDir`: per-session scratch directory path.
|
- `codex.tmpDir`: per-session scratch directory path.
|
||||||
- `codex.tool(name, args?)`: executes a normal Codex tool call from inside `js_repl` (including shell tools like `shell` / `shell_command` when available).
|
- `codex.tool(name, args?)`: executes a normal Codex tool call from inside `js_repl` (including shell tools like `shell` / `shell_command` when available).
|
||||||
- `codex.emitImage(imageLike)`: explicitly adds exactly one image to the outer `js_repl` function output.
|
- `codex.emitImage(imageLike)`: explicitly adds exactly one image to the outer `js_repl` function output.
|
||||||
|
- Imported local files run in the same VM context, so they can also access `codex.*`, the captured `console`, and Node-like `import.meta` helpers.
|
||||||
- Each `codex.tool(...)` call emits a bounded summary at `info` level from the `codex_core::tools::js_repl` logger. At `trace` level, the same path also logs the exact raw response object or error string seen by JavaScript.
|
- Each `codex.tool(...)` call emits a bounded summary at `info` level from the `codex_core::tools::js_repl` logger. At `trace` level, the same path also logs the exact raw response object or error string seen by JavaScript.
|
||||||
- Nested `codex.tool(...)` outputs stay inside JavaScript unless you emit them explicitly.
|
- Nested `codex.tool(...)` outputs stay inside JavaScript unless you emit them explicitly.
|
||||||
- `codex.emitImage(...)` accepts a direct image URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object that contains exactly one image and no text.
|
- `codex.emitImage(...)` accepts a direct image URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object that contains exactly one image and no text.
|
||||||
|
|||||||
Reference in New Issue
Block a user