From ff0341dc94f1c476d7ca525b7dc5c379868aedae Mon Sep 17 00:00:00 2001 From: aaronl-openai Date: Wed, 4 Mar 2026 22:40:31 -0800 Subject: [PATCH] [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 --- codex-rs/core/src/project_doc.rs | 8 +- codex-rs/core/src/tools/js_repl/kernel.js | 235 ++++++++- codex-rs/core/src/tools/js_repl/mod.rs | 598 +++++++++++++++++++++- docs/js_repl.md | 11 +- 4 files changed, 830 insertions(+), 22 deletions(-) diff --git a/codex-rs/core/src/project_doc.rs b/codex-rs/core/src/project_doc.rs index 042e5b9f13..d6edef0c91 100644 --- a/codex-rs/core/src/project_doc.rs +++ b/codex-rs/core/src/project_doc.rs @@ -65,7 +65,7 @@ fn render_js_repl_instructions(config: &Config) -> Option { 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 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) { 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) .await .expect("js_repl instructions expected"); - let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\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); } @@ -511,7 +511,7 @@ mod tests { let res = get_user_instructions(&cfg, None, None) .await .expect("js_repl instructions expected"); - let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\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); } @@ -530,7 +530,7 @@ mod tests { let res = get_user_instructions(&cfg, None, None) .await .expect("js_repl instructions expected"); - let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\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); } diff --git a/codex-rs/core/src/tools/js_repl/kernel.js b/codex-rs/core/src/tools/js_repl/kernel.js index 038783fb2b..88b2fd3056 100644 --- a/codex-rs/core/src/tools/js_repl/kernel.js +++ b/codex-rs/core/src/tools/js_repl/kernel.js @@ -9,7 +9,9 @@ const { builtinModules, createRequire } = require("node:module"); const { createInterface } = require("node:readline"); const { performance } = require("node:perf_hooks"); 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 vm = require("node:vm"); @@ -151,6 +153,14 @@ const moduleSearchBases = (() => { const importResolveConditions = new Set(["node", "import"]); 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) { 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) { let req = requireByBase.get(base); 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) { if ( typeof specifier !== "string" || @@ -239,7 +306,61 @@ function resolveBareSpecifier(specifier) { 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 (isDeniedBuiltin(specifier)) { throw new Error( @@ -249,9 +370,13 @@ function resolveSpecifier(specifier) { return { kind: "builtin", specifier: toNodeBuiltinSpecifier(specifier) }; } + if (isPathSpecifier(specifier)) { + return resolvePathSpecifier(specifier, referrerIdentifier); + } + if (!isBarePackageSpecifier(specifier)) { 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}`); } - return { kind: "path", path: resolvedBare }; + return { kind: "package", path: resolvedBare, specifier }; } -function importResolved(resolved) { +function importNativeResolved(resolved) { if (resolved.kind === "builtin") { return import(resolved.specifier); } - if (resolved.kind === "path") { + if (resolved.kind === "package") { return import(pathToFileURL(resolved.path).href); } 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) { if (!pattern) return; switch (pattern.type) { @@ -730,6 +934,7 @@ function normalizeEmitImageValue(value) { } async function handleExec(message) { + clearLocalFileModuleCaches(); activeExecId = message.id; const pendingBackgroundTasks = new Set(); const tool = (toolName, args) => { @@ -816,14 +1021,18 @@ async function handleExec(message) { context.tmpDir = tmpDir; await withCapturedConsole(context, async (logs) => { + const cellIdentifier = path.join( + process.cwd(), + `.codex_js_repl_cell_${cellCounter++}.mjs`, + ); const module = new SourceTextModule(source, { context, - identifier: `cell-${cellCounter++}.mjs`, + identifier: cellIdentifier, initializeImportMeta(meta, mod) { - meta.url = `file://${mod.identifier}`; + setImportMeta(meta, mod, true); }, - importModuleDynamically(specifier) { - return importResolved(resolveSpecifier(specifier)); + importModuleDynamically(specifier, referrer) { + return importResolved(resolveSpecifier(specifier, referrer?.identifier)); }, }); @@ -846,9 +1055,9 @@ async function handleExec(message) { ); return synthetic; } - - const resolved = resolveSpecifier(specifier); - return importResolved(resolved); + throw new Error( + `Top-level static import "${specifier}" is not supported in js_repl. Use await import("${specifier}") instead.`, + ); }); await module.evaluate(); diff --git a/codex-rs/core/src/tools/js_repl/mod.rs b/codex-rs/core/src/tools/js_repl/mod.rs index 86463e1b95..ad12737bc9 100644 --- a/codex-rs/core/src/tools/js_repl/mod.rs +++ b/codex-rs/core/src/tools/js_repl/mod.rs @@ -2140,6 +2140,19 @@ mod tests { 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] async fn js_repl_timeout_does_not_deadlock() -> anyhow::Result<()> { if !can_run_js_repl_runtime_tests().await { @@ -3186,7 +3199,338 @@ await codex.emitImage(out); } #[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 { return Ok(()); } @@ -3203,13 +3547,259 @@ await codex.emitImage(out); turn, tracker, JsReplArgs { - code: "await import(\"./local.js\");".to_string(), + code: "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")); + .expect_err("expected top-level static import to be rejected"); + 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(()) } } diff --git a/docs/js_repl.md b/docs/js_repl.md index dd33902900..97d8b3023c 100644 --- a/docs/js_repl.md +++ b/docs/js_repl.md @@ -40,7 +40,8 @@ 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. +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: @@ -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. +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 - `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` - 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")`. +- 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. ## 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.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. +- 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. - 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.