diff --git a/changelog.d/fixes/12491-codex-wreq-standalone-runtime.md b/changelog.d/fixes/12491-codex-wreq-standalone-runtime.md new file mode 100644 index 0000000000..461aff2789 --- /dev/null +++ b/changelog.d/fixes/12491-codex-wreq-standalone-runtime.md @@ -0,0 +1 @@ +- **fix(sse):** Codex WebSocket transport (including the app-server) no longer fails to load in the Next.js standalone Docker runtime — the wreq-js loader now resolves its module name dynamically instead of a literal Turbopack could rewrite to an unreachable build-time symlink (#12491) — thanks @marshalfevzi diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index 8e478cb833..c74cc00bff 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -43,6 +43,7 @@ import { errorResponse } from "../utils/error.ts"; import { normalizeCodexResponsesInput } from "../utils/responsesInputNormalization.ts"; import * as prl from "../utils/providerRequestLogging.ts"; import { createRequire } from "module"; +import { loadDynamicModule } from "./codex/wreqLoader.ts"; // Quota parsing/scheduling extracted to a pure leaf; re-exported for the // Codex account module and tests. export { @@ -90,7 +91,7 @@ function getCodexWebSocketTransport(): WebsocketFn | null { if (_wreqChecked) return _websocketFn; _wreqChecked = true; try { - const mod = _wreqRequire("wreq-js") as { websocket?: WebsocketFn }; + const mod = loadDynamicModule(_wreqRequire, "wreq-js") as { websocket?: WebsocketFn }; _websocketFn = typeof mod.websocket === "function" ? mod.websocket : null; } catch { console.warn("[codex] wreq-js import failed, websocket disabled"); diff --git a/open-sse/executors/codex/wreqLoader.ts b/open-sse/executors/codex/wreqLoader.ts new file mode 100644 index 0000000000..61cbc0b518 --- /dev/null +++ b/open-sse/executors/codex/wreqLoader.ts @@ -0,0 +1,9 @@ +// #12491 — keep the module-name argument dynamic (never a literal string) +// when calling a createRequire()-returned function. Turbopack statically +// detects a literal specifier and rewrites the call to a hashed require() +// target that resolves only via a `.next`-relative symlink generated at +// build time, which is absent from the standalone Docker runtime. Mirrors +// open-sse/utils/tlsClient.ts's loadRuntimeModule(). +export function loadDynamicModule(requireFn: NodeRequire, moduleName: string): unknown { + return Reflect.apply(requireFn, undefined, [moduleName]); +} diff --git a/open-sse/utils/tlsClient.ts b/open-sse/utils/tlsClient.ts index e0767d1b64..7106d3b5aa 100644 --- a/open-sse/utils/tlsClient.ts +++ b/open-sse/utils/tlsClient.ts @@ -5,7 +5,13 @@ import { getTlsClientTimeoutConfig } from "@/shared/utils/runtimeTimeouts"; // import the first-byte watchdog alongside TlsClient without adding a line. export { guardTlsFirstByte } from "./tlsFirstByteWatchdog.ts"; -const runtimeRequire = nodeModule.createRequire(import.meta.url); +// #12491 — anchor on process.argv[1]||cwd() rather than import.meta.url: the +// standalone Docker runtime re-lays-out files at a different relative depth +// than the build, so an import.meta.url-relative resolution can miss even +// though this loader already keeps the specifier itself dynamic (see +// loadRuntimeModule() below). Matches src/lib/machineToken.ts and +// src/lib/db/adapters/runtimeRequire.ts's established anchor pattern. +const runtimeRequire = nodeModule.createRequire(process.argv[1] || process.cwd()); function loadRuntimeModule(moduleName: string): unknown { // Keep the specifier dynamic. Turbopack rewrites a literal createRequire call diff --git a/tests/unit/codex-wreq-literal-require-turbopack-12491.test.ts b/tests/unit/codex-wreq-literal-require-turbopack-12491.test.ts new file mode 100644 index 0000000000..acc6d1c35d --- /dev/null +++ b/tests/unit/codex-wreq-literal-require-turbopack-12491.test.ts @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); + +/** + * Regression guard for #12491: the Codex app-server WebSocket transport + * ("Codex app-server websocket transport unavailable") never comes up inside + * the Next.js standalone Docker runtime because open-sse/executors/codex.ts + * resolves wreq-js with a LITERAL specifier: + * + * const _wreqRequire = createRequire(import.meta.url); + * const mod = _wreqRequire("wreq-js"); + * + * Turbopack statically detects the literal string argument and rewrites the + * call into `require("wreq-js-")`, satisfied only by a symlink + * Turbopack drops at `.next/node_modules/wreq-js- -> node_modules/wreq-js` + * during the build. That symlink is a `.next`-relative build artifact; it is not + * reachable through the same relative path once files are re-laid-out for a + * standalone Docker image, so `require("wreq-js-")` throws + * MODULE_NOT_FOUND at runtime — even though `node -e "require('wreq-js')"` + * (the literal package name) succeeds fine in the very same container. + * + * Verified interactively against a real `next build --turbopack` (Next 16.3.3): + * compiling `const _wreqRequire = createRequire(import.meta.url); _wreqRequire("wreq-js")` + * emits `64301,(e,t,r)=>{t.exports=e.x("wreq-js-3b69dd5e46bd26d3",()=>require("wreq-js-3b69dd5e46bd26d3"))}` + * in the compiled chunk, and the build directory gained + * `.next/node_modules/wreq-js-3b69dd5e46bd26d3 -> ../../node_modules/wreq-js`. + * `open-sse/utils/tlsClient.ts` (see its `loadRuntimeModule()`) was already + * hardened against exactly this by keeping the specifier a runtime variable + * (`Reflect.apply(runtimeRequire, undefined, [moduleName])`), which Turbopack + * cannot statically rewrite — codex.ts's own wreq-js loader (the one that feeds + * `getCodexAppServerWebsocketTransport()` / `CodexAppServerClient.connect()`) + * never received the same treatment. + */ + +const CODEX_TS_PATH = join(ROOT, "open-sse", "executors", "codex.ts"); + +test("codex.ts must not pass a literal specifier to the wreq-js createRequire() loader", () => { + const source = readFileSync(CODEX_TS_PATH, "utf8"); + + assert.match( + source, + /const _wreqRequire = createRequire\(import\.meta\.url\)/, + "expected open-sse/executors/codex.ts to still define _wreqRequire via createRequire(import.meta.url) " + + "— update this test's assumptions if the loader was restructured" + ); + + const literalCallPattern = /_wreqRequire\(\s*["'`]wreq-js["'`]\s*\)/; + assert.doesNotMatch( + source, + literalCallPattern, + 'open-sse/executors/codex.ts calls _wreqRequire("wreq-js") with a LITERAL specifier. ' + + 'Turbopack statically rewrites this into `require("wreq-js-")`, which only ' + + "resolves via a `.next/node_modules/wreq-js-` symlink generated at build time — " + + "not reachable in the standalone Docker runtime, so the Codex app-server WebSocket transport " + + "(and the plain Codex WS transport sharing this loader) is permanently disabled in production " + + "(#12491). Route the specifier through a variable the bundler cannot statically analyze, " + + "e.g. Reflect.apply(_wreqRequire, undefined, [moduleName]) — the exact pattern already used by " + + "open-sse/utils/tlsClient.ts's loadRuntimeModule()." + ); +});