diff --git a/README.md b/README.md index 0e81a144..64bc5e8a 100644 --- a/README.md +++ b/README.md @@ -165,9 +165,10 @@ If browser launch is blocked, use the alternate login paths in [docs/getting-sta | Command | What it answers | | --- | --- | -| `codex auth report --live --json` | How do I get the full machine-readable health report? | -| `codex auth fix --live --model gpt-5-codex` | How do I run live repair probes with a chosen model? | -| `codex auth why-selected --json` | Which account does the selector pick now, and why? | +| `codex auth report --live --json` | How do I get the full machine-readable health report? | +| `codex auth fix --live --model gpt-5-codex` | How do I run live repair probes with a chosen model? | +| `codex auth why-selected --json` | Which account does the selector pick now, and why? | +| `codex auth rotation status` | Is live runtime account rotation enabled for forwarded Codex sessions? | ### Reliability behavior @@ -230,9 +231,13 @@ Selected runtime/environment overrides: | Variable | Effect | | --- | --- | | `CODEX_MULTI_AUTH_DIR` | Override settings/accounts root | -| `CODEX_MULTI_AUTH_CONFIG_PATH` | Alternate config file path | -| `CODEX_MODE=0/1` | Disable/enable Codex mode | -| `CODEX_TUI_V2=0/1` | Disable/enable TUI v2 | +| `CODEX_MULTI_AUTH_CONFIG_PATH` | Alternate config file path | +| `CODEX_MODE=0/1` | Disable/enable Codex mode | +| `CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY=0/1` | Opt in/out of live Responses proxy rotation for forwarded Codex CLI/app sessions | +| `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 during install/update or rotation enable | +| `CODEX_MULTI_AUTH_APP_LAUNCHER_INSTALL=0/1` | Opt out/in of routing supported app shortcuts during rotation enable | +| `CODEX_TUI_V2=0/1` | Disable/enable TUI v2 | | `CODEX_TUI_COLOR_PROFILE=truecolor|ansi256|ansi16` | TUI color profile | | `CODEX_TUI_GLYPHS=ascii|unicode|auto` | TUI glyph style | | `CODEX_AUTH_BACKGROUND_RESPONSES=0/1` | Opt in/out of stateful Responses `background: true` compatibility | diff --git a/docs/configuration.md b/docs/configuration.md index 4a9004ce..9f55bb51 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -29,6 +29,7 @@ Runtime configuration is resolved from unified settings, optional override files }, "pluginConfig": { "codexMode": true, + "codexRuntimeRotationProxy": false, "liveAccountSync": true, "sessionAffinity": true, "proactiveRefreshGuardian": true, @@ -63,6 +64,7 @@ These are safe for most operators and frequently used in day-to-day workflows. | `CODEX_MULTI_AUTH_DIR` | Override root directory for plugin-managed runtime files | | `CODEX_MULTI_AUTH_CONFIG_PATH` | Load configuration from alternate path | | `CODEX_MODE=0/1` | Disable or enable Codex mode | +| `CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY=0/1` | Opt in to live Codex Responses routing through the localhost account-rotation proxy | | `CODEX_TUI_V2=0/1` | Disable or enable TUI v2 | | `CODEX_TUI_COLOR_PROFILE=truecolor|ansi256|ansi16` | Color profile selection | | `CODEX_TUI_GLYPHS=ascii|unicode|auto` | Glyph mode selection | @@ -99,6 +101,22 @@ Keep these enabled for most environments: --- +## Runtime Rotation Proxy + +`codexRuntimeRotationProxy` is disabled by default. When enabled through settings, `codex auth rotation enable`, or `CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY=1`, the `codex` wrapper starts a localhost-only Responses proxy for forwarded official Codex sessions, including CLI request commands, `codex app-server`, and `codex app` launches through the wrapper. The wrapper writes a temporary shadow `CODEX_HOME/config.toml` that selects a custom provider named `codex-multi-auth-runtime-proxy`, launches the official Codex surface against that provider, and removes the shadow home after the owning process exits. + +The proxy preserves request bodies and streaming responses, replaces outbound auth headers with the selected managed account, and rotates to another account before response bytes are streamed when it sees rate limits, server errors, network failures, or refresh failures. If every account is unavailable, the proxy returns a structured pool-exhaustion error that points to `codex auth rotation status`. + +For `codex app` launches that go through the wrapper, the wrapper automatically starts a small internal helper so rotation can keep working if the desktop app launcher detaches. The helper stores only local runtime status, uses the same per-session proxy client key as the CLI path, and exits after an idle timeout. + +`codex auth rotation enable` also binds the packaged desktop app to a persistent localhost router. This backs up the real Codex `config.toml`, writes the `codex-multi-auth-runtime-proxy` provider into the real Codex home, starts the router immediately, and installs a user login startup entry: a Startup `.cmd` on Windows or a LaunchAgent on macOS. The persistent provider is marked as not requiring OpenAI auth and uses a local app-bind client token, so the desktop runtime does not display the selected multi-auth account while codex-multi-auth status and quota views still read the router's last-account telemetry. `codex auth rotation disable` and `codex auth rotation unbind-app` stop that router, remove the startup entry, and restore the backed-up Codex config. The official app files are not patched. + +Package install/update also self-heals this bind when runtime rotation was already enabled and a Codex desktop app is detected. Set `CODEX_MULTI_AUTH_APP_BIND_INSTALL=0` to skip install/update self-heal, or `CODEX_MULTI_AUTH_APP_BIND_INSTALL=1` to force it. Supported user-level launcher routing remains available for `.lnk` and managed wrapper app cases; set `CODEX_MULTI_AUTH_APP_LAUNCHER_INSTALL=0` before enabling rotation to skip that shortcut routing, or run `codex-multi-auth-app-launcher --remove` to restore backed-up Windows shortcuts or remove the managed macOS wrapper later. + +Some Windows installs expose Codex only as a packaged `shell:AppsFolder` app entry. Those entries cannot be retargeted like `.lnk` files, so the persistent app bind is the supported path for making the pinned packaged app use rotation automatically. + +--- + ## Shipped Templates The shipped config templates expose first-class GPT-5.5 model aliases: diff --git a/docs/development/CONFIG_FIELDS.md b/docs/development/CONFIG_FIELDS.md index f5bf7b88..dfeb5ac0 100644 --- a/docs/development/CONFIG_FIELDS.md +++ b/docs/development/CONFIG_FIELDS.md @@ -44,6 +44,7 @@ Used only for host plugin mode through the host runtime config file. | Key | Default | | --- | --- | | `codexMode` | `true` | +| `codexRuntimeRotationProxy` | `false` | | `codexTuiV2` | `true` | | `codexTuiColorProfile` | `truecolor` | | `codexTuiGlyphMode` | `ascii` | @@ -200,6 +201,7 @@ Upgrade note: | `CODEX_MULTI_AUTH_DIR` | Custom root for settings/accounts/cache/logs | | `CODEX_MULTI_AUTH_CONFIG_PATH` | Alternate config file input | | `CODEX_MODE` | Toggle Codex mode | +| `CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY` | Toggle opt-in localhost Responses proxy for forwarded Codex sessions (`1`/`true` to enable, `0`/`false` to disable) | | `CODEX_TUI_V2` | Toggle TUI v2 | | `CODEX_TUI_COLOR_PROFILE` | TUI color profile | | `CODEX_TUI_GLYPHS` | TUI glyph mode | diff --git a/docs/features.md b/docs/features.md index dbeea5c2..1bfb8c5d 100644 --- a/docs/features.md +++ b/docs/features.md @@ -24,6 +24,7 @@ User-facing capability map for `codex-multi-auth`. | Readiness and risk forecast | Suggests the best next account | `codex auth forecast` | | Live quota probe mode | Uses live headers for stronger decisions | `codex auth forecast --live` | | JSON report output | Lets you inspect account state in automation or support workflows | `codex auth report --live --json` | +| Runtime rotation proxy (opt-in) | Lets forwarded official Codex CLI/app sessions rotate managed accounts between Responses requests without restarting the session. Disabled by default; enable per install. | `codex auth rotation enable` | --- diff --git a/docs/reference/commands.md b/docs/reference/commands.md index d9134256..5d821402 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -58,6 +58,7 @@ Compatibility aliases are supported: | `codex auth features` | Print implemented feature summary | | `codex auth report` | Generate full health report | | `codex auth why-selected [--now|--last]` | Explain which account the selector picks now or via the last persisted runtime snapshot | +| `codex auth rotation enable\|disable\|status\|bind-app\|unbind-app` | Manage the opt-in runtime Responses proxy for live Codex account rotation | --- @@ -150,6 +151,39 @@ The `runtimeSnapshot` field is present only with `--last`. `selected` is --- +## `codex auth rotation` + +Manages the opt-in runtime Responses proxy used by forwarded official Codex sessions. This is separate from normal `codex auth switch`: the proxy can rotate managed accounts between backend Responses requests while a Codex session stays open. + +Usage: + +```bash +codex auth rotation enable +codex auth rotation disable +codex auth rotation status +codex auth rotation bind-app +codex auth rotation unbind-app +``` + +Behavior: + +- `enable` persists `codexRuntimeRotationProxy=true`, binds the packaged desktop app to the same persistent localhost router, and routes supported user-level app shortcuts when possible. +- `disable` persists `codexRuntimeRotationProxy=false` and removes the persistent packaged-app bind. +- `status` prints the effective setting, environment override state, automatic Codex app helper state, persistent Codex app bind state, account count, current account, disabled accounts, cooldowns, and rate-limit waits. +- `bind-app` repairs or installs the persistent packaged-app bind without changing the stored rotation setting. +- `unbind-app` removes the persistent packaged-app bind and restores the backed-up Codex config. +- `CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY=1` enables the proxy for the current process without changing settings. + +When enabled, the wrapper creates a temporary shadow `CODEX_HOME/config.toml` with a custom provider named `codex-multi-auth-runtime-proxy`, starts a `127.0.0.1` proxy on a random port, and forwards official Codex Responses traffic through that provider. This applies to CLI request commands plus `codex app-server` and `codex app` when they are launched through the wrapper. Existing behavior is unchanged while the setting and env override are off. + +Packaged desktop app support uses a reversible bind instead of patching app files. It backs up the real Codex `config.toml`, writes the same custom provider to the real Codex home, starts a localhost-only router, and installs a user login startup entry: a Startup `.cmd` on Windows or a LaunchAgent on macOS. The provider uses a local app-bind client token and `requires_openai_auth=false`, which keeps the selected multi-auth account out of the runtime composer while preserving router last-account telemetry for codex-multi-auth status and quota views. Package install/update runs the same bind only when runtime rotation was already enabled and a Codex desktop app is detected; set `CODEX_MULTI_AUTH_APP_BIND_INSTALL=0` to skip that self-heal or `CODEX_MULTI_AUTH_APP_BIND_INSTALL=1` to force it. + +The app launcher routing helper is also available directly as `codex-multi-auth-app-launcher`. On Windows, it retargets existing user-level `Codex` shortcuts and taskbar pins to the wrapper while backing up their original target for restore. On macOS, it creates or removes a user-level `Codex Multi Auth.app` wrapper because Dock entries cannot safely launch a shell command directly. It does not patch the official app files. Use `codex-multi-auth-app-launcher --remove` to restore backed-up Windows shortcuts or remove the managed macOS wrapper. + +If Windows exposes Codex only as a packaged `shell:AppsFolder` entry, shortcut routing may still report that there is no retargetable `.lnk`. The persistent app bind is the path that makes those packaged entries use rotation when the official app is opened directly. + +--- + ## `codex auth verify` Supersedes `codex auth verify-flagged` as a single entry point for diff --git a/docs/reference/settings.md b/docs/reference/settings.md index d6cd63d2..63f07563 100644 --- a/docs/reference/settings.md +++ b/docs/reference/settings.md @@ -185,6 +185,8 @@ Common operator overrides: - `CODEX_MULTI_AUTH_DIR` - `CODEX_MULTI_AUTH_CONFIG_PATH` - `CODEX_MODE` +- `CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY` +- `CODEX_MULTI_AUTH_APP_BIND_INSTALL` - `CODEX_TUI_V2` - `CODEX_TUI_COLOR_PROFILE` - `CODEX_TUI_GLYPHS` diff --git a/docs/releases/v1.3.1.md b/docs/releases/v1.3.1.md index 4949a453..81d15f3f 100644 --- a/docs/releases/v1.3.1.md +++ b/docs/releases/v1.3.1.md @@ -23,6 +23,14 @@ This patch release finalizes the GPT-5.5 runtime rollout work for `codex-multi-a - verified native `gpt-5.5` behavior on official Codex `0.124.0` - preserved deterministic fallback behavior for older official Codex runtimes and non-entitled or quota-limited accounts +### Runtime Rotation Proxy + +- added opt-in `codexRuntimeRotationProxy` and `CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY=1` +- added `codex auth rotation enable|disable|status` +- forwarded official Codex sessions can use a temporary shadow `CODEX_HOME` and localhost Responses proxy to rotate managed accounts between backend requests +- `codex app-server` and wrapper-launched `codex app` now use the same runtime rotation path automatically when rotation is enabled +- rotation enable routes existing Windows user-level `Codex` shortcuts and taskbar pins through the wrapper-backed `codex app` path, and creates a managed macOS wrapper for the same path, without patching official Codex app files + ## Validation - `npm run build` diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 4c5bf159..7a2b1417 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -71,6 +71,7 @@ import { import { runForecastCommand } from "./codex-manager/commands/forecast.js"; import { runInitConfigCommand } from "./codex-manager/commands/init-config.js"; import { runReportCommand } from "./codex-manager/commands/report.js"; +import { runRotationCommand } from "./codex-manager/commands/rotation.js"; import { runFeaturesCommand, runStatusCommand, @@ -83,7 +84,17 @@ import { configureUnifiedSettings, resolveMenuLayoutMode, } from "./codex-manager/settings-hub.js"; -import { getPluginConfigExplainReport } from "./config.js"; +import { + getCodexRuntimeRotationProxy, + getPluginConfigExplainReport, + loadPluginConfig, + savePluginConfig, +} from "./config.js"; +import { + bindCodexAppRuntimeRotation, + getAppBindStatus, + unbindCodexAppRuntimeRotation, +} from "./runtime/app-bind.js"; import { ACCOUNT_LIMITS } from "./constants.js"; import { type DashboardAccountSortMode, @@ -1193,6 +1204,55 @@ function toExistingAccountInfo( })); } +function activeAccountMatchesCodexCliState( + account: AccountMetadataV3, + state: Awaited>, +): boolean { + if (!state) return true; + const accountId = account.accountId?.trim(); + const activeAccountId = state.activeAccountId?.trim(); + if (accountId && activeAccountId) { + return accountId === activeAccountId; + } + + const email = sanitizeEmail(account.email); + const activeEmail = sanitizeEmail(state.activeEmail); + if (email && activeEmail) { + return email === activeEmail; + } + + return false; +} + +async function syncCodexCliActiveSelectionIfDrifted( + storage: AccountStorageV3, +): Promise { + const activeIndex = resolveActiveIndex(storage, "codex"); + if (activeIndex < 0 || activeIndex >= storage.accounts.length) { + return false; + } + const account = storage.accounts[activeIndex]; + if (!account) { + return false; + } + + try { + const cliState = await loadCodexCliState({ forceRefresh: true }); + if (!cliState || activeAccountMatchesCodexCliState(account, cliState)) { + return false; + } + return setCodexCliActiveSelection({ + accountId: account.accountId, + email: account.email, + accessToken: account.accessToken, + refreshToken: account.refreshToken, + expiresAt: account.expiresAt, + }); + } catch { + return false; + } +} + function resolveAccountSelection( tokens: TokenSuccess, ): TokenSuccessWithAccount { @@ -2726,6 +2786,7 @@ async function runAuthLogin(args: string[]): Promise { } } const flaggedStorage = await loadFlaggedAccounts(); + await syncCodexCliActiveSelectionIfDrifted(currentStorage); const menuResult = await promptLoginMode( toExistingAccountInfo(currentStorage, quotaCache, displaySettings), @@ -3378,6 +3439,20 @@ export async function runCodexMultiAuthCli(rawArgs: string[]): Promise { loadPersistedRuntimeObservabilitySnapshot, }); } + if (command === "rotation") { + return runRotationCommand(rest, { + loadPluginConfig, + savePluginConfig, + getCodexRuntimeRotationProxy, + setStoragePath, + getStoragePath, + loadAccounts, + resolveActiveIndex, + bindCodexApp: bindCodexAppRuntimeRotation, + unbindCodexApp: unbindCodexAppRuntimeRotation, + getCodexAppBindStatus: getAppBindStatus, + }); + } if (command === "why-selected") { return runWhySelectedCommand(rest, { parseWhySelectedArgs, diff --git a/lib/codex-manager/commands/rotation.ts b/lib/codex-manager/commands/rotation.ts new file mode 100644 index 00000000..08be687c --- /dev/null +++ b/lib/codex-manager/commands/rotation.ts @@ -0,0 +1,332 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { formatAccountLabel, formatCooldown, formatWaitTime } from "../../accounts.js"; +import { getCodexMultiAuthDir } from "../../runtime-paths.js"; +import { + formatAppBindStatus, + type AppBindResult, + type AppBindStatus, +} from "../../runtime/app-bind.js"; +import { APP_RUNTIME_HELPER_STATUS_FILE } from "../../runtime-constants.js"; +import type { PluginConfig } from "../../types.js"; +import type { AccountStorageV3 } from "../../storage.js"; + +type LoadedStorage = AccountStorageV3 | null; + +interface AppRuntimeHelperStatus { + kind: string | null; + state: string | null; + pid: number | null; + idleExpiresAt: number | null; + totalRequests: number | null; + rotations: number | null; + lastAccountIndex: number | null; + lastAccountLabel: string | null; + lastAccountEmail: string | null; + lastAccountId: string | null; + lastAccountUpdatedAt: number | null; + updatedAt: number | null; +} + +export interface RotationCommandDeps { + loadPluginConfig: () => PluginConfig; + savePluginConfig: (config: Partial) => Promise; + getCodexRuntimeRotationProxy: (config: PluginConfig) => boolean; + loadAccounts: () => Promise; + resolveActiveIndex: (storage: AccountStorageV3) => number; + getStoragePath: () => string | null; + setStoragePath: (path: string | null) => void; + bindCodexApp?: () => Promise; + unbindCodexApp?: () => Promise; + getCodexAppBindStatus?: () => Promise; + getNow?: () => number; + logInfo?: (message: string) => void; + logError?: (message: string) => void; +} + +function printRotationUsage(logInfo: (message: string) => void): void { + logInfo( + [ + "Usage:", + " codex auth rotation enable", + " codex auth rotation disable", + " codex auth rotation status", + " codex auth rotation bind-app", + " codex auth rotation unbind-app", + "", + "Behavior:", + " - Enables an opt-in localhost Responses proxy for live Codex runtime account rotation", + " - Binds the packaged Codex desktop app to the same localhost router when enabled", + " - Env override: CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY=1", + ].join("\n"), + ); +} + +function parseBooleanEnv(value: string | undefined): boolean | null { + if (value === undefined || value.trim().length === 0) return null; + const normalized = value.trim().toLowerCase(); + if (normalized === "1" || normalized === "true" || normalized === "yes") { + return true; + } + if (normalized === "0" || normalized === "false" || normalized === "no") { + return false; + } + return null; +} + +function formatEnvOverride(): string { + const raw = process.env.CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY; + if (raw === undefined || raw.trim().length === 0) return "none"; + const parsed = parseBooleanEnv(raw); + if (parsed === null) return `invalid (${raw})`; + return parsed ? "enabled" : "disabled"; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function readOptionalNumber(record: Record, key: string): number | null { + const value = record[key]; + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function readOptionalString(record: Record, key: string): string | null { + const value = record[key]; + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : null; +} + +function readAppRuntimeHelperStatus(): AppRuntimeHelperStatus | null { + const statusPath = join(getCodexMultiAuthDir(), APP_RUNTIME_HELPER_STATUS_FILE); + if (!existsSync(statusPath)) return null; + try { + const parsed = JSON.parse(readFileSync(statusPath, "utf8")) as unknown; + if (!isRecord(parsed)) return null; + return { + state: readOptionalString(parsed, "state"), + kind: readOptionalString(parsed, "kind"), + pid: readOptionalNumber(parsed, "pid"), + idleExpiresAt: readOptionalNumber(parsed, "idleExpiresAt"), + totalRequests: readOptionalNumber(parsed, "totalRequests"), + rotations: readOptionalNumber(parsed, "rotations"), + lastAccountIndex: readOptionalNumber(parsed, "lastAccountIndex"), + lastAccountLabel: readOptionalString(parsed, "lastAccountLabel"), + lastAccountEmail: null, + lastAccountId: readOptionalString(parsed, "lastAccountId"), + lastAccountUpdatedAt: readOptionalNumber(parsed, "lastAccountUpdatedAt"), + updatedAt: readOptionalNumber(parsed, "updatedAt"), + }; + } catch { + return null; + } +} + +function isProcessAlive(pid: number | null): boolean { + if (!pid) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + const code = + error && typeof error === "object" && "code" in error ? error.code : null; + return code === "EPERM"; + } +} + +function formatHelperLastAccount(status: AppRuntimeHelperStatus): string | null { + if (status.lastAccountLabel && !status.lastAccountLabel.includes("@")) { + return status.lastAccountLabel; + } + if (status.lastAccountId) { + return status.lastAccountIndex !== null + ? `Account ${status.lastAccountIndex + 1} (${status.lastAccountId})` + : status.lastAccountId; + } + if (status.lastAccountIndex !== null) { + return `Account ${status.lastAccountIndex + 1}`; + } + return null; +} + +function formatAppRuntimeHelperStatus(now: number): string { + const status = readAppRuntimeHelperStatus(); + if (!status) return "Codex app helper: not running"; + if (status.kind !== "codex-app-runtime-rotation-helper") { + return "Codex app helper: not running"; + } + const alive = isProcessAlive(status.pid); + if (!alive || status.state === "stopped" || status.state === "idle-timeout") { + return "Codex app helper: not running"; + } + const parts = [`running${status.pid ? ` pid=${status.pid}` : ""}`]; + if (status.totalRequests !== null) parts.push(`requests=${status.totalRequests}`); + if (status.rotations !== null) parts.push(`rotations=${status.rotations}`); + const lastAccount = formatHelperLastAccount(status); + if (lastAccount) parts.push(`lastAccount=${lastAccount}`); + if (status.idleExpiresAt !== null && status.idleExpiresAt > now) { + parts.push(`idle-expires=${formatWaitTime(status.idleExpiresAt - now)}`); + } + return `Codex app helper: ${parts.join(", ")}`; +} + +function shouldAutoBindCodexApp(env: NodeJS.ProcessEnv = process.env): boolean { + const override = (env.CODEX_MULTI_AUTH_APP_BIND_INSTALL ?? "1") + .trim() + .toLowerCase(); + return !new Set(["0", "false", "no"]).has(override); +} + +async function printCodexAppBindStatus(deps: RotationCommandDeps): Promise { + const logInfo = deps.logInfo ?? console.log; + if (!deps.getCodexAppBindStatus) { + logInfo("Codex app bind: unavailable"); + return; + } + try { + logInfo(formatAppBindStatus(await deps.getCodexAppBindStatus())); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logInfo(`Codex app bind: unavailable (${message})`); + } +} + +async function printRotationStatus(deps: RotationCommandDeps): Promise { + const logInfo = deps.logInfo ?? console.log; + const previousStoragePath = deps.getStoragePath(); + let config!: PluginConfig; + let enabled!: boolean; + let storage!: LoadedStorage; + let storagePath!: string | null; + const now = deps.getNow?.() ?? Date.now(); + try { + // Rotation status reports the shared Codex account pool, not a project-scoped override. + deps.setStoragePath(null); + config = deps.loadPluginConfig(); + const envOverride = parseBooleanEnv(process.env.CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY); + enabled = envOverride ?? deps.getCodexRuntimeRotationProxy(config); + storage = await deps.loadAccounts(); + storagePath = deps.getStoragePath(); + } finally { + deps.setStoragePath(previousStoragePath); + } + + logInfo(`Runtime rotation proxy: ${enabled ? "enabled" : "disabled"}`); + logInfo( + `Stored setting: ${config.codexRuntimeRotationProxy === true ? "enabled" : "disabled"}`, + ); + logInfo(`Env override: ${formatEnvOverride()}`); + logInfo(formatAppRuntimeHelperStatus(now)); + await printCodexAppBindStatus(deps); + logInfo(`Storage: ${storagePath}`); + + if (!storage || storage.accounts.length === 0) { + logInfo("Accounts: none configured"); + return 0; + } + + const activeIndex = deps.resolveActiveIndex(storage); + logInfo(`Accounts: ${storage.accounts.length}`); + for (let index = 0; index < storage.accounts.length; index += 1) { + const account = storage.accounts[index]; + if (!account) continue; + const markers: string[] = []; + if (index === activeIndex) markers.push("current"); + if (account.enabled === false) markers.push("disabled"); + const cooldown = formatCooldown(account, now); + if (cooldown) markers.push(`cooldown:${cooldown}`); + const rateLimitResetTimes = Object.values(account.rateLimitResetTimes ?? {}) + .filter((value): value is number => typeof value === "number") + .filter((value) => value > now); + if (rateLimitResetTimes.length > 0) { + const waitMs = Math.min(...rateLimitResetTimes) - now; + markers.push(`rate-limited:${formatWaitTime(waitMs)}`); + } + const markerLabel = markers.length > 0 ? ` [${markers.join(", ")}]` : ""; + logInfo(`${index + 1}. ${formatAccountLabel(account, index)}${markerLabel}`); + } + + return 0; +} + +export async function runRotationCommand( + args: string[], + deps: RotationCommandDeps, +): Promise { + const logInfo = deps.logInfo ?? console.log; + const logError = deps.logError ?? console.error; + const [subcommand, ...rest] = args; + if (!subcommand || subcommand === "status") { + if (rest.length > 0) { + logError(`Unknown rotation status option: ${rest[0]}`); + return 1; + } + return printRotationStatus(deps); + } + if (subcommand === "--help" || subcommand === "-h" || subcommand === "help") { + printRotationUsage(logInfo); + return 0; + } + if (rest.length > 0) { + logError(`Unknown rotation option: ${rest[0]}`); + return 1; + } + if (subcommand === "enable") { + await deps.savePluginConfig({ codexRuntimeRotationProxy: true }); + logInfo("Runtime rotation proxy enabled."); + logInfo("New Codex sessions will route Responses traffic through the localhost proxy."); + if (deps.bindCodexApp && shouldAutoBindCodexApp()) { + try { + const result = await deps.bindCodexApp(); + logInfo(result.message); + logInfo(formatAppBindStatus(result.status)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logError(`Codex app bind failed: ${message}`); + logInfo("Wrapper-launched CLI and app sessions still use runtime rotation."); + } + } + return 0; + } + if (subcommand === "disable") { + await deps.savePluginConfig({ codexRuntimeRotationProxy: false }); + logInfo("Runtime rotation proxy disabled."); + if (deps.unbindCodexApp) { + try { + const result = await deps.unbindCodexApp(); + logInfo(result.message); + logInfo(formatAppBindStatus(result.status)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logError(`Codex app unbind failed: ${message}`); + return 1; + } + } + return 0; + } + if (subcommand === "bind-app") { + if (!deps.bindCodexApp) { + logError("Codex app bind is unavailable in this build."); + return 1; + } + const result = await deps.bindCodexApp(); + logInfo(result.message); + logInfo(formatAppBindStatus(result.status)); + return 0; + } + if (subcommand === "unbind-app") { + if (!deps.unbindCodexApp) { + logError("Codex app bind is unavailable in this build."); + return 1; + } + const result = await deps.unbindCodexApp(); + logInfo(result.message); + logInfo(formatAppBindStatus(result.status)); + return 0; + } + + logError(`Unknown rotation command: ${subcommand}`); + printRotationUsage(logInfo); + return 1; +} diff --git a/lib/codex-manager/commands/status.ts b/lib/codex-manager/commands/status.ts index a0609dc4..dc7cc3d7 100644 --- a/lib/codex-manager/commands/status.ts +++ b/lib/codex-manager/commands/status.ts @@ -12,6 +12,7 @@ import type { RuntimeObservabilitySnapshot } from "../../runtime/runtime-observa import type { AccountStorageV3, StorageHealthSummary } from "../../storage.js"; type LoadedStorage = AccountStorageV3 | null; +type RestoreReason = "empty-storage" | "intentional-reset" | "missing-storage"; export interface StatusCommandDeps { setStoragePath: (path: string | null) => void; @@ -32,6 +33,41 @@ export interface StatusCommandDeps { logInfo?: (message: string) => void; } +function isRestoreReason(value: unknown): value is RestoreReason { + return ( + value === "empty-storage" || + value === "intentional-reset" || + value === "missing-storage" + ); +} + +function readRestoreReason(storage: AccountStorageV3): RestoreReason | undefined { + if (!("restoreReason" in storage)) return undefined; + return isRestoreReason(storage.restoreReason) + ? storage.restoreReason + : undefined; +} + +function formatRuntimeLastAccount( + runtimeSnapshot: RuntimeObservabilitySnapshot, +): string | null { + if ( + runtimeSnapshot.lastAccountLabel && + !runtimeSnapshot.lastAccountLabel.includes("@") + ) { + return runtimeSnapshot.lastAccountLabel; + } + if (runtimeSnapshot.lastAccountId) { + return typeof runtimeSnapshot.lastAccountIndex === "number" + ? `Account ${runtimeSnapshot.lastAccountIndex + 1} (${runtimeSnapshot.lastAccountId})` + : runtimeSnapshot.lastAccountId; + } + if (typeof runtimeSnapshot.lastAccountIndex === "number") { + return `Account ${runtimeSnapshot.lastAccountIndex + 1}`; + } + return null; +} + export async function runStatusCommand( deps: StatusCommandDeps, ): Promise { @@ -41,15 +77,15 @@ export async function runStatusCommand( const storageHealth = await deps.inspectStorageHealth?.(); const logInfo = deps.logInfo ?? console.log; if (!storage || storage.accounts.length === 0) { - // When loadAccounts() returns null, the caller has detected an intentional - // reset (e.g. via the reset-intent marker) that inspectStorageHealth() may - // not see if the storage path has already been cleared or redirected. Treat - // the null return as a stronger "reset" signal than the filesystem probe's - // "empty" fallback so the output message is accurate. + const restoreReason = storage ? readRestoreReason(storage) : undefined; const effectiveState: StorageHealthSummary["state"] | undefined = - storage === null && (!storageHealth || storageHealth.state === "empty") + restoreReason === "intentional-reset" ? "intentional-reset" - : storageHealth?.state; + : storageHealth?.state ?? + (restoreReason === "empty-storage" || + restoreReason === "missing-storage" + ? "empty" + : undefined); logInfo( effectiveState === "intentional-reset" ? "No accounts configured. Storage was intentionally reset." @@ -103,6 +139,10 @@ export async function runStatusCommand( logInfo( `Runtime: responses=${runtimeSnapshot.responsesRequests}, refresh=${runtimeSnapshot.authRefreshRequests}, probes=${runtimeSnapshot.diagnosticProbeRequests}, budgetExhaustions=${runtimeMetrics.requestAttemptBudgetExhaustions}`, ); + const lastRuntimeAccount = formatRuntimeLastAccount(runtimeSnapshot); + if (lastRuntimeAccount) { + logInfo(`Last runtime account: ${lastRuntimeAccount}`); + } if (poolCooldown || serverCooldown) { logInfo( `Cooldowns: pool=${poolCooldown ?? "none"}, server-burst=${serverCooldown ?? "none"}`, diff --git a/lib/codex-manager/help.ts b/lib/codex-manager/help.ts index 4bd9b23e..01133a49 100644 --- a/lib/codex-manager/help.ts +++ b/lib/codex-manager/help.ts @@ -21,6 +21,7 @@ export function printUsage(): void { " codex auth doctor [--json] [--fix] [--dry-run]", "", "Diagnostics:", + " codex auth rotation ", " codex auth why-selected [--now | --last] [--json]", "", "Advanced:", diff --git a/lib/config.ts b/lib/config.ts index 326d9a23..3bff974b 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -153,6 +153,7 @@ function resolvePluginConfigPath(): string | null { */ export const DEFAULT_PLUGIN_CONFIG: PluginConfig = { codexMode: true, + codexRuntimeRotationProxy: false, codexTuiV2: true, codexTuiColorProfile: "truecolor", codexTuiGlyphMode: "ascii", @@ -802,6 +803,16 @@ export function getCodexMode(pluginConfig: PluginConfig): boolean { return resolveBooleanSetting("CODEX_MODE", pluginConfig.codexMode, true); } +export function getCodexRuntimeRotationProxy( + pluginConfig: PluginConfig, +): boolean { + return resolveBooleanSetting( + "CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY", + pluginConfig.codexRuntimeRotationProxy, + false, + ); +} + export function getCodexTuiV2(pluginConfig: PluginConfig): boolean { return resolveBooleanSetting("CODEX_TUI_V2", pluginConfig.codexTuiV2, true); } @@ -1614,6 +1625,11 @@ function normalizeConfigExplainValue(value: unknown): unknown { const CONFIG_EXPLAIN_ENTRIES: ConfigExplainMeta[] = [ { key: "codexMode", envNames: ["CODEX_MODE"], getValue: getCodexMode }, + { + key: "codexRuntimeRotationProxy", + envNames: ["CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY"], + getValue: getCodexRuntimeRotationProxy, + }, { key: "codexTuiV2", envNames: ["CODEX_TUI_V2"], getValue: getCodexTuiV2 }, { key: "codexTuiColorProfile", diff --git a/lib/constants.ts b/lib/constants.ts index 2492b2fc..c1840107 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -19,6 +19,7 @@ export const PROVIDER_ID = "openai"; export const HTTP_STATUS = { BAD_REQUEST: 400, OK: 200, + PAYLOAD_TOO_LARGE: 413, FORBIDDEN: 403, UNAUTHORIZED: 401, NOT_FOUND: 404, diff --git a/lib/fs-retry.ts b/lib/fs-retry.ts new file mode 100644 index 00000000..3e05a413 --- /dev/null +++ b/lib/fs-retry.ts @@ -0,0 +1,41 @@ +export const FILE_RETRY_CODES = new Set([ + "EBUSY", + "EPERM", + "EAGAIN", + "ENOTEMPTY", + "EACCES", +]); +export const FILE_RETRY_MAX_ATTEMPTS = 6; +export const FILE_RETRY_BASE_DELAY_MS = 25; +export const FILE_RETRY_JITTER_MS = 20; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export function shouldRetryFileOperation(error: unknown): boolean { + return ( + error instanceof Error && + "code" in error && + typeof error.code === "string" && + FILE_RETRY_CODES.has(error.code) + ); +} + +export async function withFileOperationRetry( + operation: () => Promise, +): Promise { + for (let attempt = 1; ; attempt += 1) { + try { + return await operation(); + } catch (error) { + if (!shouldRetryFileOperation(error) || attempt >= FILE_RETRY_MAX_ATTEMPTS) { + throw error; + } + const delayMs = + FILE_RETRY_BASE_DELAY_MS * 2 ** (attempt - 1) + + Math.floor(Math.random() * FILE_RETRY_JITTER_MS); + await sleep(delayMs); + } + } +} diff --git a/lib/index.ts b/lib/index.ts index 8d89351d..3114a461 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -32,6 +32,7 @@ export * from "./refresh-lease.js"; export * from "./request/failure-policy.js"; export * from "./entitlement-cache.js"; export * from "./preemptive-quota-scheduler.js"; +export * from "./runtime-rotation-proxy.js"; export * from "./unified-settings.js"; export * from "./capability-policy.js"; export * from "./request/stream-failover.js"; diff --git a/lib/logger.ts b/lib/logger.ts index c0a933ee..71261b3f 100644 --- a/lib/logger.ts +++ b/lib/logger.ts @@ -46,6 +46,7 @@ const SENSITIVE_KEYS = new Set([ "authorization", "apikey", "api_key", + "experimentalbearertoken", "secret", "password", "credential", diff --git a/lib/runtime-constants.ts b/lib/runtime-constants.ts new file mode 100644 index 00000000..2bb1ca4c --- /dev/null +++ b/lib/runtime-constants.ts @@ -0,0 +1,5 @@ +export const RUNTIME_ROTATION_PROXY_PROVIDER_ID = + "codex-multi-auth-runtime-proxy" as const; + +export const APP_RUNTIME_HELPER_STATUS_FILE = + "runtime-rotation-app-helper.json" as const; diff --git a/lib/runtime-rotation-proxy.ts b/lib/runtime-rotation-proxy.ts new file mode 100644 index 00000000..1bc4d048 --- /dev/null +++ b/lib/runtime-rotation-proxy.ts @@ -0,0 +1,1107 @@ +import { timingSafeEqual } from "node:crypto"; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import type { Socket } from "node:net"; +import { + AccountManager, + extractAccountId, + type ManagedAccount, +} from "./accounts.js"; +import { + getFetchTimeoutMs, + getNetworkErrorCooldownMs, + getRetryAllAccountsMaxRetries, + getServerErrorCooldownMs, + getSessionAffinity, + getSessionAffinityMaxEntries, + getSessionAffinityTtlMs, + getStreamStallTimeoutMs, + getTokenRefreshSkewMs, + loadPluginConfig, +} from "./config.js"; +import { + CODEX_BASE_URL, + HTTP_STATUS, + OPENAI_HEADERS, + OPENAI_HEADER_VALUES, + URL_PATHS, +} from "./constants.js"; +import { getModelFamily, type ModelFamily } from "./prompts/codex.js"; +import { queuedRefresh } from "./refresh-queue.js"; +import { mutateRuntimeObservabilitySnapshot } from "./runtime/runtime-observability.js"; +import { SessionAffinityStore } from "./session-affinity.js"; +import type { OAuthAuthDetails, RequestBody, TokenResult } from "./types.js"; +import { isRecord } from "./utils.js"; + +export interface RuntimeRotationProxyServer { + host: string; + port: number; + baseUrl: string; + close: () => Promise; + getStatus: () => RuntimeRotationProxyStatus; +} + +export interface RuntimeRotationProxyStatus { + startedAt: number; + totalRequests: number; + upstreamRequests: number; + retries: number; + rotations: number; + streamsStarted: number; + lastError: string | null; + lastAccountIndex: number | null; + lastAccountLabel: string | null; + lastAccountId: string | null; + lastAccountUpdatedAt: number | null; +} + +export interface RuntimeRotationProxyOptions { + host?: string; + port?: number; + upstreamBaseUrl?: string; + clientApiKey: string; + accountManager?: AccountManager; + fetchImpl?: typeof fetch; + now?: () => number; + quotaRemainingPercentThreshold?: number; + maxRequestBodyBytes?: number; + fetchTimeoutMs?: number; + streamStallTimeoutMs?: number; +} + +interface RequestContext { + body: Buffer; + headers: Headers; + model: string | null; + family: ModelFamily; + stream: boolean; + sessionKey: string | null; +} + +type ExhaustionReason = + | "rate-limit" + | "server-error" + | "network-error" + | "auth-failure" + | "budget" + | "no-account"; +type RuntimeProxyHttpError = Error & { + statusCode: number; + code: string; +}; + +interface RuntimeRotationAccountIdentity { + index: number; + label: string; + accountId: string | null; + updatedAt: number; +} + +const DEFAULT_HOST = "127.0.0.1"; +const DEFAULT_QUOTA_REMAINING_THRESHOLD = 10; +const DEFAULT_AUTH_FAILURE_COOLDOWN_MS = 30_000; +const DEFAULT_MAX_RUNTIME_ACCOUNT_ATTEMPTS = 4; +const MAX_REQUEST_BODY_BYTES = 64 * 1024 * 1024; +const HOP_BY_HOP_HEADERS = new Set([ + "connection", + "content-length", + "expect", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]); +const PRIVATE_CLIENT_RESPONSE_HEADERS = new Set([ + "x-codex-multi-auth-account-index", + "x-codex-multi-auth-account-label", + "x-codex-multi-auth-account-email", + "x-codex-multi-auth-account-id", +]); +const DECODED_UPSTREAM_RESPONSE_HEADERS = new Set([ + // Node fetch returns decoded bytes while preserving the upstream encoding header. + "content-encoding", +]); +const ALLOWED_RESPONSES_PATHS = new Set([ + URL_PATHS.RESPONSES, + URL_PATHS.CODEX_RESPONSES, + `/v1${URL_PATHS.RESPONSES}`, + `/v1${URL_PATHS.CODEX_RESPONSES}`, +]); + +function isResponsesPath(pathname: string): boolean { + return ALLOWED_RESPONSES_PATHS.has(pathname); +} + +function headersFromIncoming(req: IncomingMessage): Headers { + const headers = new Headers(); + for (const [key, value] of Object.entries(req.headers)) { + if (value === undefined) continue; + if (Array.isArray(value)) { + for (const item of value) { + headers.append(key, item); + } + continue; + } + headers.set(key, value); + } + return headers; +} + +function createOutboundHeaders( + incoming: Headers, + account: ManagedAccount, + accessToken: string, + accountId: string, +): Headers { + const headers = new Headers(incoming); + for (const name of HOP_BY_HOP_HEADERS) { + headers.delete(name); + } + headers.delete("host"); + headers.delete("x-api-key"); + headers.set("authorization", `Bearer ${accessToken}`); + headers.set(OPENAI_HEADERS.ACCOUNT_ID, accountId); + headers.set(OPENAI_HEADERS.BETA, OPENAI_HEADER_VALUES.BETA_RESPONSES); + headers.set(OPENAI_HEADERS.ORIGINATOR, OPENAI_HEADER_VALUES.ORIGINATOR_CODEX); + return headers; +} + +function isAuthorizedClient(headers: Headers, clientApiKey: string): boolean { + const authorization = headers.get("authorization") ?? ""; + const bearerMatch = authorization.match(/^Bearer\s+(.+)$/i); + const bearer = bearerMatch?.[1]?.trim(); + if (bearer && safeEqual(bearer, clientApiKey)) return true; + const apiKey = headers.get("x-api-key"); + return typeof apiKey === "string" && safeEqual(apiKey, clientApiKey); +} + +function safeEqual(left: string, right: string): boolean { + const leftBuffer = Buffer.from(left, "utf8"); + const rightBuffer = Buffer.from(right, "utf8"); + const compareLength = Math.max(leftBuffer.length, rightBuffer.length, 1); + const paddedLeft = Buffer.alloc(compareLength); + const paddedRight = Buffer.alloc(compareLength); + leftBuffer.copy(paddedLeft); + rightBuffer.copy(paddedRight); + return timingSafeEqual(paddedLeft, paddedRight) && leftBuffer.length === rightBuffer.length; +} + +function readTrimmedString(value: string | undefined): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function accountIdentityFromAccount( + account: ManagedAccount, + updatedAt: number, +): RuntimeRotationAccountIdentity { + return { + index: account.index, + label: `Account ${account.index + 1}`, + accountId: readTrimmedString(account.accountId), + updatedAt, + }; +} + +function recordLastRuntimeAccount( + status: RuntimeRotationProxyStatus, + identity: RuntimeRotationAccountIdentity, +): void { + status.lastAccountIndex = identity.index; + status.lastAccountLabel = identity.label; + status.lastAccountId = identity.accountId; + status.lastAccountUpdatedAt = identity.updatedAt; + mutateRuntimeObservabilitySnapshot((snapshot) => { + snapshot.lastAccountIndex = identity.index; + snapshot.lastAccountLabel = identity.label; + snapshot.lastAccountEmail = null; + snapshot.lastAccountId = identity.accountId; + snapshot.lastAccountUpdatedAt = identity.updatedAt; + }); +} + +async function persistRuntimeActiveAccount( + accountManager: AccountManager, + account: ManagedAccount, + family: ModelFamily, +): Promise { + try { + accountManager.markSwitched(account, "rotation", family); + accountManager.saveToDiskDebounced(); + await accountManager.syncCodexCliActiveSelectionForIndex(account.index); + } catch { + // Runtime forwarding must not fail after a valid upstream response just + // because the local status mirrors are temporarily locked. + } +} + +function responseHeadersForClient(upstreamHeaders: Headers): Record { + const headers: Record = {}; + for (const [key, value] of upstreamHeaders.entries()) { + const normalizedKey = key.toLowerCase(); + if (HOP_BY_HOP_HEADERS.has(normalizedKey)) continue; + if (PRIVATE_CLIENT_RESPONSE_HEADERS.has(normalizedKey)) continue; + if (DECODED_UPSTREAM_RESPONSE_HEADERS.has(normalizedKey)) continue; + headers[key] = value; + } + return headers; +} + +function createRuntimeProxyHttpError( + message: string, + statusCode: number, + code: string, +): RuntimeProxyHttpError { + return Object.assign(new Error(message), { statusCode, code }); +} + +function isRuntimeProxyHttpError(error: unknown): error is RuntimeProxyHttpError { + return ( + error instanceof Error && + "statusCode" in error && + typeof error.statusCode === "number" && + "code" in error && + typeof error.code === "string" + ); +} + +async function readRequestBody( + req: IncomingMessage, + maxBytes = MAX_REQUEST_BODY_BYTES, +): Promise { + const chunks: Buffer[] = []; + let totalBytes = 0; + for await (const chunk of req) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + totalBytes += buffer.byteLength; + if (totalBytes > maxBytes) { + throw createRuntimeProxyHttpError( + "Runtime rotation proxy request body is too large.", + HTTP_STATUS.PAYLOAD_TOO_LARGE, + "runtime_rotation_proxy_payload_too_large", + ); + } + chunks.push(buffer); + } + return Buffer.concat(chunks); +} + +function parseRequestBody(body: Buffer): RequestBody | null { + if (body.length === 0) return null; + try { + const parsed = JSON.parse(body.toString("utf8")) as unknown; + return isRecord(parsed) ? (parsed as RequestBody) : null; + } catch { + return null; + } +} + +function readStringRecordValue(record: Record, key: string): string | null { + const value = record[key]; + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : null; +} + +function resolveSessionKey(headers: Headers, parsedBody: RequestBody | null): string | null { + const headerKey = + headers.get(OPENAI_HEADERS.SESSION_ID) ?? + headers.get(OPENAI_HEADERS.CONVERSATION_ID) ?? + null; + if (headerKey && headerKey.trim().length > 0) return headerKey.trim(); + if (!parsedBody) return null; + if (typeof parsedBody.prompt_cache_key === "string") { + const key = parsedBody.prompt_cache_key.trim(); + if (key.length > 0) return key; + } + if (typeof parsedBody.previous_response_id === "string") { + const key = parsedBody.previous_response_id.trim(); + if (key.length > 0) return key; + } + const metadata = parsedBody.metadata; + if (isRecord(metadata)) { + return ( + readStringRecordValue(metadata, "session_id") ?? + readStringRecordValue(metadata, "conversation_id") ?? + readStringRecordValue(metadata, "thread_id") + ); + } + return null; +} + +function buildRequestContext(req: IncomingMessage, body: Buffer): RequestContext { + const headers = headersFromIncoming(req); + const parsedBody = parseRequestBody(body); + const model = + typeof parsedBody?.model === "string" && parsedBody.model.trim().length > 0 + ? parsedBody.model.trim() + : null; + return { + body, + headers, + model, + family: getModelFamily(model ?? "gpt-5-codex"), + stream: parsedBody?.stream === true, + sessionKey: resolveSessionKey(headers, parsedBody), + }; +} + +function buildUpstreamUrl(req: IncomingMessage, upstreamBaseUrl: string): string { + const incomingUrl = new URL(req.url ?? "/", "http://127.0.0.1"); + const upstream = new URL(upstreamBaseUrl); + const basePath = upstream.pathname.replace(/\/+$/, ""); + upstream.pathname = `${basePath}${URL_PATHS.CODEX_RESPONSES}`; + upstream.search = incomingUrl.search; + return upstream.toString(); +} + +function hasUsableAccessToken( + account: ManagedAccount, + now: number, + skewMs: number, +): boolean { + return ( + typeof account.access === "string" && + account.access.trim().length > 0 && + typeof account.expires === "number" && + account.expires > now + Math.max(0, skewMs) + ); +} + +function isTokenRefreshRetryable(result: Extract): boolean { + if (result.reason === "network_error" || result.reason === "unknown") return true; + if (result.reason === "invalid_response") return true; + if (result.reason === "http_error") { + return !( + result.statusCode === HTTP_STATUS.BAD_REQUEST || + result.statusCode === HTTP_STATUS.UNAUTHORIZED || + result.statusCode === HTTP_STATUS.FORBIDDEN + ); + } + return false; +} + +const runtimeRefreshCommitQueues = new WeakMap< + AccountManager, + Map> +>(); + +async function commitRefreshedAuthOnce( + accountManager: AccountManager, + account: ManagedAccount, + auth: OAuthAuthDetails, +): Promise { + const key = [ + account.index, + account.accountId ?? "", + account.email ?? "", + account.refreshToken, + ].join("\0"); + let queue = runtimeRefreshCommitQueues.get(accountManager); + if (!queue) { + queue = new Map(); + runtimeRefreshCommitQueues.set(accountManager, queue); + } + const existing = queue.get(key); + if (existing) return existing; + const pending = accountManager + .commitRefreshedAuth(account, auth) + .finally(() => queue?.delete(key)); + queue.set(key, pending); + return pending; +} + +async function ensureFreshAccessToken(params: { + accountManager: AccountManager; + account: ManagedAccount; + family: ModelFamily; + model: string | null; + now: number; + tokenRefreshSkewMs: number; +}): Promise<{ ok: true; accessToken: string; account: ManagedAccount } | { ok: false; retryable: boolean }> { + const { accountManager, account, family, model, now, tokenRefreshSkewMs } = params; + if (hasUsableAccessToken(account, now, tokenRefreshSkewMs)) { + return { ok: true, accessToken: account.access ?? "", account }; + } + + const refreshResult = await queuedRefresh(account.refreshToken); + if (refreshResult.type === "failed") { + accountManager.recordFailure(account, family, model); + accountManager.incrementAuthFailures(account); + accountManager.markAccountCoolingDown( + account, + DEFAULT_AUTH_FAILURE_COOLDOWN_MS, + "auth-failure", + ); + accountManager.saveToDiskDebounced(); + return { ok: false, retryable: isTokenRefreshRetryable(refreshResult) }; + } + + const auth: OAuthAuthDetails = { + type: "oauth", + access: refreshResult.access, + refresh: refreshResult.refresh, + expires: refreshResult.expires, + }; + try { + const updatedAccount = (await commitRefreshedAuthOnce( + accountManager, + account, + auth, + )) ?? account; + return { + ok: true, + accessToken: updatedAccount.access ?? refreshResult.access, + account: updatedAccount, + }; + } catch { + accountManager.recordFailure(account, family, model); + accountManager.markAccountCoolingDown( + account, + DEFAULT_AUTH_FAILURE_COOLDOWN_MS, + "auth-failure", + ); + accountManager.saveToDiskDebounced(); + return { ok: false, retryable: true }; + } +} + +function resolveAccountId(account: ManagedAccount, accessToken: string): string | null { + const stored = account.accountId?.trim(); + if (stored) return stored; + return extractAccountId(accessToken)?.trim() || null; +} + +function parseRetryAfterHeaderMs(headers: Headers, now: number): number | null { + const retryAfterMs = headers.get("retry-after-ms"); + if (retryAfterMs) { + const parsed = Number.parseInt(retryAfterMs, 10); + if (Number.isFinite(parsed) && parsed > 0) return parsed; + } + const retryAfter = headers.get("retry-after"); + if (!retryAfter) return null; + const asSeconds = Number.parseInt(retryAfter, 10); + if (Number.isFinite(asSeconds) && asSeconds > 0) return asSeconds * 1000; + const asDate = Date.parse(retryAfter); + if (Number.isFinite(asDate) && asDate > now) return asDate - now; + return null; +} + +function parseRetryAfterBodyMs(bodyText: string, now: number): number | null { + if (!bodyText.trim()) return null; + try { + const parsed = JSON.parse(bodyText) as unknown; + if (!isRecord(parsed) || !isRecord(parsed.error)) return null; + const retryAfterMs = Number(parsed.error.retry_after_ms); + if (Number.isFinite(retryAfterMs) && retryAfterMs > 0) return retryAfterMs; + const retryAfterSeconds = Number(parsed.error.retry_after); + if (Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0) { + return retryAfterSeconds * 1000; + } + const resetAtRaw = Number(parsed.error.resets_at ?? parsed.error.reset_at); + if (Number.isFinite(resetAtRaw) && resetAtRaw > 0) { + const resetAtMs = resetAtRaw < 10_000_000_000 ? resetAtRaw * 1000 : resetAtRaw; + if (resetAtMs > now) return resetAtMs - now; + } + } catch { + return null; + } + return null; +} + +async function readErrorBody(response: Response): Promise { + try { + return await response.text(); + } catch { + return ""; + } +} + +function getQuotaWindowWaitMs(headers: Headers, prefix: string, now: number): number { + const resetAfterSeconds = Number.parseInt( + headers.get(`${prefix}-reset-after-seconds`) ?? "", + 10, + ); + if (Number.isFinite(resetAfterSeconds) && resetAfterSeconds > 0) { + return resetAfterSeconds * 1000; + } + const resetAtRaw = headers.get(`${prefix}-reset-at`); + if (!resetAtRaw) return 0; + const trimmed = resetAtRaw.trim(); + let resetAtMs = 0; + if (/^\d+$/.test(trimmed)) { + const parsed = Number.parseInt(trimmed, 10); + if (Number.isFinite(parsed) && parsed > 0) { + resetAtMs = parsed < 10_000_000_000 ? parsed * 1000 : parsed; + } + } else { + const parsedDate = Date.parse(trimmed); + if (Number.isFinite(parsedDate)) resetAtMs = parsedDate; + } + return resetAtMs > now ? resetAtMs - now : 0; +} + +function getQuotaNearExhaustionWaitMs( + headers: Headers, + remainingThreshold: number, + now: number, +): number { + const usedThreshold = 100 - Math.max(0, Math.min(100, remainingThreshold)); + const candidates: number[] = []; + for (const prefix of ["x-codex-primary", "x-codex-secondary"]) { + const used = Number(headers.get(`${prefix}-used-percent`) ?? ""); + if (!Number.isFinite(used) || used < usedThreshold) continue; + const waitMs = getQuotaWindowWaitMs(headers, prefix, now); + if (waitMs > 0) candidates.push(waitMs); + } + return candidates.length > 0 ? Math.max(...candidates) : 0; +} + +function chooseAccount(params: { + accountManager: AccountManager; + sessionAffinityStore: SessionAffinityStore | null; + sessionKey: string | null; + family: ModelFamily; + model: string | null; + attemptedIndexes: ReadonlySet; + now: number; +}): ManagedAccount | null { + const { + accountManager, + sessionAffinityStore, + sessionKey, + family, + model, + attemptedIndexes, + now, + } = params; + const preferredIndex = sessionAffinityStore?.getPreferredAccountIndex(sessionKey, now); + if (typeof preferredIndex === "number" && !attemptedIndexes.has(preferredIndex)) { + const preferred = accountManager.getAccountByIndex(preferredIndex); + if ( + preferred && + accountManager.isAccountAvailableForFamily(preferred.index, family, model) + ) { + accountManager.markSwitched(preferred, "rotation", family); + return preferred; + } + } + + const selected = accountManager.getCurrentOrNextForFamilyHybrid(family, model); + if (selected && !attemptedIndexes.has(selected.index)) return selected; + + for (const account of accountManager.getAccountsSnapshot()) { + if (attemptedIndexes.has(account.index)) continue; + if (accountManager.isAccountAvailableForFamily(account.index, family, model)) { + const live = accountManager.getAccountByIndex(account.index); + if (!live) continue; + accountManager.markSwitched(live, "rotation", family); + return live; + } + } + + return null; +} + +function writeJson(res: ServerResponse, status: number, payload: Record): void { + res.writeHead(status, { "content-type": "application/json; charset=utf-8" }); + res.end(`${JSON.stringify(payload)}\n`); +} + +function writeMethodOrPathError(res: ServerResponse): void { + writeJson(res, 404, { + error: { + message: "Runtime rotation proxy only accepts Responses API requests.", + code: "runtime_rotation_proxy_not_found", + }, + }); +} + +function writeUnauthorized(res: ServerResponse): void { + writeJson(res, HTTP_STATUS.UNAUTHORIZED, { + error: { + message: "Runtime rotation proxy rejected an unauthenticated local request.", + code: "runtime_rotation_proxy_unauthorized", + }, + }); +} + +function normalizeExhaustionStatus(reason: ExhaustionReason): number { + return reason === "rate-limit" ? HTTP_STATUS.TOO_MANY_REQUESTS : 503; +} + +function writePoolExhausted(params: { + res: ServerResponse; + accountManager: AccountManager; + family: ModelFamily; + model: string | null; + reason: ExhaustionReason; +}): void { + const { res, accountManager, family, model, reason } = params; + const waitMs = accountManager.getMinWaitTimeForFamily(family, model); + writeJson(res, normalizeExhaustionStatus(reason), { + error: { + message: + "All managed Codex accounts are temporarily unavailable for this runtime request.", + code: "codex_runtime_rotation_pool_exhausted", + reason, + retry_after_ms: waitMs, + hint: "Run `codex auth rotation status` to inspect account state.", + }, + }); +} + +async function withTimeout( + promise: Promise, + timeoutMs: number, + onTimeout: () => void, + message: string, +): Promise { + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => { + timeout = setTimeout(() => { + onTimeout(); + reject(new Error(message)); + }, Math.max(1, timeoutMs)); + }), + ]); + } finally { + if (timeout) clearTimeout(timeout); + } +} + +async function forwardStreamingResponse( + upstream: Response, + res: ServerResponse, + status: RuntimeRotationProxyStatus, + onStreamError: () => void, + streamStallTimeoutMs: number, +): Promise { + status.streamsStarted += 1; + res.writeHead( + upstream.status, + responseHeadersForClient(upstream.headers), + ); + if (!upstream.body) { + res.end(); + return; + } + + const reader = upstream.body.getReader(); + res.on("close", () => { + if (!res.writableEnded) { + void reader.cancel().catch(() => undefined); + } + }); + try { + while (true) { + const { done, value } = await withTimeout( + reader.read(), + streamStallTimeoutMs, + () => { + void reader.cancel().catch(() => undefined); + }, + `upstream stream stalled after ${streamStallTimeoutMs}ms`, + ); + if (done) break; + if (value && value.byteLength > 0) { + res.write(Buffer.from(value)); + } + } + res.end(); + } catch (error) { + status.lastError = error instanceof Error ? error.message : String(error); + onStreamError(); + if (!res.destroyed) { + res.destroy(error instanceof Error ? error : undefined); + } + } +} + +export async function startRuntimeRotationProxy( + options: RuntimeRotationProxyOptions, +): Promise { + const pluginConfig = loadPluginConfig(); + const accountManager = options.accountManager ?? (await AccountManager.loadFromDisk()); + const fetchImpl = options.fetchImpl ?? fetch; + const host = options.host ?? DEFAULT_HOST; + const port = options.port ?? 0; + const upstreamBaseUrl = options.upstreamBaseUrl ?? CODEX_BASE_URL; + const clientApiKey = + typeof options.clientApiKey === "string" && + options.clientApiKey.trim().length > 0 + ? options.clientApiKey.trim() + : null; + if (!clientApiKey) { + throw new Error("Runtime rotation proxy requires a clientApiKey."); + } + const now = options.now ?? Date.now; + const tokenRefreshSkewMs = getTokenRefreshSkewMs(pluginConfig); + const networkErrorCooldownMs = getNetworkErrorCooldownMs(pluginConfig); + const serverErrorCooldownMs = getServerErrorCooldownMs(pluginConfig); + const fetchTimeoutMs = options.fetchTimeoutMs ?? getFetchTimeoutMs(pluginConfig); + const streamStallTimeoutMs = + options.streamStallTimeoutMs ?? getStreamStallTimeoutMs(pluginConfig); + const configuredMaxRetries = getRetryAllAccountsMaxRetries(pluginConfig); + const maxRuntimeAccountAttempts = + configuredMaxRetries > 0 + ? configuredMaxRetries + 1 + : DEFAULT_MAX_RUNTIME_ACCOUNT_ATTEMPTS; + const maxRequestBodyBytes = + options.maxRequestBodyBytes ?? MAX_REQUEST_BODY_BYTES; + const quotaRemainingPercentThreshold = + options.quotaRemainingPercentThreshold ?? DEFAULT_QUOTA_REMAINING_THRESHOLD; + const sessionAffinityStore = getSessionAffinity(pluginConfig) + ? new SessionAffinityStore({ + ttlMs: getSessionAffinityTtlMs(pluginConfig), + maxEntries: getSessionAffinityMaxEntries(pluginConfig), + }) + : null; + const status: RuntimeRotationProxyStatus = { + startedAt: now(), + totalRequests: 0, + upstreamRequests: 0, + retries: 0, + rotations: 0, + streamsStarted: 0, + lastError: null, + lastAccountIndex: null, + lastAccountLabel: null, + lastAccountId: null, + lastAccountUpdatedAt: null, + }; + + const handleRequest = async ( + req: IncomingMessage, + res: ServerResponse, + ): Promise => { + try { + const incomingUrl = new URL(req.url ?? "/", "http://127.0.0.1"); + if (req.method !== "POST" || !isResponsesPath(incomingUrl.pathname)) { + writeMethodOrPathError(res); + return; + } + + const incomingHeaders = headersFromIncoming(req); + if (!isAuthorizedClient(incomingHeaders, clientApiKey)) { + writeUnauthorized(res); + return; + } + + status.totalRequests += 1; + const context = buildRequestContext( + req, + await readRequestBody(req, maxRequestBodyBytes), + ); + const upstreamUrl = buildUpstreamUrl(req, upstreamBaseUrl); + const attemptedIndexes = new Set(); + let exhaustionReason: ExhaustionReason = "no-account"; + const accountAttemptLimit = Math.max( + 1, + Math.min(accountManager.getAccountCount(), maxRuntimeAccountAttempts), + ); + + while (attemptedIndexes.size < accountAttemptLimit) { + const selected = chooseAccount({ + accountManager, + sessionAffinityStore, + sessionKey: context.sessionKey, + family: context.family, + model: context.model, + attemptedIndexes, + now: now(), + }); + if (!selected) break; + attemptedIndexes.add(selected.index); + + if (!accountManager.consumeToken(selected, context.family, context.model)) { + exhaustionReason = "rate-limit"; + continue; + } + + const refreshed = await ensureFreshAccessToken({ + accountManager, + account: selected, + family: context.family, + model: context.model, + now: now(), + tokenRefreshSkewMs, + }); + if (!refreshed.ok) { + accountManager.refundToken(selected, context.family, context.model); + exhaustionReason = "auth-failure"; + if (!refreshed.retryable) continue; + status.retries += 1; + status.rotations += 1; + continue; + } + + const accountId = resolveAccountId(refreshed.account, refreshed.accessToken); + if (!accountId) { + accountManager.refundToken(refreshed.account, context.family, context.model); + accountManager.recordFailure(refreshed.account, context.family, context.model); + accountManager.markAccountCoolingDown( + refreshed.account, + DEFAULT_AUTH_FAILURE_COOLDOWN_MS, + "auth-failure", + ); + exhaustionReason = "auth-failure"; + status.retries += 1; + status.rotations += 1; + continue; + } + + const accountIdentity = accountIdentityFromAccount(refreshed.account, now()); + recordLastRuntimeAccount(status, accountIdentity); + + const outboundHeaders = createOutboundHeaders( + context.headers, + refreshed.account, + refreshed.accessToken, + accountId, + ); + + let upstream: Response; + try { + status.upstreamRequests += 1; + const fetchAbortController = new AbortController(); + upstream = await withTimeout( + fetchImpl(upstreamUrl, { + method: "POST", + headers: outboundHeaders, + body: context.body, + signal: fetchAbortController.signal, + }), + fetchTimeoutMs, + () => fetchAbortController.abort(), + `upstream fetch timed out after ${fetchTimeoutMs}ms`, + ); + } catch (error) { + status.lastError = error instanceof Error ? error.message : String(error); + accountManager.refundToken(refreshed.account, context.family, context.model); + accountManager.recordFailure(refreshed.account, context.family, context.model); + accountManager.markAccountCoolingDown( + refreshed.account, + networkErrorCooldownMs, + "network-error", + ); + accountManager.saveToDiskDebounced(); + exhaustionReason = "network-error"; + status.retries += 1; + status.rotations += 1; + continue; + } + + if (upstream.status === HTTP_STATUS.TOO_MANY_REQUESTS) { + const bodyText = await readErrorBody(upstream); + const retryAfterMs = + parseRetryAfterHeaderMs(upstream.headers, now()) ?? + parseRetryAfterBodyMs(bodyText, now()) ?? + 60_000; + // A 429 is the upstream quota signal for the attempted account, so + // keep the consumed runtime token drained. + accountManager.recordRateLimit(refreshed.account, context.family, context.model); + accountManager.markRateLimitedWithReason( + refreshed.account, + retryAfterMs, + context.family, + "quota", + context.model, + ); + accountManager.saveToDiskDebounced(); + exhaustionReason = "rate-limit"; + status.retries += 1; + status.rotations += 1; + continue; + } + + if (upstream.status === HTTP_STATUS.UNAUTHORIZED) { + await readErrorBody(upstream); + accountManager.refundToken(refreshed.account, context.family, context.model); + accountManager.recordFailure(refreshed.account, context.family, context.model); + accountManager.markAccountCoolingDown( + refreshed.account, + DEFAULT_AUTH_FAILURE_COOLDOWN_MS, + "auth-failure", + ); + accountManager.saveToDiskDebounced(); + exhaustionReason = "auth-failure"; + status.retries += 1; + status.rotations += 1; + continue; + } + + if (upstream.status >= 500) { + await readErrorBody(upstream); + accountManager.refundToken(refreshed.account, context.family, context.model); + accountManager.recordFailure(refreshed.account, context.family, context.model); + accountManager.markAccountCoolingDown( + refreshed.account, + serverErrorCooldownMs, + "server-error", + ); + accountManager.saveToDiskDebounced(); + exhaustionReason = "server-error"; + status.retries += 1; + status.rotations += 1; + continue; + } + + accountManager.recordSuccess(refreshed.account, context.family, context.model); + const nearExhaustionWaitMs = getQuotaNearExhaustionWaitMs( + upstream.headers, + quotaRemainingPercentThreshold, + now(), + ); + if (nearExhaustionWaitMs > 0) { + accountManager.markRateLimitedWithReason( + refreshed.account, + nearExhaustionWaitMs, + context.family, + "quota", + context.model, + ); + sessionAffinityStore?.forgetSession(context.sessionKey); + accountManager.saveToDiskDebounced(); + } else { + sessionAffinityStore?.remember( + context.sessionKey, + refreshed.account.index, + now(), + ); + } + await persistRuntimeActiveAccount( + accountManager, + refreshed.account, + context.family, + ); + + await forwardStreamingResponse( + upstream, + res, + status, + () => { + accountManager.recordFailure( + refreshed.account, + context.family, + context.model, + ); + accountManager.markAccountCoolingDown( + refreshed.account, + networkErrorCooldownMs, + "network-error", + ); + sessionAffinityStore?.forgetSession(context.sessionKey); + accountManager.saveToDiskDebounced(); + }, + streamStallTimeoutMs, + ); + return; + } + + if ( + attemptedIndexes.size >= accountAttemptLimit && + accountAttemptLimit < accountManager.getAccountCount() + ) { + exhaustionReason = "budget"; + } + + writePoolExhausted({ + res, + accountManager, + family: context.family, + model: context.model, + reason: exhaustionReason, + }); + } catch (error) { + status.lastError = error instanceof Error ? error.message : String(error); + if (!res.headersSent) { + if (isRuntimeProxyHttpError(error)) { + writeJson(res, error.statusCode, { + error: { + message: error.message, + code: error.code, + }, + }); + return; + } + writeJson(res, 500, { + error: { + message: "Runtime rotation proxy failed before forwarding the request.", + code: "codex_runtime_rotation_proxy_error", + }, + }); + } else if (!res.destroyed) { + res.destroy(error instanceof Error ? error : undefined); + } + } + }; + + const server = createServer((req, res) => { + void handleRequest(req, res); + }); + const sockets = new Set(); + server.on("connection", (socket) => { + sockets.add(socket); + socket.once("close", () => { + sockets.delete(socket); + }); + }); + const onPostStartupServerError = (error: Error): void => { + status.lastError = error.message; + }; + + await new Promise((resolve, reject) => { + const onError = (error: Error): void => { + server.off("listening", onListening); + reject(error); + }; + const onListening = (): void => { + server.off("error", onError); + resolve(); + }; + server.once("error", onError); + server.once("listening", onListening); + server.listen(port, host); + }); + server.on("error", onPostStartupServerError); + + const address = server.address(); + const resolvedPort = + typeof address === "object" && address ? address.port : port; + + return { + host, + port: resolvedPort, + baseUrl: `http://${host}:${resolvedPort}`, + close: async () => { + await closeServer(server, sockets); + await accountManager.flushPendingSave(); + }, + getStatus: () => ({ ...status }), + }; +} + +async function closeServer(server: Server, sockets: Set): Promise { + if (!server.listening) return; + const closed = new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + server.closeIdleConnections?.(); + for (const socket of sockets) { + socket.destroy(); + } + await closed; +} diff --git a/lib/runtime/app-bind.ts b/lib/runtime/app-bind.ts new file mode 100644 index 00000000..e000d206 --- /dev/null +++ b/lib/runtime/app-bind.ts @@ -0,0 +1,796 @@ +import { spawn } from "node:child_process"; +import { createHash, randomBytes } from "node:crypto"; +import { closeSync, existsSync, mkdirSync, openSync } from "node:fs"; +import { mkdir, open, readFile, rename, unlink } from "node:fs/promises"; +import { homedir } from "node:os"; +import { basename, dirname, join } from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; +import { withFileOperationRetry } from "../fs-retry.js"; +import { getCodexMultiAuthDir } from "../runtime-paths.js"; +import { + restoreConfigTomlFromRuntimeRotationProvider, + rewriteConfigTomlForRuntimeRotationProvider, +} from "./config-toml.js"; + +const APP_BIND_DIR_NAME = "app-bind"; +const APP_BIND_STATE_FILE = "runtime-rotation-app-bind.json"; +const APP_BIND_BACKUP_FILE = "codex-config-backup.json"; +const APP_BIND_STATUS_FILE = "runtime-rotation-app-bind-status.json"; +const WINDOWS_STARTUP_FILE = "Codex Multi Auth Runtime Router.cmd"; +const MACOS_LAUNCH_AGENT_ID = "com.ndycode.codex-multi-auth.runtime-router"; +const DEFAULT_ROUTER_READY_TIMEOUT_MS = 15_000; +const ROUTER_STATUS_POLL_INTERVAL_MS = 100; +const APP_ROUTER_MAX_LOG_BYTES = 1024 * 1024; +const appBindLocks = new Map>(); + +export interface AppBindPaths { + codexHome: string; + configPath: string; + bindDir: string; + statePath: string; + backupPath: string; + statusPath: string; + logPath: string; + routerScriptPath: string; + startupPath: string | null; + launchAgentPath: string | null; +} + +interface AppBindBackup { + version: 1; + configPath: string; + existed: boolean; + content: string; + createdAt: number; +} + +export interface AppBindState { + version: 1; + platform: NodeJS.Platform; + host: string; + port: number; + baseUrl: string; + configPath: string; + statePath: string; + backupPath: string; + statusPath: string; + logPath: string; + nodePath: string; + routerScriptPath: string; + clientApiKey: string; + startupPath: string | null; + launchAgentPath: string | null; + boundConfigHash: string; + updatedAt: number; +} + +export interface AppBindRouterStatus { + state: string | null; + pid: number | null; + baseUrl: string | null; + totalRequests: number | null; + lastAccountIndex: number | null; + lastAccountLabel: string | null; + lastAccountEmail: string | null; + lastAccountId: string | null; + updatedAt: number | null; + lastError: string | null; +} + +export interface AppBindStatus { + bound: boolean; + running: boolean; + state: AppBindState | null; + router: AppBindRouterStatus | null; + paths: AppBindPaths; +} + +export interface AppBindResult { + status: AppBindStatus; + message: string; +} + +export interface AppBindOptions { + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; + home?: string; + now?: () => number; + nodePath?: string; + routerScriptPath?: string; + routerScriptCandidates?: string[]; + spawnDetached?: boolean; + routerReadyTimeoutMs?: number; + log?: (message: string) => void; +} + +async function withAppBindLock( + key: string, + operation: () => Promise, +): Promise { + const previous = appBindLocks.get(key) ?? Promise.resolve(); + let releaseCurrent: () => void = () => undefined; + const current = new Promise((resolve) => { + releaseCurrent = resolve; + }); + const tail = previous.catch(() => undefined).then(() => current); + appBindLocks.set(key, tail); + await previous.catch(() => undefined); + try { + return await operation(); + } finally { + releaseCurrent(); + if (appBindLocks.get(key) === tail) { + appBindLocks.delete(key); + } + } +} + +export function rewriteConfigTomlForAppBind( + rawConfig: string, + baseUrl: string, + clientApiKey = "", +): string { + return rewriteConfigTomlForRuntimeRotationProvider( + rawConfig, + baseUrl, + clientApiKey, + ); +} + +export function restoreConfigTomlFromAppBind(currentConfig: string, originalConfig: string): string { + return restoreConfigTomlFromRuntimeRotationProvider( + currentConfig, + originalConfig, + ); +} + +function sha256(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function createAppBindClientApiKey(): string { + return randomBytes(32).toString("hex"); +} + +function parseJsonRecord(value: string): Record | null { + try { + const parsed = JSON.parse(value) as unknown; + return typeof parsed === "object" && parsed !== null + ? (parsed as Record) + : null; + } catch { + return null; + } +} + +function readString(record: Record, key: string): string | null { + const value = record[key]; + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : null; +} + +function readNumber(record: Record, key: string): number | null { + const value = record[key]; + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +async function syncDirectoryBestEffort(path: string): Promise { + let handle: Awaited> | null = null; + try { + handle = await open(path, "r"); + await handle.sync(); + } catch { + // Directory fsync is not portable; the file-level fsync still guards contents. + } finally { + await handle?.close().catch(() => undefined); + } +} + +async function atomicWriteFile( + target: string, + content: string, + mode = 0o600, +): Promise { + await withFileOperationRetry(async () => { + await mkdir(dirname(target), { recursive: true }); + const tempPath = join( + dirname(target), + [ + `.${basename(target)}`, + String(process.pid), + String(Date.now()), + randomBytes(4).toString("hex"), + "tmp", + ].join("."), + ); + let moved = false; + let handle: Awaited> | null = null; + try { + handle = await open(tempPath, "w", mode); + await handle.writeFile(content, "utf8"); + await handle.sync(); + await handle.close(); + handle = null; + await rename(tempPath, target); + moved = true; + await syncDirectoryBestEffort(dirname(target)); + } finally { + await handle?.close().catch(() => undefined); + if (!moved) { + await unlink(tempPath).catch(() => undefined); + } + } + }); +} + +async function unlinkIfExists(path: string): Promise { + try { + await withFileOperationRetry(() => unlink(path)); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + return; + } + throw error; + } +} + +function readAppBindStateRecord(record: Record): AppBindState | null { + const port = readNumber(record, "port"); + const host = readString(record, "host"); + const baseUrl = readString(record, "baseUrl"); + const configPath = readString(record, "configPath"); + const backupPath = readString(record, "backupPath"); + const statePath = readString(record, "statePath"); + const statusPath = readString(record, "statusPath"); + const logPath = readString(record, "logPath"); + const nodePath = readString(record, "nodePath"); + const routerScriptPath = readString(record, "routerScriptPath"); + const clientApiKey = readString(record, "clientApiKey"); + const boundConfigHash = readString(record, "boundConfigHash"); + const updatedAt = readNumber(record, "updatedAt"); + const platformValue = readString(record, "platform"); + if ( + port === null || + !host || + !baseUrl || + !configPath || + !statePath || + !backupPath || + !statusPath || + !logPath || + !nodePath || + !routerScriptPath || + !clientApiKey || + !boundConfigHash || + updatedAt === null + ) { + return null; + } + return { + version: 1, + platform: platformValue ? (platformValue as NodeJS.Platform) : process.platform, + host, + port, + baseUrl, + configPath, + statePath, + backupPath, + statusPath, + logPath, + nodePath, + routerScriptPath, + clientApiKey, + startupPath: readString(record, "startupPath"), + launchAgentPath: readString(record, "launchAgentPath"), + boundConfigHash, + updatedAt, + }; +} + +async function readJsonFile(path: string): Promise | null> { + try { + const raw = await readFile(path, "utf8"); + return parseJsonRecord(raw); + } catch { + return null; + } +} + +async function readAppBindState(path: string): Promise { + const record = await readJsonFile(path); + return record ? readAppBindStateRecord(record) : null; +} + +async function readAppBindBackup(path: string): Promise { + const record = await readJsonFile(path); + if (!record) return null; + const configPath = readString(record, "configPath"); + const content = typeof record.content === "string" ? record.content : null; + const createdAt = readNumber(record, "createdAt"); + if (!configPath || content === null || createdAt === null) return null; + return { + version: 1, + configPath, + existed: record.existed === true, + content, + createdAt, + }; +} + +async function readRouterStatus(path: string): Promise { + const record = await readJsonFile(path); + if (!record) return null; + return { + state: readString(record, "state"), + pid: readNumber(record, "pid"), + baseUrl: readString(record, "baseUrl"), + totalRequests: readNumber(record, "totalRequests"), + lastAccountIndex: readNumber(record, "lastAccountIndex"), + lastAccountLabel: readString(record, "lastAccountLabel"), + lastAccountEmail: readString(record, "lastAccountEmail"), + lastAccountId: readString(record, "lastAccountId"), + updatedAt: readNumber(record, "updatedAt"), + lastError: readString(record, "lastError"), + }; +} + +function isProcessAlive(pid: number | null): boolean { + if (!pid) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + const code = + error && typeof error === "object" && "code" in error ? error.code : null; + return code === "EPERM"; + } +} + +function resolveWindowsStartupPath(env: NodeJS.ProcessEnv, home: string): string { + const appData = (env.APPDATA ?? "").trim() || join(home, "AppData", "Roaming"); + return join( + appData, + "Microsoft", + "Windows", + "Start Menu", + "Programs", + "Startup", + WINDOWS_STARTUP_FILE, + ); +} + +function resolveMacLaunchAgentPath(home: string): string { + return join(home, "Library", "LaunchAgents", `${MACOS_LAUNCH_AGENT_ID}.plist`); +} + +function resolveRouterScriptPath( + override?: string, + candidateOverride?: string[], +): string { + if (override) return override; + const candidates = + candidateOverride ?? [ + fileURLToPath(new URL("../../../scripts/codex-app-router.js", import.meta.url)), + fileURLToPath(new URL("../../scripts/codex-app-router.js", import.meta.url)), + ]; + for (const candidate of candidates) { + if (existsSync(candidate)) return candidate; + } + throw new Error( + `codex-app-router.js not found; checked: ${candidates.join(", ")}`, + ); +} + +export function resolveAppBindPaths(options: AppBindOptions = {}): AppBindPaths { + const env = options.env ?? process.env; + const platform = options.platform ?? process.platform; + const home = options.home ?? homedir(); + const codexHome = + (env.CODEX_MULTI_AUTH_APP_BIND_CODEX_HOME ?? "").trim() || join(home, ".codex"); + const multiAuthDir = (env.CODEX_MULTI_AUTH_DIR ?? "").trim() || getCodexMultiAuthDir(); + const bindDir = join(multiAuthDir, APP_BIND_DIR_NAME); + return { + codexHome, + configPath: join(codexHome, "config.toml"), + bindDir, + statePath: join(bindDir, APP_BIND_STATE_FILE), + backupPath: join(bindDir, APP_BIND_BACKUP_FILE), + statusPath: join(bindDir, APP_BIND_STATUS_FILE), + logPath: join(bindDir, "runtime-rotation-app-router.log"), + routerScriptPath: resolveRouterScriptPath( + options.routerScriptPath, + options.routerScriptCandidates, + ), + startupPath: + platform === "win32" ? resolveWindowsStartupPath(env, home) : null, + launchAgentPath: platform === "darwin" ? resolveMacLaunchAgentPath(home) : null, + }; +} + +function formatBaseUrl(host: string, port: number): string { + const normalizedHost = + host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; + return `http://${normalizedHost}:${port}`; +} + +function readPortFromBaseUrl(baseUrl: string | null, fallback: number): number { + if (!baseUrl) return fallback; + try { + const port = Number.parseInt(new URL(baseUrl).port, 10); + return Number.isFinite(port) && port > 0 ? port : fallback; + } catch { + return fallback; + } +} + +function escapeWindowsBatchPath(value: string): string { + return value.replace(/%/g, "%%"); +} + +function createWindowsStartupCommand(state: AppBindState): string { + const nodePath = escapeWindowsBatchPath(state.nodePath); + const routerScriptPath = escapeWindowsBatchPath(state.routerScriptPath); + const statusPath = escapeWindowsBatchPath(state.statusPath); + const statePath = escapeWindowsBatchPath(state.statePath); + const logPath = escapeWindowsBatchPath(state.logPath); + return [ + "@echo off", + `"${nodePath}" "${routerScriptPath}" --port ${state.port} --status "${statusPath}" --state "${statePath}" --log "${logPath}" --max-log-bytes ${APP_ROUTER_MAX_LOG_BYTES} >> "${logPath}" 2>&1`, + "", + ].join("\r\n"); +} + +function xmlEscape(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">"); +} + +function createMacLaunchAgentPlist(state: AppBindState): string { + const args = [ + state.nodePath, + state.routerScriptPath, + "--port", + String(state.port), + "--status", + state.statusPath, + "--state", + state.statePath, + "--log", + state.logPath, + "--max-log-bytes", + String(APP_ROUTER_MAX_LOG_BYTES), + ]; + return [ + '', + '', + '', + "", + " Label", + ` ${MACOS_LAUNCH_AGENT_ID}`, + " ProgramArguments", + " ", + ...args.map((arg) => ` ${xmlEscape(arg)}`), + " ", + " RunAtLoad", + " ", + " KeepAlive", + " ", + " StandardOutPath", + ` ${xmlEscape(state.logPath)}`, + " StandardErrorPath", + ` ${xmlEscape(state.logPath)}`, + "", + "", + "", + ].join("\n"); +} + +async function writeAppBindStartup(state: AppBindState): Promise { + if (state.platform === "win32" && state.startupPath) { + await mkdir(dirname(state.startupPath), { recursive: true }); + await atomicWriteFile(state.startupPath, createWindowsStartupCommand(state)); + return; + } + if (state.platform === "darwin" && state.launchAgentPath) { + await mkdir(dirname(state.launchAgentPath), { recursive: true }); + await atomicWriteFile(state.launchAgentPath, createMacLaunchAgentPlist(state)); + } +} + +async function removeAppBindStartup(state: AppBindState): Promise { + const candidates = [state.startupPath, state.launchAgentPath].filter( + (path): path is string => typeof path === "string" && path.length > 0, + ); + for (const candidate of candidates) { + try { + await unlinkIfExists(candidate); + } catch { + // Best-effort cleanup. + } + } +} + +function spawnRouter(state: AppBindState): void { + mkdirSync(dirname(state.logPath), { recursive: true }); + const logFd = openSync(state.logPath, "a", 0o600); + try { + const child = spawn( + state.nodePath, + [ + state.routerScriptPath, + "--port", + String(state.port), + "--status", + state.statusPath, + "--state", + state.statePath, + "--log", + state.logPath, + "--max-log-bytes", + String(APP_ROUTER_MAX_LOG_BYTES), + ], + { + detached: true, + stdio: ["ignore", logFd, logFd], + windowsHide: true, + }, + ); + child.unref(); + } finally { + closeSync(logFd); + } +} + +async function maybeStartRouter(state: AppBindState, options: AppBindOptions): Promise { + if (options.spawnDetached === false) return false; + const router = await readRouterStatus(state.statusPath); + if (router && isProcessAlive(router.pid) && router.state === "running") return false; + spawnRouter(state); + return true; +} + +function resolveRouterReadyTimeoutMs(options: AppBindOptions): number { + const value = options.routerReadyTimeoutMs; + return typeof value === "number" && Number.isFinite(value) && value > 0 + ? value + : DEFAULT_ROUTER_READY_TIMEOUT_MS; +} + +async function waitForRouterStatus( + statusPath: string, + timeoutMs: number, +): Promise { + let latest: AppBindRouterStatus | null = null; + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const router = await readRouterStatus(statusPath); + latest = router ?? latest; + if (router?.state === "error") { + const suffix = router.lastError ? `: ${router.lastError}` : ""; + throw new Error(`Codex app runtime router failed to start${suffix}`); + } + if (router?.state === "running" && isProcessAlive(router.pid)) return router; + await new Promise((resolve) => setTimeout(resolve, ROUTER_STATUS_POLL_INTERVAL_MS)); + } + const suffix = latest?.lastError ? `: ${latest.lastError}` : ""; + throw new Error(`Codex app runtime router did not report ready${suffix}`); +} + +async function stopRouter(router: AppBindRouterStatus | null): Promise { + if (!router?.pid || !isProcessAlive(router.pid)) return; + try { + process.kill(router.pid, "SIGTERM"); + } catch { + return; + } + for (let attempt = 0; attempt < 20; attempt += 1) { + if (!isProcessAlive(router.pid)) return; + await new Promise((resolve) => setTimeout(resolve, 100)); + } +} + +async function readConfigIfExists(configPath: string): Promise<{ existed: boolean; content: string }> { + try { + return { existed: true, content: await readFile(configPath, "utf8") }; + } catch { + return { existed: false, content: "" }; + } +} + +export async function getAppBindStatus(options: AppBindOptions = {}): Promise { + const paths = resolveAppBindPaths(options); + const state = await readAppBindState(paths.statePath); + const router = await readRouterStatus(paths.statusPath); + return { + bound: state !== null, + running: router !== null && router.state === "running" && isProcessAlive(router.pid), + state, + router, + paths, + }; +} + +export async function bindCodexAppRuntimeRotation( + options: AppBindOptions = {}, +): Promise { + const paths = resolveAppBindPaths(options); + return withAppBindLock(paths.bindDir, () => + bindCodexAppRuntimeRotationLocked(options, paths), + ); +} + +async function bindCodexAppRuntimeRotationLocked( + options: AppBindOptions, + paths: AppBindPaths, +): Promise { + const platform = options.platform ?? process.platform; + const now = options.now?.() ?? Date.now(); + const existingState = await readAppBindState(paths.statePath); + const host = existingState?.host ?? "127.0.0.1"; + let port = existingState && existingState.port > 0 ? existingState.port : 0; + let baseUrl = existingState?.baseUrl ?? formatBaseUrl(host, port); + const clientApiKey = + existingState && existingState.clientApiKey.length > 0 + ? existingState.clientApiKey + : createAppBindClientApiKey(); + const { existed, content } = await readConfigIfExists(paths.configPath); + const backup = (await readAppBindBackup(paths.backupPath)) ?? { + version: 1, + configPath: paths.configPath, + existed, + content, + createdAt: now, + }; + let boundConfig = rewriteConfigTomlForAppBind(content, baseUrl, clientApiKey); + let state: AppBindState = { + version: 1, + platform, + host, + port, + baseUrl, + configPath: paths.configPath, + statePath: paths.statePath, + backupPath: paths.backupPath, + statusPath: paths.statusPath, + logPath: paths.logPath, + nodePath: options.nodePath ?? process.execPath, + routerScriptPath: paths.routerScriptPath, + clientApiKey, + startupPath: paths.startupPath, + launchAgentPath: paths.launchAgentPath, + boundConfigHash: sha256(boundConfig), + updatedAt: now, + }; + + await mkdir(paths.bindDir, { recursive: true }); + await mkdir(dirname(paths.configPath), { recursive: true }); + await atomicWriteFile(paths.backupPath, `${JSON.stringify(backup, null, 2)}\n`); + await atomicWriteFile(paths.statePath, `${JSON.stringify(state, null, 2)}\n`); + const startedRouter = await maybeStartRouter(state, options); + const router = startedRouter + ? await waitForRouterStatus( + state.statusPath, + resolveRouterReadyTimeoutMs(options), + ) + : await readRouterStatus(state.statusPath); + const routerBaseUrl = router?.baseUrl ?? null; + const routerIsUsable = + !!routerBaseUrl && + router !== null && + (startedRouter || (router.state === "running" && isProcessAlive(router.pid))); + if (routerIsUsable) { + port = readPortFromBaseUrl(routerBaseUrl, port); + baseUrl = routerBaseUrl; + } else if (existingState && existingState.port > 0) { + port = existingState.port; + baseUrl = existingState.baseUrl; + } + if (port <= 0) { + throw new Error( + "Codex app bind could not resolve a runtime router port; refusing to write config.toml with port=0.", + ); + } + boundConfig = rewriteConfigTomlForAppBind(content, baseUrl, clientApiKey); + state = { + ...state, + port, + baseUrl, + boundConfigHash: sha256(boundConfig), + updatedAt: options.now?.() ?? Date.now(), + }; + if (startedRouter) { + options.log?.(`Codex app runtime router started on ${baseUrl}`); + } + await atomicWriteFile(paths.configPath, boundConfig); + await atomicWriteFile(paths.statePath, `${JSON.stringify(state, null, 2)}\n`); + await writeAppBindStartup(state); + const status = await getAppBindStatus(options); + return { + status, + message: `Bound Codex app config ${paths.configPath} to ${baseUrl}`, + }; +} + +export async function unbindCodexAppRuntimeRotation( + options: AppBindOptions = {}, +): Promise { + const paths = resolveAppBindPaths(options); + return withAppBindLock(paths.bindDir, () => + unbindCodexAppRuntimeRotationLocked(options, paths), + ); +} + +async function unbindCodexAppRuntimeRotationLocked( + options: AppBindOptions, + paths: AppBindPaths, +): Promise { + const state = await readAppBindState(paths.statePath); + const router = await readRouterStatus(paths.statusPath); + if (state) { + await stopRouter(router); + await removeAppBindStartup(state); + } + + const backup = await readAppBindBackup(paths.backupPath); + if (backup) { + const current = await readConfigIfExists(backup.configPath); + if (state && current.existed && sha256(current.content) !== state.boundConfigHash) { + await atomicWriteFile( + backup.configPath, + restoreConfigTomlFromAppBind(current.content, backup.content), + ); + } else if (backup.existed) { + await mkdir(dirname(backup.configPath), { recursive: true }); + await atomicWriteFile(backup.configPath, backup.content); + } else { + await unlinkIfExists(backup.configPath); + } + } else if (state) { + const current = await readConfigIfExists(state.configPath); + if (current.existed) { + await atomicWriteFile( + state.configPath, + restoreConfigTomlFromAppBind(current.content, ""), + ); + } + } + + for (const candidate of [ + paths.statePath, + paths.backupPath, + paths.statusPath, + ]) { + try { + await unlinkIfExists(candidate); + } catch { + // Best-effort cleanup. + } + } + + const status = await getAppBindStatus(options); + return { + status, + message: backup + ? `Unbound Codex app config ${backup.configPath}` + : "Codex app bind was not configured", + }; +} + +export function formatAppBindStatus(status: AppBindStatus): string { + if (!status.bound || !status.state) return "Codex app bind: not configured"; + const parts = [ + status.running ? "running" : "configured but router not running", + `port=${status.state.port}`, + `config=${status.state.configPath}`, + ]; + if (status.router?.lastAccountLabel && !status.router.lastAccountLabel.includes("@")) { + parts.push(`lastAccount=${status.router.lastAccountLabel}`); + } else if (status.router?.lastAccountIndex !== null && status.router?.lastAccountIndex !== undefined) { + parts.push(`lastAccount=Account ${status.router.lastAccountIndex + 1}`); + } + return `Codex app bind: ${parts.join(", ")}`; +} diff --git a/lib/runtime/config-toml.ts b/lib/runtime/config-toml.ts new file mode 100644 index 00000000..02a36019 --- /dev/null +++ b/lib/runtime/config-toml.ts @@ -0,0 +1,168 @@ +import { RUNTIME_ROTATION_PROXY_PROVIDER_ID } from "../runtime-constants.js"; + +export function tomlStringLiteral(value: string): string { + return `"${value.replace(/[\u0000-\u001f\u007f\\"]/g, (character) => { + switch (character) { + case "\b": + return "\\b"; + case "\t": + return "\\t"; + case "\n": + return "\\n"; + case "\f": + return "\\f"; + case "\r": + return "\\r"; + case '"': + return '\\"'; + case "\\": + return "\\\\"; + default: + return `\\u${character.charCodeAt(0).toString(16).padStart(4, "0").toUpperCase()}`; + } + })}"`; +} + +export function readTomlTableName(line: string): string | null { + const match = /^\s*\[{1,2}\s*([^\]]+?)\s*\]{1,2}\s*$/.exec(line); + return match?.[1]?.trim() ?? null; +} + +export function removeRuntimeRotationProviderBlock(rawConfig: string): string { + const lines = rawConfig.split(/\r?\n/); + const output: string[] = []; + let skipping = false; + const providerTable = `model_providers.${RUNTIME_ROTATION_PROXY_PROVIDER_ID}`; + for (const line of lines) { + const tableName = readTomlTableName(line); + if (tableName === providerTable) { + skipping = true; + continue; + } + if (skipping && tableName) { + if (tableName === providerTable || tableName.startsWith(`${providerTable}.`)) { + continue; + } + skipping = false; + } + if (!skipping) output.push(line); + } + return output.join(rawConfig.includes("\r\n") ? "\r\n" : "\n"); +} + +export function rewriteTopLevelModelProvider(rawConfig: string): string { + const lineEnding = rawConfig.includes("\r\n") ? "\r\n" : "\n"; + const lines = rawConfig.length > 0 ? rawConfig.split(/\r?\n/) : []; + const rewrittenLine = `model_provider = ${tomlStringLiteral(RUNTIME_ROTATION_PROXY_PROVIDER_ID)}`; + let replaced = false; + const output: string[] = []; + + for (const line of lines) { + const isTable = readTomlTableName(line) !== null; + if (!replaced && isTable) { + output.push(rewrittenLine); + replaced = true; + } + if (!replaced && /^\s*model_provider\s*=/.test(line)) { + output.push(rewrittenLine); + replaced = true; + continue; + } + output.push(line); + } + + if (!replaced) output.push(rewrittenLine); + return output.join(lineEnding); +} + +function extractTopLevelModelProviderLine(rawConfig: string): string | null { + for (const line of rawConfig.split(/\r?\n/)) { + if (readTomlTableName(line) !== null) return null; + if (/^\s*model_provider\s*=/.test(line)) return line; + } + return null; +} + +export function restoreTopLevelModelProvider( + currentConfig: string, + originalConfig: string, +): string { + const lineEnding = currentConfig.includes("\r\n") ? "\r\n" : "\n"; + const originalLine = extractTopLevelModelProviderLine(originalConfig); + const lines = currentConfig.length > 0 ? currentConfig.split(/\r?\n/) : []; + const output: string[] = []; + let handled = false; + + for (const line of lines) { + const isRuntimeProviderLine = + /^\s*model_provider\s*=/.test(line) && + line.includes(RUNTIME_ROTATION_PROXY_PROVIDER_ID); + if (isRuntimeProviderLine && !handled) { + if (originalLine) output.push(originalLine); + handled = true; + continue; + } + output.push(line); + } + + return output.join(lineEnding); +} + +export function ensureTomlTrailingNewline(value: string): string { + return value.replace(/[\r\n]*$/, "\n"); +} + +export function createRuntimeRotationProviderBlock( + baseUrl: string, + clientApiKey = "", +): string[] { + const lines = [ + `[model_providers.${RUNTIME_ROTATION_PROXY_PROVIDER_ID}]`, + 'name = "codex-multi-auth"', + `base_url = ${tomlStringLiteral(baseUrl)}`, + "requires_openai_auth = false", + 'wire_api = "responses"', + ]; + if (clientApiKey.trim().length > 0) { + lines.splice( + 4, + 0, + `experimental_bearer_token = ${tomlStringLiteral(clientApiKey)}`, + ); + } + return lines; +} + +export function rewriteConfigTomlForRuntimeRotationProvider( + rawConfig: string, + baseUrl: string, + clientApiKey = "", +): string { + const lineEnding = rawConfig.includes("\r\n") ? "\r\n" : "\n"; + const withoutOldProvider = removeRuntimeRotationProviderBlock(rawConfig).replace( + /[\r\n]*$/, + "", + ); + const withModelProvider = rewriteTopLevelModelProvider(withoutOldProvider).replace( + /[\r\n]*$/, + "", + ); + const providerBlock = createRuntimeRotationProviderBlock( + baseUrl, + clientApiKey, + ).join(lineEnding); + return `${withModelProvider}${lineEnding}${lineEnding}${providerBlock}${lineEnding}`; +} + +export function restoreConfigTomlFromRuntimeRotationProvider( + currentConfig: string, + originalConfig: string, +): string { + const withoutProvider = removeRuntimeRotationProviderBlock(currentConfig); + return ensureTomlTrailingNewline( + restoreTopLevelModelProvider(withoutProvider, originalConfig).replace( + /[\r\n]*$/, + "", + ), + ); +} diff --git a/lib/runtime/runtime-observability.ts b/lib/runtime/runtime-observability.ts index 09599f25..b21f5402 100644 --- a/lib/runtime/runtime-observability.ts +++ b/lib/runtime/runtime-observability.ts @@ -42,6 +42,11 @@ export interface RuntimeObservabilitySnapshot { diagnosticProbeRequests: number; poolExhaustionCooldownUntil: number | null; serverBurstCooldownUntil: number | null; + lastAccountIndex?: number | null; + lastAccountLabel?: string | null; + lastAccountEmail?: string | null; + lastAccountId?: string | null; + lastAccountUpdatedAt?: number | null; runtimeMetrics: RuntimeMetricsSnapshot; } @@ -67,6 +72,11 @@ function createDefaultSnapshot(): RuntimeObservabilitySnapshot { diagnosticProbeRequests: 0, poolExhaustionCooldownUntil: null, serverBurstCooldownUntil: null, + lastAccountIndex: null, + lastAccountLabel: null, + lastAccountEmail: null, + lastAccountId: null, + lastAccountUpdatedAt: null, runtimeMetrics: { startedAt: 0, totalRequests: 0, diff --git a/lib/schemas.ts b/lib/schemas.ts index 041657f9..3b8622ac 100644 --- a/lib/schemas.ts +++ b/lib/schemas.ts @@ -24,6 +24,7 @@ function schemaLog(): ScopedLogger | null { export const PluginConfigSchema = z.object({ codexMode: z.boolean().optional(), + codexRuntimeRotationProxy: z.boolean().optional(), codexTuiV2: z.boolean().optional(), codexTuiColorProfile: z.enum(["truecolor", "ansi16", "ansi256"]).optional(), codexTuiGlyphMode: z.enum(["ascii", "unicode", "auto"]).optional(), diff --git a/package.json b/package.json index 056dfc06..6f824ba2 100644 --- a/package.json +++ b/package.json @@ -98,16 +98,18 @@ "audit:prod": "npm audit --omit=dev --audit-level=high", "audit:all": "npm audit --audit-level=high", "audit:dev:allowlist": "node scripts/audit-dev-allowlist.js", - "audit:ci": "npm run audit:prod && npm run audit:dev:allowlist", - "vendor:verify": "node scripts/verify-vendor-provenance.mjs", - "vendor:update-manifest": "node scripts/update-vendor-provenance.mjs", - "prepublishOnly": "npm run build", - "prepare": "husky" - }, - "bin": { - "codex": "scripts/codex.js", - "codex-multi-auth": "scripts/codex-multi-auth.js" - }, + "audit:ci": "npm run audit:prod && npm run audit:dev:allowlist", + "vendor:verify": "node scripts/verify-vendor-provenance.mjs", + "vendor:update-manifest": "node scripts/update-vendor-provenance.mjs", + "postinstall": "node scripts/postinstall.js", + "prepublishOnly": "npm run build", + "prepare": "husky" + }, + "bin": { + "codex": "scripts/codex.js", + "codex-multi-auth-app-launcher": "scripts/codex-app-launcher.js", + "codex-multi-auth": "scripts/codex-multi-auth.js" + }, "files": [ "dist/", "assets/", diff --git a/scripts/check-pack-budget-lib.js b/scripts/check-pack-budget-lib.js index bf8a9c30..c6f4741e 100644 --- a/scripts/check-pack-budget-lib.js +++ b/scripts/check-pack-budget-lib.js @@ -1,6 +1,15 @@ import { exec } from "node:child_process"; import { promisify } from "node:util"; +/** + * @typedef {{ packageSize: number, paths: string[] }} ParsedPackMetadata + * @typedef {{ windowsHide: boolean, maxBuffer: number }} ExecOptions + * @typedef {{ stdout: string | Buffer, stderr?: string | Buffer }} ExecResult + * @typedef {(command: string, options: ExecOptions) => Promise} ExecAsync + * @typedef {{ execAsync?: ExecAsync, log?: (message: string) => void }} RunPackBudgetDeps + */ + +/** @type {ExecAsync} */ const execAsync = promisify(exec); export const MAX_PACKAGE_SIZE = 8 * 1024 * 1024; @@ -25,18 +34,35 @@ export const FORBIDDEN_PREFIXES = [ ".codex/", ]; +/** + * @param {unknown} value + * @returns {value is Record} + */ +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * @param {string} filePath + * @returns {string} + */ export function normalizePackPath(filePath) { return filePath.replaceAll("\\", "/"); } +/** + * @param {string} stdout + * @returns {ParsedPackMetadata} + */ export function parsePackMetadata(stdout) { + /** @type {unknown} */ const packs = JSON.parse(stdout); if (!Array.isArray(packs) || packs.length === 0) { throw new Error("npm pack --dry-run --json returned no package metadata"); } const pack = packs[0]; - if (!pack || !Array.isArray(pack.files)) { + if (!isRecord(pack) || !Array.isArray(pack.files)) { throw new Error("npm pack metadata did not include file list"); } @@ -46,13 +72,17 @@ export function parsePackMetadata(stdout) { } const paths = pack.files - .map((file) => file?.path) + .map((file) => (isRecord(file) ? file.path : undefined)) .filter((value) => typeof value === "string") .map((value) => normalizePackPath(value)); return { packageSize, paths }; } +/** + * @param {ParsedPackMetadata} metadata + * @returns {string} + */ export function validatePackMetadata({ packageSize, paths }) { if (packageSize > MAX_PACKAGE_SIZE) { throw new Error( @@ -83,20 +113,33 @@ export function validatePackMetadata({ packageSize, paths }) { return `Pack budget ok: ${packageSize} bytes across ${paths.length} files`; } +/** + * @param {RunPackBudgetDeps} [deps] + * @returns {Promise} + */ export async function runPackBudgetCheck(deps = {}) { const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; const runExec = deps.execAsync ?? execAsync; const log = deps.log ?? console.log; let stdout = ""; try { - ({ stdout } = await runExec(`${npmCommand} pack --dry-run --json`, { + const result = await runExec(`${npmCommand} pack --dry-run --json`, { windowsHide: true, maxBuffer: 10 * 1024 * 1024, - })); + }); + if (result.stdout === null || result.stdout === undefined) { + throw new Error("npm pack --dry-run --json returned no stdout"); + } + stdout = + typeof result.stdout === "string" + ? result.stdout + : result.stdout.toString("utf8"); } catch (error) { const message = error instanceof Error ? error.message : String(error); - const stdoutText = typeof error === "object" && error && "stdout" in error ? String(error.stdout ?? "") : ""; - const stderrText = typeof error === "object" && error && "stderr" in error ? String(error.stderr ?? "") : ""; + const stdoutText = + isRecord(error) && "stdout" in error ? String(error.stdout ?? "") : ""; + const stderrText = + isRecord(error) && "stderr" in error ? String(error.stderr ?? "") : ""; throw new Error(`npm pack --dry-run --json failed via ${npmCommand}: ${message}${stdoutText ? ` stdout: ${stdoutText.slice(0, 500)}` : ""}${stderrText ? ` stderr: ${stderrText.slice(0, 500)}` : ""}`); diff --git a/scripts/codex-app-launcher.js b/scripts/codex-app-launcher.js new file mode 100644 index 00000000..baf2f8fb --- /dev/null +++ b/scripts/codex-app-launcher.js @@ -0,0 +1,653 @@ +#!/usr/bin/env node + +// @ts-check + +import { chmod, mkdir, rm, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; +import { spawn } from "node:child_process"; +import { withFileOperationRetry } from "./install-codex-auth-utils.js"; + +const OFFICIAL_LAUNCHER_NAME = "Codex"; +const MANAGED_LAUNCHER_NAME = "Codex Multi Auth"; +const WINDOWS_SHORTCUT_NAME = `${OFFICIAL_LAUNCHER_NAME}.lnk`; +const LINUX_DESKTOP_FILE_NAME = "codex-multi-auth.desktop"; +const MACOS_APP_NAME = `${MANAGED_LAUNCHER_NAME}.app`; +const WINDOWS_BACKUP_FILE_NAME = "app-shortcuts.json"; +const MANAGED_SHORTCUT_DESCRIPTION = + "Launch Codex through codex-multi-auth runtime rotation"; + +/** + * @param {string} value + */ +function quotePowerShellSingle(value) { + return `'${value.replace(/'/g, "''")}'`; +} + +/** + * @param {string} value + */ +function encodePowerShellCommand(value) { + return Buffer.from(value, "utf16le").toString("base64"); +} + +/** + * @param {boolean} value + */ +function quotePowerShellBoolean(value) { + return value ? "$true" : "$false"; +} + +/** + * @param {string[]} values + */ +function quotePowerShellArray(values) { + if (values.length === 0) { + return "@()"; + } + return `@(${values.map(quotePowerShellSingle).join(", ")})`; +} + +/** + * @param {string} value + */ +function quoteDesktopExec(value) { + return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; +} + +/** + * @param {string} value + */ +function quotePosixShell(value) { + return `'${String(value).replace(/'/g, "'\\''")}'`; +} + +/** + * @param {string[]} values + */ +function uniqueStrings(values) { + return [...new Set(values.filter((value) => value.trim().length > 0))]; +} + +/** + * @param {NodeJS.ProcessEnv} env + * @param {string} home + */ +function resolveWindowsStartMenuDir(env, home) { + const appData = (env.APPDATA ?? "").trim() || join(home, "AppData", "Roaming"); + return join(appData, "Microsoft", "Windows", "Start Menu", "Programs"); +} + +/** + * @param {NodeJS.ProcessEnv} env + */ +function resolveWindowsPowerShellPath(env) { + const systemRoot = + (env.SystemRoot ?? env.SYSTEMROOT ?? "").trim() || "C:\\Windows"; + return join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"); +} + +/** + * @param {NodeJS.ProcessEnv} env + * @param {string} home + */ +function resolveWindowsTaskbarPinnedDir(env, home) { + const appData = (env.APPDATA ?? "").trim() || join(home, "AppData", "Roaming"); + return join( + appData, + "Microsoft", + "Internet Explorer", + "Quick Launch", + "User Pinned", + "TaskBar", + ); +} + +/** + * @param {NodeJS.ProcessEnv} env + * @param {string} home + */ +function resolveWindowsDesktopDirs(env, home) { + const configured = (env.CODEX_MULTI_AUTH_APP_LAUNCHER_WINDOWS_DESKTOP_DIR ?? "").trim(); + const onedriveRoots = [ + env.OneDrive, + env.OneDriveConsumer, + env.OneDriveCommercial, + ].filter((value) => typeof value === "string" && value.trim().length > 0); + return uniqueStrings([ + configured, + ...onedriveRoots.map((root) => join(String(root), "Desktop")), + join(home, "Desktop"), + ]); +} + +/** + * @param {NodeJS.ProcessEnv} env + * @param {string} home + */ +function resolveCodexMultiAuthDir(env, home) { + return (env.CODEX_MULTI_AUTH_DIR ?? "").trim() || join(home, ".codex", "multi-auth"); +} + +/** + * @param {NodeJS.ProcessEnv} env + * @param {string} home + */ +function resolveLinuxApplicationsDir(env, home) { + const dataHome = (env.XDG_DATA_HOME ?? "").trim() || join(home, ".local", "share"); + return join(dataHome, "applications"); +} + +/** + * @param {NodeJS.ProcessEnv} env + * @param {string} home + */ +function resolveMacApplicationsDir(env, home) { + return (env.CODEX_MULTI_AUTH_APP_LAUNCHER_MACOS_DIR ?? "").trim() || join(home, "Applications"); +} + +/** + * @param {string} moduleUrl + */ +function resolveCurrentScriptPath(moduleUrl) { + return fileURLToPath(moduleUrl); +} + +/** + * @param {{ + * nodePath: string, + * codexScriptPath: string, + * workingDirectory: string, + * }} params + */ +function createWindowsLauncherCommandArgs(params) { + const command = [ + "$ErrorActionPreference = 'Stop'", + `Set-Location -LiteralPath ${quotePowerShellSingle(params.workingDirectory)}`, + `& ${quotePowerShellSingle(params.nodePath)} ${quotePowerShellSingle(params.codexScriptPath)} app`, + ].join("; "); + return `-NoProfile -ExecutionPolicy Bypass -EncodedCommand ${encodePowerShellCommand(command)}`; +} + +/** + * @param {{ + * env?: NodeJS.ProcessEnv, + * platform?: NodeJS.Platform, + * home?: string, + * moduleUrl?: string, + * }} [options] + */ +export function resolveAppLauncherPlan(options = {}) { + const env = options.env ?? process.env; + const platform = options.platform ?? process.platform; + const home = options.home ?? homedir(); + const moduleUrl = options.moduleUrl ?? import.meta.url; + const scriptPath = resolveCurrentScriptPath(moduleUrl); + const codexScriptPath = join(dirname(scriptPath), "codex.js"); + const nodePath = process.execPath; + const commandArgv = [codexScriptPath, "app"]; + + if (platform === "win32") { + const startMenuDir = resolveWindowsStartMenuDir(env, home); + return { + platform, + mode: "route-existing", + launcherPath: join(startMenuDir, WINDOWS_SHORTCUT_NAME), + shortcutRoots: [ + startMenuDir, + resolveWindowsTaskbarPinnedDir(env, home), + ...resolveWindowsDesktopDirs(env, home), + ], + backupPath: join(resolveCodexMultiAuthDir(env, home), WINDOWS_BACKUP_FILE_NAME), + commandPath: resolveWindowsPowerShellPath(env), + commandArgs: createWindowsLauncherCommandArgs({ + nodePath, + codexScriptPath, + workingDirectory: home, + }), + commandArgv, + workingDirectory: home, + iconPath: nodePath, + }; + } + + if (platform === "darwin") { + const appPath = join(resolveMacApplicationsDir(env, home), MACOS_APP_NAME); + return { + platform, + mode: "create-managed", + launcherPath: appPath, + commandPath: nodePath, + commandArgs: `"${codexScriptPath}" app`, + commandArgv, + workingDirectory: home, + iconPath: nodePath, + }; + } + + const desktopPath = join(resolveLinuxApplicationsDir(env, home), LINUX_DESKTOP_FILE_NAME); + return { + platform, + mode: "create-managed", + launcherPath: desktopPath, + commandPath: nodePath, + commandArgs: `"${codexScriptPath}" app %F`, + commandArgv: [codexScriptPath, "app", "%F"], + workingDirectory: home, + iconPath: "utilities-terminal", + }; +} + +/** + * @param {ReturnType} plan + * @param {{ dryRun?: boolean, remove?: boolean }} [options] + */ +export function createWindowsShortcutPowerShellScript(plan, options = {}) { + const shortcutRoots = Array.isArray(plan.shortcutRoots) ? plan.shortcutRoots : []; + const backupPath = typeof plan.backupPath === "string" ? plan.backupPath : ""; + const dryRun = options.dryRun === true; + const remove = options.remove === true; + + if (remove) { + return [ + "$ErrorActionPreference = 'Stop'", + `$DryRun = ${quotePowerShellBoolean(dryRun)}`, + `$BackupPath = ${quotePowerShellSingle(backupPath)}`, + "$Restored = @()", + "$Skipped = @()", + "if (Test-Path -LiteralPath $BackupPath) {", + " $Raw = Get-Content -LiteralPath $BackupPath -Raw -Encoding UTF8", + " $Backups = @($Raw | ConvertFrom-Json)", + " $Shell = New-Object -ComObject WScript.Shell", + " foreach ($Backup in $Backups) {", + " if ($null -eq $Backup.Path -or -not (Test-Path -LiteralPath $Backup.Path)) {", + " if ($null -ne $Backup.Path) { $Skipped += [string]$Backup.Path }", + " continue", + " }", + " if (-not $DryRun) {", + " $Shortcut = $Shell.CreateShortcut([string]$Backup.Path)", + " $Shortcut.TargetPath = [string]$Backup.TargetPath", + " $Shortcut.Arguments = [string]$Backup.Arguments", + " $Shortcut.WorkingDirectory = [string]$Backup.WorkingDirectory", + " $Shortcut.IconLocation = [string]$Backup.IconLocation", + " $Shortcut.Description = [string]$Backup.Description", + " $Shortcut.Save()", + " }", + " $Restored += [string]$Backup.Path", + " }", + " if (-not $DryRun) { Remove-Item -LiteralPath $BackupPath -Force -ErrorAction SilentlyContinue }", + "}", + "$Result = [ordered]@{ action = 'restore'; dryRun = $DryRun; backupPath = $BackupPath; restored = @($Restored); skipped = @($Skipped) }", + "$Result | ConvertTo-Json -Depth 6 -Compress", + ].join("\r\n"); + } + + return [ + "$ErrorActionPreference = 'Stop'", + `$DryRun = ${quotePowerShellBoolean(dryRun)}`, + `$ShortcutRoots = ${quotePowerShellArray(shortcutRoots)}`, + "$ShellDesktop = [Environment]::GetFolderPath('Desktop')", + "if (-not [string]::IsNullOrWhiteSpace($ShellDesktop)) { $ShortcutRoots = @($ShortcutRoots + $ShellDesktop) | Sort-Object -Unique }", + `$BackupPath = ${quotePowerShellSingle(backupPath)}`, + `$ShortcutName = ${quotePowerShellSingle(OFFICIAL_LAUNCHER_NAME)}`, + `$TargetPath = ${quotePowerShellSingle(plan.commandPath)}`, + `$Arguments = ${quotePowerShellSingle(plan.commandArgs)}`, + `$WorkingDirectory = ${quotePowerShellSingle(plan.workingDirectory)}`, + `$ManagedDescription = ${quotePowerShellSingle(MANAGED_SHORTCUT_DESCRIPTION)}`, + "$Candidates = @()", + "$PackagedApps = @()", + "foreach ($Root in $ShortcutRoots) {", + " if (-not (Test-Path -LiteralPath $Root)) { continue }", + " $Candidates += Get-ChildItem -LiteralPath $Root -Filter '*.lnk' -File -Recurse -ErrorAction SilentlyContinue | Where-Object { $_.BaseName -ieq $ShortcutName } | ForEach-Object { $_.FullName }", + "}", + "$Candidates = @($Candidates | Sort-Object -Unique)", + "try {", + " $AppsFolder = (New-Object -ComObject Shell.Application).Namespace('shell:AppsFolder')", + " if ($null -ne $AppsFolder) {", + " $PackagedApps = @($AppsFolder.Items() | Where-Object { $_.Name -ieq $ShortcutName } | ForEach-Object { [ordered]@{ Name = [string]$_.Name; Path = [string]$_.Path } })", + " }", + "} catch { $PackagedApps = @() }", + "$Shell = New-Object -ComObject WScript.Shell", + "$ExistingBackups = @()", + "if (Test-Path -LiteralPath $BackupPath) {", + " try {", + " $Raw = Get-Content -LiteralPath $BackupPath -Raw -Encoding UTF8", + " if ($Raw.Trim().Length -gt 0) { $ExistingBackups = @($Raw | ConvertFrom-Json) }", + " } catch { $ExistingBackups = @() }", + "}", + "$BackupByPath = @{}", + "$BackupsToWrite = New-Object System.Collections.ArrayList", + "foreach ($Backup in $ExistingBackups) {", + " if ($null -eq $Backup.Path) { continue }", + " $BackupByPath[[string]$Backup.Path] = $Backup", + " [void]$BackupsToWrite.Add($Backup)", + "}", + "$Routed = @()", + "$Skipped = @()", + "foreach ($Path in $Candidates) {", + " $Shortcut = $Shell.CreateShortcut($Path)", + " $ShortcutText = (($Shortcut.TargetPath, $Shortcut.Arguments, $Shortcut.Description) -join ' ')", + " if ($ShortcutText -notmatch '(?i)codex') {", + " $Skipped += $Path", + " continue", + " }", + " $AlreadyManaged = (([string]$Shortcut.Description) -eq $ManagedDescription) -or ((([string]$Shortcut.TargetPath) -ieq $TargetPath) -and (([string]$Shortcut.Arguments) -ieq $Arguments))", + " if (-not $BackupByPath.ContainsKey($Path) -and -not $AlreadyManaged) {", + " $IconLocation = [string]$Shortcut.IconLocation", + " if ([string]::IsNullOrWhiteSpace($IconLocation)) { $IconLocation = [string]$Shortcut.TargetPath }", + " $Backup = [ordered]@{", + " Path = [string]$Path", + " TargetPath = [string]$Shortcut.TargetPath", + " Arguments = [string]$Shortcut.Arguments", + " WorkingDirectory = [string]$Shortcut.WorkingDirectory", + " IconLocation = $IconLocation", + " Description = [string]$Shortcut.Description", + " }", + " [void]$BackupsToWrite.Add($Backup)", + " $BackupByPath[$Path] = $Backup", + " }", + " if (-not $DryRun) {", + " $Backup = $BackupByPath[$Path]", + " $Shortcut.TargetPath = $TargetPath", + " $Shortcut.Arguments = $Arguments", + " $Shortcut.WorkingDirectory = $WorkingDirectory", + " if ($null -ne $Backup -and $null -ne $Backup.IconLocation) { $Shortcut.IconLocation = [string]$Backup.IconLocation }", + " $Shortcut.Description = $ManagedDescription", + " $Shortcut.Save()", + " }", + " $Routed += $Path", + "}", + "if (-not $DryRun -and $Routed.Count -gt 0) {", + " New-Item -ItemType Directory -Force -Path (Split-Path -Parent $BackupPath) | Out-Null", + " @($BackupsToWrite) | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $BackupPath -Encoding UTF8", + "}", + "$Result = [ordered]@{ action = 'route'; dryRun = $DryRun; backupPath = $BackupPath; candidates = @($Candidates); packagedApps = @($PackagedApps); routed = @($Routed); skipped = @($Skipped); targetPath = $TargetPath; arguments = $Arguments }", + "$Result | ConvertTo-Json -Depth 6 -Compress", + ].join("\r\n"); +} + +/** + * @param {ReturnType} plan + */ +function createLinuxDesktopFile(plan) { + return [ + "[Desktop Entry]", + "Type=Application", + `Name=${MANAGED_LAUNCHER_NAME}`, + "Comment=Launch Codex through codex-multi-auth runtime rotation", + `Exec=${quoteDesktopExec(plan.commandPath)} ${plan.commandArgs}`, + `Path=${plan.workingDirectory}`, + `Icon=${plan.iconPath}`, + "Terminal=false", + "Categories=Development;", + "StartupNotify=true", + "", + ].join("\n"); +} + +/** + * @param {ReturnType} plan + */ +function createMacInfoPlist(plan) { + return [ + '', + '', + '', + "", + " CFBundleExecutable", + " Codex", + " CFBundleIdentifier", + " com.ndycode.codex-multi-auth.launcher", + " CFBundleName", + ` ${MANAGED_LAUNCHER_NAME}`, + " CFBundlePackageType", + " APPL", + "", + "", + "", + ].join("\n"); +} + +/** + * @param {ReturnType} plan + */ +function createMacLauncherScript(plan) { + const args = Array.isArray(plan.commandArgv) + ? plan.commandArgv.map(quotePosixShell).join(" ") + : plan.commandArgs; + return [ + "#!/bin/sh", + `cd ${quotePosixShell(plan.workingDirectory)}`, + `exec ${quotePosixShell(plan.commandPath)} ${args}`, + "", + ].join("\n"); +} + +/** + * @param {string} command + * @param {string[]} args + * @param {NodeJS.ProcessEnv} env + */ +function runCommand(command, args, env) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + env, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + let stdout = ""; + let stderr = ""; + child.stdout?.setEncoding("utf8"); + child.stderr?.setEncoding("utf8"); + child.stdout?.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr?.on("data", (chunk) => { + stderr += chunk; + }); + child.once("error", reject); + child.once("close", (code) => { + if (code === 0) { + resolve({ stdout, stderr }); + return; + } + reject(new Error(`${command} exited with ${code ?? "unknown"}: ${stderr.trim()}`)); + }); + }); +} + +/** + * @param {ReturnType} plan + * @param {{ env: NodeJS.ProcessEnv, dryRun?: boolean, remove?: boolean }} options + */ +async function installWindowsShortcut(plan, options) { + const script = createWindowsShortcutPowerShellScript(plan, { + dryRun: options.dryRun, + remove: options.remove, + }); + const powershell = + (options.env.SystemRoot ?? options.env.SYSTEMROOT ?? "C:\\Windows") + + "\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"; + const result = await runCommand( + powershell, + [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + script, + ], + options.env, + ); + const output = result.stdout.trim().split(/\r?\n/).filter(Boolean).at(-1); + if (!output) { + return { action: options.remove ? "restore" : "route", routed: [], restored: [], skipped: [] }; + } + try { + return JSON.parse(output); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error( + `codex-multi-auth-app-launcher: unexpected powershell output (${detail}): ${result.stdout.trim().slice(-512)}`, + ); + } +} + +/** + * @param {ReturnType} plan + */ +async function installLinuxDesktopFile(plan) { + await withFileOperationRetry(() => mkdir(dirname(plan.launcherPath), { recursive: true })); + await withFileOperationRetry(() => + writeFile(plan.launcherPath, createLinuxDesktopFile(plan), "utf8"), + ); + await chmod(plan.launcherPath, 0o755); +} + +/** + * @param {ReturnType} plan + */ +async function installMacAppBundle(plan) { + const contentsDir = join(plan.launcherPath, "Contents"); + const macosDir = join(contentsDir, "MacOS"); + await withFileOperationRetry(() => mkdir(macosDir, { recursive: true })); + await withFileOperationRetry(() => + writeFile(join(contentsDir, "Info.plist"), createMacInfoPlist(plan), "utf8"), + ); + const launcherScriptPath = join(macosDir, "Codex"); + await withFileOperationRetry(() => + writeFile(launcherScriptPath, createMacLauncherScript(plan), "utf8"), + ); + await chmod(launcherScriptPath, 0o755); +} + +/** + * @param {{ + * env?: NodeJS.ProcessEnv, + * platform?: NodeJS.Platform, + * home?: string, + * moduleUrl?: string, + * dryRun?: boolean, + * remove?: boolean, + * log?: (message: string) => void, + * }} [options] + */ +export async function installCodexAppLauncher(options = {}) { + const env = options.env ?? process.env; + const plan = resolveAppLauncherPlan({ + env, + platform: options.platform, + home: options.home, + moduleUrl: options.moduleUrl, + }); + const log = options.log ?? console.log; + + if (plan.platform === "win32") { + const result = await installWindowsShortcut(plan, { + env, + dryRun: options.dryRun, + remove: options.remove, + }); + const routedCount = Array.isArray(result.routed) ? result.routed.length : 0; + const restoredCount = Array.isArray(result.restored) ? result.restored.length : 0; + const packagedAppCount = Array.isArray(result.packagedApps) + ? result.packagedApps.length + : 0; + if (options.remove) { + const prefix = options.dryRun ? "[dry-run] Would restore" : "Restored"; + log(`${prefix} ${restoredCount} Codex app shortcut(s) from ${plan.backupPath}`); + return plan; + } + if (routedCount === 0) { + const prefix = options.dryRun ? "[dry-run] No" : "No"; + log( + `${prefix} existing Codex app shortcuts or taskbar pins found to route through codex-multi-auth.`, + ); + if (packagedAppCount > 0) { + log( + `Detected ${packagedAppCount} packaged Codex app entry; packaged app entries cannot be retargeted without a persistent background router.`, + ); + } + return plan; + } + const prefix = options.dryRun ? "[dry-run] Would route" : "Routed"; + log(`${prefix} ${routedCount} existing Codex app shortcut(s) through codex-multi-auth`); + if (options.dryRun) { + log(`[dry-run] Target: ${plan.commandPath} ${plan.commandArgs}`); + } + return plan; + } + + if (options.remove) { + if (options.dryRun) { + log(`[dry-run] Would remove ${plan.launcherPath}`); + return plan; + } + await withFileOperationRetry(() => rm(plan.launcherPath, { recursive: true, force: true })); + log(`Removed ${MANAGED_LAUNCHER_NAME} app launcher: ${plan.launcherPath}`); + return plan; + } + + if (options.dryRun) { + log(`[dry-run] Would install ${MANAGED_LAUNCHER_NAME} app launcher: ${plan.launcherPath}`); + log(`[dry-run] Target: ${plan.commandPath} ${plan.commandArgs}`); + return plan; + } + + if (plan.platform === "darwin") { + await installMacAppBundle(plan); + } else { + await installLinuxDesktopFile(plan); + } + log(`Installed ${MANAGED_LAUNCHER_NAME} app launcher: ${plan.launcherPath}`); + return plan; +} + +function printHelp() { + console.log( + [ + "Usage: codex-multi-auth-app-launcher [--remove] [--dry-run]", + "", + "Routes existing user-level Codex app shortcuts through codex-multi-auth on Windows.", + `On other platforms, installs a user-level ${MANAGED_LAUNCHER_NAME} app launcher that runs \`codex app\` through codex-multi-auth.`, + "", + "Options:", + " --remove Remove the managed launcher", + " --dry-run Print planned changes without writing", + " --help Show this help", + "", + ].join("\n"), + ); +} + +async function main() { + const args = new Set(process.argv.slice(2)); + if (args.has("--help") || args.has("-h")) { + printHelp(); + return 0; + } + await installCodexAppLauncher({ + dryRun: args.has("--dry-run"), + remove: args.has("--remove"), + }); + return 0; +} + +const isDirectRun = (() => { + try { + return resolve(process.argv[1] ?? "") === fileURLToPath(import.meta.url); + } catch { + return false; + } +})(); + +if (isDirectRun) { + main().catch((error) => { + console.error( + `Codex app launcher routing failed: ${error instanceof Error ? error.message : String(error)}`, + ); + process.exit(1); + }); +} diff --git a/scripts/codex-app-router.js b/scripts/codex-app-router.js new file mode 100644 index 00000000..d79766e6 --- /dev/null +++ b/scripts/codex-app-router.js @@ -0,0 +1,322 @@ +#!/usr/bin/env node + +import { + chmodSync, + closeSync, + fstatSync, + ftruncateSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + rmSync, + statSync, + truncateSync, + writeSync, + writeFileSync, +} from "node:fs"; +import { basename, dirname, join } from "node:path"; +import process from "node:process"; + +const DEFAULT_MAX_LOG_BYTES = 1024 * 1024; +const LOG_SIZE_CHECK_INTERVAL_MS = 60_000; + +function parsePort(value) { + if (typeof value !== "string" && typeof value !== "number") return Number.NaN; + const text = String(value).trim(); + if (!/^\d+$/.test(text)) return Number.NaN; + const port = Number(text); + return Number.isInteger(port) && port >= 0 && port <= 65535 + ? port + : Number.NaN; +} + +function parseArgs(argv) { + const result = { + host: "127.0.0.1", + port: 0, + statusPath: "", + statePath: "", + logPath: "", + maxLogBytes: DEFAULT_MAX_LOG_BYTES, + }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + const next = argv[index + 1] ?? ""; + if (arg === "--host") { + result.host = next; + index += 1; + continue; + } + if (arg === "--port") { + result.port = parsePort(next); + index += 1; + continue; + } + if (arg === "--status") { + result.statusPath = next; + index += 1; + continue; + } + if (arg === "--state") { + result.statePath = next; + index += 1; + continue; + } + if (arg === "--max-log-bytes") { + const parsed = Number.parseInt(next, 10); + result.maxLogBytes = + Number.isFinite(parsed) && parsed > 0 + ? parsed + : DEFAULT_MAX_LOG_BYTES; + index += 1; + continue; + } + if (arg === "--log") { + result.logPath = next; + index += 1; + } + } + return result; +} + +function readState(path) { + if (!path) return null; + try { + return JSON.parse(readFileSync(path, "utf8")); + } catch { + return null; + } +} + +function readTrimmedString(record, key) { + const value = record?.[key]; + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : undefined; +} + +function writeStatus(statusPath, payload) { + if (!statusPath) return; + const statusDir = dirname(statusPath); + const tempPath = join( + statusDir, + [ + `.${basename(statusPath)}`, + String(process.pid), + String(Date.now()), + "tmp", + ].join("."), + ); + let fd = null; + try { + mkdirSync(statusDir, { recursive: true }); + fd = openSync(tempPath, "w", 0o600); + writeFileSync(fd, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); + closeSync(fd); + fd = null; + chmodSync(tempPath, 0o600); + renameSync(tempPath, statusPath); + chmodSync(statusPath, 0o600); + } catch { + // Status is best-effort. The router should keep serving if telemetry is locked. + try { + if (fd !== null) closeSync(fd); + } catch { + // Preserve the original status-write failure. + } + try { + rmSync(tempPath, { force: true }); + } catch { + // Preserve the original status-write failure. + } + } +} + +function createStatusPayload({ state, proxyServer, error, stateRecord }) { + const proxyStatus = + typeof proxyServer?.getStatus === "function" ? proxyServer.getStatus() : {}; + const lastAccountIndex = proxyStatus.lastAccountIndex ?? null; + const lastAccountLabel = + typeof proxyStatus.lastAccountLabel === "string" && + !proxyStatus.lastAccountLabel.includes("@") + ? proxyStatus.lastAccountLabel + : typeof lastAccountIndex === "number" + ? `Account ${lastAccountIndex + 1}` + : null; + return { + version: 1, + kind: "codex-app-runtime-rotation-router", + state, + pid: process.pid, + updatedAt: Date.now(), + baseUrl: proxyServer?.baseUrl ?? stateRecord?.baseUrl ?? null, + totalRequests: proxyStatus.totalRequests ?? 0, + upstreamRequests: proxyStatus.upstreamRequests ?? 0, + retries: proxyStatus.retries ?? 0, + rotations: proxyStatus.rotations ?? 0, + lastAccountIndex, + lastAccountLabel, + lastAccountId: proxyStatus.lastAccountId ?? null, + lastAccountUpdatedAt: proxyStatus.lastAccountUpdatedAt ?? null, + lastError: error ? (error instanceof Error ? error.message : String(error)) : proxyStatus.lastError ?? null, + }; +} + +function isLoopbackHost(host) { + if (typeof host !== "string") return false; + const normalized = host.trim().toLowerCase(); + const unbracketed = + normalized.startsWith("[") && normalized.endsWith("]") + ? normalized.slice(1, -1) + : normalized; + return ( + unbracketed === "127.0.0.1" || + unbracketed === "::1" || + unbracketed === "localhost" + ); +} + +function truncateLogFdIfTooLarge(fd, maxBytes) { + if (!Number.isFinite(maxBytes) || maxBytes <= 0) return; + try { + const stats = fstatSync(fd); + if (!stats.isFile() || stats.size <= maxBytes) return; + ftruncateSync(fd, 0); + writeSync( + fd, + `codex-multi-auth app router log truncated after exceeding ${maxBytes} bytes\n`, + ); + } catch { + // stdout/stderr may be pipes or otherwise unavailable; logging must not fail startup. + } +} + +function truncateLogPathIfTooLarge(logPath, maxBytes) { + if (!logPath || !Number.isFinite(maxBytes) || maxBytes <= 0) return false; + try { + const stats = statSync(logPath); + if (!stats.isFile() || stats.size <= maxBytes) return false; + truncateSync(logPath, 0); + return true; + } catch { + // Log path may not exist yet or may be locked; fd-level checks can still work. + return false; + } +} + +function writeLogTruncatedMarker(maxBytes) { + try { + writeSync( + 1, + `codex-multi-auth app router log truncated after exceeding ${maxBytes} bytes\n`, + ); + } catch { + // A closed stdout/stderr should not crash the router while enforcing log bounds. + } +} + +function installLogBounds(maxBytes, logPath) { + if (truncateLogPathIfTooLarge(logPath, maxBytes)) { + writeLogTruncatedMarker(maxBytes); + } + truncateLogFdIfTooLarge(1, maxBytes); + truncateLogFdIfTooLarge(2, maxBytes); + return setInterval(() => { + if (truncateLogPathIfTooLarge(logPath, maxBytes)) { + writeLogTruncatedMarker(maxBytes); + } + truncateLogFdIfTooLarge(1, maxBytes); + truncateLogFdIfTooLarge(2, maxBytes); + }, LOG_SIZE_CHECK_INTERVAL_MS); +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + installLogBounds(args.maxLogBytes, args.logPath).unref?.(); + const stateRecord = readState(args.statePath); + if (args.statePath && stateRecord === null) { + const error = new Error( + "Codex app runtime router state is unreadable; refusing to bind an ephemeral port.", + ); + writeStatus( + args.statusPath, + createStatusPayload({ state: "error", proxyServer: null, error, stateRecord: null }), + ); + throw error; + } + const host = + typeof stateRecord?.host === "string" && stateRecord.host.trim().length > 0 + ? stateRecord.host.trim() + : args.host; + const statePort = parsePort(stateRecord?.port); + const port = Number.isFinite(statePort) ? statePort : args.port; + const clientApiKey = readTrimmedString(stateRecord, "clientApiKey"); + if (!Number.isInteger(port) || port < 0 || port > 65535) { + throw new Error( + "A valid --port in the range 0-65535 is required for the Codex app runtime router.", + ); + } + if (!isLoopbackHost(host)) { + throw new Error( + "Codex app runtime router host must be loopback-only (127.0.0.1, ::1, or localhost).", + ); + } + if (!clientApiKey) { + throw new Error( + "Codex app runtime router state is missing its client token.", + ); + } + + let proxyServer = null; + const writeCurrentStatus = (state, error) => { + writeStatus( + args.statusPath || stateRecord?.statusPath || "", + createStatusPayload({ state, proxyServer, error, stateRecord }), + ); + }; + + try { + const proxyModule = await import("../dist/lib/runtime-rotation-proxy.js"); + proxyServer = await proxyModule.startRuntimeRotationProxy({ + host, + port, + clientApiKey, + }); + writeCurrentStatus("running"); + const timer = setInterval(() => writeCurrentStatus("running"), 1000); + let cleanupPromise = null; + const cleanup = async (state) => { + clearInterval(timer); + try { + await proxyServer?.close?.(); + } finally { + writeCurrentStatus(state); + } + }; + const cleanupOnce = (state) => { + cleanupPromise ??= cleanup(state); + return cleanupPromise; + }; + process.once("SIGINT", () => { + void cleanupOnce("stopped").finally(() => process.exit(130)); + }); + process.once("SIGTERM", () => { + void cleanupOnce("stopped").finally(() => process.exit(0)); + }); + process.once("SIGHUP", () => { + void cleanupOnce("stopped").finally(() => process.exit(0)); + }); + await new Promise(() => undefined); + } catch (error) { + writeCurrentStatus("error", error); + throw error; + } +} + +main().catch((error) => { + console.error( + `codex-multi-auth app router failed: ${error instanceof Error ? error.message : String(error)}`, + ); + process.exitCode = 1; +}); diff --git a/scripts/codex-multi-auth.js b/scripts/codex-multi-auth.js index 3c15c274..666dfed2 100755 --- a/scripts/codex-multi-auth.js +++ b/scripts/codex-multi-auth.js @@ -27,7 +27,9 @@ if (version.length > 0) { process.env.CODEX_MULTI_AUTH_CLI_VERSION = version; } -if (args.length === 1 && versionFlags.has(args[0])) { +const firstArg = args[0] ?? ""; + +if (args.length === 1 && versionFlags.has(firstArg)) { if (version.length > 0) { process.stdout.write(`${version}\n`); process.exitCode = 0; diff --git a/scripts/codex-routing.js b/scripts/codex-routing.js index 297bf78d..2c7835f5 100644 --- a/scripts/codex-routing.js +++ b/scripts/codex-routing.js @@ -7,10 +7,16 @@ const AUTH_SUBCOMMANDS = new Set([ "check", "features", "verify-flagged", + "verify", "forecast", "report", "fix", "doctor", + "rotation", + "why-selected", + "config", + "init-config", + "debug", ]); export function normalizeAuthAlias(args) { diff --git a/scripts/codex.js b/scripts/codex.js index 36ca45bf..e7b8b48d 100755 --- a/scripts/codex.js +++ b/scripts/codex.js @@ -1,23 +1,29 @@ #!/usr/bin/env node import { spawn } from "node:child_process"; +import { createHash, randomBytes } from "node:crypto"; import { chmodSync, copyFileSync, + cpSync, existsSync, + linkSync, mkdirSync, mkdtempSync, + readdirSync, renameSync, readFileSync, rmSync, statSync, + symlinkSync, writeFileSync, } from "node:fs"; import { createRequire } from "node:module"; import { homedir, tmpdir } from "node:os"; import { basename, delimiter, dirname, join, resolve as resolvePath } from "node:path"; import process from "node:process"; -import { fileURLToPath } from "node:url"; +import { StringDecoder } from "node:string_decoder"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { resolveRealCodexBin as resolveRealCodexBinFromEnvironment, splitPathEntries, @@ -26,7 +32,37 @@ import { normalizeAuthAlias, shouldHandleMultiAuthAuth } from "./codex-routing.j const RETRYABLE_SHADOW_HOME_CLEANUP_CODES = new Set(["EBUSY", "EPERM", "ENOTEMPTY"]); const SHADOW_HOME_CLEANUP_BACKOFF_MS = [20, 60, 120]; +const SHADOW_HOME_ORPHAN_LOCK_STALE_AGE_MS = 2_000; +const SHADOW_HOME_SYNC_LOCK_WAIT_TIMEOUT_MS = + SHADOW_HOME_ORPHAN_LOCK_STALE_AGE_MS + + SHADOW_HOME_CLEANUP_BACKOFF_MS.reduce((total, value) => total + value, 0); const SHADOW_HOME_STATE_FILES = ["auth.json", "accounts.json", ".codex-global-state.json"]; +const RUNTIME_ROTATION_SHADOW_HOME_OMIT_STATE_FILES = new Set([ + "auth.json", + "accounts.json", +]); +const SHADOW_HOME_STATE_FILE_SET = new Set(SHADOW_HOME_STATE_FILES); +const SHADOW_HOME_CONFIG_FILE = "config.toml"; +const SHADOW_HOME_SYNC_LOCK_DIR = ".codex-multi-auth-shadow-sync.lock"; +const SHADOW_HOME_SYNC_STATE_FILE = ".codex-multi-auth-shadow-sync-state.json"; +const APP_SERVER_ACCOUNT_DISPLAY_NAME = "codex-multi-auth"; +const RUNTIME_CONSTANTS = await loadRuntimeConstants(); +const RUNTIME_ROTATION_PROXY_PROVIDER_ID = + RUNTIME_CONSTANTS.RUNTIME_ROTATION_PROXY_PROVIDER_ID; +const APP_SERVER_ACCOUNT_LABEL_ENV = "CODEX_MULTI_AUTH_APP_SERVER_ACCOUNT_LABEL"; +const INTERNAL_RUNTIME_ROTATION_APP_HELPER_ARG = + "--codex-multi-auth-runtime-app-helper"; +const APP_RUNTIME_HELPER_OWNER_PID_ENV = + "CODEX_MULTI_AUTH_APP_ROTATION_OWNER_PID"; +const APP_RUNTIME_HELPER_REAL_CODEX_HOME_ENV = + "CODEX_MULTI_AUTH_REAL_CODEX_HOME"; +const APP_RUNTIME_HELPER_STATUS_FILE = + RUNTIME_CONSTANTS.APP_RUNTIME_HELPER_STATUS_FILE; +const DEFAULT_APP_RUNTIME_HELPER_IDLE_MS = 12 * 60 * 60 * 1000; +const DEFAULT_APP_RUNTIME_HELPER_DETACH_GRACE_MS = 5_000; +const APP_RUNTIME_HELPER_LAUNCH_TIMEOUT_MS = 15_000; +const APP_SERVER_SHIM_DIR_NAME = "app-server-shims"; +const APP_SERVER_SHIM_HELPER_PREFIX = "helper-"; let shadowHomeCleanupBusyFailuresRemaining = Number.parseInt( process.env.CODEX_MULTI_AUTH_TEST_SHADOW_CLEANUP_BUSY_FAILURES ?? "0", 10, @@ -35,8 +71,45 @@ let shadowHomeCleanupPreflightReadBusyFailuresRemaining = Number.parseInt( process.env.CODEX_MULTI_AUTH_TEST_SHADOW_PREFLIGHT_READ_BUSY_FAILURES ?? "0", 10, ); +let shadowHomeSyncLockRecreateStaleCount = Number.parseInt( + process.env.CODEX_MULTI_AUTH_TEST_SHADOW_LOCK_RECREATE_STALE_COUNT ?? "0", + 10, +); +let shadowHomeSyncMetadataBusyFailuresRemaining = Number.parseInt( + process.env.CODEX_MULTI_AUTH_TEST_SHADOW_SYNC_METADATA_BUSY_FAILURES ?? "0", + 10, +); +let shadowHomeSyncLockOwnerWriteFailuresRemaining = Number.parseInt( + process.env.CODEX_MULTI_AUTH_TEST_SHADOW_LOCK_OWNER_WRITE_FAILURES ?? "0", + 10, +); const shadowHomeCleanupRetryMarkerDir = (process.env.CODEX_MULTI_AUTH_TEST_SHADOW_RETRY_MARKER_DIR ?? "").trim(); +let warnedInvalidRuntimeRotationProxyEnv = false; +let warnedPendingAccountReadIdOverflow = false; + +async function loadRuntimeConstants() { + const fallback = { + RUNTIME_ROTATION_PROXY_PROVIDER_ID: `${APP_SERVER_ACCOUNT_DISPLAY_NAME}-runtime-proxy`, + APP_RUNTIME_HELPER_STATUS_FILE: "runtime-rotation-app-helper.json", + }; + try { + const mod = await import("../dist/lib/runtime-constants.js"); + return { + RUNTIME_ROTATION_PROXY_PROVIDER_ID: + typeof mod.RUNTIME_ROTATION_PROXY_PROVIDER_ID === "string" + ? mod.RUNTIME_ROTATION_PROXY_PROVIDER_ID + : fallback.RUNTIME_ROTATION_PROXY_PROVIDER_ID, + APP_RUNTIME_HELPER_STATUS_FILE: + typeof mod.APP_RUNTIME_HELPER_STATUS_FILE === "string" + ? mod.APP_RUNTIME_HELPER_STATUS_FILE + : fallback.APP_RUNTIME_HELPER_STATUS_FILE, + }; + } catch { + // Keep wrapper startup resilient when dist has not been built yet. + } + return fallback; +} function isRetryableShadowHomeCleanupError(error) { const code = error && typeof error === "object" && "code" in error ? error.code : undefined; @@ -81,6 +154,37 @@ function hydrateCliVersionEnv() { } } +function isRotationEnableCommand(args) { + return args[0] === "auth" && args[1] === "rotation" && args[2] === "enable"; +} + +function shouldAutoInstallCodexAppLauncher(env = process.env) { + const override = (env.CODEX_MULTI_AUTH_APP_LAUNCHER_INSTALL ?? "1").trim().toLowerCase(); + return !new Set(["0", "false", "no"]).has(override); +} + +async function maybeInstallCodexAppLauncherAfterRotationEnable(args, exitCode) { + if (exitCode !== 0 || !isRotationEnableCommand(args)) { + return; + } + if (!shouldAutoInstallCodexAppLauncher()) { + return; + } + try { + const mod = await import("./codex-app-launcher.js"); + if (typeof mod.installCodexAppLauncher !== "function") { + return; + } + await mod.installCodexAppLauncher({ + log: (message) => console.error(`codex-multi-auth: ${message}`), + }); + } catch (error) { + console.error( + `codex-multi-auth: could not route Codex app launchers: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + async function loadRunCodexMultiAuthCli() { try { const mod = await import("../dist/lib/codex-manager.js"); @@ -105,6 +209,66 @@ async function loadRunCodexMultiAuthCli() { } } +async function loadRuntimeRotationProxyModule() { + try { + const mod = await import("../dist/lib/runtime-rotation-proxy.js"); + if (typeof mod.startRuntimeRotationProxy !== "function") { + console.error( + "dist/lib/runtime-rotation-proxy.js is missing required export: startRuntimeRotationProxy", + ); + return null; + } + return mod; + } catch (error) { + if (error && typeof error === "object" && "code" in error && error.code === "ERR_MODULE_NOT_FOUND") { + console.error( + [ + "codex-multi-auth runtime rotation proxy requires built runtime files, but dist output is missing.", + "Run: npm run build", + ].join("\n"), + ); + return null; + } + throw error; + } +} + +async function loadRuntimeRotationConfigModule() { + try { + const mod = await import("../dist/lib/config.js"); + if ( + typeof mod.loadPluginConfig !== "function" || + typeof mod.getCodexRuntimeRotationProxy !== "function" + ) { + return null; + } + return mod; + } catch (error) { + if (error && typeof error === "object" && "code" in error && error.code === "ERR_MODULE_NOT_FOUND") { + return null; + } + throw error; + } +} + +async function loadRuntimeConfigTomlModule() { + try { + const mod = await import("../dist/lib/runtime/config-toml.js"); + if ( + typeof mod.rewriteConfigTomlForRuntimeRotationProvider !== "function" || + typeof mod.tomlStringLiteral !== "function" + ) { + return null; + } + return mod; + } catch (error) { + if (error && typeof error === "object" && "code" in error && error.code === "ERR_MODULE_NOT_FOUND") { + return null; + } + throw error; + } +} + async function autoSyncManagerActiveSelectionIfEnabled() { const enabled = (process.env.CODEX_MULTI_AUTH_AUTO_SYNC_ON_STARTUP ?? "1").trim() !== "0"; if (!enabled) return; @@ -265,6 +429,187 @@ function shouldCaptureForwardedCodexOutput(env = process.env) { return process.stdout.isTTY !== true || process.stderr.isTTY !== true; } +function jsonRpcIdKey(id) { + if ( + typeof id === "string" || + typeof id === "number" || + typeof id === "boolean" || + id === null + ) { + return `${typeof id}:${JSON.stringify(id)}`; + } + return null; +} + +function parseJsonObjectLine(line) { + const trimmed = line.trim(); + if (!trimmed.startsWith("{")) { + return null; + } + try { + const parsed = JSON.parse(trimmed); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? parsed + : null; + } catch { + return null; + } +} + +function splitProtocolLineEnding(line) { + if (line.endsWith("\r\n")) { + return { body: line.slice(0, -2), lineEnding: "\r\n" }; + } + if (line.endsWith("\n")) { + return { body: line.slice(0, -1), lineEnding: "\n" }; + } + return { body: line, lineEnding: "" }; +} + +function createProtocolLineAccumulator(onLine) { + const decoder = new StringDecoder("utf8"); + let buffer = ""; + const drain = () => { + let newlineIndex = buffer.indexOf("\n"); + while (newlineIndex >= 0) { + const line = buffer.slice(0, newlineIndex + 1); + buffer = buffer.slice(newlineIndex + 1); + onLine(line); + newlineIndex = buffer.indexOf("\n"); + } + }; + + return { + write(chunk) { + buffer += typeof chunk === "string" ? chunk : decoder.write(chunk); + drain(); + }, + end() { + buffer += decoder.end(); + if (buffer.length > 0) { + onLine(buffer); + buffer = ""; + } + }, + }; +} + +function createSyntheticAppServerAccountReadResult() { + return { + account: { + type: "chatgpt", + email: APP_SERVER_ACCOUNT_DISPLAY_NAME, + planType: "unknown", + }, + requiresOpenaiAuth: false, + }; +} + +function createSyntheticAppServerAuthStatusResult() { + return { + authMethod: "apikey", + authToken: null, + requiresOpenaiAuth: false, + }; +} + +function createSyntheticAppServerRateLimitsResult() { + return { + rateLimits: { + limitId: null, + limitName: null, + primary: null, + secondary: null, + credits: null, + planType: null, + rateLimitReachedType: null, + }, + rateLimitsByLimitId: null, + }; +} + +function createAppServerAccountReadProtocolProxy() { + const maxPendingAuthRequestIds = 4096; + const pendingAuthRequestMethodsById = new Map(); + const inputLines = createProtocolLineAccumulator((line) => { + const { body } = splitProtocolLineEnding(line); + const message = parseJsonObjectLine(body); + if ( + ![ + "account/read", + "account/rateLimits/read", + "getAuthStatus", + ].includes(message?.method) || + !Object.hasOwn(message, "id") + ) { + return; + } + const key = jsonRpcIdKey(message.id); + if (key) { + if (pendingAuthRequestMethodsById.size >= maxPendingAuthRequestIds) { + pendingAuthRequestMethodsById.clear(); + if (!warnedPendingAccountReadIdOverflow) { + warnedPendingAccountReadIdOverflow = true; + console.error( + "codex-multi-auth: cleared pending app-server auth request ids after exceeding the safety cap.", + ); + } + } + pendingAuthRequestMethodsById.set(key, message.method); + } + }); + const outputLines = createProtocolLineAccumulator((line) => { + process.stdout.write( + rewriteAppServerAccountReadResponseLine(line, pendingAuthRequestMethodsById), + ); + }); + + return { + observeInput(chunk) { + inputLines.write(chunk); + }, + flushInput() { + inputLines.end(); + }, + writeOutput(chunk) { + outputLines.write(chunk); + }, + flushOutput() { + outputLines.end(); + }, + }; +} + +function rewriteAppServerAccountReadResponseLine(line, pendingAuthRequestMethodsById) { + const { body, lineEnding } = splitProtocolLineEnding(line); + const message = parseJsonObjectLine(body); + if (!message || !Object.hasOwn(message, "id")) { + return line; + } + const key = jsonRpcIdKey(message.id); + if (!key || !pendingAuthRequestMethodsById.has(key)) { + return line; + } + const method = pendingAuthRequestMethodsById.get(key); + pendingAuthRequestMethodsById.delete(key); + const result = + method === "account/read" + ? createSyntheticAppServerAccountReadResult() + : method === "account/rateLimits/read" + ? createSyntheticAppServerRateLimitsResult() + : method === "getAuthStatus" + ? createSyntheticAppServerAuthStatusResult() + : null; + if (!result) { + return line; + } + return `${JSON.stringify({ + jsonrpc: typeof message.jsonrpc === "string" ? message.jsonrpc : "2.0", + id: message.id, + result, + })}${lineEnding}`; +} + function forwardToRealCodexOnce( codexBin, args, @@ -277,13 +622,21 @@ function forwardToRealCodexOnce( let stdout = ""; let stderr = ""; const captureOutput = options.captureOutput === true; - const finalize = (exitCode) => { + const proxyAppServerAccountRead = + options.proxyAppServerAccountRead === true; + const protocolProxy = proxyAppServerAccountRead + ? createAppServerAccountReadProtocolProxy() + : null; + let cleanupProtocolProxy = () => {}; + const finalize = async (exitCode) => { if (settled) { return; } settled = true; + cleanupProtocolProxy(); + protocolProxy?.flushOutput(); try { - cleanup?.(); + await cleanup?.({ exitCode }); } catch { // Best-effort cleanup only. } @@ -306,7 +659,11 @@ function forwardToRealCodexOnce( }; try { child = spawn(command, commandArgs, { - stdio: captureOutput ? ["inherit", "pipe", "pipe"] : "inherit", + stdio: proxyAppServerAccountRead + ? ["pipe", "pipe", "pipe"] + : captureOutput + ? ["inherit", "pipe", "pipe"] + : "inherit", env, }); } catch (error) { @@ -314,7 +671,61 @@ function forwardToRealCodexOnce( return; } - if (captureOutput) { + if (proxyAppServerAccountRead && protocolProxy) { + let stdinClosed = false; + let stdoutClosed = false; + const closeChildStdin = () => { + if (stdinClosed) return; + stdinClosed = true; + protocolProxy.flushInput(); + child.stdin?.end(); + }; + const onProcessStdinData = (chunk) => { + protocolProxy.observeInput(chunk); + if (child.stdin && !child.stdin.destroyed && !child.stdin.write(chunk)) { + process.stdin.pause(); + } + }; + const onProcessStdinEnd = () => { + closeChildStdin(); + }; + const onProcessStdinError = () => { + closeChildStdin(); + }; + const onChildStdinDrain = () => { + process.stdin.resume(); + }; + const onChildStdoutData = (chunk) => { + protocolProxy.writeOutput(chunk); + }; + const onChildStdoutEnd = () => { + if (stdoutClosed) return; + stdoutClosed = true; + protocolProxy.flushOutput(); + }; + const onChildStderrData = (chunk) => { + process.stderr.write(chunk); + }; + cleanupProtocolProxy = () => { + process.stdin.removeListener("data", onProcessStdinData); + process.stdin.removeListener("end", onProcessStdinEnd); + process.stdin.removeListener("close", onProcessStdinEnd); + process.stdin.removeListener("error", onProcessStdinError); + child.stdin?.removeListener("drain", onChildStdinDrain); + child.stdout?.removeListener("data", onChildStdoutData); + child.stdout?.removeListener("end", onChildStdoutEnd); + child.stderr?.removeListener("data", onChildStderrData); + process.stdin.resume(); + }; + process.stdin.on("data", onProcessStdinData); + process.stdin.once("end", onProcessStdinEnd); + process.stdin.once("close", onProcessStdinEnd); + process.stdin.once("error", onProcessStdinError); + child.stdin?.on("drain", onChildStdinDrain); + child.stdout?.on("data", onChildStdoutData); + child.stdout?.on("end", onChildStdoutEnd); + child.stderr?.on("data", onChildStderrData); + } else if (captureOutput) { child.stdout?.on("data", (chunk) => { const text = typeof chunk === "string" ? chunk : chunk.toString("utf8"); stdout += text; @@ -360,13 +771,25 @@ async function forwardToRealCodex(codexBin, rawArgs, baseEnv = process.env) { compatibilityRequestedModel, baseEnv, ); + const runtimeProxyContext = await createRuntimeRotationProxyContextIfEnabled( + compatibility, + rawArgs, + ); const result = await forwardToRealCodexOnce( codexBin, - compatibility.args, - compatibility.env, - compatibility.cleanup, + runtimeProxyContext.args, + runtimeProxyContext.env, + runtimeProxyContext.cleanup, { - captureOutput: shouldCaptureForwardedCodexOutput(compatibility.env), + captureOutput: shouldCaptureForwardedOutputForArgs( + rawArgs, + runtimeProxyContext.env, + ), + proxyAppServerAccountRead: + isCodexAppServerCommand(rawArgs) && + (runtimeProxyContext.proxyAppServerAccountRead === true || + (runtimeProxyContext.env[APP_SERVER_ACCOUNT_LABEL_ENV] ?? "").trim() === + "1"), }, ); lastExitCode = result.exitCode; @@ -515,6 +938,27 @@ function maybeThrowSimulatedShadowHomePreflightReadBusyError() { } } +function maybeThrowSimulatedShadowHomeSyncMetadataBusyError(targetPath) { + if ( + basename(targetPath) === SHADOW_HOME_SYNC_STATE_FILE && + shadowHomeSyncMetadataBusyFailuresRemaining > 0 + ) { + shadowHomeSyncMetadataBusyFailuresRemaining -= 1; + const error = new Error("simulated busy shadow-home sync metadata write"); + error.code = "EBUSY"; + throw error; + } +} + +function maybeThrowSimulatedShadowHomeSyncLockOwnerWriteError() { + if (shadowHomeSyncLockOwnerWriteFailuresRemaining > 0) { + shadowHomeSyncLockOwnerWriteFailuresRemaining -= 1; + const error = new Error("simulated shadow sync lock owner write failure"); + error.code = "EPERM"; + throw error; + } +} + function writeShadowHomeCleanupRetryMarker(destinationPath, attempt) { if (shadowHomeCleanupRetryMarkerDir.length === 0) { return; @@ -912,6 +1356,214 @@ function shadowHomeStateMatches(left, right) { ); } +function hashShadowHomeState(state) { + if (state.unreadable) { + return null; + } + if (!state.exists) { + return "missing"; + } + if (typeof state.content !== "string") { + return null; + } + return `sha256:${createHash("sha256").update(state.content).digest("hex")}`; +} + +function readShadowHomeSyncState(originalCodexHome) { + try { + const parsed = JSON.parse( + readFileSync(join(originalCodexHome, SHADOW_HOME_SYNC_STATE_FILE), "utf8"), + ); + if ( + !parsed || + typeof parsed !== "object" || + parsed.version !== 1 || + !parsed.files || + typeof parsed.files !== "object" + ) { + return { version: 1, files: {} }; + } + return parsed; + } catch { + return { version: 1, files: {} }; + } +} + +function rememberShadowHomeSyncState( + originalCodexHome, + syncState, + name, + baseState, + syncedState, +) { + const baseHash = hashShadowHomeState(baseState); + const syncedHash = hashShadowHomeState(syncedState); + if (!baseHash || !syncedHash) { + return; + } + syncState.files[name] = { + baseHash, + syncedHash, + updatedAt: Date.now(), + }; + try { + writeOwnerOnlyJsonFileAtomicSync( + join(originalCodexHome, SHADOW_HOME_SYNC_STATE_FILE), + syncState, + ); + } catch { + // Best-effort metadata; failed metadata must not fail auth cleanup. + } +} + +function canRebaseShadowHomeSyncState(syncState, name, baseState, currentState) { + const entry = syncState.files?.[name]; + if (!entry || typeof entry !== "object") { + return false; + } + // Permit a later shadow session to write over an earlier shadow sync from + // the same launch snapshot, while still refusing unrelated external edits. + return ( + entry.baseHash === hashShadowHomeState(baseState) && + entry.syncedHash === hashShadowHomeState(currentState) + ); +} + +function readShadowHomeSyncLockOwnerPid(lockPath) { + try { + const rawOwner = JSON.parse(readFileSync(join(lockPath, "owner.json"), "utf8")); + const pid = Number(rawOwner?.pid); + return Number.isInteger(pid) && pid > 0 ? pid : null; + } catch { + return null; + } +} + +function isShadowHomeSyncLockOldEnoughToSteal(lockPath) { + try { + const stats = statSync(lockPath); + const newestTimestamp = Math.max(stats.mtimeMs, stats.ctimeMs); + return Date.now() - newestTimestamp >= SHADOW_HOME_ORPHAN_LOCK_STALE_AGE_MS; + } catch { + return true; + } +} + +function removeStaleShadowHomeSyncLock(lockPath) { + const ownerPid = readShadowHomeSyncLockOwnerPid(lockPath); + if (ownerPid !== null && isProcessAlive(ownerPid)) { + return false; + } + if (ownerPid === null && !isShadowHomeSyncLockOldEnoughToSteal(lockPath)) { + return false; + } + try { + removeDirectoryWithRetry(lockPath); + if (shadowHomeSyncLockRecreateStaleCount > 0) { + shadowHomeSyncLockRecreateStaleCount -= 1; + mkdirSync(lockPath, { recursive: true }); + writeShadowHomeSyncLockOwner(lockPath, { + pid: 2_147_483_647, + createdAt: 1, + }); + } + return true; + } catch { + return false; + } +} + +function writeShadowHomeSyncLockOwner(lockPath, owner) { + const ownerPath = join(lockPath, "owner.json"); + maybeThrowSimulatedShadowHomeSyncLockOwnerWriteError(); + writeFileSync(ownerPath, `${JSON.stringify(owner)}\n`, { + encoding: "utf8", + mode: 0o600, + }); + try { + chmodSync(ownerPath, 0o600); + } catch { + // Best-effort only; permission semantics vary by platform. + } +} + +function writeShadowHomeSyncLockOwnerWithRetry(lockPath, owner) { + for (let attempt = 0; attempt <= SHADOW_HOME_CLEANUP_BACKOFF_MS.length; attempt += 1) { + try { + writeShadowHomeSyncLockOwner(lockPath, owner); + return; + } catch (error) { + if ( + !isRetryableShadowHomeCleanupError(error) || + attempt === SHADOW_HOME_CLEANUP_BACKOFF_MS.length + ) { + throw error; + } + sleepSync(SHADOW_HOME_CLEANUP_BACKOFF_MS[attempt]); + } + } +} + +function acquireShadowHomeSyncLock(originalCodexHome) { + const lockPath = join(originalCodexHome, SHADOW_HOME_SYNC_LOCK_DIR); + mkdirSync(originalCodexHome, { recursive: true }); + const maxStaleRecoveries = SHADOW_HOME_CLEANUP_BACKOFF_MS.length + 1; + let staleRecoveries = 0; + let attempt = 0; + const deadline = Date.now() + SHADOW_HOME_SYNC_LOCK_WAIT_TIMEOUT_MS; + while (true) { + try { + mkdirSync(lockPath); + try { + writeShadowHomeSyncLockOwnerWithRetry(lockPath, { + pid: process.pid, + createdAt: Date.now(), + }); + } catch (error) { + try { + removeDirectoryWithRetry(lockPath); + } catch { + // Preserve the owner write failure while avoiding orphaned locks when possible. + } + throw error; + } + return () => { + try { + removeDirectoryWithRetry(lockPath); + } catch { + // Best-effort lock cleanup only. + } + }; + } catch (error) { + const code = + error && typeof error === "object" && "code" in error + ? error.code + : undefined; + if (code !== "EEXIST") { + throw error; + } + if ( + staleRecoveries < maxStaleRecoveries && + removeStaleShadowHomeSyncLock(lockPath) + ) { + staleRecoveries += 1; + attempt = 0; + continue; + } + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) { + throw error; + } + const backoffMs = + SHADOW_HOME_CLEANUP_BACKOFF_MS[ + Math.min(attempt, SHADOW_HOME_CLEANUP_BACKOFF_MS.length - 1) + ] ?? SHADOW_HOME_CLEANUP_BACKOFF_MS[0]; + sleepSync(Math.min(backoffMs, remainingMs)); + attempt += 1; + } + } +} + function syncShadowHomeStateFile( sourcePath, destinationPath, @@ -935,11 +1587,341 @@ function syncShadowHomeStateFile( } } -function rewriteConfigTomlReasoningEffort(rawConfig, requestedModel) { - const lineEnding = rawConfig.includes("\r\n") ? "\r\n" : "\n"; - let changed = false; - const nextLines = rawConfig.split(/\r?\n/).map((line) => { - const quotedMatch = line.match( +function syncShadowHomeStateFileBestEffort( + sourcePath, + destinationPath, + expectedDestinationState, + tightenFile, +) { + try { + syncShadowHomeStateFile( + sourcePath, + destinationPath, + expectedDestinationState, + ); + tightenFile(destinationPath); + return true; + } catch { + // Best-effort sync-back: keep attempting sibling files after Windows locks. + return false; + } +} + +function isDirectoryLike(path) { + try { + return statSync(path).isDirectory(); + } catch { + return false; + } +} + +function isFileLike(path) { + try { + return statSync(path).isFile(); + } catch { + return false; + } +} + +function mirrorDirectoryIntoShadowHome(sourcePath, destinationPath) { + try { + if ((process.env.CODEX_MULTI_AUTH_TEST_FORCE_SHADOW_DIR_COPY ?? "").trim() === "1") { + throw new Error("simulated directory link failure"); + } + symlinkSync( + sourcePath, + destinationPath, + process.platform === "win32" ? "junction" : "dir", + ); + return "linked"; + } catch { + // Fall back to a copy when links are unavailable. Directory links are + // preferred because they keep sessions, plugins, and skills live. + } + cpSync(sourcePath, destinationPath, { + recursive: true, + dereference: false, + }); + return "copied"; +} + +function linkFileIntoShadowHome(sourcePath, destinationPath) { + try { + symlinkSync(sourcePath, destinationPath, "file"); + return true; + } catch { + // File symlinks keep SQLite/cache-style root files realtime when allowed. + } + try { + linkSync(sourcePath, destinationPath); + return true; + } catch { + // Hard links cover platforms where file symlinks require extra privileges. + } + return false; +} + +function mirrorFileIntoShadowHome(sourcePath, destinationPath, tightenFile) { + if (linkFileIntoShadowHome(sourcePath, destinationPath)) { + return; + } + copyFileSync(sourcePath, destinationPath); + tightenFile(destinationPath); +} + +function collectShadowHomeSyncFileNames(shadowCodexHome, syncFileNames) { + try { + for (const entry of readdirSync(shadowCodexHome, { withFileTypes: true })) { + const name = entry.name; + if ( + name === SHADOW_HOME_CONFIG_FILE || + name === SHADOW_HOME_SYNC_STATE_FILE || + syncFileNames.has(name) + ) { + continue; + } + const shadowPath = join(shadowCodexHome, name); + let fileLike = entry.isFile(); + if (entry.isSymbolicLink()) { + fileLike = isFileLike(shadowPath); + } + if (fileLike) { + syncFileNames.add(name); + } + } + } catch { + // Best-effort; cleanup still syncs the known state files. + } + return syncFileNames; +} + +function syncCopiedShadowHomeDirectories(originalCodexHome, shadowCodexHome, names) { + for (const name of names) { + const shadowPath = join(shadowCodexHome, name); + if (!isDirectoryLike(shadowPath)) { + continue; + } + try { + cpSync(shadowPath, join(originalCodexHome, name), { + recursive: true, + dereference: false, + force: true, + }); + } catch { + // Best-effort sync-back; sibling directories and state files still run. + } + } +} + +function syncShadowHomeAuthBundle( + originalCodexHome, + shadowCodexHome, + originalFileStates, + tightenFile, + skipSyncBackNames = new Set(), +) { + const syncState = readShadowHomeSyncState(originalCodexHome); + for (const name of SHADOW_HOME_STATE_FILES) { + if (skipSyncBackNames.has(name)) { + continue; + } + const shadowPath = join(shadowCodexHome, name); + const shadowState = captureShadowHomeState(shadowPath); + if (!shadowState.exists || shadowState.unreadable) { + continue; + } + const originalPath = join(originalCodexHome, name); + const originalSnapshot = + originalFileStates.get(name) ?? { exists: false, content: null }; + const currentOriginalState = captureShadowHomeState(originalPath); + let expectedDestinationState = originalSnapshot; + if (!shadowHomeStateMatches(currentOriginalState, originalSnapshot)) { + if ( + !canRebaseShadowHomeSyncState( + syncState, + name, + originalSnapshot, + currentOriginalState, + ) + ) { + continue; + } + expectedDestinationState = currentOriginalState; + } + if ( + expectedDestinationState.unreadable || + shadowHomeStateMatches(shadowState, expectedDestinationState) + ) { + continue; + } + if (syncShadowHomeStateFileBestEffort( + shadowPath, + originalPath, + expectedDestinationState, + tightenFile, + )) { + rememberShadowHomeSyncState( + originalCodexHome, + syncState, + name, + originalSnapshot, + shadowState, + ); + } + } +} + +function syncAdditionalShadowHomeFiles( + originalCodexHome, + shadowCodexHome, + names, + originalFileStates, + tightenFile, + skipSyncBackNames = new Set(), +) { + for (const name of names) { + if (SHADOW_HOME_STATE_FILE_SET.has(name) || skipSyncBackNames.has(name)) { + continue; + } + const shadowPath = join(shadowCodexHome, name); + const shadowState = captureShadowHomeState(shadowPath); + if (!shadowState.exists || shadowState.unreadable) { + continue; + } + + const originalPath = join(originalCodexHome, name); + const originalSnapshot = + originalFileStates.get(name) ?? { exists: false, content: null }; + const currentOriginalState = captureShadowHomeState(originalPath); + if (!shadowHomeStateMatches(currentOriginalState, originalSnapshot)) { + continue; + } + if (shadowHomeStateMatches(shadowState, originalSnapshot)) { + continue; + } + syncShadowHomeStateFileBestEffort( + shadowPath, + originalPath, + originalSnapshot, + tightenFile, + ); + } +} + +function createShadowHomeMirror( + originalCodexHome, + shadowCodexHome, + tightenFile, + options = {}, +) { + const syncFileNames = new Set(SHADOW_HOME_STATE_FILES); + const skipSyncBackNames = new Set(options.skipSyncBackNames ?? []); + const originalFileStates = new Map(); + const copiedDirectoryNames = new Set(); + const rememberSyncFile = (name) => { + if (!originalFileStates.has(name)) { + originalFileStates.set( + name, + captureShadowHomeState(join(originalCodexHome, name)), + ); + } + syncFileNames.add(name); + }; + for (const name of SHADOW_HOME_STATE_FILES) { + rememberSyncFile(name); + } + + if (existsSync(originalCodexHome)) { + for (const entry of readdirSync(originalCodexHome, { withFileTypes: true })) { + const name = entry.name; + if ( + name === SHADOW_HOME_CONFIG_FILE || + name === SHADOW_HOME_SYNC_STATE_FILE || + name === SHADOW_HOME_SYNC_LOCK_DIR + ) { + continue; + } + const isKnownStateFile = SHADOW_HOME_STATE_FILE_SET.has(name); + const sourcePath = join(originalCodexHome, name); + const destinationPath = join(shadowCodexHome, name); + if (existsSync(destinationPath)) { + continue; + } + + let directoryLike = entry.isDirectory(); + let fileLike = entry.isFile(); + if (entry.isSymbolicLink()) { + directoryLike = isDirectoryLike(sourcePath); + fileLike = !directoryLike && isFileLike(sourcePath); + } + + try { + if (isKnownStateFile && !fileLike) { + throw new Error(`Expected ${name} to be a file`); + } + if (directoryLike) { + if (mirrorDirectoryIntoShadowHome(sourcePath, destinationPath) === "copied") { + copiedDirectoryNames.add(name); + } + continue; + } + if (fileLike) { + rememberSyncFile(name); + if (isKnownStateFile) { + copyFileSync(sourcePath, destinationPath); + tightenFile(destinationPath); + } else { + mirrorFileIntoShadowHome(sourcePath, destinationPath, tightenFile); + } + } + } catch (error) { + if (isKnownStateFile) { + throw error; + } + // A missing or locked optional home entry should not block runtime + // launch; auth/config files still get handled explicitly. + } + } + } + + return () => { + let releaseLock = () => {}; + try { + const names = collectShadowHomeSyncFileNames(shadowCodexHome, syncFileNames); + releaseLock = acquireShadowHomeSyncLock(originalCodexHome); + syncShadowHomeAuthBundle( + originalCodexHome, + shadowCodexHome, + originalFileStates, + tightenFile, + skipSyncBackNames, + ); + syncCopiedShadowHomeDirectories( + originalCodexHome, + shadowCodexHome, + copiedDirectoryNames, + ); + syncAdditionalShadowHomeFiles( + originalCodexHome, + shadowCodexHome, + names, + originalFileStates, + tightenFile, + skipSyncBackNames, + ); + } catch { + // Best-effort only; runtime auth refreshes should not fail cleanup. + } finally { + releaseLock(); + } + }; +} + +function rewriteConfigTomlReasoningEffort(rawConfig, requestedModel) { + const lineEnding = rawConfig.includes("\r\n") ? "\r\n" : "\n"; + let changed = false; + const nextLines = rawConfig.split(/\r?\n/).map((line) => { + const quotedMatch = line.match( /^(\s*model_reasoning_effort\s*=\s*)(["'])([^"']+)(\2.*)$/, ); const bareMatch = quotedMatch @@ -949,37 +1931,794 @@ function rewriteConfigTomlReasoningEffort(rawConfig, requestedModel) { ); if (!quotedMatch && !bareMatch) return line; - const prefix = quotedMatch?.[1] ?? bareMatch?.[1] ?? ""; - const openingQuote = quotedMatch?.[2] ?? ""; - const currentEffort = quotedMatch?.[3] ?? bareMatch?.[2] ?? ""; - const suffix = quotedMatch?.[4] ?? bareMatch?.[3] ?? ""; - const coercedEffort = coerceReasoningEffortForModel( - requestedModel, - currentEffort, + const prefix = quotedMatch?.[1] ?? bareMatch?.[1] ?? ""; + const openingQuote = quotedMatch?.[2] ?? ""; + const currentEffort = quotedMatch?.[3] ?? bareMatch?.[2] ?? ""; + const suffix = quotedMatch?.[4] ?? bareMatch?.[3] ?? ""; + const coercedEffort = coerceReasoningEffortForModel( + requestedModel, + currentEffort, + ); + if (coercedEffort === currentEffort) { + return line; + } + + changed = true; + return quotedMatch + ? `${prefix}${openingQuote}${coercedEffort}${suffix}` + : `${prefix}${coercedEffort}${suffix}`; + }); + + if (!changed) { + return rawConfig; + } + + return ensureTrailingNewline(nextLines.join(lineEnding)); +} + +function resolveOriginalMultiAuthDir(env) { + const explicit = (env.CODEX_MULTI_AUTH_DIR ?? "").trim(); + if (explicit.length > 0) { + return explicit; + } + return undefined; +} + +function parseRuntimeRotationProxyEnv(value) { + if (value === undefined) return undefined; + const normalized = value.trim().toLowerCase(); + if (normalized.length === 0) return undefined; + if (normalized === "1" || normalized === "true" || normalized === "yes") { + return true; + } + if (normalized === "0" || normalized === "false" || normalized === "no") { + return false; + } + if (!warnedInvalidRuntimeRotationProxyEnv) { + warnedInvalidRuntimeRotationProxyEnv = true; + console.error( + "codex-multi-auth: ignoring invalid CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY value. Expected 0/1, true/false, or yes/no.", + ); + } + return undefined; +} + +async function isRuntimeRotationProxyEnabled(rawArgs, baseEnv = process.env) { + if ((baseEnv.CODEX_MULTI_AUTH_BYPASS ?? "").trim() === "1") { + return false; + } + if (!shouldUseRuntimeRoutingForForwardedArgs(rawArgs)) { + return false; + } + + const envOverride = parseRuntimeRotationProxyEnv( + baseEnv.CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY, + ); + if (envOverride !== undefined) { + return envOverride; + } + + const configModule = await loadRuntimeRotationConfigModule(); + if (!configModule) { + return false; + } + const pluginConfig = configModule.loadPluginConfig(); + return configModule.getCodexRuntimeRotationProxy(pluginConfig) === true; +} + +function createRuntimeRotationProxyClientApiKey() { + return randomBytes(32).toString("hex"); +} + +function omitRuntimeRotationShadowHomeStateFiles(shadowCodexHome) { + for (const name of RUNTIME_ROTATION_SHADOW_HOME_OMIT_STATE_FILES) { + const targetPath = join(shadowCodexHome, name); + try { + if (!existsSync(targetPath)) { + continue; + } + if (isDirectoryLike(targetPath)) { + removeDirectoryWithRetry(targetPath); + } else { + rmSync(targetPath, { force: true }); + } + } catch { + // Best-effort: stale official auth state should not block runtime rotation. + } + } +} + +function resolveRuntimeRotationProxyOriginalCodexHome(baseEnv) { + const override = (baseEnv[APP_RUNTIME_HELPER_REAL_CODEX_HOME_ENV] ?? "").trim(); + return override || resolveCodexHomeDir(baseEnv); +} + +function createRuntimeRotationProxyCodexHome( + baseEnv, + proxyBaseUrl, + clientApiKey, + configTomlModule, +) { + const originalCodexHome = resolveRuntimeRotationProxyOriginalCodexHome(baseEnv); + const shadowCodexHome = mkdtempSync(join(tmpdir(), "codex-multi-auth-runtime-home-")); + let syncShadowHomeStateBack = () => {}; + const cleanup = () => { + try { + removeDirectoryWithRetry(shadowCodexHome); + } catch { + // Best-effort cleanup only. + } + }; + const tightenShadowHomePermissions = (path) => { + try { + chmodSync(path, 0o600); + } catch { + // Best-effort only; permission semantics vary by platform. + } + }; + + try { + syncShadowHomeStateBack = createShadowHomeMirror( + originalCodexHome, + shadowCodexHome, + tightenShadowHomePermissions, + { + skipSyncBackNames: RUNTIME_ROTATION_SHADOW_HOME_OMIT_STATE_FILES, + }, + ); + omitRuntimeRotationShadowHomeStateFiles(shadowCodexHome); + const originalConfigPath = join(originalCodexHome, "config.toml"); + const rawConfig = existsSync(originalConfigPath) + ? readFileSync(originalConfigPath, "utf8") + : ""; + const runtimeConfig = configTomlModule.rewriteConfigTomlForRuntimeRotationProvider( + rawConfig, + proxyBaseUrl, + clientApiKey, + ); + const runtimeConfigPath = join(shadowCodexHome, "config.toml"); + writeFileSync(runtimeConfigPath, runtimeConfig, "utf8"); + tightenShadowHomePermissions(runtimeConfigPath); + } catch (error) { + cleanup(); + throw error; + } + + const forwardedEnv = { + ...baseEnv, + CODEX_HOME: shadowCodexHome, + OPENAI_API_KEY: clientApiKey, + }; + const originalMultiAuthDir = resolveOriginalMultiAuthDir(baseEnv); + if (originalMultiAuthDir) { + forwardedEnv.CODEX_MULTI_AUTH_DIR = originalMultiAuthDir; + } + + return { + env: forwardedEnv, + cleanup: () => { + syncShadowHomeStateBack(); + cleanup(); + }, + }; +} + +function appendNodeImportOption(nodeOptions, preloadPath) { + const importOption = `--import=${pathToFileURL(preloadPath).href}`; + const trimmed = (nodeOptions ?? "").trim(); + return trimmed.length > 0 ? `${trimmed} ${importOption}` : importOption; +} + +function createRuntimeRotationAppServerPreloadSource(wrapperScriptPath) { + return [ + 'import { spawn } from "node:child_process";', + 'import { basename } from "node:path";', + 'import process from "node:process";', + "", + `const wrapperScriptPath = ${JSON.stringify(wrapperScriptPath)};`, + `const accountLabelEnv = ${JSON.stringify(APP_SERVER_ACCOUNT_LABEL_ENV)};`, + "const rawArgs = process.argv.slice(1);", + "const firstArg = rawArgs[0] ?? \"\";", + 'if (basename(firstArg).toLowerCase() === "app-server") {', + ' const args = ["app-server", ...rawArgs.slice(1)];', + " const env = {", + " ...process.env,", + ' CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY: "0",', + " [accountLabelEnv]: \"1\",", + " };", + " const child = spawn(process.execPath, [wrapperScriptPath, ...args], {", + " stdio: \"inherit\",", + " env,", + " });", + " child.once(\"error\", (error) => {", + ' console.error(`codex-multi-auth app-server shim failed: ${error instanceof Error ? error.message : String(error)}`);', + " process.exit(1);", + " });", + " child.once(\"exit\", (code, signal) => {", + " if (signal) {", + ' process.exit(signal === "SIGINT" ? 130 : 1);', + " return;", + " }", + " process.exit(typeof code === \"number\" ? code : 1);", + " });", + " await new Promise(() => undefined);", + "}", + "", + ].join("\n"); +} + +function sweepStaleRuntimeRotationAppServerShimDirs(shimRootDir) { + let entries = []; + try { + entries = readdirSync(shimRootDir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (!entry.isDirectory() || !entry.name.startsWith(APP_SERVER_SHIM_HELPER_PREFIX)) { + continue; + } + const pidText = entry.name.slice(APP_SERVER_SHIM_HELPER_PREFIX.length); + const pid = Number.parseInt(pidText, 10); + if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid || isProcessAlive(pid)) { + continue; + } + try { + removeDirectoryWithRetry(join(shimRootDir, entry.name)); + } catch { + // Best-effort stale shim cleanup only. + } + } +} + +function installRuntimeRotationAppServerCliShim(forwardedEnv) { + const shadowCodexHome = forwardedEnv.CODEX_HOME; + if (!shadowCodexHome) { + throw new Error("runtime app-server shim requires CODEX_HOME"); + } + const multiAuthDir = + resolveOriginalMultiAuthDir(forwardedEnv) ?? + join(resolveRuntimeRotationProxyOriginalCodexHome(forwardedEnv), "multi-auth"); + const shimRootDir = join(multiAuthDir, APP_SERVER_SHIM_DIR_NAME); + sweepStaleRuntimeRotationAppServerShimDirs(shimRootDir); + const shimDir = join( + shimRootDir, + `${APP_SERVER_SHIM_HELPER_PREFIX}${process.pid}`, + ); + mkdirSync(shimDir, { recursive: true }); + const executableName = process.platform === "win32" ? "codex.exe" : "codex"; + const executablePath = join(shimDir, executableName); + const preloadPath = join(shimDir, "codex-multi-auth-app-server-preload.mjs"); + try { + try { + rmSync(executablePath, { force: true }); + } catch { + // Best-effort stale shim cleanup only. + } + try { + linkSync(process.execPath, executablePath); + } catch { + copyFileSync(process.execPath, executablePath); + } + if (process.platform !== "win32") { + chmodSync(executablePath, 0o755); + } + writeFileSync( + preloadPath, + createRuntimeRotationAppServerPreloadSource(fileURLToPath(import.meta.url)), + { encoding: "utf8", mode: 0o600 }, + ); + try { + chmodSync(preloadPath, 0o600); + } catch { + // Best-effort only; permission semantics vary by platform. + } + } catch (error) { + try { + removeDirectoryWithRetry(shimDir); + } catch { + // Preserve the original installation failure. + } + throw error; + } + forwardedEnv.CODEX_CLI_PATH = shimDir; + forwardedEnv.NODE_OPTIONS = appendNodeImportOption( + forwardedEnv.NODE_OPTIONS, + preloadPath, + ); + forwardedEnv.CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY = "0"; + forwardedEnv[APP_SERVER_ACCOUNT_LABEL_ENV] = "1"; + return shimDir; +} + +function resolveRuntimeRotationAppHelperStatusPath(env = process.env) { + const multiAuthDir = + resolveOriginalMultiAuthDir(env) ?? join(resolveCodexHomeDir(env), "multi-auth"); + return join(multiAuthDir, APP_RUNTIME_HELPER_STATUS_FILE); +} + +function writeOwnerOnlyJsonFileAtomicSync(targetPath, payload) { + const targetDir = dirname(targetPath); + mkdirSync(targetDir, { recursive: true }); + for (let attempt = 0; attempt <= SHADOW_HOME_CLEANUP_BACKOFF_MS.length; attempt += 1) { + const tempPath = join( + targetDir, + [ + `.${basename(targetPath)}`, + String(process.pid), + String(Date.now()), + randomBytes(4).toString("hex"), + "tmp", + ].join("."), + ); + try { + writeFileSync(tempPath, `${JSON.stringify(payload, null, 2)}\n`, { + encoding: "utf8", + mode: 0o600, + }); + chmodSync(tempPath, 0o600); + maybeThrowSimulatedShadowHomeSyncMetadataBusyError(targetPath); + renameSync(tempPath, targetPath); + chmodSync(targetPath, 0o600); + return; + } catch (error) { + try { + rmSync(tempPath, { force: true }); + } catch { + // Preserve the original write failure. + } + if ( + isRetryableShadowHomeCleanupError(error) && + attempt < SHADOW_HOME_CLEANUP_BACKOFF_MS.length + ) { + sleepSync(SHADOW_HOME_CLEANUP_BACKOFF_MS[attempt]); + continue; + } + throw error; + } + } +} + +function resolveRuntimeRotationAppHelperIdleMs(env = process.env) { + const parsed = Number.parseInt( + env.CODEX_MULTI_AUTH_APP_ROTATION_IDLE_MS ?? "", + 10, + ); + return Number.isFinite(parsed) && parsed > 0 + ? Math.max(50, parsed) + : DEFAULT_APP_RUNTIME_HELPER_IDLE_MS; +} + +function resolveRuntimeRotationAppHelperOwnerPid(env = process.env) { + const parsed = Number.parseInt(env[APP_RUNTIME_HELPER_OWNER_PID_ENV] ?? "", 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +} + +function isProcessAlive(pid) { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error && typeof error === "object" && error.code === "EPERM"; + } +} + +function isRuntimeRotationAppHelperOwnerAlive(pid) { + return isProcessAlive(pid); +} + +function resolveRuntimeRotationAppHelperDetachGraceMs(env = process.env) { + const parsed = Number.parseInt( + env.CODEX_MULTI_AUTH_APP_ROTATION_DETACH_GRACE_MS ?? "", + 10, + ); + return Number.isFinite(parsed) && parsed >= 0 + ? parsed + : DEFAULT_APP_RUNTIME_HELPER_DETACH_GRACE_MS; +} + +function pickRuntimeRotationAppHelperEnv(env) { + const picked = { + CODEX_HOME: env.CODEX_HOME, + OPENAI_API_KEY: env.OPENAI_API_KEY, + }; + for (const name of [ + "CODEX_CLI_PATH", + "NODE_OPTIONS", + "CODEX_MULTI_AUTH_REAL_CODEX_BIN", + "CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY", + APP_SERVER_ACCOUNT_LABEL_ENV, + ]) { + if (env[name]) { + picked[name] = env[name]; + } + } + if (env.CODEX_MULTI_AUTH_DIR) { + picked.CODEX_MULTI_AUTH_DIR = env.CODEX_MULTI_AUTH_DIR; + } + return picked; +} + +function writeRuntimeRotationAppHelperStatus(payload, env = process.env) { + try { + const statusPath = resolveRuntimeRotationAppHelperStatusPath(env); + writeOwnerOnlyJsonFileAtomicSync(statusPath, payload); + } catch { + // Best-effort status only; the helper must not fail because telemetry is unavailable. + } +} + +function createRuntimeRotationAppHelperStatus({ + proxyServer, + startedAt, + idleTimeoutMs, + lastActivityAt, + state, +}) { + const proxyStatus = + typeof proxyServer?.getStatus === "function" ? proxyServer.getStatus() : {}; + const lastAccountIndex = proxyStatus.lastAccountIndex ?? null; + const lastAccountLabel = + typeof proxyStatus.lastAccountLabel === "string" && + !proxyStatus.lastAccountLabel.includes("@") + ? proxyStatus.lastAccountLabel + : typeof lastAccountIndex === "number" + ? `Account ${lastAccountIndex + 1}` + : null; + return { + version: 1, + kind: "codex-app-runtime-rotation-helper", + state, + pid: process.pid, + startedAt, + updatedAt: Date.now(), + baseUrl: proxyServer?.baseUrl ?? null, + idleTimeoutMs, + idleExpiresAt: lastActivityAt + idleTimeoutMs, + totalRequests: proxyStatus.totalRequests ?? 0, + upstreamRequests: proxyStatus.upstreamRequests ?? 0, + retries: proxyStatus.retries ?? 0, + rotations: proxyStatus.rotations ?? 0, + lastAccountIndex, + lastAccountLabel, + lastAccountId: proxyStatus.lastAccountId ?? null, + lastAccountUpdatedAt: proxyStatus.lastAccountUpdatedAt ?? null, + lastError: proxyStatus.lastError ?? null, + }; +} + +async function runRuntimeRotationAppHelper() { + let proxyServer = null; + let shadowContext = null; + let appServerShimDir = null; + let statusTimer = null; + let closing = false; + const startedAt = Date.now(); + const idleTimeoutMs = resolveRuntimeRotationAppHelperIdleMs(); + const ownerPid = resolveRuntimeRotationAppHelperOwnerPid(); + let lastActivityAt = startedAt; + let lastRequestCount = 0; + + const publishStatus = (state) => { + writeRuntimeRotationAppHelperStatus( + createRuntimeRotationAppHelperStatus({ + proxyServer, + startedAt, + idleTimeoutMs, + lastActivityAt, + state, + }), ); - if (coercedEffort === currentEffort) { - return line; + }; + + const cleanup = async (state = "stopped") => { + if (closing) return; + closing = true; + if (statusTimer) { + clearInterval(statusTimer); + } + try { + if (appServerShimDir) { + try { + removeDirectoryWithRetry(appServerShimDir); + } catch { + // Best-effort shim cleanup only. + } + appServerShimDir = null; + } + shadowContext?.cleanup?.(); + } finally { + try { + await proxyServer?.close?.(); + } finally { + publishStatus(state); + } } + }; - changed = true; - return quotedMatch - ? `${prefix}${openingQuote}${coercedEffort}${suffix}` - : `${prefix}${coercedEffort}${suffix}`; + const exitAfterCleanup = (state, exitCode) => { + void cleanup(state).finally(() => { + process.exit(exitCode); + }); + }; + + process.once("SIGINT", () => exitAfterCleanup("stopped", 130)); + process.once("SIGTERM", () => exitAfterCleanup("stopped", 0)); + process.once("SIGHUP", () => exitAfterCleanup("stopped", 0)); + + try { + const proxyModule = await loadRuntimeRotationProxyModule(); + if (!proxyModule) { + throw new Error("runtime rotation proxy module is unavailable"); + } + const configTomlModule = await loadRuntimeConfigTomlModule(); + if (!configTomlModule) { + throw new Error("runtime rotation config helpers are unavailable"); + } + const clientApiKey = createRuntimeRotationProxyClientApiKey(); + proxyServer = await proxyModule.startRuntimeRotationProxy({ clientApiKey }); + shadowContext = createRuntimeRotationProxyCodexHome( + process.env, + proxyServer.baseUrl, + clientApiKey, + configTomlModule, + ); + appServerShimDir = installRuntimeRotationAppServerCliShim(shadowContext.env); + lastRequestCount = proxyServer.getStatus?.().totalRequests ?? 0; + publishStatus("running"); + process.stdout.write( + `${JSON.stringify({ + type: "ready", + pid: process.pid, + baseUrl: proxyServer.baseUrl, + statusPath: resolveRuntimeRotationAppHelperStatusPath(), + env: pickRuntimeRotationAppHelperEnv(shadowContext.env), + })}\n`, + ); + + statusTimer = setInterval(() => { + const currentTime = Date.now(); + const requestCount = proxyServer?.getStatus?.().totalRequests ?? 0; + if (requestCount !== lastRequestCount) { + lastRequestCount = requestCount; + lastActivityAt = currentTime; + } + if (ownerPid && isRuntimeRotationAppHelperOwnerAlive(ownerPid)) { + lastActivityAt = currentTime; + } + publishStatus("running"); + if (currentTime - lastActivityAt >= idleTimeoutMs) { + exitAfterCleanup("idle-timeout", 0); + } + }, Math.min(1_000, Math.max(50, Math.floor(idleTimeoutMs / 2)))); + } catch (error) { + process.stdout.write( + `${JSON.stringify({ + type: "error", + message: error instanceof Error ? error.message : String(error), + })}\n`, + ); + await cleanup("error"); + return 1; + } + + await new Promise(() => undefined); + return 0; +} + +function waitForRuntimeRotationAppHelperExit(helper, timeoutMs = 2_000) { + return new Promise((resolve) => { + let settled = false; + let timer = null; + const finish = () => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + resolve(); + }; + timer = setTimeout(finish, timeoutMs); + helper.once("close", finish); }); +} - if (!changed) { - return rawConfig; +function stopRuntimeRotationAppHelper(helper) { + if (!helper || helper.killed) { + return Promise.resolve(); + } + try { + helper.kill("SIGTERM"); + } catch { + return Promise.resolve(); } + return waitForRuntimeRotationAppHelperExit(helper); +} - return ensureTrailingNewline(nextLines.join(lineEnding)); +function startRuntimeRotationAppHelper(baseContext) { + const realCodexHome = + baseContext.originalCodexHome ?? + resolveRuntimeRotationProxyOriginalCodexHome(baseContext.env); + return new Promise((resolve, reject) => { + let stdoutBuffer = ""; + let stderrBuffer = ""; + let settled = false; + const helper = spawn( + process.execPath, + [fileURLToPath(import.meta.url), INTERNAL_RUNTIME_ROTATION_APP_HELPER_ARG], + { + env: { + ...baseContext.env, + [APP_RUNTIME_HELPER_OWNER_PID_ENV]: String(process.pid), + [APP_RUNTIME_HELPER_REAL_CODEX_HOME_ENV]: realCodexHome, + }, + stdio: ["ignore", "pipe", "pipe"], + detached: true, + }, + ); + let timeout = null; + const finish = (result) => { + if (settled) return; + settled = true; + if (timeout) clearTimeout(timeout); + resolve(result); + }; + const fail = (error) => { + if (settled) return; + settled = true; + if (timeout) clearTimeout(timeout); + void stopRuntimeRotationAppHelper(helper).finally(() => reject(error)); + }; + timeout = setTimeout(() => { + fail(new Error("timed out waiting for runtime rotation app helper")); + }, APP_RUNTIME_HELPER_LAUNCH_TIMEOUT_MS); + helper.stdout?.setEncoding("utf8"); + helper.stdout?.on("data", (chunk) => { + stdoutBuffer += chunk; + const newlineIndex = stdoutBuffer.indexOf("\n"); + if (newlineIndex < 0) return; + const line = stdoutBuffer.slice(0, newlineIndex).trim(); + try { + const message = JSON.parse(line); + if (message?.type === "ready" && message.env && message.pid) { + finish({ helper, message }); + return; + } + fail( + new Error( + message?.message ?? + "runtime rotation app helper returned an invalid startup response", + ), + ); + } catch (error) { + fail(error); + } + }); + helper.stderr?.setEncoding("utf8"); + helper.stderr?.on("data", (chunk) => { + stderrBuffer += chunk; + }); + helper.once("error", fail); + helper.once("close", (code) => { + if (settled) return; + fail( + new Error( + `runtime rotation app helper exited before startup (code ${code ?? "unknown"}): ${stderrBuffer.trim()}`, + ), + ); + }); + }); } -function resolveOriginalMultiAuthDir(env) { - const explicit = (env.CODEX_MULTI_AUTH_DIR ?? "").trim(); - if (explicit.length > 0) { - return explicit; +async function createRuntimeRotationAppHelperContext(baseContext, configTomlModule) { + const startedAt = Date.now(); + const { helper, message } = await startRuntimeRotationAppHelper(baseContext); + const helperEnv = message.env ?? {}; + const detachGraceMs = resolveRuntimeRotationAppHelperDetachGraceMs(baseContext.env); + + const cleanup = async ({ exitCode } = {}) => { + const livedMs = Date.now() - startedAt; + if (exitCode === 0 && livedMs < detachGraceMs) { + helper.stdout?.destroy(); + helper.stderr?.destroy(); + helper.unref(); + return; + } + await stopRuntimeRotationAppHelper(helper); + }; + + return { + args: [ + ...baseContext.args, + "-c", + `model_provider=${configTomlModule.tomlStringLiteral(RUNTIME_ROTATION_PROXY_PROVIDER_ID)}`, + ], + env: { + ...baseContext.env, + ...helperEnv, + }, + cleanup: async (details) => { + try { + await cleanup(details); + } finally { + baseContext.cleanup?.(); + } + }, + }; +} + +async function createRuntimeRotationProxyContextIfEnabled( + baseContext, + rawArgs, +) { + const enabled = await isRuntimeRotationProxyEnabled(rawArgs, baseContext.env); + if (!enabled) { + return baseContext; } - return undefined; + + const configTomlModule = await loadRuntimeConfigTomlModule(); + if (!configTomlModule) { + console.error( + "codex-multi-auth runtime rotation config helpers are unavailable; continuing without runtime rotation.", + ); + return baseContext; + } + + if (isCodexAppCommand(rawArgs)) { + return createRuntimeRotationAppHelperContext(baseContext, configTomlModule); + } + + const proxyModule = await loadRuntimeRotationProxyModule(); + if (!proxyModule) { + console.error( + "codex-multi-auth runtime rotation proxy is unavailable; continuing without runtime rotation.", + ); + return baseContext; + } + + let proxyServer; + let shadowContext; + try { + const clientApiKey = createRuntimeRotationProxyClientApiKey(); + proxyServer = await proxyModule.startRuntimeRotationProxy({ clientApiKey }); + shadowContext = createRuntimeRotationProxyCodexHome( + baseContext.env, + proxyServer.baseUrl, + clientApiKey, + configTomlModule, + ); + } catch (error) { + try { + await proxyServer?.close?.(); + } catch { + // Best-effort cleanup only. + } + console.error( + `codex-multi-auth runtime rotation proxy failed to start; continuing without runtime rotation: ${error instanceof Error ? error.message : String(error)}`, + ); + return baseContext; + } + + const cleanup = async () => { + try { + shadowContext.cleanup?.(); + } finally { + try { + await proxyServer.close(); + } finally { + baseContext.cleanup?.(); + } + } + }; + + return { + args: [ + ...baseContext.args, + "-c", + `model_provider=${configTomlModule.tomlStringLiteral(RUNTIME_ROTATION_PROXY_PROVIDER_ID)}`, + ], + env: shadowContext.env, + cleanup, + proxyAppServerAccountRead: isCodexAppServerCommand(rawArgs), + }; } async function loadRuntimeObservabilityModule() { @@ -1020,8 +2759,17 @@ function consumesNextArg(arg) { "--config", "--enable", "--disable", + "--listen", "--remote", "--remote-auth-token-env", + "--ws-auth", + "--ws-token-file", + "--ws-token-sha256", + "--ws-shared-secret-file", + "--ws-issuer", + "--ws-audience", + "--ws-max-clock-skew-seconds", + "--download-url", "-i", "--image", "-m", @@ -1043,7 +2791,75 @@ function consumesNextArg(arg) { ]).has(arg); } -function shouldTrackForwardedRuntimeObservability(rawArgs) { +function findForwardedCommand(rawArgs) { + if (!Array.isArray(rawArgs) || rawArgs.length === 0) { + return null; + } + for (let i = 0; i < rawArgs.length; i += 1) { + const arg = rawArgs[i]; + if (typeof arg !== "string" || arg.length === 0) continue; + if (arg === "--") { + return i + 1 < rawArgs.length + ? { command: rawArgs[i + 1], index: i + 1 } + : null; + } + if (arg.startsWith("--config=")) { + continue; + } + if (arg.startsWith("--") || (arg.startsWith("-") && arg !== "-")) { + if (consumesNextArg(arg)) { + i += 1; + } + continue; + } + return { command: arg, index: i }; + } + + return null; +} + +function findForwardedSubcommand(rawArgs, commandIndex) { + for (let i = commandIndex + 1; i < rawArgs.length; i += 1) { + const arg = rawArgs[i]; + if (typeof arg !== "string" || arg.length === 0) continue; + if (arg === "--") { + return i + 1 < rawArgs.length ? rawArgs[i + 1] : null; + } + if (arg.startsWith("--config=")) { + continue; + } + if (arg.startsWith("--") || (arg.startsWith("-") && arg !== "-")) { + if (consumesNextArg(arg)) { + i += 1; + } + continue; + } + return arg; + } + return null; +} + +function hasHelpFlagAfterCommand(rawArgs, commandIndex) { + for (let i = commandIndex + 1; i < rawArgs.length; i += 1) { + const arg = rawArgs[i]; + if (arg === "--") return false; + if (arg === "--help" || arg === "-h" || arg === "help") return true; + if (typeof arg === "string" && consumesNextArg(arg)) { + i += 1; + } + } + return false; +} + +function isCodexAppCommand(rawArgs) { + return findForwardedCommand(rawArgs)?.command === "app"; +} + +function isCodexAppServerCommand(rawArgs) { + return findForwardedCommand(rawArgs)?.command === "app-server"; +} + +function shouldUseRuntimeRoutingForForwardedArgs(rawArgs) { if (!Array.isArray(rawArgs) || rawArgs.length === 0) { return true; } @@ -1051,7 +2867,12 @@ function shouldTrackForwardedRuntimeObservability(rawArgs) { return false; } - const requestCommands = new Set(["exec", "review", "resume", "fork"]); + const command = findForwardedCommand(rawArgs); + if (!command) { + return true; + } + + const requestCommands = new Set(["exec", "review", "resume", "fork", "app"]); const nonRequestCommands = new Set([ "help", "completion", @@ -1059,7 +2880,6 @@ function shouldTrackForwardedRuntimeObservability(rawArgs) { "logout", "mcp", "mcp-server", - "app-server", "sandbox", "debug", "apply", @@ -1068,36 +2888,41 @@ function shouldTrackForwardedRuntimeObservability(rawArgs) { "auth", ]); - for (let i = 0; i < rawArgs.length; i += 1) { - const arg = rawArgs[i]; - if (typeof arg !== "string" || arg.length === 0) continue; - if (arg === "--") { - return i + 1 < rawArgs.length; - } - if (arg.startsWith("--config=")) { - continue; - } - if (arg.startsWith("--") || (arg.startsWith("-") && arg !== "-")) { - if (consumesNextArg(arg)) { - i += 1; - } - continue; - } - if (requestCommands.has(arg)) { - return true; - } - if (nonRequestCommands.has(arg)) { + if (command.command === "app-server") { + if (hasHelpFlagAfterCommand(rawArgs, command.index)) { return false; } - return true; + const subcommand = findForwardedSubcommand(rawArgs, command.index); + return !new Set(["help", "generate-ts", "generate-json-schema"]).has( + subcommand ?? "", + ); } + if (command.command === "app" && hasHelpFlagAfterCommand(rawArgs, command.index)) { + return false; + } + if (requestCommands.has(command.command)) { + return true; + } + if (nonRequestCommands.has(command.command)) { + return false; + } return true; } +function shouldTrackForwardedRuntimeObservability(rawArgs) { + return shouldUseRuntimeRoutingForForwardedArgs(rawArgs); +} + +function shouldCaptureForwardedOutputForArgs(rawArgs, env) { + if (isCodexAppServerCommand(rawArgs)) { + return false; + } + return shouldCaptureForwardedCodexOutput(env); +} + function createRuntimeSnapshotChangeToken(snapshot) { return JSON.stringify({ - updatedAt: snapshot?.updatedAt ?? null, responsesRequests: snapshot?.responsesRequests ?? null, authRefreshRequests: snapshot?.authRefreshRequests ?? null, diagnosticProbeRequests: snapshot?.diagnosticProbeRequests ?? null, @@ -1170,21 +2995,25 @@ function createCompatibilityCodexHome( requestedModel, baseEnv = process.env, ) { + const originalCodexHome = resolveCodexHomeDir(baseEnv); if (!requestedModel) { - return { args: processedArgs, env: baseEnv, cleanup: undefined }; + return { + args: processedArgs, + env: baseEnv, + cleanup: undefined, + originalCodexHome, + }; } - const originalCodexHome = resolveCodexHomeDir(baseEnv); const configPath = join(originalCodexHome, "config.toml"); if (!existsSync(configPath)) { - return { args: processedArgs, env: baseEnv, cleanup: undefined }; + return { + args: processedArgs, + env: baseEnv, + cleanup: undefined, + originalCodexHome, + }; } - const originalShadowHomeState = new Map( - SHADOW_HOME_STATE_FILES.map((name) => [ - name, - captureShadowHomeState(join(originalCodexHome, name)), - ]), - ); const rawConfig = readFileSync(configPath, "utf8"); const compatConfig = rewriteConfigTomlReasoningEffort( @@ -1192,10 +3021,16 @@ function createCompatibilityCodexHome( requestedModel, ); if (compatConfig === rawConfig) { - return { args: processedArgs, env: baseEnv, cleanup: undefined }; + return { + args: processedArgs, + env: baseEnv, + cleanup: undefined, + originalCodexHome, + }; } const shadowCodexHome = mkdtempSync(join(tmpdir(), "codex-multi-auth-home-")); + let syncShadowHomeStateBack = () => {}; const cleanup = () => { try { removeDirectoryWithRetry(shadowCodexHome); @@ -1210,44 +3045,15 @@ function createCompatibilityCodexHome( // Best-effort only; permission semantics vary by platform. } }; - const syncShadowHomeStateBack = () => { - for (const name of SHADOW_HOME_STATE_FILES) { - const shadowPath = join(shadowCodexHome, name); - const shadowState = captureShadowHomeState(shadowPath); - if (!shadowState.exists || shadowState.unreadable) { - continue; - } - - try { - const originalPath = join(originalCodexHome, name); - const originalSnapshot = - originalShadowHomeState.get(name) ?? { exists: false, content: null }; - const currentOriginalState = captureShadowHomeState(originalPath); - if (!shadowHomeStateMatches(currentOriginalState, originalSnapshot)) { - continue; - } - if (shadowHomeStateMatches(shadowState, originalSnapshot)) { - continue; - } - syncShadowHomeStateFile(shadowPath, originalPath, originalSnapshot); - tightenShadowHomePermissions(originalPath); - } catch { - // Best-effort only; runtime auth refreshes should not fail cleanup. - } - } - }; try { + syncShadowHomeStateBack = createShadowHomeMirror( + originalCodexHome, + shadowCodexHome, + tightenShadowHomePermissions, + ); const compatConfigPath = join(shadowCodexHome, "config.toml"); writeFileSync(compatConfigPath, compatConfig, "utf8"); tightenShadowHomePermissions(compatConfigPath); - for (const name of SHADOW_HOME_STATE_FILES) { - const sourcePath = join(originalCodexHome, name); - if (existsSync(sourcePath)) { - const destinationPath = join(shadowCodexHome, name); - copyFileSync(sourcePath, destinationPath); - tightenShadowHomePermissions(destinationPath); - } - } } catch (error) { cleanup(); throw error; @@ -1270,6 +3076,7 @@ function createCompatibilityCodexHome( args: processedArgs, env: forwardedEnv, cleanup: cleanupWithSync, + originalCodexHome, }; } @@ -1609,9 +3416,14 @@ function ensureWindowsShellShimGuards() { async function main() { hydrateCliVersionEnv(); - ensureWindowsShellShimGuards(); const rawArgs = process.argv.slice(2); + if (rawArgs[0] === INTERNAL_RUNTIME_ROTATION_APP_HELPER_ARG) { + return runRuntimeRotationAppHelper(); + } + + ensureWindowsShellShimGuards(); + const normalizedArgs = normalizeAuthAlias(rawArgs); const bypass = (process.env.CODEX_MULTI_AUTH_BYPASS ?? "").trim() === "1"; @@ -1622,6 +3434,10 @@ async function main() { return 1; } const exitCode = await runCodexMultiAuthCli(normalizedArgs); + await maybeInstallCodexAppLauncherAfterRotationEnable( + normalizedArgs, + normalizeExitCode(exitCode), + ); return normalizeExitCode(exitCode); } catch (error) { console.error( @@ -1644,9 +3460,13 @@ async function main() { } await autoSyncManagerActiveSelectionIfEnabled(); - return withForwardedRuntimeObservability(rawArgs, () => - forwardToRealCodex(realCodexBin, rawArgs), - ); + try { + return await withForwardedRuntimeObservability(rawArgs, () => + forwardToRealCodex(realCodexBin, rawArgs), + ); + } finally { + await autoSyncManagerActiveSelectionIfEnabled(); + } } const exitCode = await main(); diff --git a/scripts/postinstall.js b/scripts/postinstall.js new file mode 100644 index 00000000..67084c65 --- /dev/null +++ b/scripts/postinstall.js @@ -0,0 +1,237 @@ +#!/usr/bin/env node + +// @ts-check + +import { existsSync, readdirSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const TRUE_VALUES = new Set(["1", "true", "yes"]); +const FALSE_VALUES = new Set(["0", "false", "no"]); +const CI_ENV_KEYS = [ + "CI", + "GITHUB_ACTIONS", + "GITLAB_CI", + "CIRCLECI", + "BUILDKITE", + "TF_BUILD", + "TEAMCITY_VERSION", + "JENKINS_URL", + "TRAVIS", + "APPVEYOR", + "BITBUCKET_BUILD_NUMBER", +]; + +/** + * @param {string | undefined} value + */ +export function readOptionalBoolean(value) { + if (value === undefined || value.trim().length === 0) return null; + const normalized = value.trim().toLowerCase(); + if (TRUE_VALUES.has(normalized)) return true; + if (FALSE_VALUES.has(normalized)) return false; + return null; +} + +/** + * @param {NodeJS.ProcessEnv} env + */ +export function isGlobalNpmInstall(env = process.env) { + const globalFlag = readOptionalBoolean(env.npm_config_global); + if (globalFlag === true) return true; + return (env.npm_config_location ?? "").trim().toLowerCase() === "global"; +} + +/** + * @param {NodeJS.ProcessEnv} env + * @param {string} key + */ +function isEnabledEnvFlag(env, key) { + const value = env[key]; + if (value === undefined || value.trim().length === 0) return false; + const parsed = readOptionalBoolean(value); + return parsed !== false; +} + +/** + * @param {NodeJS.ProcessEnv} [env] + */ +export function isCiEnvironment(env = process.env) { + if (readOptionalBoolean(env.npm_config_ignore_scripts) === true) return true; + return CI_ENV_KEYS.some((key) => isEnabledEnvFlag(env, key)); +} + +/** + * @param {string} directory + * @param {string} prefix + */ +function directoryContainsEntryWithPrefix(directory, prefix) { + try { + return readdirSync(directory, { withFileTypes: true }).some((entry) => + entry.name.startsWith(prefix), + ); + } catch { + return false; + } +} + +/** + * @param {{ env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, home?: string }} [options] + */ +export function hasCodexDesktopApp(options = {}) { + const env = options.env ?? process.env; + const platform = options.platform ?? process.platform; + const home = options.home ?? homedir(); + + if (platform === "win32") { + const localAppData = + (env.LOCALAPPDATA ?? "").trim() || join(home, "AppData", "Local"); + const programFiles = + (env.ProgramFiles ?? env.ProgramW6432 ?? "").trim() || "C:\\Program Files"; + return ( + directoryContainsEntryWithPrefix( + join(localAppData, "Packages"), + "OpenAI.Codex_", + ) || + directoryContainsEntryWithPrefix( + join(programFiles, "WindowsApps"), + "OpenAI.Codex_", + ) + ); + } + + if (platform === "darwin") { + return ( + existsSync("/Applications/Codex.app") || + existsSync(join(home, "Applications", "Codex.app")) + ); + } + + return false; +} + +/** + * @param {{ + * env?: NodeJS.ProcessEnv, + * platform?: NodeJS.Platform, + * home?: string, + * rotationEnabled: boolean, + * appDetected?: boolean, + * }} options + */ +export function shouldAutoBindCodexAppOnInstall(options) { + const env = options.env ?? process.env; + if (isCiEnvironment(env)) return false; + + const bindOverride = readOptionalBoolean(env.CODEX_MULTI_AUTH_APP_BIND); + if (bindOverride !== null) return bindOverride; + + const installOverride = readOptionalBoolean( + env.CODEX_MULTI_AUTH_APP_BIND_INSTALL, + ); + if (installOverride !== null) return installOverride; + + if (!isGlobalNpmInstall(env)) return false; + if (!options.rotationEnabled) return false; + return ( + options.appDetected ?? + hasCodexDesktopApp({ + env, + platform: options.platform, + home: options.home, + }) + ); +} + +async function loadConfigModule() { + try { + return await import("../dist/lib/config.js"); + } catch (error) { + if ( + error && + typeof error === "object" && + "code" in error && + error.code === "ERR_MODULE_NOT_FOUND" + ) { + return null; + } + throw error; + } +} + +async function loadAppBindModule() { + try { + return await import("../dist/lib/runtime/app-bind.js"); + } catch (error) { + if ( + error && + typeof error === "object" && + "code" in error && + error.code === "ERR_MODULE_NOT_FOUND" + ) { + return null; + } + throw error; + } +} + +function resolveRotationEnabled(configModule) { + const envOverride = readOptionalBoolean( + process.env.CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY, + ); + if (envOverride !== null) return envOverride; + if ( + !configModule || + typeof configModule.loadPluginConfig !== "function" || + typeof configModule.getCodexRuntimeRotationProxy !== "function" + ) { + return false; + } + return ( + configModule.getCodexRuntimeRotationProxy(configModule.loadPluginConfig()) === + true + ); +} + +async function main() { + const appBindModule = await loadAppBindModule(); + if (!appBindModule || typeof appBindModule.bindCodexAppRuntimeRotation !== "function") { + return 0; + } + + const configModule = await loadConfigModule(); + const rotationEnabled = resolveRotationEnabled(configModule); + const currentStatus = + typeof appBindModule.getAppBindStatus === "function" + ? await appBindModule.getAppBindStatus().catch(() => null) + : null; + const appDetected = hasCodexDesktopApp() || currentStatus?.bound === true; + if (!shouldAutoBindCodexAppOnInstall({ rotationEnabled, appDetected })) { + return 0; + } + + const result = await appBindModule.bindCodexAppRuntimeRotation(); + if (result?.message) { + console.error(`codex-multi-auth: ${result.message}`); + } + return 0; +} + +const isDirectRun = (() => { + try { + return resolve(process.argv[1] ?? "") === fileURLToPath(import.meta.url); + } catch { + return false; + } +})(); + +if (isDirectRun) { + main().catch((error) => { + console.error( + `codex-multi-auth: app bind postinstall skipped: ${error instanceof Error ? error.message : String(error)}`, + ); + process.exitCode = 0; + }); +} diff --git a/test/app-bind-io-retry.test.ts b/test/app-bind-io-retry.test.ts new file mode 100644 index 00000000..47342495 --- /dev/null +++ b/test/app-bind-io-retry.test.ts @@ -0,0 +1,220 @@ +import { existsSync } from "node:fs"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { withFileOperationRetry } from "../lib/fs-retry.js"; +import type { AppBindPaths } from "../lib/runtime/app-bind.js"; + +const fsFaults = vi.hoisted(() => ({ + renameFailures: 0, + unlinkFailures: 0, +})); + +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + rename: vi.fn(async (...args: Parameters) => { + if (fsFaults.renameFailures > 0) { + fsFaults.renameFailures -= 1; + throw Object.assign(new Error("busy"), { code: "EBUSY" }); + } + return actual.rename(...args); + }), + unlink: vi.fn(async (...args: Parameters) => { + if (fsFaults.unlinkFailures > 0) { + fsFaults.unlinkFailures -= 1; + throw Object.assign(new Error("permission"), { code: "EPERM" }); + } + return actual.unlink(...args); + }), + }; +}); + +const tempRoots: string[] = []; + +async function createTempRoot(prefix: string): Promise { + const root = await mkdtemp(join(tmpdir(), prefix)); + tempRoots.push(root); + return root; +} + +afterEach(async () => { + fsFaults.renameFailures = 0; + fsFaults.unlinkFailures = 0; + await Promise.all( + tempRoots.splice(0).map((root) => + withFileOperationRetry(() => rm(root, { recursive: true, force: true })), + ), + ); +}); + +async function seedExistingState(params: { + home: string; + env: NodeJS.ProcessEnv; + nodePath: string; + routerScriptPath: string; +}): Promise { + const { resolveAppBindPaths } = await import("../lib/runtime/app-bind.js"); + const paths = resolveAppBindPaths({ + platform: "linux", + home: params.home, + env: params.env, + nodePath: params.nodePath, + routerScriptPath: params.routerScriptPath, + }); + await mkdir(paths.bindDir, { recursive: true }); + await writeFile( + paths.statePath, + `${JSON.stringify( + { + version: 1, + platform: "linux", + host: "127.0.0.1", + port: 4567, + baseUrl: "http://127.0.0.1:4567", + configPath: paths.configPath, + statePath: paths.statePath, + backupPath: paths.backupPath, + statusPath: paths.statusPath, + logPath: paths.logPath, + nodePath: params.nodePath, + routerScriptPath: params.routerScriptPath, + clientApiKey: "existing-secret", + startupPath: paths.startupPath, + launchAgentPath: paths.launchAgentPath, + boundConfigHash: "existing-hash", + updatedAt: 1, + }, + null, + 2, + )}\n`, + "utf8", + ); + return paths; +} + +describe("Codex app bind filesystem retry behavior", () => { + it("retries transient EBUSY during bind and unbind atomic renames", async () => { + const { bindCodexAppRuntimeRotation, unbindCodexAppRuntimeRotation } = + await import("../lib/runtime/app-bind.js"); + const root = await createTempRoot("codex-app-bind-io-"); + const codexHome = join(root, "codex-home"); + const env = { + CODEX_MULTI_AUTH_DIR: join(root, "multi-auth"), + CODEX_MULTI_AUTH_APP_BIND_CODEX_HOME: codexHome, + }; + const paths = await seedExistingState({ + home: root, + env, + nodePath: "node", + routerScriptPath: join(root, "codex-app-router.js"), + }); + await mkdir(codexHome, { recursive: true }); + await writeFile(paths.configPath, 'model_provider = "openai"\n', "utf8"); + + fsFaults.renameFailures = 2; + const result = await bindCodexAppRuntimeRotation({ + platform: "linux", + home: root, + env, + nodePath: "node", + routerScriptPath: join(root, "codex-app-router.js"), + spawnDetached: false, + }); + + expect(result.status.bound).toBe(true); + expect(await readFile(paths.configPath, "utf8")).toContain( + 'base_url = "http://127.0.0.1:4567"', + ); + + fsFaults.renameFailures = 2; + await expect( + unbindCodexAppRuntimeRotation({ + platform: "linux", + home: root, + env, + spawnDetached: false, + }), + ).resolves.toMatchObject({ status: { bound: false } }); + expect(await readFile(paths.configPath, "utf8")).toBe( + 'model_provider = "openai"\n', + ); + }); + + it("surfaces persistent EBUSY without truncating config.toml", async () => { + const { bindCodexAppRuntimeRotation } = await import( + "../lib/runtime/app-bind.js" + ); + const root = await createTempRoot("codex-app-bind-io-fail-"); + const codexHome = join(root, "codex-home"); + const env = { + CODEX_MULTI_AUTH_DIR: join(root, "multi-auth"), + CODEX_MULTI_AUTH_APP_BIND_CODEX_HOME: codexHome, + }; + const paths = await seedExistingState({ + home: root, + env, + nodePath: "node", + routerScriptPath: join(root, "codex-app-router.js"), + }); + await mkdir(codexHome, { recursive: true }); + await writeFile(paths.configPath, 'model_provider = "openai"\n', "utf8"); + + fsFaults.renameFailures = 20; + await expect( + bindCodexAppRuntimeRotation({ + platform: "linux", + home: root, + env, + nodePath: "node", + routerScriptPath: join(root, "codex-app-router.js"), + spawnDetached: false, + }), + ).rejects.toThrow("busy"); + expect(existsSync(paths.configPath)).toBe(true); + expect(await readFile(paths.configPath, "utf8")).toBe( + 'model_provider = "openai"\n', + ); + }); + + it("retries transient EPERM during unbind cleanup unlinks", async () => { + const { bindCodexAppRuntimeRotation, unbindCodexAppRuntimeRotation } = + await import("../lib/runtime/app-bind.js"); + const root = await createTempRoot("codex-app-bind-io-unlink-"); + const codexHome = join(root, "codex-home"); + const env = { + CODEX_MULTI_AUTH_DIR: join(root, "multi-auth"), + CODEX_MULTI_AUTH_APP_BIND_CODEX_HOME: codexHome, + }; + const paths = await seedExistingState({ + home: root, + env, + nodePath: "node", + routerScriptPath: join(root, "codex-app-router.js"), + }); + await mkdir(codexHome, { recursive: true }); + await writeFile(paths.configPath, 'model_provider = "openai"\n', "utf8"); + await bindCodexAppRuntimeRotation({ + platform: "linux", + home: root, + env, + nodePath: "node", + routerScriptPath: join(root, "codex-app-router.js"), + spawnDetached: false, + }); + + fsFaults.unlinkFailures = 2; + await expect( + unbindCodexAppRuntimeRotation({ + platform: "linux", + home: root, + env, + spawnDetached: false, + }), + ).resolves.toMatchObject({ status: { bound: false } }); + expect(existsSync(paths.statePath)).toBe(false); + expect(existsSync(paths.backupPath)).toBe(false); + }); +}); diff --git a/test/app-bind.test.ts b/test/app-bind.test.ts new file mode 100644 index 00000000..2789e96c --- /dev/null +++ b/test/app-bind.test.ts @@ -0,0 +1,767 @@ +import { closeSync, existsSync, openSync, statSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; +import { + bindCodexAppRuntimeRotation, + resolveAppBindPaths, + restoreConfigTomlFromAppBind, + rewriteConfigTomlForAppBind, + unbindCodexAppRuntimeRotation, +} from "../lib/runtime/app-bind.js"; +import { tomlStringLiteral } from "../lib/runtime/config-toml.js"; +import { withFileOperationRetry } from "../lib/fs-retry.js"; +import { RUNTIME_ROTATION_PROXY_PROVIDER_ID } from "../lib/runtime-constants.js"; + +const tempRoots: string[] = []; +const thisDir = dirname(fileURLToPath(import.meta.url)); + +async function createTempRoot(prefix: string): Promise { + const root = await mkdtemp(join(tmpdir(), prefix)); + tempRoots.push(root); + return root; +} + +function sha256(content: string): string { + return createHash("sha256").update(content).digest("hex"); +} + +async function seedExistingAppBindState(params: { + platform: NodeJS.Platform; + home: string; + env: NodeJS.ProcessEnv; + port: number; + baseUrl: string; + nodePath: string; + routerScriptPath: string; +}): Promise { + const paths = resolveAppBindPaths(params); + await mkdir(paths.bindDir, { recursive: true }); + await writeFile( + paths.statePath, + `${JSON.stringify( + { + version: 1, + platform: params.platform, + host: "127.0.0.1", + port: params.port, + baseUrl: params.baseUrl, + configPath: paths.configPath, + statePath: paths.statePath, + backupPath: paths.backupPath, + statusPath: paths.statusPath, + logPath: paths.logPath, + nodePath: params.nodePath, + routerScriptPath: params.routerScriptPath, + clientApiKey: "existing-secret", + startupPath: paths.startupPath, + launchAgentPath: paths.launchAgentPath, + boundConfigHash: "existing-hash", + updatedAt: 1, + }, + null, + 2, + )}\n`, + "utf8", + ); +} + +afterEach(async () => { + await Promise.all( + tempRoots.splice(0).map((root) => + withFileOperationRetry(() => rm(root, { recursive: true, force: true })), + ), + ); +}); + +describe("Codex app runtime rotation bind", () => { + it("rewrites and restores Codex config TOML without disturbing other sections", () => { + const original = [ + 'model_provider = "openai"', + 'model = "gpt-5.4"', + "", + "[profiles.default]", + 'model = "gpt-5.4"', + "", + ].join("\n"); + + const bound = rewriteConfigTomlForAppBind( + original, + "http://127.0.0.1:32123", + "app-secret", + ); + expect(bound).toContain( + `model_provider = "${RUNTIME_ROTATION_PROXY_PROVIDER_ID}"`, + ); + expect(bound).toContain( + `[model_providers.${RUNTIME_ROTATION_PROXY_PROVIDER_ID}]`, + ); + expect(bound).toContain('name = "codex-multi-auth"'); + expect(bound).toContain('base_url = "http://127.0.0.1:32123"'); + expect(bound).toContain("requires_openai_auth = false"); + expect(bound).toContain('experimental_bearer_token = "app-secret"'); + expect(bound).toContain('wire_api = "responses"'); + expect(bound).not.toContain("env_key"); + expect(bound).toContain("[profiles.default]"); + + const restored = restoreConfigTomlFromAppBind(bound, original); + expect(restored).toBe(original); + }); + + it("keeps model_provider top-level before TOML array tables", () => { + const original = [ + "[[profiles.experimental]]", + 'model = "gpt-5.4"', + "", + ].join("\n"); + + const bound = rewriteConfigTomlForAppBind( + original, + "http://127.0.0.1:32123", + "app-secret", + ); + + expect( + bound.startsWith( + `model_provider = "${RUNTIME_ROTATION_PROXY_PROVIDER_ID}"`, + ), + ).toBe(true); + expect( + bound.indexOf(`model_provider = "${RUNTIME_ROTATION_PROXY_PROVIDER_ID}"`), + ).toBeLessThan(bound.indexOf("[[profiles.experimental]]")); + }); + + it("removes runtime provider subtables when restoring Codex config TOML", () => { + const bound = [ + `model_provider = "${RUNTIME_ROTATION_PROXY_PROVIDER_ID}"`, + "", + `[model_providers.${RUNTIME_ROTATION_PROXY_PROVIDER_ID}]`, + 'name = "codex-multi-auth"', + 'base_url = "http://127.0.0.1:32123"', + `[model_providers.${RUNTIME_ROTATION_PROXY_PROVIDER_ID}.http_headers]`, + 'authorization = "Bearer secret"', + "[profiles.default]", + 'model = "gpt-5.4"', + "", + ].join("\n"); + + const restored = restoreConfigTomlFromAppBind(bound, 'model_provider = "openai"\n'); + + expect(restored).not.toContain(RUNTIME_ROTATION_PROXY_PROVIDER_ID); + expect(restored).not.toContain("Bearer secret"); + expect(restored).toContain("[profiles.default]"); + }); + + it("escapes TOML basic-string control characters", () => { + expect( + tomlStringLiteral( + "line\ncarriage\rtab\tbackspace\bform\fquote\"slash\\nul\u0000unit\u001fdel\u007f", + ), + ).toBe( + '"line\\ncarriage\\rtab\\tbackspace\\bform\\fquote\\"slash\\\\nul\\u0000unit\\u001Fdel\\u007F"', + ); + }); + + it("resolves app bind paths from the provided environment", async () => { + const root = await createTempRoot("codex-app-bind-paths-"); + const multiAuthDir = join(root, "multi-auth"); + const codexHome = join(root, "official-codex-home"); + const appData = join(root, "AppData", "Roaming"); + + const paths = resolveAppBindPaths({ + platform: "win32", + home: root, + env: { + CODEX_MULTI_AUTH_DIR: multiAuthDir, + CODEX_MULTI_AUTH_APP_BIND_CODEX_HOME: codexHome, + APPDATA: appData, + }, + }); + + expect(paths.configPath).toBe(join(codexHome, "config.toml")); + expect(paths.bindDir).toBe(join(multiAuthDir, "app-bind")); + expect(paths.startupPath).toBe( + join( + appData, + "Microsoft", + "Windows", + "Start Menu", + "Programs", + "Startup", + "Codex Multi Auth Runtime Router.cmd", + ), + ); + expect(paths.launchAgentPath).toBeNull(); + }); + + it("binds and unbinds the Windows app config without spawning during tests", async () => { + const root = await createTempRoot("codex-app-bind-win-"); + const multiAuthDir = join(root, "multi%auth"); + const codexHome = join(root, "codex%home"); + const appData = join(root, "App%20Data", "Roaming"); + const nodePath = join(root, "Node%20", "node.exe"); + const routerScriptPath = join(root, "router%dir", "codex-app-router.js"); + const env = { + CODEX_MULTI_AUTH_DIR: multiAuthDir, + CODEX_MULTI_AUTH_APP_BIND_CODEX_HOME: codexHome, + APPDATA: appData, + }; + await mkdir(codexHome, { recursive: true }); + await writeFile( + join(codexHome, "config.toml"), + 'model_provider = "openai"\n', + "utf8", + ); + await seedExistingAppBindState({ + platform: "win32", + home: root, + env, + port: 4567, + baseUrl: "http://127.0.0.1:4567", + nodePath, + routerScriptPath, + }); + + const result = await bindCodexAppRuntimeRotation({ + platform: "win32", + home: root, + env, + nodePath, + routerScriptPath, + spawnDetached: false, + now: () => 123, + }); + + expect(result.status.bound).toBe(true); + expect(result.status.running).toBe(false); + expect(result.status.state?.statePath).toBe( + join(multiAuthDir, "app-bind", "runtime-rotation-app-bind.json"), + ); + const config = await readFile(join(codexHome, "config.toml"), "utf8"); + expect(config).toContain( + `[model_providers.${RUNTIME_ROTATION_PROXY_PROVIDER_ID}]`, + ); + expect(config).toContain(result.status.state?.baseUrl); + expect(config).toContain("requires_openai_auth = false"); + expect(config).toContain( + `experimental_bearer_token = "${result.status.state?.clientApiKey}"`, + ); + expect(config).not.toContain("env_key"); + if (process.platform !== "win32") { + expect(statSync(join(codexHome, "config.toml")).mode & 0o777).toBe(0o600); + expect(statSync(result.status.paths.statePath).mode & 0o777).toBe(0o600); + } + const startup = await readFile(result.status.paths.startupPath ?? "", "utf8"); + expect(startup).toContain("--state"); + expect(startup).toContain("--log"); + expect(startup).toContain("--max-log-bytes 1048576"); + expect(startup).toContain("runtime-rotation-app-bind.json"); + expect(startup).toContain("Node%%20"); + expect(startup).toContain("router%%dir"); + expect(startup).toContain("multi%%auth"); + expect(startup).not.toContain("Node%20"); + expect(startup).not.toContain("router%dir"); + expect(startup).not.toContain("multi%auth"); + expect(startup).not.toContain(result.status.state?.clientApiKey ?? ""); + + const unbound = await unbindCodexAppRuntimeRotation({ + platform: "win32", + home: root, + env, + spawnDetached: false, + }); + + expect(unbound.status.bound).toBe(false); + expect(await readFile(join(codexHome, "config.toml"), "utf8")).toBe( + 'model_provider = "openai"\n', + ); + expect(existsSync(result.status.paths.startupPath ?? "")).toBe(false); + }); + + it("fails fast when the router script cannot be resolved", async () => { + const root = await createTempRoot("codex-app-bind-missing-router-"); + const multiAuthDir = join(root, "multi-auth"); + const codexHome = join(root, "codex-home"); + const env = { + CODEX_MULTI_AUTH_DIR: multiAuthDir, + CODEX_MULTI_AUTH_APP_BIND_CODEX_HOME: codexHome, + }; + + await expect( + bindCodexAppRuntimeRotation({ + platform: "linux", + home: root, + env, + nodePath: "node", + routerScriptCandidates: [ + join(root, "missing-router-a.js"), + join(root, "missing-router-b.js"), + ], + spawnDetached: false, + }), + ).rejects.toThrow(/codex-app-router\.js not found/); + }); + + it("serializes concurrent binds so state and config stay coherent", async () => { + const root = await createTempRoot("codex-app-bind-concurrent-"); + const multiAuthDir = join(root, "multi-auth"); + const codexHome = join(root, "codex-home"); + const env = { + CODEX_MULTI_AUTH_DIR: multiAuthDir, + CODEX_MULTI_AUTH_APP_BIND_CODEX_HOME: codexHome, + }; + await mkdir(codexHome, { recursive: true }); + await writeFile( + join(codexHome, "config.toml"), + 'model_provider = "openai"\n', + "utf8", + ); + await seedExistingAppBindState({ + platform: "linux", + home: root, + env, + port: 4567, + baseUrl: "http://127.0.0.1:4567", + nodePath: "node", + routerScriptPath: join(root, "codex-app-router.js"), + }); + const options = { + platform: "linux" as const, + home: root, + env, + nodePath: "node", + routerScriptPath: join(root, "codex-app-router.js"), + spawnDetached: false, + }; + + const [first, second] = await Promise.all([ + bindCodexAppRuntimeRotation(options), + bindCodexAppRuntimeRotation(options), + ]); + + expect(first.status.bound).toBe(true); + expect(second.status.bound).toBe(true); + const paths = resolveAppBindPaths(options); + const config = await readFile(paths.configPath, "utf8"); + const state = JSON.parse(await readFile(paths.statePath, "utf8")) as { + clientApiKey: string; + boundConfigHash: string; + }; + const backup = JSON.parse(await readFile(paths.backupPath, "utf8")) as { + content: string; + }; + expect(config).toContain( + `model_provider = "${RUNTIME_ROTATION_PROXY_PROVIDER_ID}"`, + ); + expect(config).toContain( + `experimental_bearer_token = "${state.clientApiKey}"`, + ); + expect(state.boundConfigHash).toBe(sha256(config)); + expect(backup.content).toBe('model_provider = "openai"\n'); + }); + + it("refuses to bind without spawning when no router port is known", async () => { + const root = await createTempRoot("codex-app-bind-no-port-"); + const multiAuthDir = join(root, "multi-auth"); + const codexHome = join(root, "codex-home"); + const env = { + CODEX_MULTI_AUTH_DIR: multiAuthDir, + CODEX_MULTI_AUTH_APP_BIND_CODEX_HOME: codexHome, + }; + await mkdir(codexHome, { recursive: true }); + + await expect( + bindCodexAppRuntimeRotation({ + platform: "linux", + home: root, + env, + nodePath: "node", + routerScriptPath: join(root, "codex-app-router.js"), + spawnDetached: false, + }), + ).rejects.toThrow("port=0"); + }); + + it("rejects corrupt app bind state without a client token", async () => { + const root = await createTempRoot("codex-app-bind-missing-token-"); + const multiAuthDir = join(root, "multi-auth"); + const codexHome = join(root, "codex-home"); + const env = { + CODEX_MULTI_AUTH_DIR: multiAuthDir, + CODEX_MULTI_AUTH_APP_BIND_CODEX_HOME: codexHome, + }; + const paths = resolveAppBindPaths({ platform: "linux", home: root, env }); + await mkdir(paths.bindDir, { recursive: true }); + await writeFile( + paths.statePath, + `${JSON.stringify( + { + version: 1, + platform: "linux", + host: "127.0.0.1", + port: 4567, + baseUrl: "http://127.0.0.1:4567", + configPath: paths.configPath, + statePath: paths.statePath, + backupPath: paths.backupPath, + statusPath: paths.statusPath, + logPath: paths.logPath, + nodePath: "node", + routerScriptPath: join(root, "codex-app-router.js"), + boundConfigHash: "hash", + updatedAt: 1, + }, + null, + 2, + )}\n`, + "utf8", + ); + + await expect( + bindCodexAppRuntimeRotation({ + platform: "linux", + home: root, + env, + nodePath: "node", + routerScriptPath: join(root, "codex-app-router.js"), + spawnDetached: false, + }), + ).rejects.toThrow("port=0"); + }); + + it("resolves the router assigned port before writing app config", async () => { + const root = await createTempRoot("codex-app-bind-router-port-"); + const multiAuthDir = join(root, "multi-auth"); + const codexHome = join(root, "codex-home"); + const routerScriptPath = join(root, "fake-router.mjs"); + const env = { + CODEX_MULTI_AUTH_DIR: multiAuthDir, + CODEX_MULTI_AUTH_APP_BIND_CODEX_HOME: codexHome, + }; + await writeFile( + routerScriptPath, + [ + "#!/usr/bin/env node", + "import { mkdirSync, writeFileSync } from 'node:fs';", + "import { dirname } from 'node:path';", + "const args = process.argv.slice(2);", + "const statusPath = args[args.indexOf('--status') + 1];", + "mkdirSync(dirname(statusPath), { recursive: true });", + "writeFileSync(statusPath, JSON.stringify({ version: 1, state: 'running', pid: process.pid, baseUrl: 'http://127.0.0.1:54321', updatedAt: Date.now() }) + '\\n', 'utf8');", + "process.on('SIGTERM', () => process.exit(0));", + "setInterval(() => undefined, 1000);", + "", + ].join("\n"), + "utf8", + ); + + const result = await bindCodexAppRuntimeRotation({ + platform: "linux", + home: root, + env, + nodePath: process.execPath, + routerScriptPath, + now: () => 789, + }); + + expect(result.status.state?.port).toBe(54321); + expect(result.status.state?.baseUrl).toBe("http://127.0.0.1:54321"); + if (process.platform !== "win32") { + expect(statSync(result.status.paths.logPath).mode & 0o777).toBe(0o600); + } + const config = await readFile(join(codexHome, "config.toml"), "utf8"); + expect(config).toContain('base_url = "http://127.0.0.1:54321"'); + expect(config).toContain( + `experimental_bearer_token = "${result.status.state?.clientApiKey}"`, + ); + + await unbindCodexAppRuntimeRotation({ + platform: "linux", + home: root, + env, + }); + }); + + it("waits past cold Windows Node startup before declaring router startup failed", async () => { + const root = await createTempRoot("codex-app-bind-router-slow-port-"); + const multiAuthDir = join(root, "multi-auth"); + const codexHome = join(root, "codex-home"); + const routerScriptPath = join(root, "slow-router.mjs"); + const env = { + CODEX_MULTI_AUTH_DIR: multiAuthDir, + CODEX_MULTI_AUTH_APP_BIND_CODEX_HOME: codexHome, + }; + await writeFile( + routerScriptPath, + [ + "#!/usr/bin/env node", + "import { mkdirSync, writeFileSync } from 'node:fs';", + "import { dirname } from 'node:path';", + "const args = process.argv.slice(2);", + "const statusPath = args[args.indexOf('--status') + 1];", + "setTimeout(() => {", + " mkdirSync(dirname(statusPath), { recursive: true });", + " writeFileSync(statusPath, JSON.stringify({ version: 1, state: 'running', pid: process.pid, baseUrl: 'http://127.0.0.1:54322', updatedAt: Date.now() }) + '\\n', 'utf8');", + "}, 2300);", + "process.on('SIGTERM', () => process.exit(0));", + "setInterval(() => undefined, 1000);", + "", + ].join("\n"), + "utf8", + ); + + const result = await bindCodexAppRuntimeRotation({ + platform: "win32", + home: root, + env, + nodePath: process.execPath, + routerScriptPath, + now: () => 789, + }); + + expect(result.status.state?.port).toBe(54322); + expect(result.status.running).toBe(true); + + await unbindCodexAppRuntimeRotation({ + platform: "win32", + home: root, + env, + }); + }); + + it("fails bind when a spawned router never reports ready for an existing port", async () => { + const root = await createTempRoot("codex-app-bind-router-stale-port-"); + const multiAuthDir = join(root, "multi-auth"); + const codexHome = join(root, "codex-home"); + const routerScriptPath = join(root, "silent-router.mjs"); + const env = { + CODEX_MULTI_AUTH_DIR: multiAuthDir, + CODEX_MULTI_AUTH_APP_BIND_CODEX_HOME: codexHome, + }; + await mkdir(codexHome, { recursive: true }); + await writeFile( + join(codexHome, "config.toml"), + 'model_provider = "openai"\n', + "utf8", + ); + await writeFile(routerScriptPath, "process.exit(0);\n", "utf8"); + await seedExistingAppBindState({ + platform: "linux", + home: root, + env, + port: 4567, + baseUrl: "http://127.0.0.1:4567", + nodePath: process.execPath, + routerScriptPath, + }); + + await expect( + bindCodexAppRuntimeRotation({ + platform: "linux", + home: root, + env, + nodePath: process.execPath, + routerScriptPath, + routerReadyTimeoutMs: 500, + }), + ).rejects.toThrow("did not report ready"); + await expect(readFile(join(codexHome, "config.toml"), "utf8")).resolves.toBe( + 'model_provider = "openai"\n', + ); + }); + + it("writes a macOS LaunchAgent for login-time router startup", async () => { + const root = await createTempRoot("codex-app-bind-mac-"); + const multiAuthDir = join(root, "multi-auth"); + const codexHome = join(root, ".codex"); + const env = { + CODEX_MULTI_AUTH_DIR: multiAuthDir, + CODEX_MULTI_AUTH_APP_BIND_CODEX_HOME: codexHome, + }; + await seedExistingAppBindState({ + platform: "darwin", + home: root, + env, + port: 4568, + baseUrl: "http://127.0.0.1:4568", + nodePath: "/usr/local/bin/node", + routerScriptPath: join(root, "codex-app-router.js"), + }); + + const result = await bindCodexAppRuntimeRotation({ + platform: "darwin", + home: root, + env, + nodePath: "/usr/local/bin/node", + routerScriptPath: join(root, "codex-app-router.js"), + spawnDetached: false, + now: () => 456, + }); + + const plistPath = result.status.paths.launchAgentPath ?? ""; + const plist = await readFile(plistPath, "utf8"); + expect(plist).toContain("com.ndycode.codex-multi-auth.runtime-router"); + expect(plist).toContain("KeepAlive"); + expect(plist).toContain("--state"); + expect(plist).toContain("--log"); + expect(plist).toContain("--max-log-bytes"); + expect(plist).toContain("1048576"); + expect(plist).toContain("runtime-rotation-app-bind.json"); + expect(plist).not.toContain(result.status.state?.clientApiKey ?? ""); + }); + + it("rejects non-loopback router hosts before binding", async () => { + const root = await createTempRoot("codex-app-router-host-"); + const statusPath = join(root, "router-status.json"); + const result = spawnSync( + process.execPath, + [ + join(thisDir, "..", "scripts", "codex-app-router.js"), + "--host", + "0.0.0.0", + "--port", + "4567", + "--status", + statusPath, + ], + { + encoding: "utf8", + windowsHide: true, + }, + ); + + expect(result.error).toBeUndefined(); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("loopback-only"); + expect(existsSync(statusPath)).toBe(false); + }); + + it.each([ + ["fractional", "12.5"], + ["suffix", "123abc"], + ["out of range", "70000"], + ])("rejects %s router port values", async (_label, port) => { + const root = await createTempRoot("codex-app-router-port-"); + const statusPath = join(root, "router-status.json"); + const result = spawnSync( + process.execPath, + [ + join(thisDir, "..", "scripts", "codex-app-router.js"), + "--port", + port, + "--status", + statusPath, + ], + { + encoding: "utf8", + windowsHide: true, + }, + ); + + expect(result.error).toBeUndefined(); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("valid --port"); + expect(existsSync(statusPath)).toBe(false); + }); + + it("rejects router startup when state is missing its client token", async () => { + const root = await createTempRoot("codex-app-router-token-"); + const statusPath = join(root, "router-status.json"); + const statePath = join(root, "router-state.json"); + await writeFile( + statePath, + `${JSON.stringify({ host: "127.0.0.1", port: 0 })}\n`, + "utf8", + ); + const result = spawnSync( + process.execPath, + [ + join(thisDir, "..", "scripts", "codex-app-router.js"), + "--status", + statusPath, + "--state", + statePath, + ], + { + encoding: "utf8", + windowsHide: true, + }, + ); + + expect(result.error).toBeUndefined(); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("missing its client token"); + expect(existsSync(statusPath)).toBe(false); + }); + + it("rejects router startup when state is transiently unreadable instead of binding port 0", async () => { + const root = await createTempRoot("codex-app-router-missing-state-"); + const statusPath = join(root, "router-status.json"); + const statePath = join(root, "missing-state.json"); + const result = spawnSync( + process.execPath, + [ + join(thisDir, "..", "scripts", "codex-app-router.js"), + "--port", + "0", + "--status", + statusPath, + "--state", + statePath, + ], + { + encoding: "utf8", + windowsHide: true, + }, + ); + + expect(result.error).toBeUndefined(); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("state is unreadable"); + const status = JSON.parse(await readFile(statusPath, "utf8")) as { + state: string; + baseUrl: string | null; + }; + expect(status.state).toBe("error"); + expect(status.baseUrl).toBeNull(); + }); + + it("bounds router stdout and stderr log growth", async () => { + const root = await createTempRoot("codex-app-router-log-bound-"); + const statusPath = join(root, "router-status.json"); + const logPath = join(root, "router.log"); + await writeFile(logPath, "x".repeat(2048), "utf8"); + const logFd = openSync(logPath, "a"); + try { + const result = spawnSync( + process.execPath, + [ + join(thisDir, "..", "scripts", "codex-app-router.js"), + "--port", + "4567", + "--status", + statusPath, + "--log", + logPath, + "--max-log-bytes", + "1024", + ], + { + stdio: ["ignore", logFd, logFd], + windowsHide: true, + }, + ); + expect(result.error).toBeUndefined(); + expect(result.status).not.toBe(0); + } finally { + closeSync(logFd); + } + + expect(statSync(logPath).size).toBeLessThan(2048); + expect(await readFile(logPath, "utf8")).toContain("log truncated"); + }); +}); diff --git a/test/check-pack-budget.test.ts b/test/check-pack-budget.test.ts index e0cf101d..eba62435 100644 --- a/test/check-pack-budget.test.ts +++ b/test/check-pack-budget.test.ts @@ -33,6 +33,13 @@ describe("parsePackMetadata", () => { parsePackMetadata(JSON.stringify([{ size: 0, files: [] }])), ).toThrow(/valid package size/); }); + + it("rejects array-shaped package metadata records", () => { + expect(() => + parsePackMetadata(JSON.stringify([[{ size: 1, files: [] }]])), + ).toThrow(/file list/); + }); + it("wraps npm pack execution errors with command context", async () => { await expect( runPackBudgetCheck({ @@ -53,6 +60,15 @@ describe("parsePackMetadata", () => { ).rejects.toThrow(/stdout: not-json/); }); + it("reports missing npm pack stdout clearly", async () => { + await expect( + runPackBudgetCheck({ + execAsync: vi.fn(async () => ({}) as { stdout: string }), + log: vi.fn(), + }), + ).rejects.toThrow(/returned no stdout/); + }); + }); describe("validatePackMetadata", () => { diff --git a/test/codex-app-router.test.ts b/test/codex-app-router.test.ts new file mode 100644 index 00000000..d30f0b7d --- /dev/null +++ b/test/codex-app-router.test.ts @@ -0,0 +1,253 @@ +import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; +import { withFileOperationRetry } from "../lib/fs-retry.js"; + +const tempRoots: string[] = []; +const thisDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = join(thisDir, ".."); + +async function createTempRoot(prefix: string): Promise { + const root = await mkdtemp(join(tmpdir(), prefix)); + tempRoots.push(root); + return root; +} + +function createRouterFixture(root: string, options: { withProxyModule?: boolean } = {}): string { + const scriptsDir = join(root, "scripts"); + mkdirSync(scriptsDir, { recursive: true }); + writeFileSync( + join(root, "package.json"), + `${JSON.stringify({ type: "module" }, null, 2)}\n`, + "utf8", + ); + const scriptPath = join(scriptsDir, "codex-app-router.js"); + copyFileSync(join(repoRoot, "scripts", "codex-app-router.js"), scriptPath); + if (options.withProxyModule !== false) { + const distDir = join(root, "dist", "lib"); + mkdirSync(distDir, { recursive: true }); + writeFileSync( + join(distDir, "runtime-rotation-proxy.js"), + [ + 'import { appendFileSync, mkdirSync } from "node:fs";', + 'import { dirname } from "node:path";', + "function marker(line) {", + " const path = process.env.CODEX_APP_ROUTER_TEST_MARKER ?? '';", + " if (!path) return;", + " mkdirSync(dirname(path), { recursive: true });", + " appendFileSync(path, `${line}\\n`, 'utf8');", + "}", + "export async function startRuntimeRotationProxy(options) {", + " if (process.env.CODEX_APP_ROUTER_TEST_FAIL_PROXY === '1') throw new Error('proxy boom');", + " marker(`start:${options.host}:${options.port}:${options.clientApiKey}`);", + " return {", + " baseUrl: `http://${options.host}:${options.port || 4567}`,", + " close: async () => marker('close'),", + " getStatus: () => ({", + " totalRequests: 2,", + " upstreamRequests: 1,", + " retries: 0,", + " rotations: 1,", + " lastAccountIndex: 1,", + " lastAccountLabel: 'Account 2 (hidden@example.com)',", + " lastAccountId: 'acc_2',", + " lastAccountUpdatedAt: 123,", + " lastError: null,", + " }),", + " };", + "}", + ].join("\n"), + "utf8", + ); + } + return scriptPath; +} + +async function writeState(path: string, state: Record): Promise { + await writeFile(path, `${JSON.stringify(state, null, 2)}\n`, "utf8"); +} + +async function readJsonWhen( + path: string, + predicate: (value: Record) => boolean, +): Promise> { + let latest: Record | null = null; + for (let attempt = 0; attempt < 60; attempt += 1) { + if (existsSync(path)) { + latest = JSON.parse(readFileSync(path, "utf8")) as Record; + if (predicate(latest)) return latest; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error(`status did not reach expected state; latest=${JSON.stringify(latest)}`); +} + +async function stopChild(child: ChildProcessWithoutNullStreams): Promise { + if (child.exitCode !== null) return; + child.kill("SIGTERM"); + await new Promise((resolve) => { + child.once("close", () => resolve()); + setTimeout(resolve, 2_000); + }); +} + +afterEach(async () => { + await Promise.all( + tempRoots.splice(0).map((root) => + withFileOperationRetry(() => rm(root, { recursive: true, force: true })), + ), + ); +}); + +describe("codex app router daemon", () => { + it("starts, serializes redacted running status, and cleans up on SIGTERM", async () => { + const root = await createTempRoot("codex-app-router-ok-"); + const scriptPath = createRouterFixture(root); + const statePath = join(root, "state.json"); + const statusPath = join(root, "status.json"); + const markerPath = join(root, "marker.log"); + await writeState(statePath, { + clientApiKey: "router-secret", + host: "127.0.0.1", + port: 0, + baseUrl: "http://127.0.0.1:0", + statusPath, + }); + const child = spawn( + process.execPath, + [scriptPath, "--status", statusPath, "--state", statePath], + { + env: { ...process.env, CODEX_APP_ROUTER_TEST_MARKER: markerPath }, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }, + ); + try { + const running = await readJsonWhen( + statusPath, + (status) => status.state === "running", + ); + expect(running.kind).toBe("codex-app-runtime-rotation-router"); + expect(running.baseUrl).toBe("http://127.0.0.1:4567"); + expect(running.lastAccountLabel).toBe("Account 2"); + expect(running).not.toHaveProperty("clientApiKey"); + if (process.platform !== "win32") { + expect(statSync(statusPath).mode & 0o777).toBe(0o600); + } + child.kill("SIGTERM"); + if (process.platform !== "win32") { + await readJsonWhen(statusPath, (status) => status.state === "stopped"); + expect(readFileSync(markerPath, "utf8")).toContain("close\n"); + } + } finally { + await stopChild(child); + } + }, 10_000); + + it("rejects non-loopback hosts before starting the proxy", async () => { + const root = await createTempRoot("codex-app-router-host-"); + const scriptPath = createRouterFixture(root); + const statePath = join(root, "state.json"); + const statusPath = join(root, "status.json"); + await writeState(statePath, { + clientApiKey: "router-secret", + host: "0.0.0.0", + port: 1234, + statusPath, + }); + + const result = spawnSync( + process.execPath, + [scriptPath, "--status", statusPath, "--state", statePath], + { encoding: "utf8", windowsHide: true }, + ); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("loopback-only"); + expect(existsSync(statusPath)).toBe(false); + }); + + it("rejects state without a client token before starting the proxy", async () => { + const root = await createTempRoot("codex-app-router-token-"); + const scriptPath = createRouterFixture(root); + const statePath = join(root, "state.json"); + const statusPath = join(root, "status.json"); + await writeState(statePath, { + host: "127.0.0.1", + port: 1234, + statusPath, + }); + + const result = spawnSync( + process.execPath, + [scriptPath, "--status", statusPath, "--state", statePath], + { encoding: "utf8", windowsHide: true }, + ); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("missing its client token"); + expect(existsSync(statusPath)).toBe(false); + }); + + it("writes an error status when proxy startup fails", async () => { + const root = await createTempRoot("codex-app-router-fail-"); + const scriptPath = createRouterFixture(root); + const statePath = join(root, "state.json"); + const statusPath = join(root, "status.json"); + await writeState(statePath, { + clientApiKey: "router-secret", + host: "127.0.0.1", + port: 1234, + statusPath, + }); + + const result = spawnSync( + process.execPath, + [scriptPath, "--status", statusPath, "--state", statePath], + { + encoding: "utf8", + env: { ...process.env, CODEX_APP_ROUTER_TEST_FAIL_PROXY: "1" }, + windowsHide: true, + }, + ); + + expect(result.status).not.toBe(0); + const status = JSON.parse(readFileSync(statusPath, "utf8")) as { + state: string; + lastError: string; + }; + expect(status.state).toBe("error"); + expect(status.lastError).toBe("proxy boom"); + }); + + it("writes an error status when the proxy module is missing", async () => { + const root = await createTempRoot("codex-app-router-missing-dist-"); + const scriptPath = createRouterFixture(root, { withProxyModule: false }); + const statePath = join(root, "state.json"); + const statusPath = join(root, "status.json"); + await writeState(statePath, { + clientApiKey: "router-secret", + host: "127.0.0.1", + port: 1234, + statusPath, + }); + + const result = spawnSync( + process.execPath, + [scriptPath, "--status", statusPath, "--state", statePath], + { encoding: "utf8", windowsHide: true }, + ); + + expect(result.status).not.toBe(0); + const status = JSON.parse(readFileSync(statusPath, "utf8")) as { + state: string; + lastError: string; + }; + expect(status.state).toBe("error"); + expect(status.lastError).toContain("runtime-rotation-proxy.js"); + }); +}); diff --git a/test/codex-bin-wrapper.test.ts b/test/codex-bin-wrapper.test.ts index 32e55075..9bbfce5e 100644 --- a/test/codex-bin-wrapper.test.ts +++ b/test/codex-bin-wrapper.test.ts @@ -2,12 +2,15 @@ import { type SpawnSyncReturns, spawn, spawnSync } from "node:child_process"; import { chmodSync, copyFileSync, + existsSync, linkSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, + statSync, + utimesSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; @@ -15,6 +18,7 @@ import { delimiter, dirname, join } from "node:path"; import process from "node:process"; import { fileURLToPath, pathToFileURL } from "node:url"; import { afterEach, describe, expect, it } from "vitest"; +import { RUNTIME_ROTATION_PROXY_PROVIDER_ID } from "../lib/runtime-constants.js"; import { sleep } from "../lib/utils.js"; import { resolveRealCodexBin } from "../scripts/codex-bin-resolver.js"; @@ -22,6 +26,7 @@ const createdDirs: string[] = []; const testFileDir = dirname(fileURLToPath(import.meta.url)); const repoRootDir = join(testFileDir, ".."); const EXIT_SUCCESS_LINE = "exit 0"; +const SHADOW_HOME_ORPHAN_LOCK_TEST_AGE_MS = 2_200; function isRetriableFsError(error: unknown): boolean { if (!error || typeof error !== "object" || !("code" in error)) { @@ -71,6 +76,18 @@ function createWrapperFixture(): string { join(repoRootDir, "scripts", "codex-bin-resolver.js"), join(scriptDir, "codex-bin-resolver.js"), ); + copyFileSync( + join(repoRootDir, "scripts", "codex-app-launcher.js"), + join(scriptDir, "codex-app-launcher.js"), + ); + copyFileSync( + join(repoRootDir, "scripts", "codex-app-router.js"), + join(scriptDir, "codex-app-router.js"), + ); + copyFileSync( + join(repoRootDir, "scripts", "install-codex-auth-utils.js"), + join(scriptDir, "install-codex-auth-utils.js"), + ); return fixtureRoot; } @@ -157,6 +174,148 @@ function createRuntimeObservabilityFixtureModule(fixtureRoot: string): string { return modulePath; } +function createRuntimeConfigTomlFixtureModule(fixtureRoot: string): string { + const runtimeDir = join(fixtureRoot, "dist", "lib", "runtime"); + mkdirSync(runtimeDir, { recursive: true }); + const modulePath = join(runtimeDir, "config-toml.js"); + writeFileSync( + modulePath, + [ + `const providerId = ${JSON.stringify(RUNTIME_ROTATION_PROXY_PROVIDER_ID)};`, + "export function tomlStringLiteral(value) {", + " const escaped = String(value).replace(/[\\u0000-\\u001f\\u007f\\\\\"]/g, (character) => {", + " switch (character) {", + ' case "\\b": return "\\\\b";', + ' case "\\t": return "\\\\t";', + ' case "\\n": return "\\\\n";', + ' case "\\f": return "\\\\f";', + ' case "\\r": return "\\\\r";', + ' case "\\"": return "\\\\\\"";', + ' case "\\\\": return "\\\\\\\\";', + " default: return `\\\\u${character.charCodeAt(0).toString(16).padStart(4, '0').toUpperCase()}`;", + " }", + " });", + " return `\"${escaped}\"`;", + "}", + "function readTomlTableName(line) {", + " const match = /^\\s*\\[{1,2}\\s*([^\\]]+?)\\s*\\]{1,2}\\s*$/.exec(line);", + " return match?.[1]?.trim() ?? null;", + "}", + "function removeProviderBlock(rawConfig) {", + " const lines = rawConfig.split(/\\r?\\n/);", + " const output = [];", + " let skipping = false;", + " const providerTable = `model_providers.${providerId}`;", + " for (const line of lines) {", + " const tableName = readTomlTableName(line);", + " if (tableName === providerTable) { skipping = true; continue; }", + " if (skipping && tableName) {", + " if (tableName === providerTable || tableName.startsWith(`${providerTable}.`)) continue;", + " skipping = false;", + " }", + " if (!skipping) output.push(line);", + " }", + " return output.join(rawConfig.includes('\\r\\n') ? '\\r\\n' : '\\n');", + "}", + "function rewriteModelProvider(rawConfig) {", + " const lineEnding = rawConfig.includes('\\r\\n') ? '\\r\\n' : '\\n';", + " const lines = rawConfig.length > 0 ? rawConfig.split(/\\r?\\n/) : [];", + " const rewrittenLine = `model_provider = ${tomlStringLiteral(providerId)}`;", + " let replaced = false;", + " const output = [];", + " for (const line of lines) {", + " const isTable = readTomlTableName(line) !== null;", + " if (!replaced && isTable) { output.push(rewrittenLine); replaced = true; }", + " if (!replaced && /^\\s*model_provider\\s*=/.test(line)) { output.push(rewrittenLine); replaced = true; continue; }", + " output.push(line);", + " }", + " if (!replaced) output.push(rewrittenLine);", + " return output.join(lineEnding);", + "}", + "export function rewriteConfigTomlForRuntimeRotationProvider(rawConfig, baseUrl, clientApiKey = '') {", + " const lineEnding = rawConfig.includes('\\r\\n') ? '\\r\\n' : '\\n';", + " const withoutOldProvider = removeProviderBlock(rawConfig).replace(/[\\r\\n]*$/, '');", + " const withModelProvider = rewriteModelProvider(withoutOldProvider).replace(/[\\r\\n]*$/, '');", + " const providerBlock = [", + " `[model_providers.${providerId}]`,", + " 'name = \"codex-multi-auth\"',", + " `base_url = ${tomlStringLiteral(baseUrl)}`,", + " 'requires_openai_auth = false',", + " `experimental_bearer_token = ${tomlStringLiteral(clientApiKey)}`,", + " 'wire_api = \"responses\"',", + " ];", + " return `${withModelProvider}${lineEnding}${lineEnding}${providerBlock.join(lineEnding)}${lineEnding}`;", + "}", + ].join("\n"), + "utf8", + ); + return modulePath; +} + +function createRuntimeRotationProxyFixtureModule(fixtureRoot: string): string { + createRuntimeConfigTomlFixtureModule(fixtureRoot); + const distLibDir = join(fixtureRoot, "dist", "lib"); + mkdirSync(distLibDir, { recursive: true }); + const modulePath = join(distLibDir, "runtime-rotation-proxy.js"); + writeFileSync( + modulePath, + [ + 'import { appendFileSync, mkdirSync } from "node:fs";', + 'import { dirname } from "node:path";', + "", + "function appendMarker(line) {", + " const marker = (process.env.CODEX_MULTI_AUTH_TEST_PROXY_MARKER ?? '').trim();", + " if (marker.length === 0) return;", + " mkdirSync(dirname(marker), { recursive: true });", + " appendFileSync(marker, `${line}\\n`, 'utf8');", + "}", + "", + "function readOptionalNumberEnv(name) {", + " const parsed = Number.parseInt(process.env[name] ?? '', 10);", + " return Number.isFinite(parsed) ? parsed : null;", + "}", + "", + "function readOptionalStringEnv(name) {", + " const value = (process.env[name] ?? '').trim();", + " return value.length > 0 ? value : null;", + "}", + "", + "function buildStatus() {", + " return {", + " totalRequests: readOptionalNumberEnv('CODEX_MULTI_AUTH_TEST_PROXY_REQUESTS') ?? 0,", + " upstreamRequests: 0,", + " retries: 0,", + " rotations: readOptionalNumberEnv('CODEX_MULTI_AUTH_TEST_PROXY_ROTATIONS') ?? 0,", + " lastAccountIndex: readOptionalNumberEnv('CODEX_MULTI_AUTH_TEST_PROXY_LAST_ACCOUNT_INDEX'),", + " lastAccountLabel: readOptionalStringEnv('CODEX_MULTI_AUTH_TEST_PROXY_LAST_ACCOUNT_LABEL'),", + " lastAccountEmail: readOptionalStringEnv('CODEX_MULTI_AUTH_TEST_PROXY_LAST_ACCOUNT_EMAIL'),", + " lastAccountId: readOptionalStringEnv('CODEX_MULTI_AUTH_TEST_PROXY_LAST_ACCOUNT_ID'),", + " lastAccountUpdatedAt: readOptionalNumberEnv('CODEX_MULTI_AUTH_TEST_PROXY_LAST_ACCOUNT_UPDATED_AT'),", + " lastError: null,", + " };", + "}", + "", + "export async function startRuntimeRotationProxy() {", + " 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_ENV ?? '').trim() === '1') {", + " appendMarker(`codex-home-env:${process.env.CODEX_HOME ?? ''}`);", + " appendMarker(`real-home-env:${process.env.CODEX_MULTI_AUTH_REAL_CODEX_HOME ?? ''}`);", + " }", + " appendMarker((process.env.CODEX_MULTI_AUTH_TEST_PROXY_MARKER_PID ?? '').trim() === '1' ? `start:${baseUrl}:pid=${process.pid}` : `start:${baseUrl}`);", + " return {", + " host: '127.0.0.1',", + " port: 4567,", + " baseUrl,", + " close: async () => appendMarker('close'),", + " getStatus: () => buildStatus(),", + " };", + "}", + ].join("\n"), + "utf8", + ); + return modulePath; +} + function createFakeCodexBin(rootDir: string): string { const fakeBin = join(rootDir, "fake-codex.js"); writeFileSync( @@ -276,6 +435,32 @@ function injectShadowPreflightReadBusyFailures( }; } +function injectShadowSyncMetadataBusyFailures( + failuresBeforeSuccess = 10, +): NodeJS.ProcessEnv { + return { + CODEX_MULTI_AUTH_TEST_SHADOW_SYNC_METADATA_BUSY_FAILURES: String( + failuresBeforeSuccess, + ), + }; +} + +function injectShadowLockRecreatedStaleCount(count = 2): NodeJS.ProcessEnv { + return { + CODEX_MULTI_AUTH_TEST_SHADOW_LOCK_RECREATE_STALE_COUNT: String(count), + }; +} + +function injectShadowLockOwnerWriteFailures( + failuresBeforeSuccess = 1, +): NodeJS.ProcessEnv { + return { + CODEX_MULTI_AUTH_TEST_SHADOW_LOCK_OWNER_WRITE_FAILURES: String( + failuresBeforeSuccess, + ), + }; +} + function createFakeGlobalCodexInstall(rootDir: string): string { const fakeBin = join(rootDir, "@openai", "codex", "bin", "codex.js"); mkdirSync(dirname(fakeBin), { recursive: true }); @@ -365,6 +550,40 @@ function runWrapper( ); } +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error && typeof error === "object" && "code" in error + ? error.code === "EPERM" + : false; + } +} + +async function ageShadowSyncLockForSteal(lockDir: string): Promise { + const staleTimestamp = new Date(Date.now() - SHADOW_HOME_ORPHAN_LOCK_TEST_AGE_MS); + utimesSync(lockDir, staleTimestamp, staleTimestamp); + await sleep(SHADOW_HOME_ORPHAN_LOCK_TEST_AGE_MS); +} + +function runWrapperWithInput( + fixtureRoot: string, + args: string[], + input: string, + extraEnv: NodeJS.ProcessEnv = {}, +): SpawnSyncReturns { + return spawnSync( + process.execPath, + [join(fixtureRoot, "scripts", "codex.js"), ...args], + { + encoding: "utf8", + env: buildWrapperEnv(extraEnv), + input, + }, + ); +} + function runWrapperScript( scriptPath: string, args: string[], @@ -429,6 +648,36 @@ function runWrapperAsync( }); } +async function waitForPath(path: string, timeoutMs = 3_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (existsSync(path)) return; + await sleep(20); + } + throw new Error(`timed out waiting for ${path}`); +} + +async function waitForFileText( + path: string, + expected: string, + timeoutMs = 5_000, +): Promise { + const deadline = Date.now() + timeoutMs; + let lastContent = ""; + while (Date.now() < deadline) { + try { + lastContent = readFileSync(path, "utf8"); + if (lastContent === expected) return; + } catch { + // Keep polling until the file appears or the timeout expires. + } + await sleep(20); + } + throw new Error( + `timed out waiting for ${path} to equal ${JSON.stringify(expected)}; last content: ${JSON.stringify(lastContent)}`, + ); +} + function combinedOutput( result: SpawnSyncReturns | WrapperAsyncResult, ): string { @@ -518,239 +767,1671 @@ describe("codex bin wrapper", () => { ); }); - it("records forwarded exec traffic in runtime observability when the child process does not update it", () => { + it("starts the opt-in runtime rotation proxy with a shadow CODEX_HOME provider", () => { const fixtureRoot = createWrapperFixture(); - createRuntimeObservabilityFixtureModule(fixtureRoot); - const fakeBin = createFakeCodexBin(fixtureRoot); - const multiAuthDir = join(fixtureRoot, "multi-auth"); + createRuntimeRotationProxyFixtureModule(fixtureRoot); + const fakeBin = createCustomFakeCodexBin(fixtureRoot, [ + "#!/usr/bin/env node", + 'const fs = require("node:fs");', + 'const path = require("node:path");', + 'console.log(`FORWARDED:${process.argv.slice(2).join(" ")}`);', + 'console.log(`CODEX_HOME:${process.env.CODEX_HOME ?? ""}`);', + 'console.log(`CODEX_HOME_IS_ORIGINAL:${process.env.CODEX_HOME === process.env.ORIGINAL_CODEX_HOME}`);', + 'console.log(`OPENAI_API_KEY:${process.env.OPENAI_API_KEY ?? ""}`);', + 'console.log(`SESSION_EXISTS:${fs.existsSync(path.join(process.env.CODEX_HOME ?? "", "sessions", "resume.jsonl"))}`);', + 'console.log(`PLUGIN_EXISTS:${fs.existsSync(path.join(process.env.CODEX_HOME ?? "", "plugins", "plugin.txt"))}`);', + 'console.log(`SKILL_EXISTS:${fs.existsSync(path.join(process.env.CODEX_HOME ?? "", "skills", "skill.txt"))}`);', + 'console.log(`MEMORY_EXISTS:${fs.existsSync(path.join(process.env.CODEX_HOME ?? "", "memories", "user.md"))}`);', + 'console.log(`INSTRUCTION_EXISTS:${fs.existsSync(path.join(process.env.CODEX_HOME ?? "", "instructions", "profile.md"))}`);', + 'const statePath = path.join(process.env.CODEX_HOME ?? "", "state_5.sqlite");', + 'fs.appendFileSync(statePath, "shadow\\n", "utf8");', + 'console.log(`ROOT_STATE_REALTIME:${fs.readFileSync(path.join(process.env.ORIGINAL_CODEX_HOME ?? "", "state_5.sqlite"), "utf8").includes("shadow")}`);', + 'fs.writeFileSync(path.join(process.env.CODEX_HOME ?? "", "new-root-state.json"), "new\\n", "utf8");', + 'fs.writeFileSync(path.join(process.env.CODEX_HOME ?? "", "sessions", "runtime-session.jsonl"), "runtime\\n", "utf8");', + 'fs.writeFileSync(path.join(process.env.CODEX_HOME ?? "", "auth.json"), \'{"token":"proxy-scoped"}\\n\', "utf8");', + 'fs.writeFileSync(path.join(process.env.CODEX_HOME ?? "", "accounts.json"), \'{"accounts":["proxy-scoped"]}\\n\', "utf8");', + 'fs.writeFileSync(path.join(process.env.CODEX_HOME ?? "", ".codex-global-state.json"), \'{"last":"runtime"}\\n\', "utf8");', + 'const configPath = path.join(process.env.CODEX_HOME ?? "", "config.toml");', + 'console.log("CONFIG_START");', + 'console.log(fs.readFileSync(configPath, "utf8").trim());', + 'console.log("CONFIG_END");', + "process.exit(0);", + ]); + const originalHome = join(fixtureRoot, "codex-home"); + const markerPath = join(fixtureRoot, "proxy-marker.txt"); + mkdirSync(originalHome, { recursive: true }); + mkdirSync(join(originalHome, "sessions"), { recursive: true }); + mkdirSync(join(originalHome, "plugins"), { recursive: true }); + mkdirSync(join(originalHome, "skills"), { recursive: true }); + mkdirSync(join(originalHome, "memories"), { recursive: true }); + mkdirSync(join(originalHome, "instructions"), { recursive: true }); + writeFileSync(join(originalHome, "sessions", "resume.jsonl"), "resume\n", "utf8"); + writeFileSync(join(originalHome, "plugins", "plugin.txt"), "plugin\n", "utf8"); + writeFileSync(join(originalHome, "skills", "skill.txt"), "skill\n", "utf8"); + writeFileSync(join(originalHome, "memories", "user.md"), "memory\n", "utf8"); + writeFileSync(join(originalHome, "auth.json"), '{"token":"original"}\n', "utf8"); + writeFileSync( + join(originalHome, "accounts.json"), + '{"accounts":["original"]}\n', + "utf8", + ); + writeFileSync( + join(originalHome, ".codex-global-state.json"), + '{"last":"original"}\n', + "utf8", + ); + writeFileSync( + join(originalHome, "instructions", "profile.md"), + "instruction\n", + "utf8", + ); + writeFileSync(join(originalHome, "state_5.sqlite"), "state\n", "utf8"); + writeFileSync( + join(originalHome, "config.toml"), + [ + 'model = "gpt-5-codex"', + 'model_provider = "openai"', + "", + "[model_providers.existing]", + 'name = "Existing"', + 'base_url = "https://example.invalid"', + "", + `[ model_providers.${RUNTIME_ROTATION_PROXY_PROVIDER_ID} ]`, + 'name = "Stale Runtime Proxy"', + 'base_url = "http://127.0.0.1:1"', + ].join("\n"), + "utf8", + ); + const result = runWrapper(fixtureRoot, ["exec", "status"], { CODEX_MULTI_AUTH_REAL_CODEX_BIN: fakeBin, - CODEX_MULTI_AUTH_DIR: multiAuthDir, + CODEX_HOME: originalHome, + ORIGINAL_CODEX_HOME: originalHome, + CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY: "1", + CODEX_MULTI_AUTH_TEST_PROXY_BASE_URL: "http://127.0.0.1:4567", + CODEX_MULTI_AUTH_TEST_PROXY_MARKER: markerPath, + OPENAI_API_KEY: undefined, }); + const output = combinedOutput(result); expect(result.status).toBe(0); - const snapshot = JSON.parse( - readFileSync(join(multiAuthDir, "runtime-observability.json"), "utf8"), - ) as { - responsesRequests: number; - runtimeMetrics: { - totalRequests: number; - responsesRequests: number; - successfulRequests: number; - failedRequests: number; - lastRequestAt: number | null; - lastError: string | null; - }; - }; - expect(snapshot.responsesRequests).toBe(1); - expect(snapshot.runtimeMetrics.totalRequests).toBe(1); - expect(snapshot.runtimeMetrics.responsesRequests).toBe(1); - expect(snapshot.runtimeMetrics.successfulRequests).toBe(1); - expect(snapshot.runtimeMetrics.failedRequests).toBe(0); - expect(snapshot.runtimeMetrics.lastRequestAt).not.toBeNull(); - expect(snapshot.runtimeMetrics.lastError).toBeNull(); + expect(output).toContain( + `FORWARDED:exec status -c cli_auth_credentials_store="file" -c model_provider="${RUNTIME_ROTATION_PROXY_PROVIDER_ID}"`, + ); + expect(output).toContain("CODEX_HOME_IS_ORIGINAL:false"); + expect(output).toContain("SESSION_EXISTS:true"); + expect(output).toContain("PLUGIN_EXISTS:true"); + expect(output).toContain("SKILL_EXISTS:true"); + expect(output).toContain("MEMORY_EXISTS:true"); + expect(output).toContain("INSTRUCTION_EXISTS:true"); + expect(output).toContain("ROOT_STATE_REALTIME:true"); + const apiKeyMatch = output.match(/^OPENAI_API_KEY:([0-9a-f]{64})$/m); + expect(apiKeyMatch?.[1]).toBeTruthy(); + expect(output).toContain( + `model_provider = "${RUNTIME_ROTATION_PROXY_PROVIDER_ID}"`, + ); + expect(output).toContain( + `[model_providers.${RUNTIME_ROTATION_PROXY_PROVIDER_ID}]`, + ); + expect(output).toContain('name = "codex-multi-auth"'); + expect(output).toContain('base_url = "http://127.0.0.1:4567"'); + expect(output).toContain("requires_openai_auth = false"); + expect(output).toContain('name = "codex-multi-auth"'); + expect(output).toContain( + `experimental_bearer_token = "${apiKeyMatch?.[1]}"`, + ); + expect(output).toContain('wire_api = "responses"'); + expect(output).not.toContain("env_key"); + expect(output).not.toContain('base_url = "http://127.0.0.1:1"'); + expect((output.match(/\[model_providers\.codex-multi-auth-runtime-proxy\]/g) ?? []).length).toBe(1); + const shadowHomeMatch = output.match(/^CODEX_HOME:(.+)$/m); + expect(shadowHomeMatch?.[1]).toBeTruthy(); + if (shadowHomeMatch?.[1]) { + expect(existsSync(shadowHomeMatch[1])).toBe(false); + } + expect(readFileSync(markerPath, "utf8")).toBe( + "start:http://127.0.0.1:4567\nclose\n", + ); + expect(readFileSync(join(originalHome, "config.toml"), "utf8")).toContain( + 'model_provider = "openai"', + ); + expect( + readFileSync(join(originalHome, "sessions", "runtime-session.jsonl"), "utf8"), + ).toBe("runtime\n"); + expect(readFileSync(join(originalHome, "state_5.sqlite"), "utf8")).toContain( + "shadow", + ); + expect(readFileSync(join(originalHome, "new-root-state.json"), "utf8")).toBe( + "new\n", + ); + expect(readFileSync(join(originalHome, "auth.json"), "utf8").trim()).toBe( + '{"token":"original"}', + ); + expect(readFileSync(join(originalHome, "accounts.json"), "utf8").trim()).toBe( + '{"accounts":["original"]}', + ); + expect( + readFileSync(join(originalHome, ".codex-global-state.json"), "utf8").trim(), + ).toBe('{"last":"runtime"}'); }); - it("does not double-count forwarded exec traffic when the child process already updates runtime observability", () => { + it("inserts the runtime model provider before TOML array tables", () => { const fixtureRoot = createWrapperFixture(); - createRuntimeObservabilityFixtureModule(fixtureRoot); + createRuntimeRotationProxyFixtureModule(fixtureRoot); const fakeBin = createCustomFakeCodexBin(fixtureRoot, [ "#!/usr/bin/env node", 'const fs = require("node:fs");', 'const path = require("node:path");', - 'const root = process.env.CODEX_MULTI_AUTH_DIR ?? "";', - 'const snapshotPath = path.join(root, "runtime-observability.json");', - "const snapshot = {", - " version: 1,", - " updatedAt: Date.now(),", - " currentRequestId: null,", - " responsesRequests: 1,", - " authRefreshRequests: 0,", - " diagnosticProbeRequests: 0,", - " poolExhaustionCooldownUntil: null,", - " serverBurstCooldownUntil: null,", - " runtimeMetrics: {", - " startedAt: Date.now(),", - " totalRequests: 1,", - " successfulRequests: 1,", - " failedRequests: 0,", - " responsesRequests: 1,", - " authRefreshRequests: 0,", - " diagnosticProbeRequests: 0,", - " outboundRequestAttemptBudget: null,", - " outboundRequestAttemptsConsumed: 0,", - " requestAttemptBudgetExhaustions: 0,", - " poolExhaustionFastFails: 0,", - " serverBurstFastFails: 0,", - " rateLimitedResponses: 0,", - " serverErrors: 0,", - " networkErrors: 0,", - " userAborts: 0,", - " authRefreshFailures: 0,", - " emptyResponseRetries: 0,", - " accountRotations: 0,", - " sameAccountRetries: 0,", - " streamFailoverAttempts: 0,", - " streamFailoverCandidatesConsidered: 0,", - " lastStreamFailoverCandidateCount: 0,", - " streamFailoverRecoveries: 0,", - " streamFailoverCrossAccountRecoveries: 0,", - " cumulativeLatencyMs: 10,", - " lastRequestAt: Date.now(),", - " lastError: null,", - " },", - "};", - "fs.mkdirSync(root, { recursive: true });", - "fs.writeFileSync(snapshotPath, JSON.stringify(snapshot), 'utf8');", + 'console.log(fs.readFileSync(path.join(process.env.CODEX_HOME, "config.toml"), "utf8"));', + ]); + const originalHome = join(fixtureRoot, "codex-home"); + mkdirSync(originalHome, { recursive: true }); + writeFileSync( + join(originalHome, "config.toml"), + ['[[profiles.experimental]]', 'model = "gpt-5-codex"', ""].join("\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", + OPENAI_API_KEY: undefined, + }); + + expect(result.status).toBe(0); + expect( + result.stdout.indexOf( + `model_provider = "${RUNTIME_ROTATION_PROXY_PROVIDER_ID}"`, + ), + ).toBeLessThan( + result.stdout.indexOf("[[profiles.experimental]]"), + ); + }); + + it("starts the opt-in runtime rotation proxy for app-server without capturing protocol stdio", () => { + const fixtureRoot = createWrapperFixture(); + createRuntimeRotationProxyFixtureModule(fixtureRoot); + const fakeBin = createCustomFakeCodexBin(fixtureRoot, [ + "#!/usr/bin/env node", + 'const fs = require("node:fs");', + 'const path = require("node:path");', + 'console.log(`FORWARDED:${process.argv.slice(2).join(" ")}`);', + 'console.log(`CODEX_HOME:${process.env.CODEX_HOME ?? ""}`);', + 'console.log(`OPENAI_API_KEY:${process.env.OPENAI_API_KEY ?? ""}`);', + 'console.log(`CODEX_CLI_PATH:${process.env.CODEX_CLI_PATH ?? ""}`);', + 'console.log(`APP_SERVER_LABEL:${process.env.CODEX_MULTI_AUTH_APP_SERVER_ACCOUNT_LABEL ?? ""}`);', + 'console.log(`RUNTIME_PROXY_ENV:${process.env.CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY ?? ""}`);', + 'console.log(`NODE_OPTIONS_HAS_APP_SERVER_PRELOAD:${(process.env.NODE_OPTIONS ?? "").includes("codex-multi-auth-app-server-preload.mjs")}`);', + 'const configPath = path.join(process.env.CODEX_HOME ?? "", "config.toml");', + 'console.log(fs.readFileSync(configPath, "utf8"));', + "process.exit(0);", + ]); + 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, ["app-server", "--listen", "stdio://"], { + CODEX_MULTI_AUTH_REAL_CODEX_BIN: fakeBin, + CODEX_HOME: originalHome, + CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY: "1", + CODEX_MULTI_AUTH_TEST_PROXY_MARKER: markerPath, + OPENAI_API_KEY: undefined, + }); + + const output = combinedOutput(result); + expect(result.status).toBe(0); + expect(output).toContain( + `FORWARDED:app-server --listen stdio:// -c cli_auth_credentials_store="file" -c model_provider="${RUNTIME_ROTATION_PROXY_PROVIDER_ID}"`, + ); + const apiKeyMatch = output.match(/^OPENAI_API_KEY:([0-9a-f]{64})$/m); + expect(apiKeyMatch?.[1]).toBeTruthy(); + expect(output).toContain("requires_openai_auth = false"); + expect(output).toContain('name = "codex-multi-auth"'); + expect(output).toContain( + `experimental_bearer_token = "${apiKeyMatch?.[1]}"`, + ); + expect(output).toContain('wire_api = "responses"'); + expect(output).not.toContain("env_key"); + expect(readFileSync(markerPath, "utf8")).toBe( + "start:http://127.0.0.1:4567\nclose\n", + ); + }); + + it("rewrites app-server account/read responses to the codex-multi-auth display name", () => { + const fixtureRoot = createWrapperFixture(); + createRuntimeRotationProxyFixtureModule(fixtureRoot); + const fakeBin = createCustomFakeCodexBin(fixtureRoot, [ + "#!/usr/bin/env node", + 'const readline = require("node:readline");', + 'const rl = readline.createInterface({ input: process.stdin });', + 'rl.on("line", (line) => {', + " const message = JSON.parse(line);", + ' if (message.method === "account/read") {', + " console.log(JSON.stringify({", + ' jsonrpc: "2.0",', + " id: message.id,", + " result: {", + ' account: { type: "chatgpt", email: "real-user@example.com", planType: "plus" },', + " requiresOpenaiAuth: true,", + " },", + " }));", + " return;", + " }", + ' console.log(JSON.stringify({ jsonrpc: "2.0", id: message.id, result: { ok: true } }));', + "});", + 'rl.on("close", () => process.exit(0));', + ]); + const originalHome = join(fixtureRoot, "codex-home"); + mkdirSync(originalHome, { recursive: true }); + writeFileSync(join(originalHome, "config.toml"), 'model_provider = "openai"\n', "utf8"); + const input = [ + JSON.stringify({ + jsonrpc: "2.0", + id: 7, + method: "account/read", + params: { refreshToken: false }, + }), + JSON.stringify({ + jsonrpc: "2.0", + id: 8, + method: "thread/list", + params: {}, + }), + "", + ].join("\n"); + + const result = runWrapperWithInput( + fixtureRoot, + ["app-server", "--listen", "stdio://"], + input, + { + CODEX_MULTI_AUTH_REAL_CODEX_BIN: fakeBin, + CODEX_HOME: originalHome, + CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY: "1", + OPENAI_API_KEY: undefined, + }, + ); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("codex-multi-auth"); + expect(result.stdout).not.toContain("real-user@example.com"); + expect(result.stdout).toContain('"requiresOpenaiAuth":false'); + expect(result.stdout).toContain('"id":8'); + expect(result.stdout).toContain('"ok":true'); + }); + + it("resumes process stdin when cleaning up app-server protocol proxy listeners", () => { + const source = readFileSync( + join(repoRootDir, "scripts", "codex.js"), + "utf8", + ); + const cleanupMatch = source.match( + /cleanupProtocolProxy = \(\) => \{[\s\S]*?child\.stderr\?\.removeListener\("data", onChildStderrData\);[\s\S]*?\};/, + ); + + expect(cleanupMatch?.[0]).toContain("process.stdin.resume();"); + }); + + it("suppresses app-server account/read errors with a synthetic multi-auth account", () => { + const fixtureRoot = createWrapperFixture(); + createRuntimeRotationProxyFixtureModule(fixtureRoot); + const fakeBin = createCustomFakeCodexBin(fixtureRoot, [ + "#!/usr/bin/env node", + 'const readline = require("node:readline");', + 'const rl = readline.createInterface({ input: process.stdin });', + 'rl.on("line", (line) => {', + " const message = JSON.parse(line);", + ' if (message.method === "account/read") {', + ' console.log(JSON.stringify({ jsonrpc: "2.0", id: message.id, error: { code: -32000, message: "Your access token could not be refreshed because your refresh token was already used" } }));', + " }", + "});", + 'rl.on("close", () => process.exit(0));', + ]); + const originalHome = join(fixtureRoot, "codex-home"); + mkdirSync(originalHome, { recursive: true }); + writeFileSync(join(originalHome, "config.toml"), 'model_provider = "openai"\n', "utf8"); + const input = `${JSON.stringify({ + jsonrpc: "2.0", + id: 7, + method: "account/read", + params: { refreshToken: false }, + })}\n`; + + const result = runWrapperWithInput( + fixtureRoot, + ["app-server", "--listen", "stdio://"], + input, + { + CODEX_MULTI_AUTH_REAL_CODEX_BIN: fakeBin, + CODEX_HOME: originalHome, + CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY: "1", + OPENAI_API_KEY: undefined, + }, + ); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("codex-multi-auth"); + expect(result.stdout).toContain('"requiresOpenaiAuth":false'); + expect(result.stdout).not.toContain('"error"'); + expect(result.stdout).not.toContain("refresh token was already used"); + }); + + it("rewrites app-server auth status and rate-limit responses to avoid ChatGPT auth prompts", () => { + const fixtureRoot = createWrapperFixture(); + createRuntimeRotationProxyFixtureModule(fixtureRoot); + const fakeBin = createCustomFakeCodexBin(fixtureRoot, [ + "#!/usr/bin/env node", + 'const readline = require("node:readline");', + 'const rl = readline.createInterface({ input: process.stdin });', + 'rl.on("line", (line) => {', + " const message = JSON.parse(line);", + ' if (message.method === "getAuthStatus") {', + ' console.log(JSON.stringify({ jsonrpc: "2.0", id: message.id, error: { code: -32000, message: "chatgpt refresh failed" } }));', + " return;", + " }", + ' if (message.method === "account/rateLimits/read") {', + ' console.log(JSON.stringify({ jsonrpc: "2.0", id: message.id, error: { code: -32000, message: "rate limits need chatgpt auth" } }));', + " return;", + " }", + ' console.log(JSON.stringify({ jsonrpc: "2.0", id: message.id, result: { ok: true } }));', + "});", + 'rl.on("close", () => process.exit(0));', + ]); + const originalHome = join(fixtureRoot, "codex-home"); + mkdirSync(originalHome, { recursive: true }); + writeFileSync(join(originalHome, "config.toml"), 'model_provider = "openai"\n', "utf8"); + const input = [ + JSON.stringify({ + jsonrpc: "2.0", + id: "auth-status", + method: "getAuthStatus", + params: { includeToken: true, refreshToken: true }, + }), + JSON.stringify({ + jsonrpc: "2.0", + id: "rate-limits", + method: "account/rateLimits/read", + }), + JSON.stringify({ + jsonrpc: "2.0", + id: "other", + method: "thread/list", + params: {}, + }), + "", + ].join("\n"); + + const result = runWrapperWithInput( + fixtureRoot, + ["app-server", "--listen", "stdio://"], + input, + { + CODEX_MULTI_AUTH_REAL_CODEX_BIN: fakeBin, + CODEX_HOME: originalHome, + CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY: "1", + OPENAI_API_KEY: undefined, + }, + ); + + expect(result.status).toBe(0); + expect(result.stdout).toContain('"authMethod":"apikey"'); + expect(result.stdout).toContain('"authToken":null'); + expect(result.stdout).toContain('"requiresOpenaiAuth":false'); + expect(result.stdout).toContain('"id":"rate-limits"'); + expect(result.stdout).toContain('"rateLimitsByLimitId":null'); + expect(result.stdout).not.toContain("chatgpt refresh failed"); + expect(result.stdout).not.toContain("rate limits need chatgpt auth"); + expect(result.stdout).toContain('"id":"other"'); + }); + + it.each([ + ["app help", ["app", "--help"]], + ["app-server help", ["app-server", "--help"]], + ["app-server TypeScript generation", ["app-server", "generate-ts"]], + ["app-server JSON schema generation", ["app-server", "generate-json-schema"]], + ])("does not start runtime rotation proxy for %s", (_label, args) => { + const fixtureRoot = createWrapperFixture(); + createRuntimeRotationProxyFixtureModule(fixtureRoot); + const fakeBin = createFakeCodexBin(fixtureRoot); + const markerPath = join(fixtureRoot, "proxy-marker.txt"); + + const result = runWrapper(fixtureRoot, args, { + CODEX_MULTI_AUTH_REAL_CODEX_BIN: fakeBin, + CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY: "1", + CODEX_MULTI_AUTH_TEST_PROXY_MARKER: markerPath, + }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain(`FORWARDED:${args.join(" ")}`); + expect(existsSync(markerPath)).toBe(false); + }); + + it("starts an automatic runtime rotation helper for codex app launches", async () => { + const fixtureRoot = createWrapperFixture(); + createRuntimeRotationProxyFixtureModule(fixtureRoot); + const fakeBin = createCustomFakeCodexBin(fixtureRoot, [ + "#!/usr/bin/env node", + 'const { spawnSync } = require("node:child_process");', + 'const fs = require("node:fs");', + 'const path = require("node:path");', + 'const { fileURLToPath } = require("node:url");', + 'if (process.argv.slice(2)[0] === "app-server") {', + ' console.log(`APP_SERVER_FORWARDED:${process.argv.slice(2).join(" ")}`);', + ' console.log(`APP_SERVER_LABEL_ENV:${process.env.CODEX_MULTI_AUTH_APP_SERVER_ACCOUNT_LABEL ?? ""}`);', + " process.exit(0);", + "}", + 'console.log(`FORWARDED:${process.argv.slice(2).join(" ")}`);', + 'console.log(`CODEX_HOME:${process.env.CODEX_HOME ?? ""}`);', + 'console.log(`OPENAI_API_KEY:${process.env.OPENAI_API_KEY ?? ""}`);', + 'console.log(`CODEX_CLI_PATH:${process.env.CODEX_CLI_PATH ?? ""}`);', + 'console.log(`APP_SERVER_LABEL:${process.env.CODEX_MULTI_AUTH_APP_SERVER_ACCOUNT_LABEL ?? ""}`);', + 'console.log(`RUNTIME_PROXY_ENV:${process.env.CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY ?? ""}`);', + 'console.log(`NODE_OPTIONS_HAS_APP_SERVER_PRELOAD:${(process.env.NODE_OPTIONS ?? "").includes("codex-multi-auth-app-server-preload.mjs")}`);', + 'const preloadMatch = (process.env.NODE_OPTIONS ?? "").match(/--import=(\\S*codex-multi-auth-app-server-preload\\.mjs)/);', + "const preloadCheck = preloadMatch ? spawnSync(process.execPath, ['--check', fileURLToPath(preloadMatch[1])], { encoding: 'utf8' }) : null;", + 'console.log(`APP_SERVER_PRELOAD_CHECK_STATUS:${preloadCheck?.status ?? "missing"}`);', + 'console.log(`APP_SERVER_PRELOAD_CHECK_STDERR:${(preloadCheck?.stderr ?? "").trim()}`);', + 'console.log(`SHADOW_AUTH_EXISTS:${fs.existsSync(path.join(process.env.CODEX_HOME ?? "", "auth.json"))}`);', + 'console.log(`SHADOW_ACCOUNTS_EXISTS:${fs.existsSync(path.join(process.env.CODEX_HOME ?? "", "accounts.json"))}`);', + 'console.log(`SHADOW_SESSIONS_EXISTS:${fs.existsSync(path.join(process.env.CODEX_HOME ?? "", "sessions"))}`);', + 'console.log(`SHADOW_PLUGINS_EXISTS:${fs.existsSync(path.join(process.env.CODEX_HOME ?? "", "plugins"))}`);', + 'console.log(`SHADOW_SKILLS_EXISTS:${fs.existsSync(path.join(process.env.CODEX_HOME ?? "", "skills"))}`);', + 'console.log(`SHADOW_MEMORY_EXISTS:${fs.existsSync(path.join(process.env.CODEX_HOME ?? "", "memory"))}`);', + "Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1200);", + 'const shimExe = path.join(process.env.CODEX_CLI_PATH ?? "", process.platform === "win32" ? "codex.exe" : "codex");', + 'const shimResult = spawnSync(shimExe, ["app-server", "--shim-probe"], { encoding: "utf8", env: process.env });', + 'console.log(`APP_SERVER_SHIM_STATUS:${shimResult.status}`);', + 'console.log(`APP_SERVER_SHIM_STDOUT:${(shimResult.stdout ?? "").trim()}`);', + 'console.log(`APP_SERVER_SHIM_STDERR:${(shimResult.stderr ?? "").trim()}`);', + 'const configPath = path.join(process.env.CODEX_HOME ?? "", "config.toml");', + 'console.log(fs.readFileSync(configPath, "utf8"));', + "process.exit(0);", + ]); + const originalHome = join(fixtureRoot, "codex-home"); + const multiAuthDir = join(fixtureRoot, "multi-auth"); + const markerPath = join(fixtureRoot, "proxy-marker.txt"); + mkdirSync(originalHome, { recursive: true }); + writeFileSync(join(originalHome, "config.toml"), 'model_provider = "openai"\n', "utf8"); + writeFileSync( + join(originalHome, "auth.json"), + '{"tokens":{"refresh_token":"stale-refresh-token"}}\n', + "utf8", + ); + writeFileSync( + join(originalHome, "accounts.json"), + '{"accounts":[{"email":"real-user@example.com"}]}\n', + "utf8", + ); + mkdirSync(join(originalHome, "sessions"), { recursive: true }); + mkdirSync(join(originalHome, "plugins"), { recursive: true }); + mkdirSync(join(originalHome, "skills"), { recursive: true }); + mkdirSync(join(originalHome, "memory"), { recursive: true }); + writeFileSync(join(originalHome, "sessions", "session.jsonl"), "{}\n", "utf8"); + writeFileSync(join(originalHome, "plugins", "plugin.json"), "{}\n", "utf8"); + writeFileSync(join(originalHome, "skills", "skill.md"), "# Skill\n", "utf8"); + writeFileSync(join(originalHome, "memory", "memory.md"), "# Memory\n", "utf8"); + + const result = runWrapper(fixtureRoot, ["app", "."], { + CODEX_MULTI_AUTH_REAL_CODEX_BIN: fakeBin, + CODEX_HOME: originalHome, + CODEX_MULTI_AUTH_DIR: multiAuthDir, + CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY: "1", + CODEX_MULTI_AUTH_APP_ROTATION_IDLE_MS: "1000", + CODEX_MULTI_AUTH_TEST_PROXY_MARKER: markerPath, + CODEX_MULTI_AUTH_TEST_PROXY_LAST_ACCOUNT_INDEX: "1", + CODEX_MULTI_AUTH_TEST_PROXY_LAST_ACCOUNT_LABEL: + "Account 2 (second@example.com, id:second)", + CODEX_MULTI_AUTH_TEST_PROXY_LAST_ACCOUNT_EMAIL: "second@example.com", + CODEX_MULTI_AUTH_TEST_PROXY_LAST_ACCOUNT_ID: "acc_second", + CODEX_MULTI_AUTH_TEST_PROXY_LAST_ACCOUNT_UPDATED_AT: "12345", + OPENAI_API_KEY: undefined, + }); + + const output = combinedOutput(result); + if (result.status !== 0) { + throw new Error(output); + } + expect(output).toContain( + `FORWARDED:app . -c cli_auth_credentials_store="file" -c model_provider="${RUNTIME_ROTATION_PROXY_PROVIDER_ID}"`, + ); + const apiKeyMatch = output.match(/^OPENAI_API_KEY:([0-9a-f]{64})$/m); + expect(apiKeyMatch?.[1]).toBeTruthy(); + expect(output).toMatch(/^CODEX_CLI_PATH:.+app-server-shims.+helper-\d+$/m); + expect(output).toContain("APP_SERVER_LABEL:1"); + expect(output).toContain("RUNTIME_PROXY_ENV:0"); + expect(output).toContain("NODE_OPTIONS_HAS_APP_SERVER_PRELOAD:true"); + expect(output).toContain("APP_SERVER_PRELOAD_CHECK_STATUS:0"); + expect(output).toContain("APP_SERVER_PRELOAD_CHECK_STDERR:"); + expect(output).toContain("SHADOW_AUTH_EXISTS:false"); + expect(output).toContain("SHADOW_ACCOUNTS_EXISTS:false"); + expect(output).toContain("SHADOW_SESSIONS_EXISTS:true"); + expect(output).toContain("SHADOW_PLUGINS_EXISTS:true"); + expect(output).toContain("SHADOW_SKILLS_EXISTS:true"); + expect(output).toContain("SHADOW_MEMORY_EXISTS:true"); + expect(output).toContain("APP_SERVER_SHIM_STATUS:0"); + expect(output).toContain( + "APP_SERVER_SHIM_STDOUT:APP_SERVER_FORWARDED:app-server --shim-probe", + ); + expect(output).toContain("APP_SERVER_LABEL_ENV:1"); + expect(output).toContain("requires_openai_auth = false"); + expect(output).toContain( + `experimental_bearer_token = "${apiKeyMatch?.[1]}"`, + ); + expect(output).toContain('wire_api = "responses"'); + expect(output).not.toContain("env_key"); + const shadowHomeMatch = output.match(/^CODEX_HOME:(.+)$/m); + expect(shadowHomeMatch?.[1]).toBeTruthy(); + const cliPathMatch = output.match(/^CODEX_CLI_PATH:(.+)$/m); + expect(cliPathMatch?.[1]).toBeTruthy(); + if (cliPathMatch?.[1] && shadowHomeMatch?.[1]) { + expect(cliPathMatch[1].startsWith(shadowHomeMatch[1])).toBe(false); + } + + await sleep(2200); + + expect(readFileSync(markerPath, "utf8")).toBe( + "start:http://127.0.0.1:4567\nclose\n", + ); + const helperStatus = JSON.parse( + readFileSync(join(multiAuthDir, "runtime-rotation-app-helper.json"), "utf8"), + ) as { + state: string; + totalRequests: number; + lastAccountIndex: number | null; + lastAccountLabel: string | null; + lastAccountId: string | null; + lastAccountUpdatedAt: number | null; + }; + expect(helperStatus.state).toBe("idle-timeout"); + expect(helperStatus.totalRequests).toBe(0); + expect(helperStatus.lastAccountIndex).toBe(1); + expect(helperStatus.lastAccountLabel).toBe("Account 2"); + expect(helperStatus).not.toHaveProperty("lastAccountEmail"); + expect(helperStatus.lastAccountId).toBe("acc_second"); + expect(helperStatus.lastAccountUpdatedAt).toBe(12345); + if (process.platform !== "win32") { + expect( + statSync(join(multiAuthDir, "runtime-rotation-app-helper.json")).mode & + 0o777, + ).toBe(0o600); + } + if (shadowHomeMatch?.[1]) { + expect(existsSync(shadowHomeMatch[1])).toBe(false); + } + if (cliPathMatch?.[1]) { + expect(existsSync(cliPathMatch[1])).toBe(false); + } + }); + + it("sweeps stale app-server shim directories when a helper starts", async () => { + const fixtureRoot = createWrapperFixture(); + createRuntimeRotationProxyFixtureModule(fixtureRoot); + const fakeBin = createCustomFakeCodexBin(fixtureRoot, [ + "#!/usr/bin/env node", + 'const fs = require("node:fs");', + 'console.log(`STALE_SHIM_EXISTS:${fs.existsSync(process.env.CODEX_MULTI_AUTH_TEST_STALE_SHIM_DIR ?? "")}`);', + "process.exit(0);", + ]); + const originalHome = join(fixtureRoot, "codex-home"); + const multiAuthDir = join(fixtureRoot, "multi-auth"); + const markerPath = join(fixtureRoot, "proxy-marker.txt"); + const staleShimDir = join( + multiAuthDir, + "app-server-shims", + "helper-2147483647", + ); + mkdirSync(originalHome, { recursive: true }); + mkdirSync(staleShimDir, { recursive: true }); + writeFileSync( + join(originalHome, "config.toml"), + 'model_provider = "openai"\n', + "utf8", + ); + writeFileSync( + join(staleShimDir, process.platform === "win32" ? "codex.exe" : "codex"), + "stale\n", + "utf8", + ); + + const result = runWrapper(fixtureRoot, ["app", "."], { + CODEX_MULTI_AUTH_REAL_CODEX_BIN: fakeBin, + CODEX_HOME: originalHome, + CODEX_MULTI_AUTH_DIR: multiAuthDir, + CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY: "1", + CODEX_MULTI_AUTH_APP_ROTATION_IDLE_MS: "200", + CODEX_MULTI_AUTH_TEST_PROXY_MARKER: markerPath, + CODEX_MULTI_AUTH_TEST_STALE_SHIM_DIR: staleShimDir, + OPENAI_API_KEY: undefined, + }); + + expect(result.status).toBe(0); + expect(combinedOutput(result)).toContain("STALE_SHIM_EXISTS:false"); + expect(existsSync(staleShimDir)).toBe(false); + await waitForFileText( + markerPath, + "start:http://127.0.0.1:4567\nclose\n", + ); + }); + + it("keeps app helpers alive when owner liveness probes return EPERM", async () => { + const fixtureRoot = createWrapperFixture(); + createRuntimeRotationProxyFixtureModule(fixtureRoot); + const originalHome = join(fixtureRoot, "codex-home"); + const multiAuthDir = join(fixtureRoot, "multi-auth"); + const markerPath = join(fixtureRoot, "proxy-marker.txt"); + const preloadPath = join(fixtureRoot, "owner-eperm-preload.mjs"); + mkdirSync(originalHome, { recursive: true }); + writeFileSync(join(originalHome, "config.toml"), 'model_provider = "openai"\n', "utf8"); + writeFileSync( + preloadPath, + [ + "const originalKill = process.kill.bind(process);", + "process.kill = (pid, signal) => {", + " if (signal === 0 && String(pid) === process.env.CODEX_MULTI_AUTH_APP_ROTATION_OWNER_PID) {", + ' const error = new Error("operation not permitted");', + ' error.code = "EPERM";', + " throw error;", + " }", + " return originalKill(pid, signal);", + "};", + ].join("\n"), + "utf8", + ); + + const helper = spawn( + process.execPath, + [join(fixtureRoot, "scripts", "codex.js"), "--codex-multi-auth-runtime-app-helper"], + { + env: buildWrapperEnv({ + CODEX_HOME: originalHome, + CODEX_MULTI_AUTH_DIR: multiAuthDir, + CODEX_MULTI_AUTH_REAL_CODEX_HOME: originalHome, + CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY: "1", + CODEX_MULTI_AUTH_APP_ROTATION_IDLE_MS: "250", + CODEX_MULTI_AUTH_APP_ROTATION_OWNER_PID: String(process.pid), + CODEX_MULTI_AUTH_TEST_PROXY_MARKER: markerPath, + NODE_OPTIONS: `--import=${pathToFileURL(preloadPath).href}`, + }), + stdio: ["ignore", "pipe", "pipe"], + }, + ); + let stdout = ""; + let stderr = ""; + const closed = new Promise((resolve) => { + helper.once("close", () => resolve()); + }); + helper.stdout?.setEncoding("utf8"); + helper.stderr?.setEncoding("utf8"); + helper.stdout?.on("data", (chunk: string) => { + stdout += chunk; + }); + helper.stderr?.on("data", (chunk: string) => { + stderr += chunk; + }); + + try { + const ready = await new Promise<{ statusPath: string }>((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error(`helper did not become ready\n${stdout}\n${stderr}`)); + }, 5_000); + helper.stdout?.on("data", () => { + const newlineIndex = stdout.indexOf("\n"); + if (newlineIndex < 0) return; + try { + const message = JSON.parse(stdout.slice(0, newlineIndex)) as { + type?: string; + statusPath?: string; + }; + if (message.type === "ready" && message.statusPath) { + clearTimeout(timeout); + resolve({ statusPath: message.statusPath }); + } + } catch (error) { + clearTimeout(timeout); + reject(error); + } + }); + helper.once("close", () => { + clearTimeout(timeout); + reject(new Error(`helper exited before ready\n${stdout}\n${stderr}`)); + }); + }); + + await sleep(750); + + expect(helper.pid).toBeTruthy(); + expect(isProcessAlive(helper.pid ?? -1)).toBe(true); + const status = JSON.parse(readFileSync(ready.statusPath, "utf8")) as { + state: string; + }; + expect(status.state).toBe("running"); + expect(readFileSync(markerPath, "utf8")).toBe("start:http://127.0.0.1:4567\n"); + } finally { + if (helper.pid && isProcessAlive(helper.pid)) { + helper.kill("SIGTERM"); + } + await Promise.race([closed, sleep(2_000)]); + if (helper.pid && isProcessAlive(helper.pid)) { + helper.kill("SIGKILL"); + await Promise.race([closed, sleep(2_000)]); + } + } + }); + + it("stops failed app helpers before unsupported-model retries", async () => { + const fixtureRoot = createWrapperFixture(); + createRuntimeRotationProxyFixtureModule(fixtureRoot); + const stateDir = join(fixtureRoot, "retry-state-app-helper"); + mkdirSync(stateDir, { recursive: true }); + const fakeBin = createCustomFakeCodexBin(fixtureRoot, [ + "#!/usr/bin/env node", + "const fs = require('node:fs');", + "const path = require('node:path');", + "const counterPath = path.join(process.env.CODEX_MULTI_AUTH_TEST_STATE_DIR, 'attempt.txt');", + "const attempt = fs.existsSync(counterPath) ? Number(fs.readFileSync(counterPath, 'utf8')) : 0;", + "fs.writeFileSync(counterPath, String(attempt + 1), 'utf8');", + "const args = process.argv.slice(2);", + "const modelIndex = args.indexOf('--model');", + "const requestedModel = modelIndex >= 0 ? args[modelIndex + 1] : 'unknown-model';", + "if (attempt === 0) {", + ` console.error("ERROR: {\\\"type\\\":\\\"error\\\",\\\"status\\\":400,\\\"error\\\":{\\\"type\\\":\\\"invalid_request_error\\\",\\\"message\\\":\\\"The '" + requestedModel + "' model is not supported when using Codex with a ChatGPT account.\\\"}}");`, + " process.exit(1);", + "}", + "console.log(`FORWARDED:${args.join(' ')}`);", + "process.exit(0);", + ]); + 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, + ["app", ".", "--model", "gpt-5.5"], + { + CODEX_MULTI_AUTH_REAL_CODEX_BIN: fakeBin, + CODEX_HOME: originalHome, + CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY: "1", + CODEX_MULTI_AUTH_APP_ROTATION_DETACH_GRACE_MS: "10000", + CODEX_MULTI_AUTH_APP_ROTATION_IDLE_MS: "600", + CODEX_MULTI_AUTH_TEST_PROXY_MARKER: markerPath, + CODEX_MULTI_AUTH_TEST_PROXY_MARKER_PID: "1", + CODEX_MULTI_AUTH_TEST_STATE_DIR: stateDir, + CODEX_MULTI_AUTH_CAPTURE_FORWARD_OUTPUT: "1", + OPENAI_API_KEY: undefined, + }, + ); + + const output = combinedOutput(result); + if (result.status !== 0) { + throw new Error(output); + } + expect(output).toContain("Retrying with gpt-5.4"); + expect(output).toContain("FORWARDED:app . --model gpt-5.4"); + const markerAfterRetry = readFileSync(markerPath, "utf8") + .trim() + .split(/\r?\n/); + const firstStart = markerAfterRetry[0] ?? ""; + const secondStart = markerAfterRetry.find( + (line, index) => + index > 0 && line.startsWith("start:http://127.0.0.1:4567:pid="), + ); + const firstPid = Number(firstStart.match(/:pid=(\d+)$/)?.[1] ?? NaN); + expect(firstStart).toMatch(/^start:http:\/\/127\.0\.0\.1:4567:pid=\d+$/); + expect(secondStart).toMatch( + /^start:http:\/\/127\.0\.0\.1:4567:pid=\d+$/, + ); + expect(Number.isFinite(firstPid)).toBe(true); + expect(isProcessAlive(firstPid)).toBe(false); + if (process.platform !== "win32") { + expect(markerAfterRetry.slice(0, 3)).toEqual([ + firstStart, + "close", + secondStart, + ]); + } + + await sleep(2200); + + expect(readFileSync(markerPath, "utf8")).toContain("close\n"); + }); + + it("starts detached app helpers against the real Codex home instead of a compatibility shadow", async () => { + const fixtureRoot = createWrapperFixture(); + createRuntimeRotationProxyFixtureModule(fixtureRoot); + const fakeBin = createCustomFakeCodexBin(fixtureRoot, [ + "#!/usr/bin/env node", + 'console.log(`FORWARDED:${process.argv.slice(2).join(" ")}`);', + 'console.log(`CODEX_HOME:${process.env.CODEX_HOME ?? ""}`);', + "process.exit(0);", + ]); + const originalHome = join(fixtureRoot, "codex-home"); + const markerPath = join(fixtureRoot, "proxy-marker.txt"); + mkdirSync(originalHome, { recursive: true }); + writeFileSync( + join(originalHome, "config.toml"), + 'model_reasoning_effort = "xhigh"\n', + "utf8", + ); + + const result = runWrapper(fixtureRoot, ["app", ".", "--model", "gpt-5.1"], { + CODEX_MULTI_AUTH_REAL_CODEX_BIN: fakeBin, + CODEX_HOME: originalHome, + CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY: "1", + CODEX_MULTI_AUTH_APP_ROTATION_IDLE_MS: "1000", + CODEX_MULTI_AUTH_TEST_PROXY_MARKER: markerPath, + CODEX_MULTI_AUTH_TEST_PROXY_MARKER_ENV: "1", + OPENAI_API_KEY: undefined, + }); + + const output = combinedOutput(result); + if (result.status !== 0) { + throw new Error(output); + } + expect(output).toContain("FORWARDED:app . --model gpt-5.1"); + + await sleep(2200); + + const marker = readFileSync(markerPath, "utf8"); + expect(marker).toContain(`real-home-env:${originalHome}\n`); + const compatibilityHomeMatch = marker.match(/^codex-home-env:(.+)$/m); + expect(compatibilityHomeMatch?.[1]).toBeTruthy(); + expect(compatibilityHomeMatch?.[1]).not.toBe(originalHome); + expect(marker).toContain("close\n"); + }); + + it("writes app router status files with owner-only permissions", async () => { + const fixtureRoot = createWrapperFixture(); + createRuntimeRotationProxyFixtureModule(fixtureRoot); + const bindDir = join(fixtureRoot, "app-bind"); + const statePath = join(bindDir, "state.json"); + const statusPath = join(bindDir, "status.json"); + mkdirSync(bindDir, { recursive: true }); + writeFileSync( + statePath, + `${JSON.stringify({ + clientApiKey: "router-secret", + host: "127.0.0.1", + port: 0, + baseUrl: "http://127.0.0.1:0", + statusPath, + })}\n`, + "utf8", + ); + let stderr = ""; + const child = spawn( + process.execPath, + [ + join(fixtureRoot, "scripts", "codex-app-router.js"), + "--port", + "0", + "--status", + statusPath, + "--state", + statePath, + ], + { + cwd: fixtureRoot, + env: { ...process.env }, + stdio: ["ignore", "ignore", "pipe"], + windowsHide: true, + }, + ); + child.stderr?.setEncoding("utf8"); + child.stderr?.on("data", (chunk) => { + stderr += chunk; + }); + try { + for (let attempt = 0; attempt < 40 && !existsSync(statusPath); attempt += 1) { + await sleep(50); + } + if (!existsSync(statusPath)) { + throw new Error(stderr || "router status file was not written"); + } + expect(existsSync(statusPath)).toBe(true); + if (process.platform !== "win32") { + expect(statSync(statusPath).mode & 0o777).toBe(0o600); + } + } finally { + child.kill("SIGTERM"); + await new Promise((resolve) => { + child.once("close", () => resolve()); + setTimeout(resolve, 1000); + }); + } + expect( + readdirSync(bindDir).filter((entry) => + entry.startsWith(".status.json.") && entry.endsWith(".tmp"), + ), + ).toEqual([]); + }); + + it("records forwarded exec traffic in runtime observability when the child process does not update it", () => { + const fixtureRoot = createWrapperFixture(); + createRuntimeObservabilityFixtureModule(fixtureRoot); + const fakeBin = createFakeCodexBin(fixtureRoot); + const multiAuthDir = join(fixtureRoot, "multi-auth"); + const result = runWrapper(fixtureRoot, ["exec", "status"], { + CODEX_MULTI_AUTH_REAL_CODEX_BIN: fakeBin, + CODEX_MULTI_AUTH_DIR: multiAuthDir, + }); + + expect(result.status).toBe(0); + const snapshot = JSON.parse( + readFileSync(join(multiAuthDir, "runtime-observability.json"), "utf8"), + ) as { + responsesRequests: number; + runtimeMetrics: { + totalRequests: number; + responsesRequests: number; + successfulRequests: number; + failedRequests: number; + lastRequestAt: number | null; + lastError: string | null; + }; + }; + expect(snapshot.responsesRequests).toBe(1); + expect(snapshot.runtimeMetrics.totalRequests).toBe(1); + expect(snapshot.runtimeMetrics.responsesRequests).toBe(1); + expect(snapshot.runtimeMetrics.successfulRequests).toBe(1); + expect(snapshot.runtimeMetrics.failedRequests).toBe(0); + expect(snapshot.runtimeMetrics.lastRequestAt).not.toBeNull(); + expect(snapshot.runtimeMetrics.lastError).toBeNull(); + }); + + it("does not double-count forwarded exec traffic when the child process already updates runtime observability", () => { + const fixtureRoot = createWrapperFixture(); + createRuntimeObservabilityFixtureModule(fixtureRoot); + const fakeBin = createCustomFakeCodexBin(fixtureRoot, [ + "#!/usr/bin/env node", + 'const fs = require("node:fs");', + 'const path = require("node:path");', + 'const root = process.env.CODEX_MULTI_AUTH_DIR ?? "";', + 'const snapshotPath = path.join(root, "runtime-observability.json");', + "const snapshot = {", + " version: 1,", + " updatedAt: Date.now(),", + " currentRequestId: null,", + " responsesRequests: 1,", + " authRefreshRequests: 0,", + " diagnosticProbeRequests: 0,", + " poolExhaustionCooldownUntil: null,", + " serverBurstCooldownUntil: null,", + " runtimeMetrics: {", + " startedAt: Date.now(),", + " totalRequests: 1,", + " successfulRequests: 1,", + " failedRequests: 0,", + " responsesRequests: 1,", + " authRefreshRequests: 0,", + " diagnosticProbeRequests: 0,", + " outboundRequestAttemptBudget: null,", + " outboundRequestAttemptsConsumed: 0,", + " requestAttemptBudgetExhaustions: 0,", + " poolExhaustionFastFails: 0,", + " serverBurstFastFails: 0,", + " rateLimitedResponses: 0,", + " serverErrors: 0,", + " networkErrors: 0,", + " userAborts: 0,", + " authRefreshFailures: 0,", + " emptyResponseRetries: 0,", + " accountRotations: 0,", + " sameAccountRetries: 0,", + " streamFailoverAttempts: 0,", + " streamFailoverCandidatesConsidered: 0,", + " lastStreamFailoverCandidateCount: 0,", + " streamFailoverRecoveries: 0,", + " streamFailoverCrossAccountRecoveries: 0,", + " cumulativeLatencyMs: 10,", + " lastRequestAt: Date.now(),", + " lastError: null,", + " },", + "};", + "fs.mkdirSync(root, { recursive: true });", + "fs.writeFileSync(snapshotPath, JSON.stringify(snapshot), 'utf8');", + "process.exit(0);", + ]); + const multiAuthDir = join(fixtureRoot, "multi-auth"); + const result = runWrapper(fixtureRoot, ["exec", "status"], { + CODEX_MULTI_AUTH_REAL_CODEX_BIN: fakeBin, + CODEX_MULTI_AUTH_DIR: multiAuthDir, + }); + + expect(result.status).toBe(0); + const snapshot = JSON.parse( + readFileSync(join(multiAuthDir, "runtime-observability.json"), "utf8"), + ) as { + responsesRequests: number; + runtimeMetrics: { + totalRequests: number; + responsesRequests: number; + successfulRequests: number; + }; + }; + expect(snapshot.responsesRequests).toBe(1); + expect(snapshot.runtimeMetrics.totalRequests).toBe(1); + expect(snapshot.runtimeMetrics.responsesRequests).toBe(1); + expect(snapshot.runtimeMetrics.successfulRequests).toBe(1); + }); + + it("skips file auth store forwarding when the opt-out env var is disabled", () => { + const fixtureRoot = createWrapperFixture(); + const fakeBin = createFakeCodexBin(fixtureRoot); + const result = runWrapper(fixtureRoot, ["exec", "status"], { + CODEX_MULTI_AUTH_REAL_CODEX_BIN: fakeBin, + CODEX_MULTI_AUTH_FORCE_FILE_AUTH_STORE: "0", + }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("FORWARDED:exec status"); + expect(result.stdout).not.toContain('cli_auth_credentials_store="file"'); + }); + + it("does not double-inject file auth store when caller already set it", () => { + const fixtureRoot = createWrapperFixture(); + const fakeBin = createFakeCodexBin(fixtureRoot); + const result = runWrapper( + fixtureRoot, + ["exec", "status", "-c", 'cli_auth_credentials_store="keychain"'], + { + CODEX_MULTI_AUTH_REAL_CODEX_BIN: fakeBin, + }, + ); + + expect(result.status).toBe(0); + expect(result.stdout).toContain( + 'FORWARDED:exec status -c cli_auth_credentials_store="keychain"', + ); + expect( + result.stdout.match(/cli_auth_credentials_store=/g) ?? [], + ).toHaveLength(1); + }); + + it("propagates downstream file-store write errors from forwarded wrapper execution", () => { + const fixtureRoot = createWrapperFixture(); + const fakeBin = createCustomFakeCodexBin(fixtureRoot, [ + "#!/usr/bin/env node", + "const forwarded = process.argv.slice(2);", + "if (!forwarded.includes('cli_auth_credentials_store=\"file\"')) process.exit(99);", + 'process.stderr.write("EPERM: locked auth store\\n");', + "process.exit(13);", + ]); + const result = runWrapper(fixtureRoot, ["exec", "status"], { + CODEX_MULTI_AUTH_REAL_CODEX_BIN: fakeBin, + }); + + expect(result.status).toBe(13); + expect(combinedOutput(result)).toContain("EPERM: locked auth store"); + }); + + it("creates a compatibility CODEX_HOME shadow when the requested model cannot accept xhigh defaults", () => { + const fixtureRoot = createWrapperFixture(); + const fakeBin = createCustomFakeCodexBin(fixtureRoot, [ + "#!/usr/bin/env node", + 'const fs = require("node:fs");', + 'const path = require("node:path");', + 'console.log(`FORWARDED:${process.argv.slice(2).join(" ")}`);', + 'console.log(`CODEX_HOME:${process.env.CODEX_HOME ?? ""}`);', + 'console.log(`CODEX_MULTI_AUTH_DIR_JSON:${JSON.stringify(process.env.CODEX_MULTI_AUTH_DIR ?? null)}`);', + 'const configPath = path.join(process.env.CODEX_HOME ?? "", "config.toml");', + 'const authPath = path.join(process.env.CODEX_HOME ?? "", "auth.json");', + 'console.log(`AUTH_EXISTS:${fs.existsSync(authPath)}`);', + 'if (fs.existsSync(authPath)) {', + ' console.log(`AUTH_JSON:${fs.readFileSync(authPath, "utf8").trim()}`);', + ' console.log(`AUTH_MODE:${(fs.statSync(authPath).mode & 0o777).toString(8)}`);', + '}', + 'console.log("CONFIG_START");', + 'console.log(fs.readFileSync(configPath, "utf8").trim());', + 'console.log(`CONFIG_MODE:${(fs.statSync(configPath).mode & 0o777).toString(8)}`);', + 'console.log("CONFIG_END");', + "process.exit(0);", + ]); + const originalHome = join(fixtureRoot, "codex-home"); + mkdirSync(originalHome, { recursive: true }); + writeFileSync(join(originalHome, "auth.json"), "{}\n", "utf8"); + writeFileSync( + join(originalHome, "config.toml"), + [ + 'model_reasoning_effort = "xhigh"', + 'profile = "legacy-full-access"', + "", + '[profiles."legacy-full-access"]', + 'model_reasoning_effort = "xhigh"', + "", + ].join("\n"), + "utf8", + ); + + const result = runWrapper(fixtureRoot, ["exec", "status", "--model", "gpt-5.1"], { + CODEX_MULTI_AUTH_REAL_CODEX_BIN: fakeBin, + CODEX_HOME: originalHome, + CODEX_MULTI_AUTH_DIR: undefined, + }); + + expect(result.status).toBe(0); + const output = combinedOutput(result); + expect(output).toContain('FORWARDED:exec status --model gpt-5.1 -c cli_auth_credentials_store="file"'); + expect(output).not.toContain(`CODEX_HOME:${originalHome}`); + expect(output).toContain("CODEX_MULTI_AUTH_DIR_JSON:null"); + expect(output).toContain("AUTH_EXISTS:true"); + expect(output).toContain("AUTH_JSON:{}"); + expect(output).toContain("AUTH_MODE:"); + expect(output).toContain('model_reasoning_effort = "high"'); + expect(output).toContain("CONFIG_MODE:"); + expect(output).not.toContain('model_reasoning_effort = "xhigh"'); + if (process.platform !== "win32") { + expect(output).toContain("AUTH_MODE:600"); + expect(output).toContain("CONFIG_MODE:600"); + } + }); + + it("cleans up compatibility shadow homes when staging fails", () => { + const fixtureRoot = createWrapperFixture(); + const cleanupFailureEnv = injectShadowCleanupBusyFailures(); + const fakeBin = createFakeCodexBin(fixtureRoot); + const originalHome = join(fixtureRoot, "codex-home"); + const controlledTmp = join(fixtureRoot, "tmp"); + mkdirSync(originalHome, { recursive: true }); + mkdirSync(controlledTmp, { recursive: true }); + writeFileSync(join(originalHome, "auth.json"), "{}\n", "utf8"); + mkdirSync(join(originalHome, "accounts.json"), { recursive: true }); + writeFileSync( + join(originalHome, "config.toml"), + 'model_reasoning_effort = "xhigh"\n', + "utf8", + ); + + const result = runWrapper( + fixtureRoot, + ["exec", "status", "--model", "gpt-5.1"], + { + CODEX_MULTI_AUTH_REAL_CODEX_BIN: fakeBin, + CODEX_HOME: originalHome, + TMP: controlledTmp, + TEMP: controlledTmp, + TMPDIR: controlledTmp, + ...cleanupFailureEnv, + }, + ); + + expect(result.status).toBe(1); + expect( + readdirSync(controlledTmp).filter((entry) => + entry.startsWith("codex-multi-auth-home-"), + ), + ).toEqual([]); + }); + + it("syncs copied shadow directories back before cleanup", () => { + const fixtureRoot = createWrapperFixture(); + const fakeBin = createCustomFakeCodexBin(fixtureRoot, [ + "#!/usr/bin/env node", + 'const fs = require("node:fs");', + 'const path = require("node:path");', + 'const home = process.env.CODEX_HOME ?? "";', + 'fs.mkdirSync(path.join(home, "sessions"), { recursive: true });', + 'fs.writeFileSync(path.join(home, "sessions", "new.jsonl"), "new-session\\n", "utf8");', + "process.exit(0);", + ]); + const originalHome = join(fixtureRoot, "codex-home"); + const controlledTmp = join(fixtureRoot, "tmp"); + const fakeLinkPath = join(fixtureRoot, "fake-link"); + mkdirSync(join(originalHome, "sessions"), { recursive: true }); + mkdirSync(controlledTmp, { recursive: true }); + writeFileSync(join(originalHome, "sessions", "existing.jsonl"), "existing\n", "utf8"); + writeFileSync(join(originalHome, "config.toml"), 'model_reasoning_effort = "xhigh"\n', "utf8"); + + const result = runWrapper(fixtureRoot, ["exec", "status", "--model", "gpt-5.1"], { + CODEX_MULTI_AUTH_REAL_CODEX_BIN: fakeBin, + CODEX_HOME: originalHome, + TMP: controlledTmp, + TEMP: controlledTmp, + TMPDIR: controlledTmp, + PATH: `${fakeLinkPath}${delimiter}${process.env.PATH ?? ""}`, + npm_config_prefix: fixtureRoot, + CODEX_MULTI_AUTH_TEST_FORCE_SHADOW_DIR_COPY: "1", + }); + + expect(result.status).toBe(0); + expect(readFileSync(join(originalHome, "sessions", "existing.jsonl"), "utf8")).toBe("existing\n"); + expect(readFileSync(join(originalHome, "sessions", "new.jsonl"), "utf8")).toBe("new-session\n"); + }); + + it("syncs refreshed auth state back from compatibility shadow homes before cleanup", () => { + const fixtureRoot = createWrapperFixture(); + const fakeBin = createCustomFakeCodexBin(fixtureRoot, [ + "#!/usr/bin/env node", + 'const fs = require("node:fs");', + 'const path = require("node:path");', + 'const home = process.env.CODEX_HOME ?? "";', + 'fs.writeFileSync(path.join(home, "auth.json"), \'{"token":"shadow"}\\n\', "utf8");', + 'fs.writeFileSync(path.join(home, "accounts.json"), \'{"accounts":["shadow"]}\\n\', "utf8");', + 'fs.writeFileSync(path.join(home, ".codex-global-state.json"), \'{"last":"shadow"}\\n\', "utf8");', + 'console.log(`CODEX_HOME:${home}`);', + "process.exit(0);", + ]); + const originalHome = join(fixtureRoot, "codex-home"); + const controlledTmp = join(fixtureRoot, "tmp"); + mkdirSync(originalHome, { recursive: true }); + mkdirSync(controlledTmp, { recursive: true }); + writeFileSync(join(originalHome, "auth.json"), '{"token":"original"}\n', "utf8"); + writeFileSync(join(originalHome, "accounts.json"), '{"accounts":["original"]}\n', "utf8"); + writeFileSync(join(originalHome, ".codex-global-state.json"), '{"last":"original"}\n', "utf8"); + writeFileSync(join(originalHome, "config.toml"), 'model_reasoning_effort = "xhigh"\n', "utf8"); + + const result = runWrapper( + fixtureRoot, + ["exec", "status", "--model", "gpt-5.1"], + { + CODEX_MULTI_AUTH_REAL_CODEX_BIN: fakeBin, + CODEX_HOME: originalHome, + TMP: controlledTmp, + TEMP: controlledTmp, + TMPDIR: controlledTmp, + }, + ); + + expect(result.status).toBe(0); + expect(readFileSync(join(originalHome, "auth.json"), "utf8").trim()).toBe('{"token":"shadow"}'); + expect(readFileSync(join(originalHome, "accounts.json"), "utf8").trim()).toBe('{"accounts":["shadow"]}'); + expect(readFileSync(join(originalHome, ".codex-global-state.json"), "utf8").trim()).toBe('{"last":"shadow"}'); + expect( + readdirSync(controlledTmp).filter((entry) => + entry.startsWith("codex-multi-auth-home-"), + ), + ).toEqual([]); + }); + + it("preserves the later auth sync-back from concurrent compatibility shadow homes", async () => { + const fixtureRoot = createWrapperFixture(); + const markerDir = join(fixtureRoot, "markers"); + const fakeBin = createCustomFakeCodexBin(fixtureRoot, [ + "#!/usr/bin/env node", + 'const fs = require("node:fs");', + 'const path = require("node:path");', + 'const id = process.env.CODEX_MULTI_AUTH_TEST_SESSION_ID ?? "missing";', + 'const home = process.env.CODEX_HOME ?? "";', + 'const markerDir = process.env.CODEX_MULTI_AUTH_TEST_MARKER_DIR ?? "";', + 'fs.mkdirSync(markerDir, { recursive: true });', + 'fs.writeFileSync(path.join(home, "auth.json"), JSON.stringify({ token: id }) + "\\n", "utf8");', + 'fs.writeFileSync(path.join(home, "accounts.json"), JSON.stringify({ accounts: [id] }) + "\\n", "utf8");', + 'fs.writeFileSync(path.join(home, ".codex-global-state.json"), JSON.stringify({ last: id }) + "\\n", "utf8");', + 'fs.writeFileSync(path.join(markerDir, `${id}.ready`), "ready\\n", "utf8");', + 'const releasePath = path.join(markerDir, `${id}.release`);', + "const waitForRelease = () => {", + " if (fs.existsSync(releasePath)) process.exit(0);", + " setTimeout(waitForRelease, 10);", + "};", + "waitForRelease();", + ]); + const originalHome = join(fixtureRoot, "codex-home"); + const controlledTmp = join(fixtureRoot, "tmp"); + mkdirSync(originalHome, { recursive: true }); + mkdirSync(controlledTmp, { recursive: true }); + writeFileSync(join(originalHome, "auth.json"), '{"token":"original"}\n', "utf8"); + writeFileSync(join(originalHome, "accounts.json"), '{"accounts":["original"]}\n', "utf8"); + writeFileSync(join(originalHome, ".codex-global-state.json"), '{"last":"original"}\n', "utf8"); + writeFileSync(join(originalHome, "config.toml"), 'model_reasoning_effort = "xhigh"\n', "utf8"); + + const commonEnv = { + CODEX_MULTI_AUTH_REAL_CODEX_BIN: fakeBin, + CODEX_HOME: originalHome, + CODEX_MULTI_AUTH_TEST_MARKER_DIR: markerDir, + TMP: controlledTmp, + TEMP: controlledTmp, + TMPDIR: controlledTmp, + }; + const first = runWrapperAsync( + fixtureRoot, + ["exec", "status", "--model", "gpt-5.1"], + { + ...commonEnv, + CODEX_MULTI_AUTH_TEST_SESSION_ID: "first", + ...injectShadowSyncMetadataBusyFailures(), + }, + ); + const second = runWrapperAsync( + fixtureRoot, + ["exec", "status", "--model", "gpt-5.1"], + { + ...commonEnv, + CODEX_MULTI_AUTH_TEST_SESSION_ID: "second", + }, + ); + + await waitForPath(join(markerDir, "first.ready")); + await waitForPath(join(markerDir, "second.ready")); + + writeFileSync(join(markerDir, "first.release"), "release\n", "utf8"); + expect((await first).status).toBe(0); + expect(readFileSync(join(originalHome, "auth.json"), "utf8").trim()).toBe( + '{"token":"first"}', + ); + + writeFileSync(join(markerDir, "second.release"), "release\n", "utf8"); + expect((await second).status).toBe(0); + expect(readFileSync(join(originalHome, "auth.json"), "utf8").trim()).toBe( + '{"token":"second"}', + ); + expect(readFileSync(join(originalHome, "accounts.json"), "utf8").trim()).toBe( + '{"accounts":["second"]}', + ); + expect( + readFileSync(join(originalHome, ".codex-global-state.json"), "utf8").trim(), + ).toBe('{"last":"second"}'); + }); + + it("continues shadow-home state sync after one state file remains locked", () => { + const fixtureRoot = createWrapperFixture(); + const fakeBin = createCustomFakeCodexBin(fixtureRoot, [ + "#!/usr/bin/env node", + 'const fs = require("node:fs");', + 'const path = require("node:path");', + 'const home = process.env.CODEX_HOME ?? "";', + 'fs.writeFileSync(path.join(home, "auth.json"), \'{"token":"shadow"}\\n\', "utf8");', + 'fs.writeFileSync(path.join(home, "accounts.json"), \'{"accounts":["shadow"]}\\n\', "utf8");', + 'fs.writeFileSync(path.join(home, ".codex-global-state.json"), \'{"last":"shadow"}\\n\', "utf8");', + "process.exit(0);", + ]); + const originalHome = join(fixtureRoot, "codex-home"); + const controlledTmp = join(fixtureRoot, "tmp"); + mkdirSync(originalHome, { recursive: true }); + mkdirSync(controlledTmp, { recursive: true }); + writeFileSync(join(originalHome, "auth.json"), '{"token":"original"}\n', "utf8"); + writeFileSync( + join(originalHome, "accounts.json"), + '{"accounts":["original"]}\n', + "utf8", + ); + writeFileSync( + join(originalHome, ".codex-global-state.json"), + '{"last":"original"}\n', + "utf8", + ); + writeFileSync( + join(originalHome, "config.toml"), + 'model_reasoning_effort = "xhigh"\n', + "utf8", + ); + + const result = runWrapper( + fixtureRoot, + ["exec", "status", "--model", "gpt-5.1"], + { + CODEX_MULTI_AUTH_REAL_CODEX_BIN: fakeBin, + CODEX_HOME: originalHome, + TMP: controlledTmp, + TEMP: controlledTmp, + TMPDIR: controlledTmp, + ...injectShadowCleanupBusyFailures(4), + }, + ); + + expect(result.status).toBe(0); + expect(readFileSync(join(originalHome, "auth.json"), "utf8").trim()).toBe('{"token":"original"}'); + expect(readFileSync(join(originalHome, "accounts.json"), "utf8").trim()).toBe('{"accounts":["shadow"]}'); + expect(readFileSync(join(originalHome, ".codex-global-state.json"), "utf8").trim()).toBe('{"last":"shadow"}'); + }); + + it("retries transient shadow sync lock owner write failures before sync-back", () => { + const fixtureRoot = createWrapperFixture(); + const fakeBin = createCustomFakeCodexBin(fixtureRoot, [ + "#!/usr/bin/env node", + 'const fs = require("node:fs");', + 'const path = require("node:path");', + 'const home = process.env.CODEX_HOME ?? "";', + 'fs.writeFileSync(path.join(home, "auth.json"), \'{"token":"shadow"}\\n\', "utf8");', + 'fs.writeFileSync(path.join(home, "accounts.json"), \'{"accounts":["shadow"]}\\n\', "utf8");', + 'fs.writeFileSync(path.join(home, ".codex-global-state.json"), \'{"last":"shadow"}\\n\', "utf8");', + "process.exit(0);", + ]); + const originalHome = join(fixtureRoot, "codex-home"); + const controlledTmp = join(fixtureRoot, "tmp"); + mkdirSync(originalHome, { recursive: true }); + mkdirSync(controlledTmp, { recursive: true }); + writeFileSync(join(originalHome, "auth.json"), '{"token":"original"}\n', "utf8"); + writeFileSync(join(originalHome, "accounts.json"), '{"accounts":["original"]}\n', "utf8"); + writeFileSync(join(originalHome, ".codex-global-state.json"), '{"last":"original"}\n', "utf8"); + writeFileSync(join(originalHome, "config.toml"), 'model_reasoning_effort = "xhigh"\n', "utf8"); + const lockDir = join(originalHome, ".codex-multi-auth-shadow-sync.lock"); + + const result = runWrapper( + fixtureRoot, + ["exec", "status", "--model", "gpt-5.1"], + { + CODEX_MULTI_AUTH_REAL_CODEX_BIN: fakeBin, + CODEX_HOME: originalHome, + TMP: controlledTmp, + TEMP: controlledTmp, + TMPDIR: controlledTmp, + ...injectShadowLockOwnerWriteFailures(1), + }, + ); + + expect(result.status).toBe(0); + expect(readFileSync(join(originalHome, "auth.json"), "utf8").trim()).toBe('{"token":"shadow"}'); + expect(readFileSync(join(originalHome, "accounts.json"), "utf8").trim()).toBe('{"accounts":["shadow"]}'); + expect(readFileSync(join(originalHome, ".codex-global-state.json"), "utf8").trim()).toBe('{"last":"shadow"}'); + expect(existsSync(lockDir)).toBe(false); + }); + + it("removes orphaned shadow sync locks when owner metadata cannot be written", () => { + const fixtureRoot = createWrapperFixture(); + const fakeBin = createCustomFakeCodexBin(fixtureRoot, [ + "#!/usr/bin/env node", + 'const fs = require("node:fs");', + 'const path = require("node:path");', + 'const home = process.env.CODEX_HOME ?? "";', + 'fs.writeFileSync(path.join(home, "auth.json"), \'{"token":"shadow"}\\n\', "utf8");', + 'fs.writeFileSync(path.join(home, "accounts.json"), \'{"accounts":["shadow"]}\\n\', "utf8");', + 'fs.writeFileSync(path.join(home, ".codex-global-state.json"), \'{"last":"shadow"}\\n\', "utf8");', "process.exit(0);", ]); - const multiAuthDir = join(fixtureRoot, "multi-auth"); - const result = runWrapper(fixtureRoot, ["exec", "status"], { - CODEX_MULTI_AUTH_REAL_CODEX_BIN: fakeBin, - CODEX_MULTI_AUTH_DIR: multiAuthDir, - }); + const originalHome = join(fixtureRoot, "codex-home"); + const controlledTmp = join(fixtureRoot, "tmp"); + mkdirSync(originalHome, { recursive: true }); + mkdirSync(controlledTmp, { recursive: true }); + writeFileSync(join(originalHome, "auth.json"), '{"token":"original"}\n', "utf8"); + writeFileSync(join(originalHome, "accounts.json"), '{"accounts":["original"]}\n', "utf8"); + writeFileSync(join(originalHome, ".codex-global-state.json"), '{"last":"original"}\n', "utf8"); + writeFileSync(join(originalHome, "config.toml"), 'model_reasoning_effort = "xhigh"\n', "utf8"); + const lockDir = join(originalHome, ".codex-multi-auth-shadow-sync.lock"); + + const result = runWrapper( + fixtureRoot, + ["exec", "status", "--model", "gpt-5.1"], + { + CODEX_MULTI_AUTH_REAL_CODEX_BIN: fakeBin, + CODEX_HOME: originalHome, + TMP: controlledTmp, + TEMP: controlledTmp, + TMPDIR: controlledTmp, + ...injectShadowLockOwnerWriteFailures(99), + }, + ); expect(result.status).toBe(0); - const snapshot = JSON.parse( - readFileSync(join(multiAuthDir, "runtime-observability.json"), "utf8"), - ) as { - responsesRequests: number; - runtimeMetrics: { - totalRequests: number; - responsesRequests: number; - successfulRequests: number; - }; - }; - expect(snapshot.responsesRequests).toBe(1); - expect(snapshot.runtimeMetrics.totalRequests).toBe(1); - expect(snapshot.runtimeMetrics.responsesRequests).toBe(1); - expect(snapshot.runtimeMetrics.successfulRequests).toBe(1); + expect(readFileSync(join(originalHome, "auth.json"), "utf8").trim()).toBe('{"token":"original"}'); + expect(readFileSync(join(originalHome, "accounts.json"), "utf8").trim()).toBe('{"accounts":["original"]}'); + expect(readFileSync(join(originalHome, ".codex-global-state.json"), "utf8").trim()).toBe('{"last":"original"}'); + expect(existsSync(lockDir)).toBe(false); }); - it("skips file auth store forwarding when the opt-out env var is disabled", () => { + it("removes stale shadow sync locks before publishing refreshed auth state", () => { const fixtureRoot = createWrapperFixture(); - const fakeBin = createFakeCodexBin(fixtureRoot); - const result = runWrapper(fixtureRoot, ["exec", "status"], { - CODEX_MULTI_AUTH_REAL_CODEX_BIN: fakeBin, - CODEX_MULTI_AUTH_FORCE_FILE_AUTH_STORE: "0", + const fakeBin = createCustomFakeCodexBin(fixtureRoot, [ + "#!/usr/bin/env node", + 'const fs = require("node:fs");', + 'const path = require("node:path");', + 'const home = process.env.CODEX_HOME ?? "";', + 'fs.writeFileSync(path.join(home, "auth.json"), \'{"token":"shadow"}\\n\', "utf8");', + 'fs.writeFileSync(path.join(home, "accounts.json"), \'{"accounts":["shadow"]}\\n\', "utf8");', + 'fs.writeFileSync(path.join(home, ".codex-global-state.json"), \'{"last":"shadow"}\\n\', "utf8");', + "process.exit(0);", + ]); + const originalHome = join(fixtureRoot, "codex-home"); + const controlledTmp = join(fixtureRoot, "tmp"); + mkdirSync(originalHome, { recursive: true }); + mkdirSync(controlledTmp, { recursive: true }); + writeFileSync(join(originalHome, "auth.json"), '{"token":"original"}\n', "utf8"); + writeFileSync(join(originalHome, "accounts.json"), '{"accounts":["original"]}\n', "utf8"); + writeFileSync(join(originalHome, ".codex-global-state.json"), '{"last":"original"}\n', "utf8"); + writeFileSync(join(originalHome, "config.toml"), 'model_reasoning_effort = "xhigh"\n', "utf8"); + const staleOwner = spawnSync(process.execPath, ["-e", "process.exit(0)"], { + encoding: "utf8", + windowsHide: true, }); + expect(staleOwner.status).toBe(0); + const lockDir = join(originalHome, ".codex-multi-auth-shadow-sync.lock"); + mkdirSync(lockDir, { recursive: true }); + writeFileSync( + join(lockDir, "owner.json"), + `${JSON.stringify({ pid: staleOwner.pid, createdAt: 1 })}\n`, + "utf8", + ); - expect(result.status).toBe(0); - expect(result.stdout).toContain("FORWARDED:exec status"); - expect(result.stdout).not.toContain('cli_auth_credentials_store="file"'); - }); - - it("does not double-inject file auth store when caller already set it", () => { - const fixtureRoot = createWrapperFixture(); - const fakeBin = createFakeCodexBin(fixtureRoot); const result = runWrapper( fixtureRoot, - ["exec", "status", "-c", 'cli_auth_credentials_store="keychain"'], + ["exec", "status", "--model", "gpt-5.1"], { CODEX_MULTI_AUTH_REAL_CODEX_BIN: fakeBin, + CODEX_HOME: originalHome, + TMP: controlledTmp, + TEMP: controlledTmp, + TMPDIR: controlledTmp, }, ); expect(result.status).toBe(0); - expect(result.stdout).toContain( - 'FORWARDED:exec status -c cli_auth_credentials_store="keychain"', - ); - expect( - result.stdout.match(/cli_auth_credentials_store=/g) ?? [], - ).toHaveLength(1); + expect(readFileSync(join(originalHome, "auth.json"), "utf8").trim()).toBe('{"token":"shadow"}'); + expect(readFileSync(join(originalHome, "accounts.json"), "utf8").trim()).toBe('{"accounts":["shadow"]}'); + expect(readFileSync(join(originalHome, ".codex-global-state.json"), "utf8").trim()).toBe('{"last":"shadow"}'); + expect(existsSync(lockDir)).toBe(false); }); - it("propagates downstream file-store write errors from forwarded wrapper execution", () => { + it.each([ + ["missing owner metadata", undefined], + ["corrupt owner metadata", "{not-json"], + ])("removes orphaned shadow sync locks with %s", async (_caseName, ownerContent) => { const fixtureRoot = createWrapperFixture(); const fakeBin = createCustomFakeCodexBin(fixtureRoot, [ "#!/usr/bin/env node", - "const forwarded = process.argv.slice(2);", - "if (!forwarded.includes('cli_auth_credentials_store=\"file\"')) process.exit(99);", - 'process.stderr.write("EPERM: locked auth store\\n");', - "process.exit(13);", + 'const fs = require("node:fs");', + 'const path = require("node:path");', + 'const home = process.env.CODEX_HOME ?? "";', + 'fs.writeFileSync(path.join(home, "auth.json"), \'{"token":"shadow"}\\n\', "utf8");', + 'fs.writeFileSync(path.join(home, "accounts.json"), \'{"accounts":["shadow"]}\\n\', "utf8");', + 'fs.writeFileSync(path.join(home, ".codex-global-state.json"), \'{"last":"shadow"}\\n\', "utf8");', + "process.exit(0);", ]); - const result = runWrapper(fixtureRoot, ["exec", "status"], { - CODEX_MULTI_AUTH_REAL_CODEX_BIN: fakeBin, - }); + const originalHome = join(fixtureRoot, "codex-home"); + const controlledTmp = join(fixtureRoot, "tmp"); + mkdirSync(originalHome, { recursive: true }); + mkdirSync(controlledTmp, { recursive: true }); + writeFileSync(join(originalHome, "auth.json"), '{"token":"original"}\n', "utf8"); + writeFileSync(join(originalHome, "accounts.json"), '{"accounts":["original"]}\n', "utf8"); + writeFileSync(join(originalHome, ".codex-global-state.json"), '{"last":"original"}\n', "utf8"); + writeFileSync(join(originalHome, "config.toml"), 'model_reasoning_effort = "xhigh"\n', "utf8"); + const lockDir = join(originalHome, ".codex-multi-auth-shadow-sync.lock"); + mkdirSync(lockDir, { recursive: true }); + if (ownerContent !== undefined) { + writeFileSync(join(lockDir, "owner.json"), ownerContent, "utf8"); + } + await ageShadowSyncLockForSteal(lockDir); - expect(result.status).toBe(13); - expect(combinedOutput(result)).toContain("EPERM: locked auth store"); + const result = runWrapper( + fixtureRoot, + ["exec", "status", "--model", "gpt-5.1"], + { + CODEX_MULTI_AUTH_REAL_CODEX_BIN: fakeBin, + CODEX_HOME: originalHome, + TMP: controlledTmp, + TEMP: controlledTmp, + TMPDIR: controlledTmp, + }, + ); + + expect(result.status).toBe(0); + expect(readFileSync(join(originalHome, "auth.json"), "utf8").trim()).toBe('{"token":"shadow"}'); + expect(readFileSync(join(originalHome, "accounts.json"), "utf8").trim()).toBe('{"accounts":["shadow"]}'); + expect(readFileSync(join(originalHome, ".codex-global-state.json"), "utf8").trim()).toBe('{"last":"shadow"}'); + expect(existsSync(lockDir)).toBe(false); }); - it("creates a compatibility CODEX_HOME shadow when the requested model cannot accept xhigh defaults", () => { + it("keeps retrying after consecutive stale shadow sync locks", () => { const fixtureRoot = createWrapperFixture(); const fakeBin = createCustomFakeCodexBin(fixtureRoot, [ "#!/usr/bin/env node", 'const fs = require("node:fs");', 'const path = require("node:path");', - 'console.log(`FORWARDED:${process.argv.slice(2).join(" ")}`);', - 'console.log(`CODEX_HOME:${process.env.CODEX_HOME ?? ""}`);', - 'console.log(`CODEX_MULTI_AUTH_DIR_JSON:${JSON.stringify(process.env.CODEX_MULTI_AUTH_DIR ?? null)}`);', - 'const configPath = path.join(process.env.CODEX_HOME ?? "", "config.toml");', - 'const authPath = path.join(process.env.CODEX_HOME ?? "", "auth.json");', - 'console.log(`AUTH_EXISTS:${fs.existsSync(authPath)}`);', - 'if (fs.existsSync(authPath)) {', - ' console.log(`AUTH_JSON:${fs.readFileSync(authPath, "utf8").trim()}`);', - ' console.log(`AUTH_MODE:${(fs.statSync(authPath).mode & 0o777).toString(8)}`);', - '}', - 'console.log("CONFIG_START");', - 'console.log(fs.readFileSync(configPath, "utf8").trim());', - 'console.log(`CONFIG_MODE:${(fs.statSync(configPath).mode & 0o777).toString(8)}`);', - 'console.log("CONFIG_END");', + 'const home = process.env.CODEX_HOME ?? "";', + 'fs.writeFileSync(path.join(home, "auth.json"), \'{"token":"shadow"}\\n\', "utf8");', + 'fs.writeFileSync(path.join(home, "accounts.json"), \'{"accounts":["shadow"]}\\n\', "utf8");', + 'fs.writeFileSync(path.join(home, ".codex-global-state.json"), \'{"last":"shadow"}\\n\', "utf8");', "process.exit(0);", ]); const originalHome = join(fixtureRoot, "codex-home"); + const controlledTmp = join(fixtureRoot, "tmp"); mkdirSync(originalHome, { recursive: true }); - writeFileSync(join(originalHome, "auth.json"), "{}\n", "utf8"); + mkdirSync(controlledTmp, { recursive: true }); + writeFileSync(join(originalHome, "auth.json"), '{"token":"original"}\n', "utf8"); + writeFileSync(join(originalHome, "accounts.json"), '{"accounts":["original"]}\n', "utf8"); + writeFileSync(join(originalHome, ".codex-global-state.json"), '{"last":"original"}\n', "utf8"); + writeFileSync(join(originalHome, "config.toml"), 'model_reasoning_effort = "xhigh"\n', "utf8"); + const lockDir = join(originalHome, ".codex-multi-auth-shadow-sync.lock"); + mkdirSync(lockDir, { recursive: true }); writeFileSync( - join(originalHome, "config.toml"), - [ - 'model_reasoning_effort = "xhigh"', - 'profile = "legacy-full-access"', - "", - '[profiles."legacy-full-access"]', - 'model_reasoning_effort = "xhigh"', - "", - ].join("\n"), + join(lockDir, "owner.json"), + `${JSON.stringify({ pid: 2_147_483_647, createdAt: 1 })}\n`, "utf8", ); - const result = runWrapper(fixtureRoot, ["exec", "status", "--model", "gpt-5.1"], { - CODEX_MULTI_AUTH_REAL_CODEX_BIN: fakeBin, - CODEX_HOME: originalHome, - CODEX_MULTI_AUTH_DIR: undefined, - }); + const result = runWrapper( + fixtureRoot, + ["exec", "status", "--model", "gpt-5.1"], + { + CODEX_MULTI_AUTH_REAL_CODEX_BIN: fakeBin, + CODEX_HOME: originalHome, + TMP: controlledTmp, + TEMP: controlledTmp, + TMPDIR: controlledTmp, + ...injectShadowLockRecreatedStaleCount(2), + }, + ); expect(result.status).toBe(0); - const output = combinedOutput(result); - expect(output).toContain('FORWARDED:exec status --model gpt-5.1 -c cli_auth_credentials_store="file"'); - expect(output).not.toContain(`CODEX_HOME:${originalHome}`); - expect(output).toContain("CODEX_MULTI_AUTH_DIR_JSON:null"); - expect(output).toContain("AUTH_EXISTS:true"); - expect(output).toContain("AUTH_JSON:{}"); - expect(output).toContain("AUTH_MODE:"); - expect(output).toContain('model_reasoning_effort = "high"'); - expect(output).toContain("CONFIG_MODE:"); - expect(output).not.toContain('model_reasoning_effort = "xhigh"'); - if (process.platform !== "win32") { - expect(output).toContain("AUTH_MODE:600"); - expect(output).toContain("CONFIG_MODE:600"); - } + expect(readFileSync(join(originalHome, "auth.json"), "utf8").trim()).toBe('{"token":"shadow"}'); + expect(readFileSync(join(originalHome, "accounts.json"), "utf8").trim()).toBe('{"accounts":["shadow"]}'); + expect(readFileSync(join(originalHome, ".codex-global-state.json"), "utf8").trim()).toBe('{"last":"shadow"}'); + expect(existsSync(lockDir)).toBe(false); }); - it("cleans up compatibility shadow homes when staging fails", () => { + it("writes shadow sync lock owner metadata with owner-only permissions", () => { const fixtureRoot = createWrapperFixture(); - const cleanupFailureEnv = injectShadowCleanupBusyFailures(); - const fakeBin = createFakeCodexBin(fixtureRoot); + const fakeBin = createCustomFakeCodexBin(fixtureRoot, [ + "#!/usr/bin/env node", + "process.exit(0);", + ]); const originalHome = join(fixtureRoot, "codex-home"); const controlledTmp = join(fixtureRoot, "tmp"); mkdirSync(originalHome, { recursive: true }); mkdirSync(controlledTmp, { recursive: true }); - writeFileSync(join(originalHome, "auth.json"), "{}\n", "utf8"); - mkdirSync(join(originalHome, "accounts.json"), { recursive: true }); + writeFileSync(join(originalHome, "auth.json"), '{"token":"original"}\n', "utf8"); + writeFileSync(join(originalHome, "accounts.json"), '{"accounts":["original"]}\n', "utf8"); + writeFileSync(join(originalHome, ".codex-global-state.json"), '{"last":"original"}\n', "utf8"); + writeFileSync(join(originalHome, "config.toml"), 'model_reasoning_effort = "xhigh"\n', "utf8"); + const lockDir = join(originalHome, ".codex-multi-auth-shadow-sync.lock"); + mkdirSync(lockDir, { recursive: true }); writeFileSync( - join(originalHome, "config.toml"), - 'model_reasoning_effort = "xhigh"\n', + join(lockDir, "owner.json"), + `${JSON.stringify({ pid: 2_147_483_647, createdAt: 1 })}\n`, "utf8", ); @@ -763,19 +2444,23 @@ describe("codex bin wrapper", () => { TMP: controlledTmp, TEMP: controlledTmp, TMPDIR: controlledTmp, - ...cleanupFailureEnv, + ...injectShadowLockRecreatedStaleCount(99), }, ); - expect(result.status).toBe(1); - expect( - readdirSync(controlledTmp).filter((entry) => - entry.startsWith("codex-multi-auth-home-"), - ), - ).toEqual([]); + expect(result.status).toBe(0); + expect(existsSync(lockDir)).toBe(true); + const ownerPath = join(lockDir, "owner.json"); + expect(JSON.parse(readFileSync(ownerPath, "utf8"))).toMatchObject({ + pid: 2_147_483_647, + createdAt: 1, + }); + if (process.platform !== "win32") { + expect(statSync(ownerPath).mode & 0o777).toBe(0o600); + } }); - it("syncs refreshed auth state back from compatibility shadow homes before cleanup", () => { + it("waits for fresh orphaned shadow sync locks to become stale before stealing", () => { const fixtureRoot = createWrapperFixture(); const fakeBin = createCustomFakeCodexBin(fixtureRoot, [ "#!/usr/bin/env node", @@ -785,7 +2470,6 @@ describe("codex bin wrapper", () => { 'fs.writeFileSync(path.join(home, "auth.json"), \'{"token":"shadow"}\\n\', "utf8");', 'fs.writeFileSync(path.join(home, "accounts.json"), \'{"accounts":["shadow"]}\\n\', "utf8");', 'fs.writeFileSync(path.join(home, ".codex-global-state.json"), \'{"last":"shadow"}\\n\', "utf8");', - 'console.log(`CODEX_HOME:${home}`);', "process.exit(0);", ]); const originalHome = join(fixtureRoot, "codex-home"); @@ -796,7 +2480,10 @@ describe("codex bin wrapper", () => { writeFileSync(join(originalHome, "accounts.json"), '{"accounts":["original"]}\n', "utf8"); writeFileSync(join(originalHome, ".codex-global-state.json"), '{"last":"original"}\n', "utf8"); writeFileSync(join(originalHome, "config.toml"), 'model_reasoning_effort = "xhigh"\n', "utf8"); + const lockDir = join(originalHome, ".codex-multi-auth-shadow-sync.lock"); + mkdirSync(lockDir, { recursive: true }); + const startedAt = Date.now(); const result = runWrapper( fixtureRoot, ["exec", "status", "--model", "gpt-5.1"], @@ -810,17 +2497,14 @@ describe("codex bin wrapper", () => { ); expect(result.status).toBe(0); + expect(Date.now() - startedAt).toBeGreaterThanOrEqual(1_500); expect(readFileSync(join(originalHome, "auth.json"), "utf8").trim()).toBe('{"token":"shadow"}'); expect(readFileSync(join(originalHome, "accounts.json"), "utf8").trim()).toBe('{"accounts":["shadow"]}'); - expect(readFileSync(join(originalHome, ".codex-global-state.json"), "utf8").trim()).toBe('{"last":"shadow"}'); - expect( - readdirSync(controlledTmp).filter((entry) => - entry.startsWith("codex-multi-auth-home-"), - ), - ).toEqual([]); + expect(readFileSync(join(originalHome, ".codex-global-state.json"), "utf8").trim()).toBe('{"last":"shadow"}'); + expect(existsSync(lockDir)).toBe(false); }); - it("does not clobber original auth state that changed while the compatibility shadow was active", () => { + it("syncs unchanged auth bundle files when a sibling changes during shadow use", () => { const fixtureRoot = createWrapperFixture(); const fakeBin = createCustomFakeCodexBin(fixtureRoot, [ "#!/usr/bin/env node", @@ -1909,6 +3593,36 @@ describe("codex bin wrapper", () => { expect(result.stdout).toContain("FORWARDED:auth status"); }); + it("syncs manager active selection before and after forwarded commands", () => { + const fixtureRoot = createWrapperFixture(); + const fakeBin = createFakeCodexBin(fixtureRoot); + const distLibDir = join(fixtureRoot, "dist", "lib"); + const markerPath = join(fixtureRoot, "sync-marker.txt"); + mkdirSync(distLibDir, { recursive: true }); + writeFileSync( + join(distLibDir, "codex-manager.js"), + [ + 'import { appendFileSync } from "node:fs";', + "export async function autoSyncActiveAccountToCodex() {", + ' appendFileSync(process.env.CODEX_MULTI_AUTH_TEST_SYNC_MARKER, "sync\\n", "utf8");', + "}", + ].join("\n"), + "utf8", + ); + + const result = runWrapper(fixtureRoot, ["exec", "status"], { + CODEX_MULTI_AUTH_REAL_CODEX_BIN: fakeBin, + CODEX_MULTI_AUTH_TEST_SYNC_MARKER: markerPath, + }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("FORWARDED:exec status"); + expect(readFileSync(markerPath, "utf8").trim().split(/\r?\n/)).toEqual([ + "sync", + "sync", + ]); + }); + it("surfaces non-module-not-found loader failures", () => { const fixtureRoot = createWrapperFixture(); const distLibDir = join(fixtureRoot, "dist", "lib"); diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index b9293a28..976714d0 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -6,6 +6,7 @@ const saveAccountsMock = vi.fn(); const saveFlaggedAccountsMock = vi.fn(); const setStoragePathMock = vi.fn(); const getStoragePathMock = vi.fn(() => "/mock/openai-codex-accounts.json"); +const inspectStorageHealthMock = vi.fn(); const getNamedBackupsMock = vi.fn(); const restoreAccountsFromBackupMock = vi.fn(); const queuedRefreshMock = vi.fn(); @@ -179,6 +180,7 @@ vi.mock("../lib/storage.js", async () => { withAccountStorageTransaction: withAccountStorageTransactionMock, setStoragePath: setStoragePathMock, getStoragePath: getStoragePathMock, + inspectStorageHealth: inspectStorageHealthMock, getNamedBackups: getNamedBackupsMock, restoreAccountsFromBackup: restoreAccountsFromBackupMock, exportNamedBackup: exportNamedBackupMock, @@ -656,6 +658,7 @@ describe("codex manager cli commands", () => { withAccountAndFlaggedStorageTransactionMock.mockReset(); withAccountStorageTransactionMock.mockReset(); withFlaggedStorageTransactionMock.mockReset(); + inspectStorageHealthMock.mockReset(); queuedRefreshMock.mockReset(); setCodexCliActiveSelectionMock.mockReset(); loadCodexCliStateMock.mockReset(); @@ -822,6 +825,15 @@ describe("codex manager cli commands", () => { setOpenStdinState(); setStoragePathMock.mockReset(); getStoragePathMock.mockReturnValue("/mock/openai-codex-accounts.json"); + inspectStorageHealthMock.mockResolvedValue({ + state: "empty", + path: "/mock/openai-codex-accounts.json", + resetMarkerPath: "/mock/openai-codex-accounts.json.intentional-reset", + walPath: "/mock/openai-codex-accounts.json.wal", + hasResetMarker: false, + hasWal: false, + details: "storage file is missing", + }); normalizeAccountStorageMock.mockImplementation((value) => value); const authModule = await import("../lib/auth/auth.js"); @@ -931,6 +943,15 @@ describe("codex manager cli commands", () => { it("prints empty account status for auth list", async () => { loadAccountsMock.mockResolvedValueOnce(null); + inspectStorageHealthMock.mockResolvedValueOnce({ + state: "intentional-reset", + path: "/mock/openai-codex-accounts.json", + resetMarkerPath: "/mock/openai-codex-accounts.json.intentional-reset", + walPath: "/mock/openai-codex-accounts.json.wal", + hasResetMarker: true, + hasWal: false, + details: "intentional reset marker present", + }); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); @@ -947,6 +968,30 @@ describe("codex manager cli commands", () => { expect(setStoragePathMock).toHaveBeenCalledWith(null); }); + it("prints intentional-reset status with windows-style storage paths", async () => { + loadAccountsMock.mockResolvedValueOnce(null); + getStoragePathMock.mockReturnValueOnce("C:\\mock\\openai-codex-accounts.json"); + inspectStorageHealthMock.mockResolvedValueOnce({ + state: "intentional-reset", + path: "C:\\mock\\openai-codex-accounts.json", + resetMarkerPath: "C:\\mock\\openai-codex-accounts.json.intentional-reset", + walPath: "C:\\mock\\openai-codex-accounts.json.wal", + hasResetMarker: true, + hasWal: false, + details: "intentional reset marker present", + }); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli(["auth", "list"]); + + expect(exitCode).toBe(0); + expect(logSpy).toHaveBeenCalledWith( + "Storage: C:\\mock\\openai-codex-accounts.json", + ); + expect(logSpy).toHaveBeenCalledWith("Storage health: intentional-reset"); + }); + it("prints config explain output in json mode", async () => { getPluginConfigExplainReportMock.mockReturnValueOnce({ configPath: "/mock/settings.json", @@ -6859,6 +6904,79 @@ describe("codex manager cli commands", () => { expect(firstCallAccounts[1]?.isCurrentAccount).toBe(true); }); + it("syncs Codex CLI active account before rendering the login account list", async () => { + const now = Date.now(); + const storage = { + version: 3, + activeIndex: 1, + activeIndexByFamily: { codex: 1 }, + accounts: [ + { + email: "a@example.com", + accountId: "acc_a", + refreshToken: "refresh-a", + accessToken: "access-a", + expiresAt: now + 3_600_000, + addedAt: now - 2_000, + lastUsed: now - 2_000, + enabled: true, + }, + { + email: "b@example.com", + accountId: "acc_b", + refreshToken: "refresh-b", + accessToken: "access-b", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }; + loadAccountsMock.mockResolvedValue(storage); + loadDashboardDisplaySettingsMock.mockResolvedValue({ + showPerAccountRows: true, + showQuotaDetails: true, + showForecastReasons: true, + showRecommendations: true, + showLiveProbeNotes: true, + menuAutoFetchLimits: false, + menuSortEnabled: false, + }); + loadCodexCliStateMock.mockResolvedValue({ + path: "/mock/.codex/accounts.json", + accounts: [], + activeAccountId: "acc_a", + activeEmail: "a@example.com", + }); + setCodexCliActiveSelectionMock.mockResolvedValue(true); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(setCodexCliActiveSelectionMock).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: "acc_b", + email: "b@example.com", + accessToken: "access-b", + refreshToken: "refresh-b", + expiresAt: now + 3_600_000, + }), + ); + const syncCallOrder = + setCodexCliActiveSelectionMock.mock.invocationCallOrder[0]; + const renderCallOrder = promptLoginModeMock.mock.invocationCallOrder[0]; + expect(syncCallOrder).toBeLessThan(renderCallOrder); + const firstCallAccounts = promptLoginModeMock.mock.calls[0]?.[0] as Array<{ + email?: string; + isCurrentAccount?: boolean; + }>; + expect(firstCallAccounts[1]?.email).toBe("b@example.com"); + expect(firstCallAccounts[1]?.isCurrentAccount).toBe(true); + }); + it("keeps ready accounts ahead of degraded limit rows in ready-first sorting", async () => { const now = Date.now(); loadAccountsMock.mockResolvedValue({ diff --git a/test/codex-manager-rotation-command.test.ts b/test/codex-manager-rotation-command.test.ts new file mode 100644 index 00000000..116bbcc9 --- /dev/null +++ b/test/codex-manager-rotation-command.test.ts @@ -0,0 +1,346 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runRotationCommand } from "../lib/codex-manager/commands/rotation.js"; +import type { RotationCommandDeps } from "../lib/codex-manager/commands/rotation.js"; +import type { AppBindResult, AppBindStatus } from "../lib/runtime/app-bind.js"; +import type { AccountStorageV3 } from "../lib/storage.js"; +import type { PluginConfig } from "../lib/types.js"; +import { withFileOperationRetry } from "../scripts/install-codex-auth-utils.js"; + +const originalRuntimeRotationProxyEnv = + process.env.CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY; +const originalMultiAuthDir = process.env.CODEX_MULTI_AUTH_DIR; +const tempRoots: string[] = []; + +function createStorage(now: number): AccountStorageV3 { + return { + version: 3, + activeIndex: 1, + activeIndexByFamily: { codex: 1 }, + accounts: [ + { + email: "first@example.com", + accountId: "acc_first", + refreshToken: "refresh-first", + addedAt: now - 2_000, + lastUsed: now - 2_000, + enabled: false, + }, + { + email: "second@example.com", + accountId: "acc_second", + refreshToken: "refresh-second", + addedAt: now - 1_000, + lastUsed: now - 1_000, + rateLimitResetTimes: { codex: now + 30_000 }, + }, + ], + }; +} + +function createAppBindStatus(params: Partial = {}): AppBindStatus { + const status: AppBindStatus = { + bound: false, + running: false, + state: null, + router: null, + paths: { + codexHome: "/mock/.codex", + configPath: "/mock/.codex/config.toml", + bindDir: "/mock/.codex/multi-auth/app-bind", + statePath: "/mock/.codex/multi-auth/app-bind/runtime-rotation-app-bind.json", + backupPath: "/mock/.codex/multi-auth/app-bind/codex-config-backup.json", + statusPath: "/mock/.codex/multi-auth/app-bind/runtime-rotation-app-bind-status.json", + logPath: "/mock/.codex/multi-auth/app-bind/runtime-rotation-app-router.log", + routerScriptPath: "/mock/scripts/codex-app-router.js", + startupPath: null, + launchAgentPath: null, + }, + }; + return { ...status, ...params }; +} + +function createAppBindResult(message: string, status = createAppBindStatus()): AppBindResult { + return { message, status }; +} + +async function createTempRoot(prefix: string): Promise { + const root = await mkdtemp(join(tmpdir(), prefix)); + tempRoots.push(root); + return root; +} + +function createDeps(params: { + config?: PluginConfig; + storage?: AccountStorageV3 | null; + now?: number; + appBindStatus?: AppBindStatus; +} = {}): { + deps: RotationCommandDeps; + errors: string[]; + infos: string[]; + savePluginConfigMock: ReturnType; + setStoragePathMock: ReturnType; + bindCodexAppMock: ReturnType; + unbindCodexAppMock: ReturnType; +} { + const config = params.config ?? {}; + const storage = params.storage ?? null; + const infos: string[] = []; + const errors: string[] = []; + const savePluginConfigMock = vi.fn(async () => undefined); + const setStoragePathMock = vi.fn(); + const bindCodexAppMock = vi.fn(async () => + createAppBindResult( + "Bound Codex app config /mock/.codex/config.toml to http://127.0.0.1:4567", + createAppBindStatus({ + bound: true, + running: true, + state: { + version: 1, + platform: "linux", + host: "127.0.0.1", + port: 4567, + baseUrl: "http://127.0.0.1:4567", + configPath: "/mock/.codex/config.toml", + statePath: "/mock/.codex/multi-auth/app-bind/runtime-rotation-app-bind.json", + backupPath: "/mock/.codex/multi-auth/app-bind/codex-config-backup.json", + statusPath: + "/mock/.codex/multi-auth/app-bind/runtime-rotation-app-bind-status.json", + logPath: "/mock/.codex/multi-auth/app-bind/runtime-rotation-app-router.log", + nodePath: "node", + routerScriptPath: "/mock/scripts/codex-app-router.js", + clientApiKey: "app-secret", + startupPath: null, + launchAgentPath: null, + boundConfigHash: "hash", + updatedAt: 1, + }, + }), + ), + ); + const unbindCodexAppMock = vi.fn(async () => + createAppBindResult("Unbound Codex app config /mock/.codex/config.toml"), + ); + return { + infos, + errors, + savePluginConfigMock, + setStoragePathMock, + bindCodexAppMock, + unbindCodexAppMock, + deps: { + loadPluginConfig: () => config, + savePluginConfig: savePluginConfigMock, + getCodexRuntimeRotationProxy: (pluginConfig) => { + const override = process.env.CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY; + if (override === "1") return true; + if (override === "0") return false; + return pluginConfig.codexRuntimeRotationProxy === true; + }, + loadAccounts: async () => storage, + resolveActiveIndex: (loadedStorage) => loadedStorage.activeIndex, + getStoragePath: () => "/mock/openai-codex-accounts.json", + setStoragePath: setStoragePathMock, + bindCodexApp: bindCodexAppMock, + unbindCodexApp: unbindCodexAppMock, + getCodexAppBindStatus: async () => + params.appBindStatus ?? createAppBindStatus(), + getNow: () => params.now ?? Date.now(), + logInfo: (message) => infos.push(message), + logError: (message) => errors.push(message), + }, + }; +} + +beforeEach(() => { + delete process.env.CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY; +}); + +afterEach(() => { + if (originalRuntimeRotationProxyEnv === undefined) { + delete process.env.CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY; + } else { + process.env.CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY = + originalRuntimeRotationProxyEnv; + } + if (originalMultiAuthDir === undefined) { + delete process.env.CODEX_MULTI_AUTH_DIR; + } else { + process.env.CODEX_MULTI_AUTH_DIR = originalMultiAuthDir; + } +}); + +afterEach(async () => { + await Promise.all( + tempRoots.splice(0).map((root) => + withFileOperationRetry(() => rm(root, { recursive: true, force: true })), + ), + ); +}); + +describe("codex auth rotation command", () => { + it("enables and disables the runtime rotation proxy setting", async () => { + const { deps, savePluginConfigMock, infos } = createDeps(); + + await expect(runRotationCommand(["enable"], deps)).resolves.toBe(0); + await expect(runRotationCommand(["disable"], deps)).resolves.toBe(0); + + expect(savePluginConfigMock).toHaveBeenNthCalledWith(1, { + codexRuntimeRotationProxy: true, + }); + expect(savePluginConfigMock).toHaveBeenNthCalledWith(2, { + codexRuntimeRotationProxy: false, + }); + expect(infos.join("\n")).toContain("Runtime rotation proxy enabled."); + expect(infos.join("\n")).toContain("Runtime rotation proxy disabled."); + }); + + it("prints status with env override and account state", async () => { + const now = Date.now(); + process.env.CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY = "1"; + const { deps, infos, setStoragePathMock } = createDeps({ + config: { codexRuntimeRotationProxy: false }, + storage: createStorage(now), + now, + }); + + await expect(runRotationCommand(["status"], deps)).resolves.toBe(0); + + const output = infos.join("\n"); + expect(setStoragePathMock).toHaveBeenCalledWith(null); + expect(output).toContain("Runtime rotation proxy: enabled"); + expect(output).toContain("Stored setting: disabled"); + expect(output).toContain("Env override: enabled"); + expect(output).toContain("Codex app bind: not configured"); + expect(output).toContain("Accounts: 2"); + expect(output).toContain("Account 1 (first@example.com, id:_first) [disabled]"); + expect(output).toContain("Account 2 (second@example.com, id:second)"); + expect(output).toContain("rate-limited:30s"); + expect(setStoragePathMock).toHaveBeenNthCalledWith(1, null); + expect(setStoragePathMock).toHaveBeenNthCalledWith( + 2, + "/mock/openai-codex-accounts.json", + ); + }); + + it("prints invalid env override values without coercing them", async () => { + process.env.CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY = "whatever"; + const { deps, infos } = createDeps({ + config: { codexRuntimeRotationProxy: true }, + storage: null, + }); + + await expect(runRotationCommand(["status"], deps)).resolves.toBe(0); + + const output = infos.join("\n"); + expect(output).toContain("Runtime rotation proxy: enabled"); + expect(output).toContain("Env override: invalid (whatever)"); + }); + + it("ignores stale helper status files with reused process ids", async () => { + const root = await createTempRoot("codex-rotation-helper-status-"); + process.env.CODEX_MULTI_AUTH_DIR = root; + await mkdir(root, { recursive: true }); + await writeFile( + join(root, "runtime-rotation-app-helper.json"), + `${JSON.stringify({ + version: 1, + kind: "unrelated-process", + state: "running", + pid: process.pid, + totalRequests: 12, + rotations: 3, + updatedAt: Date.now(), + })}\n`, + "utf8", + ); + const { deps, infos } = createDeps({ storage: null }); + + await expect(runRotationCommand(["status"], deps)).resolves.toBe(0); + + expect(infos.join("\n")).toContain("Codex app helper: not running"); + }); + + it("handles overlapping enable commands without dropping saves or app binds", async () => { + const { + deps, + savePluginConfigMock, + bindCodexAppMock, + infos, + } = createDeps(); + let releaseFirstSave: (() => void) | undefined; + const firstSave = new Promise((resolve) => { + releaseFirstSave = resolve; + }); + savePluginConfigMock + .mockImplementationOnce(async () => { + await firstSave; + }) + .mockResolvedValue(undefined); + + const first = runRotationCommand(["enable"], deps); + const second = runRotationCommand(["enable"], deps); + await vi.waitFor(() => expect(savePluginConfigMock).toHaveBeenCalledTimes(2)); + releaseFirstSave?.(); + + await expect(Promise.all([first, second])).resolves.toEqual([0, 0]); + expect(savePluginConfigMock).toHaveBeenNthCalledWith(1, { + codexRuntimeRotationProxy: true, + }); + expect(savePluginConfigMock).toHaveBeenNthCalledWith(2, { + codexRuntimeRotationProxy: true, + }); + expect(bindCodexAppMock).toHaveBeenCalledTimes(2); + expect(infos.filter((line) => line === "Runtime rotation proxy enabled.")).toHaveLength( + 2, + ); + }); + + it("rejects unknown subcommands with usage", async () => { + const { deps, errors, infos } = createDeps(); + + await expect(runRotationCommand(["maybe"], deps)).resolves.toBe(1); + + expect(errors).toEqual(["Unknown rotation command: maybe"]); + expect(infos.join("\n")).toContain("codex auth rotation enable"); + }); + + it("binds and unbinds the Codex app with rotation enable and disable", async () => { + const { + deps, + savePluginConfigMock, + bindCodexAppMock, + unbindCodexAppMock, + infos, + } = createDeps(); + + await expect(runRotationCommand(["enable"], deps)).resolves.toBe(0); + await expect(runRotationCommand(["disable"], deps)).resolves.toBe(0); + + expect(savePluginConfigMock).toHaveBeenNthCalledWith(1, { + codexRuntimeRotationProxy: true, + }); + expect(savePluginConfigMock).toHaveBeenNthCalledWith(2, { + codexRuntimeRotationProxy: false, + }); + expect(bindCodexAppMock).toHaveBeenCalledTimes(1); + expect(unbindCodexAppMock).toHaveBeenCalledTimes(1); + expect(infos.join("\n")).toContain("Codex app bind: running, port=4567"); + expect(infos.join("\n")).toContain("Unbound Codex app config"); + }); + + it("supports explicit app bind repair commands", async () => { + const { deps, bindCodexAppMock, unbindCodexAppMock, infos } = createDeps(); + + await expect(runRotationCommand(["bind-app"], deps)).resolves.toBe(0); + await expect(runRotationCommand(["unbind-app"], deps)).resolves.toBe(0); + + expect(bindCodexAppMock).toHaveBeenCalledTimes(1); + expect(unbindCodexAppMock).toHaveBeenCalledTimes(1); + expect(infos.join("\n")).toContain("Bound Codex app config"); + expect(infos.join("\n")).toContain("Unbound Codex app config"); + }); +}); diff --git a/test/codex-manager-status-command.test.ts b/test/codex-manager-status-command.test.ts index 2bd1f625..0b262574 100644 --- a/test/codex-manager-status-command.test.ts +++ b/test/codex-manager-status-command.test.ts @@ -6,6 +6,7 @@ import { type StatusCommandDeps, } from "../lib/codex-manager/commands/status.js"; import type { AccountStorageV3, StorageHealthSummary } from "../lib/storage.js"; +import type { RuntimeObservabilitySnapshot } from "../lib/runtime/runtime-observability.js"; function createStorage(): AccountStorageV3 { return { @@ -53,6 +54,52 @@ function createStatusDeps( }; } +function createRuntimeSnapshot( + overrides: Partial = {}, +): RuntimeObservabilitySnapshot { + return { + version: 1, + updatedAt: 2_000, + currentRequestId: null, + responsesRequests: 3, + authRefreshRequests: 1, + diagnosticProbeRequests: 0, + poolExhaustionCooldownUntil: null, + serverBurstCooldownUntil: null, + runtimeMetrics: { + startedAt: 1_000, + totalRequests: 3, + successfulRequests: 3, + failedRequests: 0, + responsesRequests: 3, + authRefreshRequests: 1, + diagnosticProbeRequests: 0, + outboundRequestAttemptBudget: null, + outboundRequestAttemptsConsumed: 0, + requestAttemptBudgetExhaustions: 0, + poolExhaustionFastFails: 0, + serverBurstFastFails: 0, + rateLimitedResponses: 0, + serverErrors: 0, + networkErrors: 0, + userAborts: 0, + authRefreshFailures: 0, + emptyResponseRetries: 0, + accountRotations: 1, + sameAccountRetries: 0, + streamFailoverAttempts: 0, + streamFailoverCandidatesConsidered: 0, + lastStreamFailoverCandidateCount: 0, + streamFailoverRecoveries: 0, + streamFailoverCrossAccountRecoveries: 0, + cumulativeLatencyMs: 30, + lastRequestAt: 1_999, + lastError: null, + }, + ...overrides, + }; +} + describe("runStatusCommand", () => { it("prints empty storage state", async () => { const deps = createStatusDeps({ loadAccounts: vi.fn(async () => null) }); @@ -66,6 +113,50 @@ describe("runStatusCommand", () => { expect(deps.logInfo).toHaveBeenCalledWith("Storage health: healthy"); }); + it("prints intentional reset state from empty storage metadata", async () => { + const deps = createStatusDeps({ + loadAccounts: vi.fn(async () => ({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [], + restoreReason: "intentional-reset", + })), + }); + + const result = await runStatusCommand(deps); + + expect(result).toBe(0); + expect(deps.logInfo).toHaveBeenCalledWith( + "No accounts configured. Storage was intentionally reset.", + ); + expect(deps.logInfo).toHaveBeenCalledWith( + "Storage health: intentional-reset", + ); + }); + + it.each([ + ["empty-storage" as const, "empty"], + ["missing-storage" as const, "empty"], + ])("maps restore reason %s to empty storage health", async (restoreReason, health) => { + const deps = createStatusDeps({ + inspectStorageHealth: undefined, + loadAccounts: vi.fn(async () => ({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [], + restoreReason, + })), + }); + + const result = await runStatusCommand(deps); + + expect(result).toBe(0); + expect(deps.logInfo).toHaveBeenCalledWith("No accounts configured."); + expect(deps.logInfo).toHaveBeenCalledWith(`Storage health: ${health}`); + }); + it("prints explicit corrupt storage state for empty result cases", async () => { const deps = createStatusDeps({ loadAccounts: vi.fn(async () => null), @@ -116,6 +207,26 @@ describe("runStatusCommand", () => { ), ); }); + + it("prints the last rotated runtime account when observability has it", async () => { + const deps = createStatusDeps({ + loadRuntimeObservabilitySnapshot: vi.fn(async () => + createRuntimeSnapshot({ + lastAccountIndex: 1, + lastAccountLabel: "Account 2 (two@example.com, id:acct_2)", + lastAccountEmail: "two@example.com", + lastAccountId: "acct_2", + lastAccountUpdatedAt: 1_999, + }), + ), + }); + + await runStatusCommand(deps); + + expect(deps.logInfo).toHaveBeenCalledWith( + "Last runtime account: Account 2 (acct_2)", + ); + }); }); describe("runFeaturesCommand", () => { diff --git a/test/codex-routing.test.ts b/test/codex-routing.test.ts index 079d0944..b5e2ec6f 100644 --- a/test/codex-routing.test.ts +++ b/test/codex-routing.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { normalizeAuthAlias, shouldHandleMultiAuthAuth } from "../scripts/codex-routing.js"; +import { + AUTH_SUBCOMMANDS, + normalizeAuthAlias, + shouldHandleMultiAuthAuth, +} from "../scripts/codex-routing.js"; describe("codex routing helpers", () => { it("normalizes supported auth aliases", () => { @@ -16,4 +20,32 @@ describe("codex routing helpers", () => { expect(shouldHandleMultiAuthAuth(["auth", "unknown-subcommand"])).toBe(false); expect(shouldHandleMultiAuthAuth(["status"])).toBe(false); }); + + it("keeps wrapper auth routing aligned with manager subcommands", () => { + const managerSubcommands = [ + "login", + "list", + "status", + "switch", + "check", + "features", + "verify-flagged", + "forecast", + "best", + "report", + "rotation", + "why-selected", + "verify", + "fix", + "doctor", + "config", + "init-config", + "debug", + ]; + + for (const subcommand of managerSubcommands) { + expect(AUTH_SUBCOMMANDS.has(subcommand), subcommand).toBe(true); + expect(shouldHandleMultiAuthAuth(["auth", subcommand]), subcommand).toBe(true); + } + }); }); diff --git a/test/documentation.test.ts b/test/documentation.test.ts index 962ce86a..3d542c1b 100644 --- a/test/documentation.test.ts +++ b/test/documentation.test.ts @@ -547,6 +547,7 @@ describe("Documentation Integrity", () => { }); expect(packageJson.bin).toEqual({ codex: "scripts/codex.js", + "codex-multi-auth-app-launcher": "scripts/codex-app-launcher.js", "codex-multi-auth": "scripts/codex-multi-auth.js", }); }); diff --git a/test/install-codex-auth.test.ts b/test/install-codex-auth.test.ts index fdd2cf0b..7cb4292c 100644 --- a/test/install-codex-auth.test.ts +++ b/test/install-codex-auth.test.ts @@ -3,6 +3,7 @@ import { mkdtempSync, readFileSync, rmSync, existsSync, writeFileSync, readdirSy import { tmpdir } from "node:os"; import path from "node:path"; import { spawnSync, execFile } from "node:child_process"; +import { pathToFileURL } from "node:url"; import { promisify } from "node:util"; import { FILE_RETRY_BASE_DELAY_MS, @@ -11,8 +12,18 @@ import { resolveInstallPaths, withFileOperationRetry, } from "../scripts/install-codex-auth-utils.js"; +import { + createWindowsShortcutPowerShellScript, + resolveAppLauncherPlan, +} from "../scripts/codex-app-launcher.js"; +import { + hasCodexDesktopApp, + isCiEnvironment, + shouldAutoBindCodexAppOnInstall, +} from "../scripts/postinstall.js"; const scriptPath = "scripts/install-codex-auth.js"; +const appLauncherScriptPath = "scripts/codex-app-launcher.js"; const tempRoots: string[] = []; const execFileAsync = promisify(execFile); @@ -33,6 +44,12 @@ function retryableError(code: string): Error & { code: string } { return error; } +function decodeWindowsEncodedCommand(commandArgs: string): string { + const marker = "-EncodedCommand "; + const encoded = commandArgs.slice(commandArgs.indexOf(marker) + marker.length).trim(); + return Buffer.from(encoded, "base64").toString("utf16le"); +} + describe("install-codex-auth script", () => { it("uses lowercase config template filenames", () => { const content = readFileSync(scriptPath, "utf8"); @@ -190,3 +207,269 @@ describe("install-codex-auth script", () => { expect(operation).toHaveBeenCalledTimes(1); }); }); + +describe("codex app launcher installer", () => { + it("resolves Windows shortcut routing that points existing Codex icons at the wrapper app command", () => { + const home = "C:\\Users\\test"; + const appData = path.join(home, "AppData", "Roaming"); + const plan = resolveAppLauncherPlan({ + platform: "win32", + home, + env: { APPDATA: appData }, + moduleUrl: pathToFileURL(path.resolve(appLauncherScriptPath)).href, + }); + + expect(plan.launcherPath).toBe( + path.join( + appData, + "Microsoft", + "Windows", + "Start Menu", + "Programs", + "Codex.lnk", + ), + ); + expect(plan.mode).toBe("route-existing"); + expect(plan.backupPath).toBe( + path.join(home, ".codex", "multi-auth", "app-shortcuts.json"), + ); + expect(plan.shortcutRoots).toEqual( + expect.arrayContaining([ + path.join(appData, "Microsoft", "Windows", "Start Menu", "Programs"), + path.join( + appData, + "Microsoft", + "Internet Explorer", + "Quick Launch", + "User Pinned", + "TaskBar", + ), + path.join(home, "Desktop"), + ]), + ); + expect(plan.commandPath).toBe( + path.join( + "C:\\Windows", + "System32", + "WindowsPowerShell", + "v1.0", + "powershell.exe", + ), + ); + expect(plan.commandArgs).toContain("-EncodedCommand "); + const decodedCommand = decodeWindowsEncodedCommand(plan.commandArgs); + expect(decodedCommand).toContain(process.execPath); + expect(decodedCommand).toContain("scripts\\codex.js"); + expect(decodedCommand).toContain(" app"); + + const psScript = createWindowsShortcutPowerShellScript(plan); + expect(psScript).toContain("$Candidates"); + expect(psScript).toContain("$BackupPath"); + expect(psScript).toContain("[Environment]::GetFolderPath('Desktop')"); + expect(psScript).toContain("shell:AppsFolder"); + expect(psScript).toContain("$AlreadyManaged"); + expect(psScript).toContain("$Shortcut.TargetPath = $TargetPath"); + expect(psScript).toContain("Launch Codex through codex-multi-auth"); + }); + + it("keeps Windows shortcut arguments free of raw percent paths", () => { + const home = "C:\\Users\\percent%home"; + const appData = path.join(home, "App%Data", "Roaming"); + const moduleUrl = pathToFileURL( + path.join(home, "pkg%root", "scripts", "codex-app-launcher.js"), + ).href; + const plan = resolveAppLauncherPlan({ + platform: "win32", + home, + env: { APPDATA: appData }, + moduleUrl, + }); + + expect(plan.commandArgs).not.toContain(home); + expect(plan.commandArgs).not.toContain("pkg%root"); + const decodedCommand = decodeWindowsEncodedCommand(plan.commandArgs); + expect(decodedCommand).toContain(home); + expect(decodedCommand).toContain("pkg%root"); + expect(decodedCommand).toContain("codex.js"); + }); + + it("includes redirected Windows desktop roots when routing app shortcuts", () => { + const home = "C:\\Users\\test"; + const appData = path.join(home, "AppData", "Roaming"); + const oneDrive = path.join(home, "OneDrive - Example"); + const plan = resolveAppLauncherPlan({ + platform: "win32", + home, + env: { + APPDATA: appData, + OneDrive: oneDrive, + }, + moduleUrl: pathToFileURL(path.resolve(appLauncherScriptPath)).href, + }); + + expect(plan.shortcutRoots).toEqual( + expect.arrayContaining([ + path.join(oneDrive, "Desktop"), + path.join(home, "Desktop"), + ]), + ); + }); + + it("resolves a macOS managed app wrapper without patching the official app bundle", () => { + const home = "/Users/test"; + const plan = resolveAppLauncherPlan({ + platform: "darwin", + home, + env: {}, + moduleUrl: pathToFileURL(path.resolve(appLauncherScriptPath)).href, + }); + + expect(plan.mode).toBe("create-managed"); + expect(plan.launcherPath).toBe(path.join(home, "Applications", "Codex Multi Auth.app")); + expect(plan.commandPath).toBe(process.execPath); + expect(plan.commandArgs).toContain("codex.js"); + expect(plan.commandArgs).toContain(" app"); + }); + + it("resolves a Linux desktop launcher under XDG_DATA_HOME", () => { + const home = "/home/test"; + const dataHome = "/tmp/test-data"; + const plan = resolveAppLauncherPlan({ + platform: "linux", + home, + env: { XDG_DATA_HOME: dataHome }, + moduleUrl: pathToFileURL(path.resolve(appLauncherScriptPath)).href, + }); + + expect(plan.launcherPath).toBe( + path.join(dataHome, "applications", "codex-multi-auth.desktop"), + ); + expect(plan.commandPath).toBe(process.execPath); + expect(plan.commandArgs).toContain("codex.js"); + expect(plan.commandArgs).toContain(" app %F"); + }); + + it("dry-run reports the launcher path without writing it", () => { + const home = mkdtempSync(path.join(tmpdir(), "codex-app-launcher-dryrun-")); + tempRoots.push(home); + const dataHome = path.join(home, "data"); + const result = spawnSync( + process.execPath, + [appLauncherScriptPath, "--dry-run"], + { + env: { + ...process.env, + XDG_DATA_HOME: dataHome, + }, + encoding: "utf8", + windowsHide: true, + }, + ); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("[dry-run]"); + if (process.platform !== "win32") { + expect(result.stdout).toContain("Codex Multi Auth app launcher"); + expect(existsSync(path.join(dataHome, "applications", "codex-multi-auth.desktop"))).toBe( + false, + ); + } + }); +}); + +describe("codex app bind postinstall gate", () => { + it("detects the packaged Windows Codex app from LOCALAPPDATA packages", () => { + const home = mkdtempSync(path.join(tmpdir(), "codex-app-bind-detect-")); + tempRoots.push(home); + const localAppData = path.join(home, "AppData", "Local"); + mkdirSync(path.join(localAppData, "Packages", "OpenAI.Codex_test"), { + recursive: true, + }); + + expect( + hasCodexDesktopApp({ + platform: "win32", + home, + env: { LOCALAPPDATA: localAppData }, + }), + ).toBe(true); + }); + + it("only auto-binds on install when opted in or globally installed with rotation enabled", () => { + expect( + shouldAutoBindCodexAppOnInstall({ + env: {}, + rotationEnabled: true, + appDetected: true, + }), + ).toBe(false); + expect( + shouldAutoBindCodexAppOnInstall({ + env: { npm_config_global: "true" }, + rotationEnabled: false, + appDetected: true, + }), + ).toBe(false); + expect( + shouldAutoBindCodexAppOnInstall({ + env: { npm_config_global: "true" }, + rotationEnabled: true, + appDetected: true, + }), + ).toBe(true); + expect( + shouldAutoBindCodexAppOnInstall({ + env: { CODEX_MULTI_AUTH_APP_BIND_INSTALL: "1" }, + rotationEnabled: false, + appDetected: false, + }), + ).toBe(true); + expect( + shouldAutoBindCodexAppOnInstall({ + env: { + npm_config_global: "true", + CODEX_MULTI_AUTH_APP_BIND: "0", + }, + rotationEnabled: true, + appDetected: true, + }), + ).toBe(false); + }); + + it("skips desktop app auto-bind in CI and when npm scripts are ignored", () => { + expect(isCiEnvironment({ CI: "true" })).toBe(true); + expect(isCiEnvironment({ GITHUB_ACTIONS: "true" })).toBe(true); + expect(isCiEnvironment({ npm_config_ignore_scripts: "true" })).toBe(true); + expect( + shouldAutoBindCodexAppOnInstall({ + env: { + CI: "true", + CODEX_MULTI_AUTH_APP_BIND_INSTALL: "1", + npm_config_global: "true", + }, + rotationEnabled: true, + appDetected: true, + }), + ).toBe(false); + expect( + shouldAutoBindCodexAppOnInstall({ + env: { + GITHUB_ACTIONS: "true", + CODEX_MULTI_AUTH_APP_BIND: "1", + }, + rotationEnabled: true, + appDetected: true, + }), + ).toBe(false); + expect( + shouldAutoBindCodexAppOnInstall({ + env: { + npm_config_ignore_scripts: "true", + CODEX_MULTI_AUTH_APP_BIND_INSTALL: "1", + }, + rotationEnabled: true, + appDetected: true, + }), + ).toBe(false); + }); +}); diff --git a/test/logger.test.ts b/test/logger.test.ts index 82ebabbf..7a1b4681 100644 --- a/test/logger.test.ts +++ b/test/logger.test.ts @@ -479,6 +479,20 @@ describe('Logger Module', () => { expect(data.access_token).toBe('this-i...alue'); }); + it('should mask experimental bearer token key variants', () => { + const mockLog = vi.fn(); + initLogger({ app: { log: mockLog } }); + logError('test', { + experimental_bearer_token: 'runtime-router-secret-value', + experimentalBearerToken: 'runtime-router-secret-value', + 'experimental-bearer-token': 'runtime-router-secret-value', + }); + const data = mockLog.mock.calls[0][0].body.extra?.data; + expect(data.experimental_bearer_token).toBe('runtim...alue'); + expect(data.experimentalBearerToken).toBe('runtim...alue'); + expect(data['experimental-bearer-token']).toBe('runtim...alue'); + }); + it('should handle arrays in sanitization', () => { const mockLog = vi.fn(); initLogger({ app: { log: mockLog } }); diff --git a/test/package-bin.test.ts b/test/package-bin.test.ts index 8d186a5d..7abcd2f1 100644 --- a/test/package-bin.test.ts +++ b/test/package-bin.test.ts @@ -10,6 +10,7 @@ describe("package bin entries", () => { }; expect(pkg.bin).toBeDefined(); expect(pkg.bin?.codex).toBe("scripts/codex.js"); + expect(pkg.bin?.["codex-multi-auth-app-launcher"]).toBe("scripts/codex-app-launcher.js"); expect(pkg.bin?.["codex-multi-auth"]).toBe("scripts/codex-multi-auth.js"); expect(pkg.bin?.["codex-multi-auth-opencode-install"]).toBeUndefined(); expect(pkg.files).toEqual(expect.arrayContaining(["vendor/codex-ai-plugin/", "vendor/codex-ai-sdk/"])); diff --git a/test/plugin-config.test.ts b/test/plugin-config.test.ts index 254c2290..50a6cb6f 100644 --- a/test/plugin-config.test.ts +++ b/test/plugin-config.test.ts @@ -27,6 +27,7 @@ import { getPreemptiveQuotaMaxDeferralMs, getResponseContinuation, getBackgroundResponses, + getCodexRuntimeRotationProxy, } from "../lib/config.js"; import type { PluginConfig } from "../lib/types.js"; import * as fs from "node:fs"; @@ -63,6 +64,7 @@ describe("Plugin Configuration", () => { "CODEX_HOME", "CODEX_MULTI_AUTH_DIR", "CODEX_MODE", + "CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY", "CODEX_TUI_V2", "CODEX_TUI_COLOR_PROFILE", "CODEX_TUI_GLYPHS", @@ -114,6 +116,7 @@ describe("Plugin Configuration", () => { expect(config).toEqual({ codexMode: true, + codexRuntimeRotationProxy: false, codexTuiV2: true, codexTuiColorProfile: "truecolor", codexTuiGlyphMode: "ascii", @@ -184,6 +187,7 @@ describe("Plugin Configuration", () => { expect(config).toEqual({ codexMode: false, + codexRuntimeRotationProxy: false, codexTuiV2: true, codexTuiColorProfile: "truecolor", codexTuiGlyphMode: "ascii", @@ -498,6 +502,7 @@ describe("Plugin Configuration", () => { expect(config).toEqual({ codexMode: true, + codexRuntimeRotationProxy: false, codexTuiV2: true, codexTuiColorProfile: "truecolor", codexTuiGlyphMode: "ascii", @@ -569,6 +574,7 @@ describe("Plugin Configuration", () => { expect(config).toEqual({ codexMode: true, + codexRuntimeRotationProxy: false, codexTuiV2: true, codexTuiColorProfile: "truecolor", codexTuiGlyphMode: "ascii", @@ -634,6 +640,7 @@ describe("Plugin Configuration", () => { expect(config).toEqual({ codexMode: true, + codexRuntimeRotationProxy: false, codexTuiV2: true, codexTuiColorProfile: "truecolor", codexTuiGlyphMode: "ascii", @@ -1106,6 +1113,22 @@ describe("Plugin Configuration", () => { // Test 3: default when neither set expect(getCodexMode({})).toBe(true); }); + + it("resolves runtime rotation proxy from env over config over default", () => { + delete process.env.CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY; + expect(getCodexRuntimeRotationProxy({})).toBe(false); + expect( + getCodexRuntimeRotationProxy({ codexRuntimeRotationProxy: true }), + ).toBe(true); + + process.env.CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY = "0"; + expect( + getCodexRuntimeRotationProxy({ codexRuntimeRotationProxy: true }), + ).toBe(false); + + process.env.CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY = "1"; + expect(getCodexRuntimeRotationProxy({})).toBe(true); + }); }); describe("Schema validation warnings", () => { diff --git a/test/runtime-observability.test.ts b/test/runtime-observability.test.ts index 924e032a..44a75aa4 100644 --- a/test/runtime-observability.test.ts +++ b/test/runtime-observability.test.ts @@ -60,6 +60,8 @@ describe("runtime observability snapshot versioning", () => { expect(snapshot?.version).toBe(1); expect(snapshot?.responsesRequests).toBe(2); + expect(snapshot?.lastAccountIndex).toBeNull(); + expect(snapshot?.lastAccountEmail).toBeNull(); expect(snapshot?.runtimeMetrics.totalRequests).toBe(3); expect(snapshot?.runtimeMetrics.failedRequests).toBe(0); }); diff --git a/test/runtime-rotation-proxy-safe-equal.test.ts b/test/runtime-rotation-proxy-safe-equal.test.ts new file mode 100644 index 00000000..f91137bf --- /dev/null +++ b/test/runtime-rotation-proxy-safe-equal.test.ts @@ -0,0 +1,79 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { AccountManager } from "../lib/accounts.js"; +import { HTTP_STATUS } from "../lib/constants.js"; +import type { RuntimeRotationProxyServer } from "../lib/runtime-rotation-proxy.js"; +import type { AccountStorageV3 } from "../lib/storage.js"; + +const openServers: RuntimeRotationProxyServer[] = []; + +function createStorage(now: number): AccountStorageV3 { + return { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "account-1@example.com", + accountId: "acc_1", + refreshToken: "refresh-1", + accessToken: "access-1", + expiresAt: now + 3_600_000, + addedAt: now - 60_000, + lastUsed: now - 60_000, + enabled: true, + }, + ], + }; +} + +afterEach(async () => { + for (const proxy of openServers.splice(0, openServers.length)) { + await proxy.close(); + } + vi.doUnmock("node:crypto"); + vi.resetModules(); +}); + +describe("runtime rotation proxy client auth comparison", () => { + it("still performs a timing-safe comparison for mismatched token lengths", async () => { + vi.resetModules(); + const timingSafeEqualMock = vi.fn( + (left: NodeJS.ArrayBufferView, right: NodeJS.ArrayBufferView) => { + expect(left.byteLength).toBe(right.byteLength); + return false; + }, + ); + vi.doMock("node:crypto", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + timingSafeEqual: timingSafeEqualMock, + }; + }); + const { startRuntimeRotationProxy } = await import( + "../lib/runtime-rotation-proxy.js" + ); + const accountManager = new AccountManager(undefined, createStorage(Date.now())); + const fetchImpl = vi.fn(); + const proxy = await startRuntimeRotationProxy({ + accountManager, + clientApiKey: "runtime-secret-with-longer-length", + fetchImpl, + upstreamBaseUrl: "https://example.test/backend-api", + }); + openServers.push(proxy); + + const response = await fetch(`${proxy.baseUrl}/responses`, { + method: "POST", + headers: { + authorization: "Bearer short", + "content-type": "application/json", + }, + body: JSON.stringify({ model: "gpt-5-codex" }), + }); + + expect(response.status).toBe(HTTP_STATUS.UNAUTHORIZED); + expect(fetchImpl).not.toHaveBeenCalled(); + expect(timingSafeEqualMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/runtime-rotation-proxy.test.ts b/test/runtime-rotation-proxy.test.ts new file mode 100644 index 00000000..8d09feef --- /dev/null +++ b/test/runtime-rotation-proxy.test.ts @@ -0,0 +1,955 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { request } from "node:http"; +import { AccountManager } from "../lib/accounts.js"; +import { HTTP_STATUS, OPENAI_HEADERS } from "../lib/constants.js"; +import { + startRuntimeRotationProxy, + type RuntimeRotationProxyServer, +} from "../lib/runtime-rotation-proxy.js"; +import { clearCircuitBreakers } from "../lib/circuit-breaker.js"; +import { resetRefreshQueue } from "../lib/refresh-queue.js"; +import { resetTrackers } from "../lib/rotation.js"; +import type { AccountStorageV3 } from "../lib/storage.js"; + +const { + refreshAccessTokenMock, + saveAccountsMock, + withAccountStorageTransactionMock, +} = vi.hoisted( + () => ({ + refreshAccessTokenMock: vi.fn(), + saveAccountsMock: vi.fn(), + withAccountStorageTransactionMock: vi.fn(), + }), +); + +vi.mock("../lib/auth/auth.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + refreshAccessToken: refreshAccessTokenMock, + }; +}); + +vi.mock("../lib/storage.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + saveAccounts: saveAccountsMock, + withAccountStorageTransaction: withAccountStorageTransactionMock, + }; +}); + +interface FetchCall { + url: string; + headers: Headers; + bodyText: string; +} + +const openServers: RuntimeRotationProxyServer[] = []; +const openManagers: AccountManager[] = []; +const DEFAULT_CLIENT_API_KEY = "runtime-secret"; + +function createStorage(now: number, count = 2): AccountStorageV3 { + return { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: Array.from({ length: count }, (_unused, index) => ({ + email: `account-${index + 1}@example.com`, + accountId: `acc_${index + 1}`, + refreshToken: `refresh-${index + 1}`, + accessToken: `access-${index + 1}`, + expiresAt: now + 3_600_000, + addedAt: now - 60_000, + lastUsed: now - (count - index) * 60_000, + enabled: true, + })), + }; +} + +function bodyTextFromInit(init: RequestInit | undefined): string { + const body = init?.body; + if (typeof body === "string") return body; + if (body instanceof Uint8Array) return Buffer.from(body).toString("utf8"); + return ""; +} + +function createRecordingFetch( + handler: (call: FetchCall, attempt: number) => Response | Promise, +): { calls: FetchCall[]; fetchImpl: typeof fetch } { + const calls: FetchCall[] = []; + const fetchImpl: typeof fetch = async (input, init) => { + const call = { + url: String(input), + headers: new Headers(init?.headers), + bodyText: bodyTextFromInit(init), + }; + calls.push(call); + return handler(call, calls.length); + }; + return { calls, fetchImpl }; +} + +function timeoutResult(ms: number): Promise<"timeout"> { + return new Promise((resolve) => { + setTimeout(() => resolve("timeout"), ms); + }); +} + +async function startProxy(params: { + accountManager: AccountManager; + fetchImpl: typeof fetch; + options?: Partial[0]>; +}): Promise { + openManagers.push(params.accountManager); + const proxy = await startRuntimeRotationProxy({ + accountManager: params.accountManager, + fetchImpl: params.fetchImpl, + upstreamBaseUrl: "https://example.test/backend-api", + clientApiKey: DEFAULT_CLIENT_API_KEY, + quotaRemainingPercentThreshold: 10, + ...params.options, + }); + openServers.push(proxy); + return proxy; +} + +async function postResponses( + proxy: RuntimeRotationProxyServer, + body: Record, + path = "/responses", + headers: Record = {}, +): Promise { + return fetch(`${proxy.baseUrl}${path}`, { + method: "POST", + headers: { + authorization: `Bearer ${DEFAULT_CLIENT_API_KEY}`, + "content-type": "application/json", + "x-api-key": "caller-key", + ...headers, + }, + body: JSON.stringify(body), + }); +} + +async function postRawResponses( + proxy: RuntimeRotationProxyServer, + body: string, + headers: Record = {}, +): Promise { + return fetch(`${proxy.baseUrl}/responses`, { + method: "POST", + headers: { + authorization: `Bearer ${DEFAULT_CLIENT_API_KEY}`, + "content-type": "application/json", + ...headers, + }, + body, + }); +} + +async function postResponsesWithHttp( + proxy: RuntimeRotationProxyServer, + body: Record, + headers: Record = {}, +): Promise<{ status: number; text: string }> { + const url = new URL(`${proxy.baseUrl}/responses`); + const payload = JSON.stringify(body); + return new Promise((resolve, reject) => { + const req = request( + { + host: url.hostname, + port: Number(url.port), + path: url.pathname, + method: "POST", + headers: { + authorization: `Bearer ${DEFAULT_CLIENT_API_KEY}`, + "content-type": "application/json", + "content-length": Buffer.byteLength(payload).toString(), + ...headers, + }, + }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (chunk) => + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)), + ); + res.on("end", () => + resolve({ + status: res.statusCode ?? 0, + text: Buffer.concat(chunks).toString("utf8"), + }), + ); + }, + ); + req.on("error", reject); + req.end(payload); + }); +} + +interface ActiveHandleProcess { + _getActiveHandles?: () => unknown[]; +} + +interface ActiveServerHandle { + address?: () => unknown; + emit?: (event: "error", error: Error) => boolean; +} + +function emitServerErrorForProxy( + proxy: RuntimeRotationProxyServer, + error: Error, +): void { + const handles = + (process as unknown as ActiveHandleProcess)._getActiveHandles?.() ?? []; + for (const handle of handles) { + const candidate = handle as ActiveServerHandle; + if ( + typeof candidate.address !== "function" || + typeof candidate.emit !== "function" + ) { + continue; + } + const address = candidate.address(); + const port = + typeof address === "object" && address !== null && "port" in address + ? (address as { port?: unknown }).port + : null; + if (port === proxy.port) { + candidate.emit("error", error); + return; + } + } + throw new Error(`runtime proxy server on port ${proxy.port} was not found`); +} + +function textEventStream(body = "data: {}\n\n", headers?: HeadersInit): Response { + return new Response(body, { + status: HTTP_STATUS.OK, + headers: { + "content-type": "text/event-stream", + ...headers, + }, + }); +} + +beforeEach(() => { + resetTrackers(); + clearCircuitBreakers(); + resetRefreshQueue(); + refreshAccessTokenMock.mockReset(); + saveAccountsMock.mockReset(); + saveAccountsMock.mockResolvedValue(undefined); + withAccountStorageTransactionMock.mockReset(); + withAccountStorageTransactionMock.mockImplementation(async (handler) => + handler(null, async () => undefined), + ); +}); + +afterEach(async () => { + for (const proxy of openServers.splice(0, openServers.length)) { + await proxy.close(); + } + for (const accountManager of openManagers.splice(0, openManagers.length)) { + await accountManager.flushPendingSave(); + } + resetTrackers(); + clearCircuitBreakers(); + resetRefreshQueue(); +}); + +describe("runtime rotation proxy", () => { + it("requires a client API key at startup", async () => { + const now = Date.now(); + const accountManager = new AccountManager(undefined, createStorage(now)); + const { fetchImpl } = createRecordingFetch(() => textEventStream()); + + await expect( + startRuntimeRotationProxy({ + accountManager, + fetchImpl, + upstreamBaseUrl: "https://example.test/backend-api", + } as Parameters[0]), + ).rejects.toThrow("clientApiKey"); + }); + + it("records post-startup server errors without throwing uncaught errors", async () => { + const now = Date.now(); + const accountManager = new AccountManager(undefined, createStorage(now)); + const { fetchImpl } = createRecordingFetch(() => textEventStream()); + const proxy = await startProxy({ accountManager, fetchImpl }); + + expect(() => + emitServerErrorForProxy(proxy, new Error("post-startup server boom")), + ).not.toThrow(); + expect(proxy.getStatus().lastError).toBe("post-startup server boom"); + }); + + it("closes active streaming clients during shutdown", async () => { + const now = Date.now(); + const accountManager = new AccountManager(undefined, createStorage(now, 1)); + const encoder = new TextEncoder(); + const { fetchImpl } = createRecordingFetch( + () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode("data: still-open\n\n")); + }, + }), + { + status: HTTP_STATUS.OK, + headers: { "content-type": "text/event-stream" }, + }, + ), + ); + const proxy = await startProxy({ + accountManager, + fetchImpl, + options: { streamStallTimeoutMs: 60_000 }, + }); + + const response = await postResponses(proxy, { + model: "gpt-5-codex", + stream: true, + }); + expect(response.status).toBe(HTTP_STATUS.OK); + const reader = response.body?.getReader(); + if (!reader) throw new Error("expected streaming response body"); + const first = await reader.read(); + expect(new TextDecoder().decode(first.value)).toBe("data: still-open\n\n"); + + await expect( + Promise.race([proxy.close().then(() => "closed" as const), timeoutResult(500)]), + ).resolves.toBe("closed"); + await reader.cancel().catch(() => undefined); + }); + + it("rejects unauthenticated local clients when a wrapper token is configured", async () => { + const now = Date.now(); + const accountManager = new AccountManager(undefined, createStorage(now)); + const { calls, fetchImpl } = createRecordingFetch(() => + textEventStream("data: forwarded\n\n", { + "x-codex-multi-auth-account-index": "1", + "x-codex-multi-auth-account-email": "account-1@example.com", + "x-codex-multi-auth-account-label": + "Account 1 (account-1@example.com, id:acc_1)", + "x-codex-multi-auth-account-id": "acc_1", + }), + ); + const proxy = await startRuntimeRotationProxy({ + accountManager, + fetchImpl, + upstreamBaseUrl: "https://example.test/backend-api", + clientApiKey: "runtime-secret", + }); + openServers.push(proxy); + openManagers.push(accountManager); + + const rejected = await postResponses( + proxy, + { model: "gpt-5-codex" }, + "/responses", + { + authorization: "Bearer caller-token", + "x-api-key": "caller-key", + }, + ); + + expect(rejected.status).toBe(HTTP_STATUS.UNAUTHORIZED); + expect(calls).toHaveLength(0); + + const accepted = await postResponses( + proxy, + { model: "gpt-5-codex" }, + "/responses", + { authorization: "Bearer runtime-secret" }, + ); + + expect(accepted.status).toBe(HTTP_STATUS.OK); + expect(await accepted.text()).toBe("data: forwarded\n\n"); + expect(calls).toHaveLength(1); + + const acceptedWithApiKey = await postResponses( + proxy, + { model: "gpt-5-codex" }, + "/responses", + { + authorization: "Bearer wrong-token", + "x-api-key": "runtime-secret", + }, + ); + + expect(acceptedWithApiKey.status).toBe(HTTP_STATUS.OK); + expect(await acceptedWithApiKey.text()).toBe("data: forwarded\n\n"); + expect(calls).toHaveLength(2); + }); + + it("forwards Responses requests unchanged while replacing caller auth", async () => { + const now = Date.now(); + const accountManager = new AccountManager(undefined, createStorage(now)); + const { calls, fetchImpl } = createRecordingFetch(() => + textEventStream("data: forwarded\n\n"), + ); + const proxy = await startProxy({ accountManager, fetchImpl }); + const requestBody = { + model: "gpt-5-codex", + stream: true, + instructions: "preserve me", + input: [{ type: "message", role: "user", content: "hello" }], + tools: [{ type: "function", function: { name: "lookup" } }], + reasoning: { encrypted_content: "ciphertext" }, + metadata: { session_id: "session-a" }, + }; + + const response = await postResponses(proxy, requestBody, "/v1/responses?trace=1"); + + expect(response.status).toBe(HTTP_STATUS.OK); + expect(await response.text()).toBe("data: forwarded\n\n"); + expect(calls).toHaveLength(1); + expect(calls[0]?.url).toBe( + "https://example.test/backend-api/codex/responses?trace=1", + ); + expect(calls[0]?.headers.get("authorization")).toBe("Bearer access-1"); + expect(calls[0]?.headers.get("x-api-key")).toBeNull(); + expect(calls[0]?.headers.get(OPENAI_HEADERS.ACCOUNT_ID)).toBe("acc_1"); + expect(response.headers.get("x-codex-multi-auth-account-index")).toBeNull(); + expect(response.headers.get("x-codex-multi-auth-account-email")).toBeNull(); + expect(response.headers.get("x-codex-multi-auth-account-label")).toBeNull(); + expect(response.headers.get("x-codex-multi-auth-account-id")).toBeNull(); + expect(proxy.getStatus()).toMatchObject({ + lastAccountIndex: 0, + lastAccountLabel: "Account 1", + lastAccountId: "acc_1", + }); + expect(proxy.getStatus()).not.toHaveProperty("lastAccountEmail"); + expect(JSON.parse(calls[0]?.bodyText ?? "{}")).toEqual(requestBody); + }); + + it("strips decoded upstream content encoding before forwarding to clients", async () => { + const now = Date.now(); + const accountManager = new AccountManager(undefined, createStorage(now)); + const { fetchImpl } = createRecordingFetch( + () => + new Response('{"ok":true}\n', { + status: HTTP_STATUS.OK, + headers: { + "content-type": "application/json", + "content-encoding": "gzip", + "content-length": "41", + }, + }), + ); + const proxy = await startProxy({ accountManager, fetchImpl }); + + const response = await postResponses(proxy, { + model: "gpt-5-codex", + }); + + expect(response.status).toBe(HTTP_STATUS.OK); + expect(response.headers.get("content-encoding")).toBeNull(); + expect(response.headers.get("content-length")).toBeNull(); + expect(await response.text()).toBe('{"ok":true}\n'); + }); + + it("rejects arbitrary local paths that merely end with responses", async () => { + const now = Date.now(); + const accountManager = new AccountManager(undefined, createStorage(now)); + const { calls, fetchImpl } = createRecordingFetch(() => + textEventStream("data: forwarded\n\n"), + ); + const proxy = await startProxy({ accountManager, fetchImpl }); + + const response = await postResponses( + proxy, + { model: "gpt-5-codex" }, + "/foo/responses", + ); + + expect(response.status).toBe(HTTP_STATUS.NOT_FOUND); + expect(calls).toHaveLength(0); + }); + + it("rejects oversized request bodies before selecting an account", async () => { + const now = Date.now(); + const accountManager = new AccountManager(undefined, createStorage(now)); + const { calls, fetchImpl } = createRecordingFetch(() => + textEventStream("data: unreachable\n\n"), + ); + const proxy = await startProxy({ + accountManager, + fetchImpl, + options: { maxRequestBodyBytes: 8 }, + }); + + const response = await postRawResponses(proxy, '{"model":"gpt-5-codex"}'); + const payload = (await response.json()) as { error: { code: string } }; + + expect(response.status).toBe(HTTP_STATUS.PAYLOAD_TOO_LARGE); + expect(payload.error.code).toBe("runtime_rotation_proxy_payload_too_large"); + expect(calls).toHaveLength(0); + }); + + it("persists the actually served account as the realtime active selection", async () => { + const previousSync = process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI; + process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI = "0"; + const persisted: AccountStorageV3[] = []; + withAccountStorageTransactionMock.mockImplementation(async (handler) => + handler(null, async (storage: AccountStorageV3) => { + persisted.push(structuredClone(storage)); + }), + ); + try { + const now = Date.now(); + const storage = createStorage(now, 2); + const firstAccount = storage.accounts[0]; + if (firstAccount) { + firstAccount.rateLimitResetTimes = { "gpt-5-codex": now + 60_000 }; + } + const accountManager = new AccountManager(undefined, storage); + const { calls, fetchImpl } = createRecordingFetch(() => + textEventStream("data: served\n\n"), + ); + const proxy = await startProxy({ accountManager, fetchImpl }); + + const response = await postResponses(proxy, { + model: "gpt-5-codex", + stream: true, + }); + + expect(response.status).toBe(HTTP_STATUS.OK); + expect(await response.text()).toBe("data: served\n\n"); + await accountManager.flushPendingSave(); + expect(calls[0]?.headers.get(OPENAI_HEADERS.ACCOUNT_ID)).toBe("acc_2"); + expect(persisted.at(-1)).toMatchObject({ + activeIndex: 0, + activeIndexByFamily: { codex: 0, "gpt-5-codex": 1 }, + }); + expect(persisted.at(-1)?.accounts[1]?.lastSwitchReason).toBe("rotation"); + } finally { + if (previousSync === undefined) { + delete process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI; + } else { + process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI = previousSync; + } + } + }); + + it("preserves caller headers except credentials and hop-by-hop values", async () => { + const now = Date.now(); + const accountManager = new AccountManager(undefined, createStorage(now)); + const { calls, fetchImpl } = createRecordingFetch(() => + textEventStream("data: forwarded\n\n"), + ); + const proxy = await startProxy({ accountManager, fetchImpl }); + + await ( + await postResponses( + proxy, + { model: "gpt-5-codex", stream: false }, + "/responses", + { + accept: "application/json", + connection: "close", + "x-custom-trace": "trace-1", + }, + ) + ).text(); + + expect(calls).toHaveLength(1); + expect(calls[0]?.headers.get("accept")).toBe("application/json"); + expect(calls[0]?.headers.get("x-custom-trace")).toBe("trace-1"); + expect(calls[0]?.headers.get("connection")).toBeNull(); + expect(calls[0]?.headers.get("authorization")).toBe("Bearer access-1"); + expect(calls[0]?.headers.get("x-api-key")).toBeNull(); + }); + + it("strips expect before forwarding to fetch", async () => { + const now = Date.now(); + const accountManager = new AccountManager(undefined, createStorage(now)); + const { calls, fetchImpl } = createRecordingFetch(() => + textEventStream("data: forwarded\n\n"), + ); + const proxy = await startProxy({ accountManager, fetchImpl }); + + const response = await postResponsesWithHttp( + proxy, + { model: "gpt-5-codex", stream: false }, + { expect: "100-continue" }, + ); + + expect(response.status).toBe(HTTP_STATUS.OK); + expect(calls).toHaveLength(1); + expect(calls[0]?.headers.get("expect")).toBeNull(); + }); + + it("rotates the next request when quota headers leave less than ten percent", async () => { + const now = Date.now(); + const accountManager = new AccountManager(undefined, createStorage(now)); + const { calls, fetchImpl } = createRecordingFetch((_call, attempt) => + textEventStream(`data: attempt-${attempt}\n\n`, { + "x-codex-primary-used-percent": attempt === 1 ? "95" : "10", + "x-codex-primary-reset-after-seconds": "60", + }), + ); + const proxy = await startProxy({ accountManager, fetchImpl }); + + await (await postResponses(proxy, { model: "gpt-5-codex", stream: true })).text(); + await (await postResponses(proxy, { model: "gpt-5-codex", stream: true })).text(); + + expect(calls.map((call) => call.headers.get(OPENAI_HEADERS.ACCOUNT_ID))).toEqual([ + "acc_1", + "acc_2", + ]); + expect(proxy.getStatus()).toMatchObject({ + lastAccountIndex: 1, + lastAccountLabel: "Account 2", + }); + expect(proxy.getStatus()).not.toHaveProperty("lastAccountEmail"); + expect( + accountManager.getAccountByIndex(0)?.rateLimitResetTimes["gpt-5-codex"], + ).toBeTypeOf("number"); + }); + + it("pins repeated session requests to the first served account", async () => { + const now = Date.now(); + const accountManager = new AccountManager(undefined, createStorage(now, 3)); + const { calls, fetchImpl } = createRecordingFetch((_call, attempt) => + textEventStream(`data: session-${attempt}\n\n`), + ); + const proxy = await startProxy({ accountManager, fetchImpl }); + const body = { + model: "gpt-5-codex", + stream: true, + metadata: { session_id: "thread-a" }, + }; + + await (await postResponses(proxy, body)).text(); + await (await postResponses(proxy, body)).text(); + + expect(calls.map((call) => call.headers.get(OPENAI_HEADERS.ACCOUNT_ID))).toEqual([ + "acc_1", + "acc_1", + ]); + }); + + it("retries a 429 on another account before returning bytes to the client", async () => { + const now = Date.now(); + const accountManager = new AccountManager(undefined, createStorage(now)); + const { calls, fetchImpl } = createRecordingFetch((_call, attempt) => { + if (attempt === 1) { + return new Response( + JSON.stringify({ error: { retry_after_ms: 60_000 } }), + { + status: HTTP_STATUS.TOO_MANY_REQUESTS, + headers: { "content-type": "application/json" }, + }, + ); + } + return textEventStream("data: recovered\n\n"); + }); + const proxy = await startProxy({ accountManager, fetchImpl }); + + const response = await postResponses(proxy, { model: "gpt-5-codex", stream: true }); + + expect(response.status).toBe(HTTP_STATUS.OK); + expect(await response.text()).toBe("data: recovered\n\n"); + expect(calls.map((call) => call.headers.get(OPENAI_HEADERS.ACCOUNT_ID))).toEqual([ + "acc_1", + "acc_2", + ]); + expect(proxy.getStatus().retries).toBe(1); + }); + + it("persists cooldowns so a restarted proxy avoids limited accounts", async () => { + const now = Date.now(); + const persisted: AccountStorageV3[] = []; + withAccountStorageTransactionMock.mockImplementation(async (handler) => + handler(null, async (storage: AccountStorageV3) => { + persisted.push(structuredClone(storage)); + }), + ); + const firstManager = new AccountManager(undefined, createStorage(now, 2)); + const firstFetch = createRecordingFetch((_call, attempt) => { + if (attempt === 1) { + return new Response( + JSON.stringify({ error: { retry_after_ms: 120_000 } }), + { + status: HTTP_STATUS.TOO_MANY_REQUESTS, + headers: { "content-type": "application/json" }, + }, + ); + } + return textEventStream("data: recovered\n\n"); + }); + const firstProxy = await startProxy({ + accountManager: firstManager, + fetchImpl: firstFetch.fetchImpl, + }); + + await (await postResponses(firstProxy, { model: "gpt-5-codex" })).text(); + await firstManager.flushPendingSave(); + + const reloadedStorage = persisted.at(-1); + expect(reloadedStorage).toBeDefined(); + if (!reloadedStorage) throw new Error("expected persisted storage"); + expect(reloadedStorage?.accounts[0]?.rateLimitResetTimes["gpt-5-codex"]).toBeTypeOf( + "number", + ); + const secondManager = new AccountManager(undefined, reloadedStorage); + const secondFetch = createRecordingFetch(() => textEventStream("data: restart\n\n")); + const secondProxy = await startProxy({ + accountManager: secondManager, + fetchImpl: secondFetch.fetchImpl, + }); + + await (await postResponses(secondProxy, { model: "gpt-5-codex" })).text(); + + expect(secondFetch.calls.map((call) => call.headers.get(OPENAI_HEADERS.ACCOUNT_ID))).toEqual([ + "acc_2", + ]); + }); + + it("cools down server-error and network-failure accounts before retrying", async () => { + const now = Date.now(); + const accountManager = new AccountManager(undefined, createStorage(now, 3)); + const saveToDiskDebouncedSpy = vi.spyOn(accountManager, "saveToDiskDebounced"); + const { calls, fetchImpl } = createRecordingFetch((_call, attempt) => { + if (attempt === 1) { + return new Response("upstream failed", { status: 503 }); + } + if (attempt === 2) { + throw new Error("socket closed"); + } + return textEventStream("data: third\n\n"); + }); + const proxy = await startProxy({ accountManager, fetchImpl }); + + const response = await postResponses(proxy, { model: "gpt-5-codex", stream: true }); + + expect(response.status).toBe(HTTP_STATUS.OK); + expect(await response.text()).toBe("data: third\n\n"); + expect(calls.map((call) => call.headers.get(OPENAI_HEADERS.ACCOUNT_ID))).toEqual([ + "acc_1", + "acc_2", + "acc_3", + ]); + expect(accountManager.getAccountByIndex(0)?.cooldownReason).toBe("server-error"); + expect(accountManager.getAccountByIndex(1)?.cooldownReason).toBe("network-error"); + expect(saveToDiskDebouncedSpy).toHaveBeenCalled(); + }); + + it("deduplicates concurrent expired-token refresh and persistence", async () => { + const now = Date.now(); + const storage = createStorage(now, 1); + const account = storage.accounts[0]; + if (!account) throw new Error("expected account"); + account.accessToken = "expired-access"; + account.expiresAt = now - 60_000; + const persisted: AccountStorageV3[] = []; + withAccountStorageTransactionMock.mockImplementation(async (handler) => + handler(null, async (nextStorage: AccountStorageV3) => { + persisted.push(structuredClone(nextStorage)); + await saveAccountsMock(nextStorage); + }), + ); + let releaseRefresh: (() => void) | undefined; + const refreshBlocked = new Promise((resolve) => { + releaseRefresh = resolve; + }); + refreshAccessTokenMock.mockImplementation(async () => { + await refreshBlocked; + return { + type: "success", + access: "fresh-access", + refresh: "refresh-1", + expires: now + 3_600_000, + }; + }); + const accountManager = new AccountManager(undefined, storage); + const { calls, fetchImpl } = createRecordingFetch((_call, attempt) => + textEventStream(`data: refreshed-${attempt}\n\n`), + ); + const proxy = await startProxy({ accountManager, fetchImpl }); + + const first = postResponses(proxy, { model: "gpt-5-codex" }); + const second = postResponses(proxy, { model: "gpt-5-codex" }); + await vi.waitFor(() => expect(refreshAccessTokenMock).toHaveBeenCalledTimes(1)); + releaseRefresh?.(); + const responses = await Promise.all([first, second]); + + expect(responses.map((response) => response.status)).toEqual([ + HTTP_STATUS.OK, + HTTP_STATUS.OK, + ]); + await Promise.all(responses.map((response) => response.text())); + expect(refreshAccessTokenMock).toHaveBeenCalledTimes(1); + expect(saveAccountsMock).toHaveBeenCalledTimes(1); + expect(persisted[0]?.accounts[0]?.accessToken).toBe("fresh-access"); + expect(calls.map((call) => call.headers.get("authorization"))).toEqual([ + "Bearer fresh-access", + "Bearer fresh-access", + ]); + await accountManager.flushPendingSave(); + }); + + it("deduplicates pending refresh commits per account when OAuth tuples differ", async () => { + const now = Date.now(); + const storage = createStorage(now, 1); + const account = storage.accounts[0]; + if (!account) throw new Error("expected account"); + account.accessToken = "expired-access"; + account.expiresAt = now - 60_000; + const persisted: AccountStorageV3[] = []; + withAccountStorageTransactionMock.mockImplementation(async (handler) => + handler(null, async (nextStorage: AccountStorageV3) => { + persisted.push(structuredClone(nextStorage)); + await saveAccountsMock(nextStorage); + }), + ); + refreshAccessTokenMock + .mockResolvedValueOnce({ + type: "success", + access: "fresh-access-1", + refresh: "refresh-1", + expires: now + 3_600_000, + }) + .mockResolvedValueOnce({ + type: "success", + access: "fresh-access-2", + refresh: "refresh-1", + expires: now + 7_200_000, + }); + const accountManager = new AccountManager(undefined, storage); + const originalCommit = accountManager.commitRefreshedAuth.bind(accountManager); + let releaseCommit: (() => void) | undefined; + const commitBlocked = new Promise((resolve) => { + releaseCommit = resolve; + }); + const commitSpy = vi + .spyOn(accountManager, "commitRefreshedAuth") + .mockImplementation(async (...args) => { + await commitBlocked; + return originalCommit(...args); + }); + const { calls, fetchImpl } = createRecordingFetch((_call, attempt) => + textEventStream(`data: refreshed-${attempt}\n\n`), + ); + const proxy = await startProxy({ accountManager, fetchImpl }); + + const first = postResponses(proxy, { model: "gpt-5-codex" }); + await vi.waitFor(() => expect(commitSpy).toHaveBeenCalledTimes(1)); + resetRefreshQueue(); + const second = postResponses(proxy, { model: "gpt-5-codex" }); + await vi.waitFor(() => expect(refreshAccessTokenMock).toHaveBeenCalledTimes(2)); + releaseCommit?.(); + const responses = await Promise.all([first, second]); + + expect(responses.map((response) => response.status)).toEqual([ + HTTP_STATUS.OK, + HTTP_STATUS.OK, + ]); + await Promise.all(responses.map((response) => response.text())); + expect(commitSpy).toHaveBeenCalledTimes(1); + expect(saveAccountsMock).toHaveBeenCalledTimes(1); + expect(persisted[0]?.accounts[0]?.accessToken).toBe("fresh-access-1"); + expect(calls.map((call) => call.headers.get("authorization"))).toEqual([ + "Bearer fresh-access-1", + "Bearer fresh-access-1", + ]); + await accountManager.flushPendingSave(); + }); + + it("returns a structured pool exhaustion response when no account can satisfy the request", async () => { + const now = Date.now(); + const accountManager = new AccountManager(undefined, createStorage(now, 1)); + const { fetchImpl } = createRecordingFetch(() => + new Response(JSON.stringify({ error: { retry_after_ms: 45_000 } }), { + status: HTTP_STATUS.TOO_MANY_REQUESTS, + headers: { "content-type": "application/json" }, + }), + ); + const proxy = await startProxy({ accountManager, fetchImpl }); + + const response = await postResponses(proxy, { model: "gpt-5-codex", stream: true }); + const payload = (await response.json()) as { + error: { code: string; reason: string; retry_after_ms: number; hint: string }; + }; + + expect(response.status).toBe(HTTP_STATUS.TOO_MANY_REQUESTS); + expect(payload.error).toMatchObject({ + code: "codex_runtime_rotation_pool_exhausted", + reason: "rate-limit", + hint: "Run `codex auth rotation status` to inspect account state.", + }); + expect(payload.error.retry_after_ms).toBeGreaterThan(0); + }); + + it("caps per-request upstream attempts instead of walking a large pool", async () => { + const now = Date.now(); + const accountManager = new AccountManager(undefined, createStorage(now, 6)); + const { calls, fetchImpl } = createRecordingFetch(() => + new Response("upstream failed", { status: 503 }), + ); + const proxy = await startProxy({ accountManager, fetchImpl }); + + const response = await postResponses(proxy, { model: "gpt-5-codex" }); + const payload = (await response.json()) as { error: { reason: string } }; + + expect(response.status).toBe(503); + expect(payload.error.reason).toBe("budget"); + expect(calls).toHaveLength(4); + }); + + it("times out a hung upstream fetch and cools down the account", async () => { + const now = Date.now(); + const accountManager = new AccountManager(undefined, createStorage(now, 1)); + const { calls, fetchImpl } = createRecordingFetch( + () => new Promise(() => undefined), + ); + const proxy = await startProxy({ + accountManager, + fetchImpl, + options: { fetchTimeoutMs: 10 }, + }); + + const response = await postResponses(proxy, { model: "gpt-5-codex" }); + const payload = (await response.json()) as { error: { reason: string } }; + + expect(response.status).toBe(503); + expect(payload.error.reason).toBe("network-error"); + expect(calls).toHaveLength(1); + expect(accountManager.getAccountByIndex(0)?.cooldownReason).toBe( + "network-error", + ); + }); + + it("does not replay a request after the upstream stream has started", async () => { + const now = Date.now(); + const accountManager = new AccountManager(undefined, createStorage(now)); + const encoder = new TextEncoder(); + const { calls, fetchImpl } = createRecordingFetch(() => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode("data: first\n\n")); + controller.error(new Error("stream interrupted")); + }, + }), + { + status: HTTP_STATUS.OK, + headers: { "content-type": "text/event-stream" }, + }, + ), + ); + const proxy = await startProxy({ accountManager, fetchImpl }); + + await expect( + postResponses(proxy, { model: "gpt-5-codex", stream: true }), + ).rejects.toThrow(); + expect(calls).toHaveLength(1); + expect(accountManager.getAccountByIndex(0)?.cooldownReason).toBe("network-error"); + expect(proxy.getStatus().streamsStarted).toBe(1); + }); +}); diff --git a/test/schemas.test.ts b/test/schemas.test.ts index 3c70fee6..e26ca4dc 100644 --- a/test/schemas.test.ts +++ b/test/schemas.test.ts @@ -33,6 +33,7 @@ describe("PluginConfigSchema", () => { it("accepts valid full config", () => { const config = { codexMode: true, + codexRuntimeRotationProxy: true, fastSession: true, retryAllAccountsRateLimited: true, retryAllAccountsMaxWaitMs: 5000, diff --git a/vitest.config.ts b/vitest.config.ts index 929cd21d..d518ba86 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,4 +1,5 @@ import { defineConfig } from 'vitest/config'; +import { resolve } from 'node:path'; const forcePlainTestOutput = process.env.CODEX_PLAIN_LOGS === '1' || @@ -13,6 +14,19 @@ if (forcePlainTestOutput) { } export default defineConfig({ + plugins: [ + { + name: 'strip-script-shebangs-for-vitest', + enforce: 'pre', + transform(code, id) { + const scriptsRoot = `${resolve(process.cwd(), 'scripts').replace(/\\/g, '/')}/`; + const normalizedId = id.replace(/\\/g, '/'); + if (!normalizedId.startsWith(scriptsRoot)) return null; + if (!code.startsWith('#!')) return null; + return code.replace(/^#!.*(?:\r?\n|$)/, ''); + }, + }, + ], test: { globals: true, environment: 'node',