diff --git a/README.md b/README.md index 0c730753..f3694327 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 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 | @@ -318,6 +319,10 @@ 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. 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. --- diff --git a/scripts/codex.js b/scripts/codex.js index 73242bec..fb72e579 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,95 @@ 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) { + 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.`, + ); + } + + if ( + parsed.protocol !== "http:" || + !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 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(/\/$/, ""); +} + +/** + * @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 }; +} + async function loadRuntimeConstants() { const fallback = { RUNTIME_ROTATION_PROXY_PROVIDER_ID: `${APP_SERVER_ACCOUNT_DISPLAY_NAME}-runtime-proxy`, @@ -4711,7 +4802,12 @@ 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, + resolveRuntimeRotationProxyUpstreamBaseUrl(), + ), + ); const useCanonicalHome = (process.env[APP_RUNTIME_HELPER_USE_CANONICAL_HOME_ENV] ?? "").trim() === "1"; @@ -5112,13 +5208,52 @@ async function createRuntimeRotationProxyContextIfEnabled( baseContext, rawArgs, ) { + // 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 = + resolveRuntimeRotationProxyUpstreamBaseUrl(baseContext.env); + } catch (error) { + baseContext.cleanup?.(); + return { + startupError: `codex-multi-auth runtime rotation upstream is invalid: ${ + error instanceof Error ? error.message : String(error) + }`, + }; + } + // 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) { + 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 +5323,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 +5340,12 @@ async function createRuntimeRotationProxyContextIfEnabled( let shadowContext; try { const clientApiKey = createRuntimeRotationProxyClientApiKey(); - proxyServer = await proxyModule.startRuntimeRotationProxy({ clientApiKey }); + proxyServer = await proxyModule.startRuntimeRotationProxy( + createRuntimeRotationProxyOptions( + clientApiKey, + configuredUpstreamBaseUrl, + ), + ); shadowContext = createRuntimeRotationProxyCodexHome( baseContext.env, proxyServer.baseUrl, @@ -5211,6 +5358,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..29670703 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,197 @@ 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([ + // 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(); + 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);