From ccf8778227d5b972e08b753e50a85c45b3e024ae Mon Sep 17 00:00:00 2001 From: Syamsul Alam Date: Tue, 8 Sep 2026 15:07:19 +0700 Subject: [PATCH 1/3] feat: support loopback runtime proxy upstream --- README.md | 3 ++ scripts/codex.js | 91 +++++++++++++++++++++++++++++++++- test/codex-bin-wrapper.test.ts | 80 +++++++++++++++++++++++++++++- 3 files changed, 171 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 0c730753..2ce83a08 100644 --- a/README.md +++ b/README.md @@ -296,6 +296,7 @@ Selected runtime/environment overrides: | `CODEX_MULTI_AUTH_FORCE_ACCOUNT=` | Force one account for a single `codex-multi-auth-codex` run (ephemeral; requires rotation proxy) | | `CODEX_MULTI_AUTH_BYPASS=1` | Skip multi-auth intercept and forward straight to official Codex | | `CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY=0/1` | Opt out/in of live Responses proxy rotation for forwarded Codex CLI/app sessions | +| `CODEX_MULTI_AUTH_RUNTIME_PROXY_UPSTREAM_BASE_URL=http://127.0.0.1:/` | Route the runtime rotation proxy through an explicit loopback HTTP upstream; requires a port and rejects credentials, query strings, fragments, HTTPS, and non-loopback hosts | | `CODEX_MULTI_AUTH_APP_ROTATION_IDLE_MS=` | Override automatic Codex app helper idle shutdown | | `CODEX_MULTI_AUTH_APP_BIND_INSTALL=0/1` | Opt out/in of packaged Codex app bind self-heal on first CLI run or rotation enable | | `CODEX_MULTI_AUTH_APP_LAUNCHER_INSTALL=0/1` | Opt out/in of routing supported app shortcuts on first CLI run or rotation enable | @@ -318,6 +319,8 @@ Responses background mode stays opt-in. Enable `backgroundResponses` in settings Runtime rotation is enabled by default for request-bearing wrapper-launched Codex sessions. Package install scripts stay side-effect-free: npm postinstall only prints a short notice (and stays silent in CI or non-interactive installs). The first CLI run after an install self-heals supported packaged Codex app binds and user-level launcher routing when possible (recorded once in a `first-run-setup.json` marker under the multi-auth runtime root), while `codex-multi-auth rotation enable` remains the explicit repair command. `codex-multi-auth rotation disable` turns the setting off and removes the persistent app bind. Set `CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY=0`, `CODEX_MULTI_AUTH_APP_BIND_INSTALL=0`, or `CODEX_MULTI_AUTH_APP_LAUNCHER_INSTALL=0` to opt out of the matching default behavior. +Advanced local proxy chains can set `CODEX_MULTI_AUTH_RUNTIME_PROXY_UPSTREAM_BASE_URL` for one wrapper process. The wrapper passes that URL to both the shadow-runtime and interactive-helper rotation paths and fails closed if the configured upstream is invalid or unavailable; it never falls back silently to the direct backend while an explicit upstream is required. + Installed wrappers may perform a best-effort daily npm version check during normal forwarded Codex startup. When a newer package is detected, the wrapper only prints a manual notice on an interactive TTY or when `CODEX_MULTI_AUTH_DEBUG=1`: `npm install -g codex-multi-auth@latest`. It never runs npm install or update commands for you. --- diff --git a/scripts/codex.js b/scripts/codex.js index 73242bec..11493e40 100755 --- a/scripts/codex.js +++ b/scripts/codex.js @@ -78,6 +78,8 @@ const APP_RUNTIME_HELPER_USE_CANONICAL_HOME_ENV = "CODEX_MULTI_AUTH_APP_ROTATION_USE_CANONICAL_HOME"; const APP_RUNTIME_HELPER_INSTALL_APP_SERVER_SHIM_ENV = "CODEX_MULTI_AUTH_APP_ROTATION_INSTALL_APP_SERVER_SHIM"; +const RUNTIME_ROTATION_PROXY_UPSTREAM_BASE_URL_ENV = + "CODEX_MULTI_AUTH_RUNTIME_PROXY_UPSTREAM_BASE_URL"; const APP_SERVER_CONFIG_ARGS_ENV = "CODEX_MULTI_AUTH_APP_SERVER_CONFIG_ARGS_JSON"; const APP_RUNTIME_HELPER_STATUS_FILE = @@ -168,6 +170,51 @@ let warnedShadowHomeSqliteLinkFailure = false; const warnedShadowHomeLinkOnlyDirectoryFailures = new Set(); const warnedShadowHomeSqliteSidecarPlaceholderFailures = new Set(); +function resolveRuntimeRotationProxyUpstreamBaseUrl(env = process.env) { + const raw = (env[RUNTIME_ROTATION_PROXY_UPSTREAM_BASE_URL_ENV] ?? "").trim(); + if (!raw) { + return undefined; + } + + let parsed; + try { + parsed = new URL(raw); + } catch { + throw new Error( + `${RUNTIME_ROTATION_PROXY_UPSTREAM_BASE_URL_ENV} must be an absolute loopback HTTP URL.`, + ); + } + + const hostname = parsed.hostname.toLowerCase(); + const loopback = + hostname === "127.0.0.1" || + hostname === "localhost" || + hostname === "[::1]" || + hostname === "::1"; + if ( + parsed.protocol !== "http:" || + !loopback || + !parsed.port || + parsed.username || + parsed.password || + parsed.search || + parsed.hash + ) { + throw new Error( + `${RUNTIME_ROTATION_PROXY_UPSTREAM_BASE_URL_ENV} must use loopback HTTP with an explicit port and no credentials, query, or fragment.`, + ); + } + + return parsed.toString().replace(/\/$/, ""); +} + +function createRuntimeRotationProxyOptions(clientApiKey, env = process.env) { + const upstreamBaseUrl = resolveRuntimeRotationProxyUpstreamBaseUrl(env); + return upstreamBaseUrl + ? { clientApiKey, upstreamBaseUrl } + : { clientApiKey }; +} + async function loadRuntimeConstants() { const fallback = { RUNTIME_ROTATION_PROXY_PROVIDER_ID: `${APP_SERVER_ACCOUNT_DISPLAY_NAME}-runtime-proxy`, @@ -4711,7 +4758,9 @@ async function runRuntimeRotationAppHelper(identityToken = "") { throw new Error("runtime rotation config helpers are unavailable"); } const clientApiKey = createRuntimeRotationProxyClientApiKey(); - proxyServer = await proxyModule.startRuntimeRotationProxy({ clientApiKey }); + proxyServer = await proxyModule.startRuntimeRotationProxy( + createRuntimeRotationProxyOptions(clientApiKey), + ); const useCanonicalHome = (process.env[APP_RUNTIME_HELPER_USE_CANONICAL_HOME_ENV] ?? "").trim() === "1"; @@ -5117,8 +5166,29 @@ async function createRuntimeRotationProxyContextIfEnabled( return baseContext; } + let configuredUpstreamBaseUrl; + try { + configuredUpstreamBaseUrl = + resolveRuntimeRotationProxyUpstreamBaseUrl(baseContext.env); + } catch (error) { + baseContext.cleanup?.(); + return { + startupError: `codex-multi-auth runtime rotation upstream is invalid: ${ + error instanceof Error ? error.message : String(error) + }`, + }; + } + const requireConfiguredUpstream = configuredUpstreamBaseUrl !== undefined; + const configTomlModule = await loadRuntimeConfigTomlModule(); if (!configTomlModule) { + if (requireConfiguredUpstream) { + baseContext.cleanup?.(); + return { + startupError: + "codex-multi-auth runtime rotation config helpers are unavailable; configured upstream routing cannot continue.", + }; + } console.error( "codex-multi-auth runtime rotation config helpers are unavailable; continuing without runtime rotation.", ); @@ -5188,6 +5258,13 @@ async function createRuntimeRotationProxyContextIfEnabled( const proxyModule = await loadRuntimeRotationProxyModule(); if (!proxyModule) { + if (requireConfiguredUpstream) { + baseContext.cleanup?.(); + return { + startupError: + "codex-multi-auth runtime rotation proxy is unavailable; configured upstream routing cannot continue.", + }; + } console.error( "codex-multi-auth runtime rotation proxy is unavailable; continuing without runtime rotation.", ); @@ -5198,7 +5275,9 @@ async function createRuntimeRotationProxyContextIfEnabled( let shadowContext; try { const clientApiKey = createRuntimeRotationProxyClientApiKey(); - proxyServer = await proxyModule.startRuntimeRotationProxy({ clientApiKey }); + proxyServer = await proxyModule.startRuntimeRotationProxy( + createRuntimeRotationProxyOptions(clientApiKey, baseContext.env), + ); shadowContext = createRuntimeRotationProxyCodexHome( baseContext.env, proxyServer.baseUrl, @@ -5211,6 +5290,14 @@ async function createRuntimeRotationProxyContextIfEnabled( } catch { // Best-effort cleanup only. } + if (requireConfiguredUpstream) { + baseContext.cleanup?.(); + return { + startupError: `codex-multi-auth runtime rotation proxy failed to start with configured upstream: ${ + error instanceof Error ? error.message : String(error) + }`, + }; + } console.error( `codex-multi-auth runtime rotation proxy failed to start; continuing without runtime rotation: ${error instanceof Error ? error.message : String(error)}`, ); diff --git a/test/codex-bin-wrapper.test.ts b/test/codex-bin-wrapper.test.ts index 2a4abaf6..fbb4326e 100644 --- a/test/codex-bin-wrapper.test.ts +++ b/test/codex-bin-wrapper.test.ts @@ -372,8 +372,11 @@ function createRuntimeRotationProxyFixtureModule(fixtureRoot: string): string { " };", "}", "", - "export async function startRuntimeRotationProxy() {", + "export async function startRuntimeRotationProxy(options = {}) {", " const baseUrl = process.env.CODEX_MULTI_AUTH_TEST_PROXY_BASE_URL ?? 'http://127.0.0.1:4567';", + " if ((process.env.CODEX_MULTI_AUTH_TEST_PROXY_MARKER_UPSTREAM ?? '').trim() === '1') {", + " appendMarker(`upstream:${options.upstreamBaseUrl ?? ''}`);", + " }", // Opt-in (#623): record the forced-account pin env the proxy process actually // observed, so a test can prove the value crossed the launcher -> detached // app-helper boundary. Gated so it never perturbs the exact-marker assertions @@ -1634,6 +1637,81 @@ describe("codex bin wrapper", () => { ); }); + it.each([ + ["shadow runtime", ["exec", "status"]], + ["interactive helper", ["resume", "session-fixture"]], + ])( + "forwards an explicit loopback upstream through the %s proxy", + async (_label, args) => { + const fixtureRoot = createWrapperFixture(); + createRuntimeRotationProxyFixtureModule(fixtureRoot); + const fakeBin = createFakeCodexBin(fixtureRoot); + const originalHome = join(fixtureRoot, "codex-home"); + const markerPath = join(fixtureRoot, "proxy-marker.txt"); + mkdirSync(originalHome, { recursive: true }); + writeFileSync( + join(originalHome, "config.toml"), + 'model_provider = "openai"\n', + "utf8", + ); + + const result = runWrapper(fixtureRoot, args, { + CODEX_MULTI_AUTH_REAL_CODEX_BIN: fakeBin, + CODEX_HOME: originalHome, + CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY: "1", + CODEX_MULTI_AUTH_RUNTIME_PROXY_UPSTREAM_BASE_URL: + "http://127.0.0.1:8787/backend-api", + CODEX_MULTI_AUTH_APP_ROTATION_IDLE_MS: "1000", + CODEX_MULTI_AUTH_APP_ROTATION_DETACHED_IDLE_MS: "150", + CODEX_MULTI_AUTH_TEST_PROXY_MARKER: markerPath, + CODEX_MULTI_AUTH_TEST_PROXY_MARKER_UPSTREAM: "1", + }); + + expect(result.status).toBe(0); + await waitForFileText( + markerPath, + [ + "upstream:http://127.0.0.1:8787/backend-api", + "start:http://127.0.0.1:4567", + "close", + "", + ].join("\n"), + ); + }, + ); + + it.each([ + "https://127.0.0.1:8787/backend-api", + "http://example.com:8787/backend-api", + "http://127.0.0.1/backend-api", + "http://user:pass@127.0.0.1:8787/backend-api", + "http://127.0.0.1:8787/backend-api?route=unsafe", + "not-a-url", + ])("fails closed for an unsafe runtime-proxy upstream: %s", (upstream) => { + const fixtureRoot = createWrapperFixture(); + createRuntimeRotationProxyFixtureModule(fixtureRoot); + const fakeBin = createFakeCodexBin(fixtureRoot); + const originalHome = join(fixtureRoot, "codex-home"); + const markerPath = join(fixtureRoot, "proxy-marker.txt"); + mkdirSync(originalHome, { recursive: true }); + writeFileSync(join(originalHome, "config.toml"), 'model_provider = "openai"\n', "utf8"); + + const result = runWrapper(fixtureRoot, ["exec", "status"], { + CODEX_MULTI_AUTH_REAL_CODEX_BIN: fakeBin, + CODEX_HOME: originalHome, + CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY: "1", + CODEX_MULTI_AUTH_RUNTIME_PROXY_UPSTREAM_BASE_URL: upstream, + CODEX_MULTI_AUTH_TEST_PROXY_MARKER: markerPath, + }); + + expect(result.status).toBe(1); + expect(combinedOutput(result)).toContain( + "codex-multi-auth runtime rotation upstream is invalid", + ); + expect(existsSync(markerPath)).toBe(false); + expect(result.stdout).not.toContain("FORWARDED:"); + }); + it("starts the opt-in runtime rotation proxy with a shadow CODEX_HOME provider", () => { const fixtureRoot = createWrapperFixture(); createRuntimeRotationProxyFixtureModule(fixtureRoot); From 5d03e7fae89b2f187993290a8fc2ee90fa73100f Mon Sep 17 00:00:00 2001 From: ndycode Date: Tue, 8 Sep 2026 21:33:49 +0800 Subject: [PATCH 2/3] fix(wrapper): enforce the configured upstream before the rotation gate Follow-up review fixes on top of the loopback-upstream change. - Resolve CODEX_MULTI_AUTH_RUNTIME_PROXY_UPSTREAM_BASE_URL BEFORE the isRuntimeRotationProxyEnabled gate. Every path that returned baseContext early (rotation set to 0, CODEX_MULTI_AUTH_BYPASS=1, a missing dist/lib/config.js) forwarded Responses traffic and its OAuth bearer straight to the real backend while the operator believed it was pinned to their local listener, contradicting the README paragraph added in this PR. A request-bearing invocation now exits non-zero instead. Subcommands that never reach the backend still pass through with the upstream unused. - Reject a named host. `localhost` was accepted without resolution, so a hosts-file entry pointing it at a routable address sent the managed bearer token off-host. The host must now be a numeric loopback literal: any of 127.0.0.0/8, or [::1]. The old `hostname === "::1"` arm was dead code, since WHATWG URL always reports an IPv6 host bracketed. - Accept an explicitly written default port. `new URL()` erases it, so `http://127.0.0.1:80/backend-api` reported `parsed.port === ""` and the `!parsed.port` check rejected a valid loopback URL. - Stop parsing the variable twice per launch. The second parse sat inside the proxy-start try, so a pure validation failure was reported as "failed to start with configured upstream" instead of "upstream is invalid". Both call sites now receive the already-resolved value. - README: document the numeric-loopback requirement, the reason for it, and what "fails closed" covers. Test additions: accepted `:80`, `127.9.9.9` and `[::1]` upstreams; rejected `localhost`, `#fragment`, `127.0.0.1.example.com` and `[::2]`; the rotation-disabled fail-closed path; and a non-request subcommand passing through. 4 of them fail against the pre-fix tree. Pre-existing and unrelated: the two "native codex executables on PATH" cases in this same file fail at the PR head as well, before any of these changes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UgsUVYrAcw3KNdqkWoFk3y --- README.md | 4 +- scripts/codex.js | 106 ++++++++++++++++++++++++------ test/codex-bin-wrapper.test.ts | 116 +++++++++++++++++++++++++++++++++ 3 files changed, 205 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 2ce83a08..c8e02b2d 100644 --- a/README.md +++ b/README.md @@ -296,7 +296,7 @@ Selected runtime/environment overrides: | `CODEX_MULTI_AUTH_FORCE_ACCOUNT=` | Force one account for a single `codex-multi-auth-codex` run (ephemeral; requires rotation proxy) | | `CODEX_MULTI_AUTH_BYPASS=1` | Skip multi-auth intercept and forward straight to official Codex | | `CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY=0/1` | Opt out/in of live Responses proxy rotation for forwarded Codex CLI/app sessions | -| `CODEX_MULTI_AUTH_RUNTIME_PROXY_UPSTREAM_BASE_URL=http://127.0.0.1:/` | Route the runtime rotation proxy through an explicit loopback HTTP upstream; requires a port and rejects credentials, query strings, fragments, HTTPS, and non-loopback hosts | +| `CODEX_MULTI_AUTH_RUNTIME_PROXY_UPSTREAM_BASE_URL=http://127.0.0.1:/` | Route the runtime rotation proxy through an explicit loopback HTTP upstream; requires an explicit port and a numeric loopback host (`127.0.0.0/8` or `[::1]`), and rejects credentials, query strings, fragments, HTTPS, and every other host | | `CODEX_MULTI_AUTH_APP_ROTATION_IDLE_MS=` | Override automatic Codex app helper idle shutdown | | `CODEX_MULTI_AUTH_APP_BIND_INSTALL=0/1` | Opt out/in of packaged Codex app bind self-heal on first CLI run or rotation enable | | `CODEX_MULTI_AUTH_APP_LAUNCHER_INSTALL=0/1` | Opt out/in of routing supported app shortcuts on first CLI run or rotation enable | @@ -319,7 +319,7 @@ Responses background mode stays opt-in. Enable `backgroundResponses` in settings Runtime rotation is enabled by default for request-bearing wrapper-launched Codex sessions. Package install scripts stay side-effect-free: npm postinstall only prints a short notice (and stays silent in CI or non-interactive installs). The first CLI run after an install self-heals supported packaged Codex app binds and user-level launcher routing when possible (recorded once in a `first-run-setup.json` marker under the multi-auth runtime root), while `codex-multi-auth rotation enable` remains the explicit repair command. `codex-multi-auth rotation disable` turns the setting off and removes the persistent app bind. Set `CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY=0`, `CODEX_MULTI_AUTH_APP_BIND_INSTALL=0`, or `CODEX_MULTI_AUTH_APP_LAUNCHER_INSTALL=0` to opt out of the matching default behavior. -Advanced local proxy chains can set `CODEX_MULTI_AUTH_RUNTIME_PROXY_UPSTREAM_BASE_URL` for one wrapper process. The wrapper passes that URL to both the shadow-runtime and interactive-helper rotation paths and fails closed if the configured upstream is invalid or unavailable; it never falls back silently to the direct backend while an explicit upstream is required. +Advanced local proxy chains can set `CODEX_MULTI_AUTH_RUNTIME_PROXY_UPSTREAM_BASE_URL` for one wrapper process. The wrapper passes that URL to both the shadow-runtime and interactive-helper rotation paths and fails closed if the configured upstream is invalid or unavailable; it never falls back silently to the direct backend while an explicit upstream is required. Because the request that reaches the upstream carries the managed OAuth bearer token, the host must be a numeric loopback literal: a name such as `localhost` is rejected, since a hosts-file entry can point it at a routable address. "Fails closed" also covers the case where runtime rotation itself is off (`CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY=0`, `CODEX_MULTI_AUTH_BYPASS=1`, or the setting disabled): a request-bearing invocation exits non-zero rather than bypassing the configured upstream. Subcommands that make no backend requests still run normally. Installed wrappers may perform a best-effort daily npm version check during normal forwarded Codex startup. When a newer package is detected, the wrapper only prints a manual notice on an interactive TTY or when `CODEX_MULTI_AUTH_DEBUG=1`: `npm install -g codex-multi-auth@latest`. It never runs npm install or update commands for you. diff --git a/scripts/codex.js b/scripts/codex.js index 11493e40..fb72e579 100755 --- a/scripts/codex.js +++ b/scripts/codex.js @@ -170,6 +170,51 @@ let warnedShadowHomeSqliteLinkFailure = false; const warnedShadowHomeLinkOnlyDirectoryFailures = new Set(); const warnedShadowHomeSqliteSidecarPlaceholderFailures = new Set(); +/** + * True only for a NUMERIC loopback literal, as `new URL().hostname` reports it. + * + * A name is deliberately not enough. `localhost` is resolved by the OS at + * connect time, so an /etc/hosts or Windows hosts entry can point it at a + * routable address, and the upstream request carries the managed OAuth bearer + * token and the request body. WHATWG URL always reports an IPv6 host in its + * bracketed form, so `::1` is only ever seen here as `[::1]`. + */ +function isLoopbackUrlHostname(hostname) { + if (hostname === "[::1]") { + return true; + } + const ipv4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(hostname); + if (!ipv4) { + return false; + } + const octets = ipv4.slice(1).map((part) => Number(part)); + if (octets.some((octet) => !Number.isInteger(octet) || octet > 255)) { + return false; + } + // The whole 127.0.0.0/8 block is loopback, not just 127.0.0.1. + return octets[0] === 127; +} + +/** + * Whether the raw URL text carries an explicit `:port`. + * + * `new URL()` erases a port that matches the scheme default, so + * `http://127.0.0.1:80/x` reports `parsed.port === ""` and a `!parsed.port` + * check would reject a perfectly valid, explicitly-ported loopback URL. + */ +function hasExplicitUrlPort(raw) { + const withoutScheme = raw.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, ""); + const authority = withoutScheme.split(/[/?#]/, 1)[0] ?? ""; + // Strip userinfo first, or a password containing ':' reads as a port. + const hostAndPort = authority.slice(authority.lastIndexOf("@") + 1); + if (hostAndPort.startsWith("[")) { + const close = hostAndPort.indexOf("]"); + return close !== -1 && /^:\d+$/.test(hostAndPort.slice(close + 1)); + } + const colon = hostAndPort.indexOf(":"); + return colon !== -1 && /^:\d+$/.test(hostAndPort.slice(colon)); +} + function resolveRuntimeRotationProxyUpstreamBaseUrl(env = process.env) { const raw = (env[RUNTIME_ROTATION_PROXY_UPSTREAM_BASE_URL_ENV] ?? "").trim(); if (!raw) { @@ -185,31 +230,30 @@ function resolveRuntimeRotationProxyUpstreamBaseUrl(env = process.env) { ); } - const hostname = parsed.hostname.toLowerCase(); - const loopback = - hostname === "127.0.0.1" || - hostname === "localhost" || - hostname === "[::1]" || - hostname === "::1"; if ( parsed.protocol !== "http:" || - !loopback || - !parsed.port || + !isLoopbackUrlHostname(parsed.hostname.toLowerCase()) || + !(parsed.port || hasExplicitUrlPort(raw)) || parsed.username || parsed.password || parsed.search || parsed.hash ) { throw new Error( - `${RUNTIME_ROTATION_PROXY_UPSTREAM_BASE_URL_ENV} must use loopback HTTP with an explicit port and no credentials, query, or fragment.`, + `${RUNTIME_ROTATION_PROXY_UPSTREAM_BASE_URL_ENV} must use HTTP with a numeric loopback host (127.0.0.0/8 or [::1]; a name such as "localhost" is not accepted because it can resolve off-host), an explicit port, and no credentials, query, or fragment.`, ); } return parsed.toString().replace(/\/$/, ""); } -function createRuntimeRotationProxyOptions(clientApiKey, env = process.env) { - const upstreamBaseUrl = resolveRuntimeRotationProxyUpstreamBaseUrl(env); +/** + * @param clientApiKey Shared secret the wrapper and proxy authenticate with. + * @param upstreamBaseUrl Already-resolved upstream, or undefined for the + * default backend. Callers pass the value they resolved rather than the env, so + * one launch never parses (and never re-reports) the same variable twice. + */ +function createRuntimeRotationProxyOptions(clientApiKey, upstreamBaseUrl) { return upstreamBaseUrl ? { clientApiKey, upstreamBaseUrl } : { clientApiKey }; @@ -4759,7 +4803,10 @@ async function runRuntimeRotationAppHelper(identityToken = "") { } const clientApiKey = createRuntimeRotationProxyClientApiKey(); proxyServer = await proxyModule.startRuntimeRotationProxy( - createRuntimeRotationProxyOptions(clientApiKey), + createRuntimeRotationProxyOptions( + clientApiKey, + resolveRuntimeRotationProxyUpstreamBaseUrl(), + ), ); const useCanonicalHome = (process.env[APP_RUNTIME_HELPER_USE_CANONICAL_HOME_ENV] ?? "").trim() === @@ -5161,11 +5208,13 @@ async function createRuntimeRotationProxyContextIfEnabled( baseContext, rawArgs, ) { - const enabled = await isRuntimeRotationProxyEnabled(rawArgs, baseContext.env); - if (!enabled) { - return baseContext; - } - + // Resolve the configured upstream BEFORE the enabled gate. Every path that + // leaves this function with `baseContext` forwards Responses traffic, and its + // managed OAuth bearer token, straight to the real backend. An explicit + // upstream is a routing requirement rather than a rotation preference, so + // `CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY=0`, `CODEX_MULTI_AUTH_BYPASS=1`, a + // missing `dist/lib/config.js` or a disabled setting must all fail closed + // here instead of silently reaching chatgpt.com. let configuredUpstreamBaseUrl; try { configuredUpstreamBaseUrl = @@ -5178,7 +5227,23 @@ async function createRuntimeRotationProxyContextIfEnabled( }`, }; } - const requireConfiguredUpstream = configuredUpstreamBaseUrl !== undefined; + // The one exception is a subcommand that never reaches the backend at all + // (`--version`, `login`, ...): there is no traffic to misroute, so it passes + // through with the upstream simply unused. + const requireConfiguredUpstream = + configuredUpstreamBaseUrl !== undefined && + shouldUseRuntimeRoutingForForwardedArgs(rawArgs); + + const enabled = await isRuntimeRotationProxyEnabled(rawArgs, baseContext.env); + if (!enabled) { + if (requireConfiguredUpstream) { + baseContext.cleanup?.(); + return { + startupError: `codex-multi-auth runtime rotation is disabled, so ${RUNTIME_ROTATION_PROXY_UPSTREAM_BASE_URL_ENV} cannot be honored and requests would go to the direct backend instead. Enable runtime rotation or unset that variable.`, + }; + } + return baseContext; + } const configTomlModule = await loadRuntimeConfigTomlModule(); if (!configTomlModule) { @@ -5276,7 +5341,10 @@ async function createRuntimeRotationProxyContextIfEnabled( try { const clientApiKey = createRuntimeRotationProxyClientApiKey(); proxyServer = await proxyModule.startRuntimeRotationProxy( - createRuntimeRotationProxyOptions(clientApiKey, baseContext.env), + createRuntimeRotationProxyOptions( + clientApiKey, + configuredUpstreamBaseUrl, + ), ); shadowContext = createRuntimeRotationProxyCodexHome( baseContext.env, diff --git a/test/codex-bin-wrapper.test.ts b/test/codex-bin-wrapper.test.ts index fbb4326e..29670703 100644 --- a/test/codex-bin-wrapper.test.ts +++ b/test/codex-bin-wrapper.test.ts @@ -1680,12 +1680,128 @@ describe("codex bin wrapper", () => { }, ); + it.each([ + // An explicit default port is still an explicit port: `new URL()` erases + // it, so the resolved value drops back to the canonical form. + [ + "http://127.0.0.1:80/backend-api", + "http://127.0.0.1/backend-api", + ], + // The whole 127.0.0.0/8 block is loopback, not only 127.0.0.1. + [ + "http://127.9.9.9:8787/backend-api", + "http://127.9.9.9:8787/backend-api", + ], + ["http://[::1]:8787/backend-api", "http://[::1]:8787/backend-api"], + ])( + "accepts the loopback runtime-proxy upstream %s", + async (upstream, expectedForwarded) => { + const fixtureRoot = createWrapperFixture(); + createRuntimeRotationProxyFixtureModule(fixtureRoot); + const fakeBin = createFakeCodexBin(fixtureRoot); + const originalHome = join(fixtureRoot, "codex-home"); + const markerPath = join(fixtureRoot, "proxy-marker.txt"); + mkdirSync(originalHome, { recursive: true }); + writeFileSync( + join(originalHome, "config.toml"), + 'model_provider = "openai"\n', + "utf8", + ); + + const result = runWrapper(fixtureRoot, ["exec", "status"], { + CODEX_MULTI_AUTH_REAL_CODEX_BIN: fakeBin, + CODEX_HOME: originalHome, + CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY: "1", + CODEX_MULTI_AUTH_RUNTIME_PROXY_UPSTREAM_BASE_URL: upstream, + CODEX_MULTI_AUTH_TEST_PROXY_MARKER: markerPath, + CODEX_MULTI_AUTH_TEST_PROXY_MARKER_UPSTREAM: "1", + }); + + expect(result.status).toBe(0); + await waitForFileText( + markerPath, + [ + `upstream:${expectedForwarded}`, + "start:http://127.0.0.1:4567", + "close", + "", + ].join("\n"), + ); + }, + ); + + it("fails closed when runtime rotation is off but an upstream is configured", () => { + // Regression: the upstream used to be resolved only AFTER the enabled + // gate, so with rotation disabled the wrapper silently talked to the real + // backend while the operator believed traffic was pinned to their local + // listener. + const fixtureRoot = createWrapperFixture(); + createRuntimeRotationProxyFixtureModule(fixtureRoot); + const fakeBin = createFakeCodexBin(fixtureRoot); + const originalHome = join(fixtureRoot, "codex-home"); + const markerPath = join(fixtureRoot, "proxy-marker.txt"); + mkdirSync(originalHome, { recursive: true }); + writeFileSync( + join(originalHome, "config.toml"), + 'model_provider = "openai"\n', + "utf8", + ); + + const result = runWrapper(fixtureRoot, ["exec", "status"], { + CODEX_MULTI_AUTH_REAL_CODEX_BIN: fakeBin, + CODEX_HOME: originalHome, + CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY: "0", + CODEX_MULTI_AUTH_RUNTIME_PROXY_UPSTREAM_BASE_URL: + "http://127.0.0.1:8787/backend-api", + CODEX_MULTI_AUTH_TEST_PROXY_MARKER: markerPath, + }); + + expect(result.status).toBe(1); + expect(combinedOutput(result)).toContain( + "codex-multi-auth runtime rotation is disabled", + ); + expect(result.stdout).not.toContain("FORWARDED:"); + }); + + it("still runs a non-request subcommand when an upstream is configured", () => { + // `--version` never reaches the backend, so there is no traffic to + // misroute and the configured upstream is simply unused. + const fixtureRoot = createWrapperFixture(); + createRuntimeRotationProxyFixtureModule(fixtureRoot); + const fakeBin = createFakeCodexBin(fixtureRoot); + const originalHome = join(fixtureRoot, "codex-home"); + mkdirSync(originalHome, { recursive: true }); + writeFileSync( + join(originalHome, "config.toml"), + 'model_provider = "openai"\n', + "utf8", + ); + + const result = runWrapper(fixtureRoot, ["--version"], { + CODEX_MULTI_AUTH_REAL_CODEX_BIN: fakeBin, + CODEX_HOME: originalHome, + CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY: "0", + CODEX_MULTI_AUTH_RUNTIME_PROXY_UPSTREAM_BASE_URL: + "http://127.0.0.1:8787/backend-api", + }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("FORWARDED:"); + }); + it.each([ "https://127.0.0.1:8787/backend-api", "http://example.com:8787/backend-api", "http://127.0.0.1/backend-api", "http://user:pass@127.0.0.1:8787/backend-api", "http://127.0.0.1:8787/backend-api?route=unsafe", + "http://127.0.0.1:8787/backend-api#unsafe", + // A name is not a loopback guarantee: a hosts-file entry can point + // `localhost` at a routable address and the upstream request carries the + // managed OAuth bearer token. + "http://localhost:8787/backend-api", + "http://127.0.0.1.example.com:8787/backend-api", + "http://[::2]:8787/backend-api", "not-a-url", ])("fails closed for an unsafe runtime-proxy upstream: %s", (upstream) => { const fixtureRoot = createWrapperFixture(); From 0e71f595af512a1a6e57a30515f7cbbb15b32799 Mon Sep 17 00:00:00 2001 From: ndycode Date: Tue, 8 Sep 2026 21:46:05 +0800 Subject: [PATCH 3/3] docs(readme): state the loopback upstream trust boundary Follow-up on the CodeRabbit CWE-319 finding against 5d03e7fa. The loopback check bounds exposure to the local machine and does not authenticate the process holding the port, so any local process that can bind it receives the managed bearer token in cleartext. Requiring HTTPS is not the fix: a local inspection proxy is the point of this variable, it terminates TLS with a certificate no public CA vouches for, and a hostile local process could serve TLS on that port just as easily. The honest resolution is to state the assumption the operator is taking on, which the README now does. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UgsUVYrAcw3KNdqkWoFk3y --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index c8e02b2d..f3694327 100644 --- a/README.md +++ b/README.md @@ -321,6 +321,8 @@ Runtime rotation is enabled by default for request-bearing wrapper-launched Code Advanced local proxy chains can set `CODEX_MULTI_AUTH_RUNTIME_PROXY_UPSTREAM_BASE_URL` for one wrapper process. The wrapper passes that URL to both the shadow-runtime and interactive-helper rotation paths and fails closed if the configured upstream is invalid or unavailable; it never falls back silently to the direct backend while an explicit upstream is required. Because the request that reaches the upstream carries the managed OAuth bearer token, the host must be a numeric loopback literal: a name such as `localhost` is rejected, since a hosts-file entry can point it at a routable address. "Fails closed" also covers the case where runtime rotation itself is off (`CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY=0`, `CODEX_MULTI_AUTH_BYPASS=1`, or the setting disabled): a request-bearing invocation exits non-zero rather than bypassing the configured upstream. Subcommands that make no backend requests still run normally. +Trust boundary: the loopback check bounds exposure to the local machine, and nothing more. It does not authenticate the process listening on that port, and plaintext HTTP is what a local inspection proxy needs, so any process that can bind the port you name receives the managed bearer token in cleartext. Set this variable only on a machine where you trust every local process that can bind a loopback port, and only for as long as you need the chain. + Installed wrappers may perform a best-effort daily npm version check during normal forwarded Codex startup. When a newer package is detected, the wrapper only prints a manual notice on an interactive TTY or when `CODEX_MULTI_AUTH_DEBUG=1`: `npm install -g codex-multi-auth@latest`. It never runs npm install or update commands for you. ---