From dde247211669f53fbebacbbb231f9b78bcf055a8 Mon Sep 17 00:00:00 2001 From: ndycode Date: Fri, 24 Apr 2026 20:00:01 +0800 Subject: [PATCH 01/42] Add runtime rotation config commands --- lib/codex-manager.ts | 19 ++- lib/codex-manager/commands/rotation.ts | 136 +++++++++++++++++++ lib/codex-manager/commands/status.ts | 30 ++++- lib/codex-manager/help.ts | 1 + lib/config.ts | 16 +++ lib/schemas.ts | 1 + scripts/codex-routing.js | 1 + test/codex-manager-cli.test.ts | 21 +++ test/codex-manager-rotation-command.test.ts | 138 ++++++++++++++++++++ test/codex-manager-status-command.test.ts | 21 +++ test/plugin-config.test.ts | 23 ++++ test/schemas.test.ts | 1 + 12 files changed, 400 insertions(+), 8 deletions(-) create mode 100644 lib/codex-manager/commands/rotation.ts create mode 100644 test/codex-manager-rotation-command.test.ts diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 4c5bf159..5322cfd8 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,12 @@ import { configureUnifiedSettings, resolveMenuLayoutMode, } from "./codex-manager/settings-hub.js"; -import { getPluginConfigExplainReport } from "./config.js"; +import { + getCodexRuntimeRotationProxy, + getPluginConfigExplainReport, + loadPluginConfig, + savePluginConfig, +} from "./config.js"; import { ACCOUNT_LIMITS } from "./constants.js"; import { type DashboardAccountSortMode, @@ -3378,6 +3384,17 @@ export async function runCodexMultiAuthCli(rawArgs: string[]): Promise { loadPersistedRuntimeObservabilitySnapshot, }); } + if (command === "rotation") { + return runRotationCommand(rest, { + loadPluginConfig, + savePluginConfig, + getCodexRuntimeRotationProxy, + setStoragePath, + getStoragePath, + loadAccounts, + resolveActiveIndex, + }); + } 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..1aebd6c3 --- /dev/null +++ b/lib/codex-manager/commands/rotation.ts @@ -0,0 +1,136 @@ +import { formatAccountLabel, formatCooldown, formatWaitTime } from "../../accounts.js"; +import type { PluginConfig } from "../../types.js"; +import type { AccountStorageV3 } from "../../storage.js"; + +type LoadedStorage = AccountStorageV3 | 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; + 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", + "", + "Behavior:", + " - Enables an opt-in localhost Responses proxy for live Codex runtime account rotation", + " - 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"; +} + +async function printRotationStatus(deps: RotationCommandDeps): Promise { + const logInfo = deps.logInfo ?? console.log; + deps.setStoragePath(null); + const config = deps.loadPluginConfig(); + const enabled = deps.getCodexRuntimeRotationProxy(config); + const storage = await deps.loadAccounts(); + const now = deps.getNow?.() ?? Date.now(); + + logInfo(`Runtime rotation proxy: ${enabled ? "enabled" : "disabled"}`); + logInfo( + `Stored setting: ${config.codexRuntimeRotationProxy === true ? "enabled" : "disabled"}`, + ); + logInfo(`Env override: ${formatEnvOverride()}`); + logInfo(`Storage: ${deps.getStoragePath()}`); + + 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."); + return 0; + } + if (subcommand === "disable") { + await deps.savePluginConfig({ codexRuntimeRotationProxy: false }); + logInfo("Runtime rotation proxy disabled."); + 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..aeb8fe45 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,21 @@ 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; +} + export async function runStatusCommand( deps: StatusCommandDeps, ): Promise { @@ -41,15 +57,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." diff --git a/lib/codex-manager/help.ts b/lib/codex-manager/help.ts index 4bd9b23e..1fb94840 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 status", " 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/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/scripts/codex-routing.js b/scripts/codex-routing.js index 297bf78d..913d325f 100644 --- a/scripts/codex-routing.js +++ b/scripts/codex-routing.js @@ -11,6 +11,7 @@ const AUTH_SUBCOMMANDS = new Set([ "report", "fix", "doctor", + "rotation", ]); export function normalizeAuthAlias(args) { diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index b9293a28..f022ec84 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"); diff --git a/test/codex-manager-rotation-command.test.ts b/test/codex-manager-rotation-command.test.ts new file mode 100644 index 00000000..41ea14c8 --- /dev/null +++ b/test/codex-manager-rotation-command.test.ts @@ -0,0 +1,138 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { runRotationCommand } from "../lib/codex-manager/commands/rotation.js"; +import type { RotationCommandDeps } from "../lib/codex-manager/commands/rotation.js"; +import type { AccountStorageV3 } from "../lib/storage.js"; +import type { PluginConfig } from "../lib/types.js"; + +const originalRuntimeRotationProxyEnv = + process.env.CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY; + +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 createDeps(params: { + config?: PluginConfig; + storage?: AccountStorageV3 | null; + now?: number; +} = {}): { + deps: RotationCommandDeps; + errors: string[]; + infos: string[]; + savePluginConfigMock: ReturnType; + setStoragePathMock: 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(); + return { + infos, + errors, + savePluginConfigMock, + setStoragePathMock, + 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, + 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; + return; + } + process.env.CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY = + originalRuntimeRotationProxyEnv; +}); + +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("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"); + }); + + 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"); + }); +}); diff --git a/test/codex-manager-status-command.test.ts b/test/codex-manager-status-command.test.ts index 2bd1f625..ddeedfdc 100644 --- a/test/codex-manager-status-command.test.ts +++ b/test/codex-manager-status-command.test.ts @@ -66,6 +66,27 @@ 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", + })), + }); + + await runStatusCommand(deps); + + expect(deps.logInfo).toHaveBeenCalledWith( + "No accounts configured. Storage was intentionally reset.", + ); + expect(deps.logInfo).toHaveBeenCalledWith( + "Storage health: intentional-reset", + ); + }); + it("prints explicit corrupt storage state for empty result cases", async () => { const deps = createStatusDeps({ loadAccounts: vi.fn(async () => null), 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/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, From daa37164ec2671b7eb416d3fab66d952f62926fa Mon Sep 17 00:00:00 2001 From: ndycode Date: Fri, 24 Apr 2026 20:00:14 +0800 Subject: [PATCH 02/42] Add runtime Responses rotation proxy --- lib/index.ts | 1 + lib/runtime-rotation-proxy.ts | 789 ++++++++++++++++++++++++++++ test/runtime-rotation-proxy.test.ts | 329 ++++++++++++ 3 files changed, 1119 insertions(+) create mode 100644 lib/runtime-rotation-proxy.ts create mode 100644 test/runtime-rotation-proxy.test.ts 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/runtime-rotation-proxy.ts b/lib/runtime-rotation-proxy.ts new file mode 100644 index 00000000..0847a59b --- /dev/null +++ b/lib/runtime-rotation-proxy.ts @@ -0,0 +1,789 @@ +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import { AccountManager, extractAccountId, type ManagedAccount } from "./accounts.js"; +import { + getNetworkErrorCooldownMs, + getServerErrorCooldownMs, + getSessionAffinity, + getSessionAffinityMaxEntries, + getSessionAffinityTtlMs, + 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 { 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; +} + +export interface RuntimeRotationProxyOptions { + host?: string; + port?: number; + upstreamBaseUrl?: string; + accountManager?: AccountManager; + fetchImpl?: typeof fetch; + now?: () => number; + quotaRemainingPercentThreshold?: 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" | "no-account"; + +const DEFAULT_HOST = "127.0.0.1"; +const DEFAULT_QUOTA_REMAINING_THRESHOLD = 10; +const DEFAULT_AUTH_FAILURE_COOLDOWN_MS = 30_000; +const HOP_BY_HOP_HEADERS = new Set([ + "connection", + "content-length", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]); + +function isResponsesPath(pathname: string): boolean { + return ( + pathname === URL_PATHS.RESPONSES || + pathname === URL_PATHS.CODEX_RESPONSES || + pathname.endsWith(URL_PATHS.RESPONSES) || + pathname.endsWith(URL_PATHS.CODEX_RESPONSES) + ); +} + +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); + if (account.accountId && account.accountId !== accountId) { + headers.set(OPENAI_HEADERS.ACCOUNT_ID, accountId); + } + return headers; +} + +function responseHeadersForClient(upstreamHeaders: Headers): Record { + const headers: Record = {}; + for (const [key, value] of upstreamHeaders.entries()) { + if (HOP_BY_HOP_HEADERS.has(key.toLowerCase())) continue; + headers[key] = value; + } + return headers; +} + +async function readRequestBody(req: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + 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; +} + +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 accountManager.commitRefreshedAuth(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 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 forwardStreamingResponse( + upstream: Response, + res: ServerResponse, + status: RuntimeRotationProxyStatus, + onStreamError: () => void, +): 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 reader.read(); + 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 now = options.now ?? Date.now; + const tokenRefreshSkewMs = getTokenRefreshSkewMs(pluginConfig); + const networkErrorCooldownMs = getNetworkErrorCooldownMs(pluginConfig); + const serverErrorCooldownMs = getServerErrorCooldownMs(pluginConfig); + 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, + }; + + 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; + } + + status.totalRequests += 1; + const context = buildRequestContext(req, await readRequestBody(req)); + const upstreamUrl = buildUpstreamUrl(req, upstreamBaseUrl); + const attemptedIndexes = new Set(); + let exhaustionReason: ExhaustionReason = "no-account"; + + while (attemptedIndexes.size < Math.max(1, accountManager.getAccountCount())) { + const selected = chooseAccount({ + accountManager, + sessionAffinityStore, + sessionKey: context.sessionKey, + family: context.family, + model: context.model, + attemptedIndexes, + now: now(), + }); + if (!selected) break; + attemptedIndexes.add(selected.index); + status.lastAccountIndex = 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 outboundHeaders = createOutboundHeaders( + context.headers, + refreshed.account, + refreshed.accessToken, + accountId, + ); + + let upstream: Response; + try { + status.upstreamRequests += 1; + upstream = await fetchImpl(upstreamUrl, { + method: "POST", + headers: outboundHeaders, + body: context.body, + }); + } 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", + ); + 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; + 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.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 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(); + }); + return; + } + + 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) { + 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); + }); + + 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); + }); + + const address = server.address(); + const resolvedPort = + typeof address === "object" && address ? address.port : port; + + return { + host, + port: resolvedPort, + baseUrl: `http://${host}:${resolvedPort}`, + close: async () => { + await accountManager.flushPendingSave(); + await closeServer(server); + }, + getStatus: () => ({ ...status }), + }; +} + +async function closeServer(server: Server): Promise { + if (!server.listening) return; + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); +} diff --git a/test/runtime-rotation-proxy.test.ts b/test/runtime-rotation-proxy.test.ts new file mode 100644 index 00000000..bdd64da8 --- /dev/null +++ b/test/runtime-rotation-proxy.test.ts @@ -0,0 +1,329 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +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 { resetTrackers } from "../lib/rotation.js"; +import type { AccountStorageV3 } from "../lib/storage.js"; + +const { saveAccountsMock, withAccountStorageTransactionMock } = vi.hoisted( + () => ({ + saveAccountsMock: vi.fn(), + withAccountStorageTransactionMock: vi.fn(), + }), +); + +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[] = []; + +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 }; +} + +async function startProxy(params: { + accountManager: AccountManager; + fetchImpl: typeof fetch; +}): Promise { + const proxy = await startRuntimeRotationProxy({ + accountManager: params.accountManager, + fetchImpl: params.fetchImpl, + upstreamBaseUrl: "https://example.test/backend-api", + quotaRemainingPercentThreshold: 10, + }); + 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 caller-token", + "content-type": "application/json", + "x-api-key": "caller-key", + ...headers, + }, + body: JSON.stringify(body), + }); +} + +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(); + 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(); + } + resetTrackers(); + clearCircuitBreakers(); +}); + +describe("runtime rotation proxy", () => { + 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(JSON.parse(calls[0]?.bodyText ?? "{}")).toEqual(requestBody); + }); + + 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("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( + accountManager.getAccountByIndex(0)?.rateLimitResetTimes["gpt-5-codex"], + ).toBeTypeOf("number"); + }); + + 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("cools down server-error and network-failure accounts before retrying", async () => { + const now = Date.now(); + const accountManager = new AccountManager(undefined, createStorage(now, 3)); + 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"); + }); + + 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("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); + }); +}); From 09cec82bb84315a33290e21368381f7f5ad8598c Mon Sep 17 00:00:00 2001 From: ndycode Date: Fri, 24 Apr 2026 20:00:24 +0800 Subject: [PATCH 03/42] Wire Codex wrapper to runtime proxy --- scripts/codex.js | 330 ++++++++++++++++++++++++++++++++- test/codex-bin-wrapper.test.ts | 111 +++++++++++ 2 files changed, 435 insertions(+), 6 deletions(-) diff --git a/scripts/codex.js b/scripts/codex.js index 36ca45bf..5b7070bd 100755 --- a/scripts/codex.js +++ b/scripts/codex.js @@ -27,6 +27,7 @@ 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_STATE_FILES = ["auth.json", "accounts.json", ".codex-global-state.json"]; +const RUNTIME_ROTATION_PROXY_PROVIDER_ID = "codex-multi-auth-runtime-proxy"; let shadowHomeCleanupBusyFailuresRemaining = Number.parseInt( process.env.CODEX_MULTI_AUTH_TEST_SHADOW_CLEANUP_BUSY_FAILURES ?? "0", 10, @@ -105,6 +106,48 @@ 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 autoSyncManagerActiveSelectionIfEnabled() { const enabled = (process.env.CODEX_MULTI_AUTH_AUTO_SYNC_ON_STARTUP ?? "1").trim() !== "0"; if (!enabled) return; @@ -277,13 +320,13 @@ function forwardToRealCodexOnce( let stdout = ""; let stderr = ""; const captureOutput = options.captureOutput === true; - const finalize = (exitCode) => { + const finalize = async (exitCode) => { if (settled) { return; } settled = true; try { - cleanup?.(); + await cleanup?.(); } catch { // Best-effort cleanup only. } @@ -360,13 +403,20 @@ async function forwardToRealCodex(codexBin, rawArgs, baseEnv = process.env) { compatibilityRequestedModel, baseEnv, ); + const runtimeProxyContext = await createRuntimeRotationProxyContextIfEnabled( + compatibility, + rawArgs, + ); + if (!runtimeProxyContext) { + return 1; + } const result = await forwardToRealCodexOnce( codexBin, - compatibility.args, - compatibility.env, - compatibility.cleanup, + runtimeProxyContext.args, + runtimeProxyContext.env, + runtimeProxyContext.cleanup, { - captureOutput: shouldCaptureForwardedCodexOutput(compatibility.env), + captureOutput: shouldCaptureForwardedCodexOutput(runtimeProxyContext.env), }, ); lastExitCode = result.exitCode; @@ -982,6 +1032,274 @@ function resolveOriginalMultiAuthDir(env) { 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; + } + 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 (!shouldTrackForwardedRuntimeObservability(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 tomlStringLiteral(value) { + return `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; +} + +function removeRuntimeRotationProviderBlock(rawConfig) { + const lines = rawConfig.split(/\r?\n/); + const output = []; + let skipping = false; + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed === `[model_providers.${RUNTIME_ROTATION_PROXY_PROVIDER_ID}]`) { + skipping = true; + continue; + } + if (skipping && /^\s*\[[^\]]+\]\s*$/.test(line)) { + skipping = false; + } + if (!skipping) { + output.push(line); + } + } + return output.join(rawConfig.includes("\r\n") ? "\r\n" : "\n"); +} + +function rewriteTopLevelModelProvider(rawConfig) { + 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 = []; + + for (const line of lines) { + const isTable = /^\s*\[[^\]]+\]\s*$/.test(line); + 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 rewriteConfigTomlForRuntimeRotationProxy(rawConfig, proxyBaseUrl) { + 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 = [ + `[model_providers.${RUNTIME_ROTATION_PROXY_PROVIDER_ID}]`, + 'name = "Codex Multi-Auth Runtime Proxy"', + `base_url = ${tomlStringLiteral(proxyBaseUrl)}`, + 'env_key = "OPENAI_API_KEY"', + 'wire_api = "responses"', + ]; + return `${withModelProvider}${lineEnding}${lineEnding}${providerBlock.join(lineEnding)}${lineEnding}`; +} + +function createRuntimeRotationProxyCodexHome(baseEnv, proxyBaseUrl) { + const originalCodexHome = resolveCodexHomeDir(baseEnv); + const shadowCodexHome = mkdtempSync(join(tmpdir(), "codex-multi-auth-runtime-home-")); + 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. + } + }; + const originalShadowHomeState = new Map( + SHADOW_HOME_STATE_FILES.map((name) => [ + name, + captureShadowHomeState(join(originalCodexHome, name)), + ]), + ); + 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 { + const originalConfigPath = join(originalCodexHome, "config.toml"); + const rawConfig = existsSync(originalConfigPath) + ? readFileSync(originalConfigPath, "utf8") + : ""; + const runtimeConfig = rewriteConfigTomlForRuntimeRotationProxy( + rawConfig, + proxyBaseUrl, + ); + const runtimeConfigPath = join(shadowCodexHome, "config.toml"); + writeFileSync(runtimeConfigPath, runtimeConfig, "utf8"); + tightenShadowHomePermissions(runtimeConfigPath); + + 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; + } + + const forwardedEnv = { + ...baseEnv, + CODEX_HOME: shadowCodexHome, + OPENAI_API_KEY: + (baseEnv.OPENAI_API_KEY ?? "").trim().length > 0 + ? baseEnv.OPENAI_API_KEY + : "codex-multi-auth-runtime-proxy", + }; + const originalMultiAuthDir = resolveOriginalMultiAuthDir(baseEnv); + if (originalMultiAuthDir) { + forwardedEnv.CODEX_MULTI_AUTH_DIR = originalMultiAuthDir; + } + + return { + env: forwardedEnv, + cleanup: () => { + syncShadowHomeStateBack(); + cleanup(); + }, + }; +} + +async function createRuntimeRotationProxyContextIfEnabled( + baseContext, + rawArgs, +) { + const enabled = await isRuntimeRotationProxyEnabled(rawArgs, baseContext.env); + if (!enabled) { + return baseContext; + } + + const proxyModule = await loadRuntimeRotationProxyModule(); + if (!proxyModule) { + baseContext.cleanup?.(); + return null; + } + + let proxyServer; + let shadowContext; + try { + proxyServer = await proxyModule.startRuntimeRotationProxy(); + shadowContext = createRuntimeRotationProxyCodexHome( + baseContext.env, + proxyServer.baseUrl, + ); + } catch (error) { + try { + await proxyServer?.close?.(); + } catch { + // Best-effort cleanup only. + } + baseContext.cleanup?.(); + console.error( + `codex-multi-auth runtime rotation proxy failed to start: ${error instanceof Error ? error.message : String(error)}`, + ); + return null; + } + + const cleanup = async () => { + try { + shadowContext.cleanup?.(); + } finally { + try { + await proxyServer.close(); + } finally { + baseContext.cleanup?.(); + } + } + }; + + return { + args: [ + ...baseContext.args, + "-c", + `model_provider=${tomlStringLiteral(RUNTIME_ROTATION_PROXY_PROVIDER_ID)}`, + ], + env: shadowContext.env, + cleanup, + }; +} + async function loadRuntimeObservabilityModule() { try { const mod = await import("../dist/lib/runtime/runtime-observability.js"); diff --git a/test/codex-bin-wrapper.test.ts b/test/codex-bin-wrapper.test.ts index 32e55075..26981b29 100644 --- a/test/codex-bin-wrapper.test.ts +++ b/test/codex-bin-wrapper.test.ts @@ -2,6 +2,7 @@ import { type SpawnSyncReturns, spawn, spawnSync } from "node:child_process"; import { chmodSync, copyFileSync, + existsSync, linkSync, mkdirSync, mkdtempSync, @@ -157,6 +158,40 @@ function createRuntimeObservabilityFixtureModule(fixtureRoot: string): string { return modulePath; } +function createRuntimeRotationProxyFixtureModule(fixtureRoot: string): string { + 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');", + "}", + "", + "export async function startRuntimeRotationProxy() {", + " const baseUrl = process.env.CODEX_MULTI_AUTH_TEST_PROXY_BASE_URL ?? 'http://127.0.0.1:4567';", + " appendMarker(`start:${baseUrl}`);", + " return {", + " host: '127.0.0.1',", + " port: 4567,", + " baseUrl,", + " close: async () => appendMarker('close'),", + " getStatus: () => ({}),", + " };", + "}", + ].join("\n"), + "utf8", + ); + return modulePath; +} + function createFakeCodexBin(rootDir: string): string { const fakeBin = join(rootDir, "fake-codex.js"); writeFileSync( @@ -518,6 +553,82 @@ describe("codex bin wrapper", () => { ); }); + it("starts the opt-in runtime rotation proxy with a shadow CODEX_HOME provider", () => { + 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(`CODEX_HOME_IS_ORIGINAL:${process.env.CODEX_HOME === process.env.ORIGINAL_CODEX_HOME}`);', + 'console.log(`OPENAI_API_KEY:${process.env.OPENAI_API_KEY ?? ""}`);', + '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 }); + writeFileSync( + join(originalHome, "config.toml"), + [ + 'model = "gpt-5-codex"', + 'model_provider = "openai"', + "", + "[model_providers.existing]", + 'name = "Existing"', + 'base_url = "https://example.invalid"', + "", + "[model_providers.codex-multi-auth-runtime-proxy]", + '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_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); + expect(output).toContain( + 'FORWARDED:exec status -c cli_auth_credentials_store="file" -c model_provider="codex-multi-auth-runtime-proxy"', + ); + expect(output).toContain("CODEX_HOME_IS_ORIGINAL:false"); + expect(output).toContain("OPENAI_API_KEY:codex-multi-auth-runtime-proxy"); + expect(output).toContain( + 'model_provider = "codex-multi-auth-runtime-proxy"', + ); + expect(output).toContain( + "[model_providers.codex-multi-auth-runtime-proxy]", + ); + expect(output).toContain('base_url = "http://127.0.0.1:4567"'); + expect(output).toContain('wire_api = "responses"'); + expect(output).not.toContain('base_url = "http://127.0.0.1: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"', + ); + }); + it("records forwarded exec traffic in runtime observability when the child process does not update it", () => { const fixtureRoot = createWrapperFixture(); createRuntimeObservabilityFixtureModule(fixtureRoot); From 9670d7f70e60dd86e67554b35f454c75a9e490e9 Mon Sep 17 00:00:00 2001 From: ndycode Date: Fri, 24 Apr 2026 20:00:33 +0800 Subject: [PATCH 04/42] Document runtime rotation proxy --- README.md | 14 ++++++++------ docs/configuration.md | 10 ++++++++++ docs/development/CONFIG_FIELDS.md | 2 ++ docs/features.md | 1 + docs/reference/commands.md | 24 ++++++++++++++++++++++++ docs/reference/settings.md | 1 + docs/releases/v1.3.1.md | 6 ++++++ 7 files changed, 52 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 0e81a144..1a83f9e6 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,10 @@ 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 sessions | +| `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..12dde768 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,14 @@ 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. The wrapper writes a temporary shadow `CODEX_HOME/config.toml` that selects a custom provider named `codex-multi-auth-runtime-proxy`, launches the official CLI against that provider, and removes the shadow home after the child 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`. + +--- + ## 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..e62b687f 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 | | `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..9de874cf 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 | Lets forwarded official Codex sessions rotate managed accounts between Responses requests without restarting the session | `codex auth rotation enable` | --- diff --git a/docs/reference/commands.md b/docs/reference/commands.md index d9134256..d3f2f16b 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` | Manage the opt-in runtime Responses proxy for live Codex account rotation | --- @@ -150,6 +151,29 @@ 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 +``` + +Behavior: + +- `enable` persists `codexRuntimeRotationProxy=true`. +- `disable` persists `codexRuntimeRotationProxy=false`. +- `status` prints the effective setting, environment override state, account count, current account, disabled accounts, cooldowns, and rate-limit waits. +- `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. Existing behavior is unchanged while the setting and env override are off. + +--- + ## `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..99c47e8e 100644 --- a/docs/reference/settings.md +++ b/docs/reference/settings.md @@ -185,6 +185,7 @@ Common operator overrides: - `CODEX_MULTI_AUTH_DIR` - `CODEX_MULTI_AUTH_CONFIG_PATH` - `CODEX_MODE` +- `CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY` - `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..dc4613af 100644 --- a/docs/releases/v1.3.1.md +++ b/docs/releases/v1.3.1.md @@ -23,6 +23,12 @@ 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 + ## Validation - `npm run build` From f22c96b2c8c1c69404169b768390c43a50ab61fe Mon Sep 17 00:00:00 2001 From: ndycode Date: Fri, 24 Apr 2026 21:38:46 +0800 Subject: [PATCH 05/42] Authenticate runtime rotation proxy clients --- lib/runtime-rotation-proxy.ts | 29 +++++++++++++++++++++++++++ scripts/codex.js | 16 +++++++++------ test/codex-bin-wrapper.test.ts | 2 +- test/runtime-rotation-proxy.test.ts | 31 +++++++++++++++++++++++++++++ 4 files changed, 71 insertions(+), 7 deletions(-) diff --git a/lib/runtime-rotation-proxy.ts b/lib/runtime-rotation-proxy.ts index 0847a59b..46592940 100644 --- a/lib/runtime-rotation-proxy.ts +++ b/lib/runtime-rotation-proxy.ts @@ -45,6 +45,7 @@ export interface RuntimeRotationProxyOptions { host?: string; port?: number; upstreamBaseUrl?: string; + clientApiKey?: string; accountManager?: AccountManager; fetchImpl?: typeof fetch; now?: () => number; @@ -123,6 +124,14 @@ function createOutboundHeaders( return headers; } +function isAuthorizedClient(headers: Headers, clientApiKey: string | null): boolean { + if (!clientApiKey) return true; + const authorization = headers.get("authorization") ?? ""; + const bearerMatch = authorization.match(/^Bearer\s+(.+)$/i); + if (bearerMatch?.[1]?.trim() === clientApiKey) return true; + return headers.get("x-api-key") === clientApiKey; +} + function responseHeadersForClient(upstreamHeaders: Headers): Record { const headers: Record = {}; for (const [key, value] of upstreamHeaders.entries()) { @@ -438,6 +447,15 @@ function writeMethodOrPathError(res: ServerResponse): void { }); } +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; } @@ -509,6 +527,11 @@ export async function startRuntimeRotationProxy( 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; const now = options.now ?? Date.now; const tokenRefreshSkewMs = getTokenRefreshSkewMs(pluginConfig); const networkErrorCooldownMs = getNetworkErrorCooldownMs(pluginConfig); @@ -543,6 +566,12 @@ export async function startRuntimeRotationProxy( return; } + const incomingHeaders = headersFromIncoming(req); + if (!isAuthorizedClient(incomingHeaders, clientApiKey)) { + writeUnauthorized(res); + return; + } + status.totalRequests += 1; const context = buildRequestContext(req, await readRequestBody(req)); const upstreamUrl = buildUpstreamUrl(req, upstreamBaseUrl); diff --git a/scripts/codex.js b/scripts/codex.js index 5b7070bd..a7680efc 100755 --- a/scripts/codex.js +++ b/scripts/codex.js @@ -1,6 +1,7 @@ #!/usr/bin/env node import { spawn } from "node:child_process"; +import { randomBytes } from "node:crypto"; import { chmodSync, copyFileSync, @@ -1143,7 +1144,11 @@ function rewriteConfigTomlForRuntimeRotationProxy(rawConfig, proxyBaseUrl) { return `${withModelProvider}${lineEnding}${lineEnding}${providerBlock.join(lineEnding)}${lineEnding}`; } -function createRuntimeRotationProxyCodexHome(baseEnv, proxyBaseUrl) { +function createRuntimeRotationProxyClientApiKey() { + return randomBytes(32).toString("hex"); +} + +function createRuntimeRotationProxyCodexHome(baseEnv, proxyBaseUrl, clientApiKey) { const originalCodexHome = resolveCodexHomeDir(baseEnv); const shadowCodexHome = mkdtempSync(join(tmpdir(), "codex-multi-auth-runtime-home-")); const cleanup = () => { @@ -1222,10 +1227,7 @@ function createRuntimeRotationProxyCodexHome(baseEnv, proxyBaseUrl) { const forwardedEnv = { ...baseEnv, CODEX_HOME: shadowCodexHome, - OPENAI_API_KEY: - (baseEnv.OPENAI_API_KEY ?? "").trim().length > 0 - ? baseEnv.OPENAI_API_KEY - : "codex-multi-auth-runtime-proxy", + OPENAI_API_KEY: clientApiKey, }; const originalMultiAuthDir = resolveOriginalMultiAuthDir(baseEnv); if (originalMultiAuthDir) { @@ -1259,10 +1261,12 @@ async function createRuntimeRotationProxyContextIfEnabled( let proxyServer; let shadowContext; try { - proxyServer = await proxyModule.startRuntimeRotationProxy(); + const clientApiKey = createRuntimeRotationProxyClientApiKey(); + proxyServer = await proxyModule.startRuntimeRotationProxy({ clientApiKey }); shadowContext = createRuntimeRotationProxyCodexHome( baseContext.env, proxyServer.baseUrl, + clientApiKey, ); } catch (error) { try { diff --git a/test/codex-bin-wrapper.test.ts b/test/codex-bin-wrapper.test.ts index 26981b29..496a3d40 100644 --- a/test/codex-bin-wrapper.test.ts +++ b/test/codex-bin-wrapper.test.ts @@ -606,7 +606,7 @@ describe("codex bin wrapper", () => { 'FORWARDED:exec status -c cli_auth_credentials_store="file" -c model_provider="codex-multi-auth-runtime-proxy"', ); expect(output).toContain("CODEX_HOME_IS_ORIGINAL:false"); - expect(output).toContain("OPENAI_API_KEY:codex-multi-auth-runtime-proxy"); + expect(output).toMatch(/^OPENAI_API_KEY:[0-9a-f]{64}$/m); expect(output).toContain( 'model_provider = "codex-multi-auth-runtime-proxy"', ); diff --git a/test/runtime-rotation-proxy.test.ts b/test/runtime-rotation-proxy.test.ts index bdd64da8..86e93483 100644 --- a/test/runtime-rotation-proxy.test.ts +++ b/test/runtime-rotation-proxy.test.ts @@ -136,6 +136,37 @@ afterEach(async () => { }); describe("runtime rotation proxy", () => { + 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"), + ); + const proxy = await startRuntimeRotationProxy({ + accountManager, + fetchImpl, + upstreamBaseUrl: "https://example.test/backend-api", + clientApiKey: "runtime-secret", + }); + openServers.push(proxy); + + const rejected = await postResponses(proxy, { model: "gpt-5-codex" }); + + 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); + }); + it("forwards Responses requests unchanged while replacing caller auth", async () => { const now = Date.now(); const accountManager = new AccountManager(undefined, createStorage(now)); From c2c1ec6b3122ff7cb1aa875a9091be6b98f0de02 Mon Sep 17 00:00:00 2001 From: ndycode Date: Fri, 24 Apr 2026 21:56:52 +0800 Subject: [PATCH 06/42] Add automatic app runtime rotation --- README.md | 3 +- docs/configuration.md | 4 +- docs/features.md | 2 +- docs/reference/commands.md | 4 +- docs/releases/v1.3.1.md | 1 + lib/codex-manager/commands/rotation.ts | 82 +++++ scripts/codex.js | 467 +++++++++++++++++++++++-- test/codex-bin-wrapper.test.ts | 116 ++++++ 8 files changed, 648 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 1a83f9e6..6b2ef3f0 100644 --- a/README.md +++ b/README.md @@ -233,7 +233,8 @@ Selected runtime/environment overrides: | `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_MULTI_AUTH_RUNTIME_ROTATION_PROXY=0/1` | Opt in/out of live Responses proxy rotation for forwarded Codex sessions | +| `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_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 | diff --git a/docs/configuration.md b/docs/configuration.md index 12dde768..85555f9d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -103,10 +103,12 @@ 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. The wrapper writes a temporary shadow `CODEX_HOME/config.toml` that selects a custom provider named `codex-multi-auth-runtime-proxy`, launches the official CLI against that provider, and removes the shadow home after the child process exits. +`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`, 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. + --- ## Shipped Templates diff --git a/docs/features.md b/docs/features.md index 9de874cf..62a44f6b 100644 --- a/docs/features.md +++ b/docs/features.md @@ -24,7 +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 | Lets forwarded official Codex sessions rotate managed accounts between Responses requests without restarting the session | `codex auth rotation enable` | +| Runtime rotation proxy | Lets forwarded official Codex CLI/app sessions rotate managed accounts between Responses requests without restarting the session | `codex auth rotation enable` | --- diff --git a/docs/reference/commands.md b/docs/reference/commands.md index d3f2f16b..d8740890 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -167,10 +167,10 @@ Behavior: - `enable` persists `codexRuntimeRotationProxy=true`. - `disable` persists `codexRuntimeRotationProxy=false`. -- `status` prints the effective setting, environment override state, account count, current account, disabled accounts, cooldowns, and rate-limit waits. +- `status` prints the effective setting, environment override state, automatic Codex app helper state, account count, current account, disabled accounts, cooldowns, and rate-limit waits. - `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. Existing behavior is unchanged while the setting and env override are off. +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. --- diff --git a/docs/releases/v1.3.1.md b/docs/releases/v1.3.1.md index dc4613af..1af05fcb 100644 --- a/docs/releases/v1.3.1.md +++ b/docs/releases/v1.3.1.md @@ -28,6 +28,7 @@ This patch release finalizes the GPT-5.5 runtime rollout work for `codex-multi-a - 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 ## Validation diff --git a/lib/codex-manager/commands/rotation.ts b/lib/codex-manager/commands/rotation.ts index 1aebd6c3..99277b8b 100644 --- a/lib/codex-manager/commands/rotation.ts +++ b/lib/codex-manager/commands/rotation.ts @@ -1,8 +1,22 @@ +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 type { PluginConfig } from "../../types.js"; import type { AccountStorageV3 } from "../../storage.js"; type LoadedStorage = AccountStorageV3 | null; +const APP_RUNTIME_HELPER_STATUS_FILE = "runtime-rotation-app-helper.json"; + +interface AppRuntimeHelperStatus { + state: string | null; + pid: number | null; + idleExpiresAt: number | null; + totalRequests: number | null; + rotations: number | null; + lastAccountIndex: number | null; + updatedAt: number | null; +} export interface RotationCommandDeps { loadPluginConfig: () => PluginConfig; @@ -52,6 +66,73 @@ function formatEnvOverride(): string { 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"), + pid: readOptionalNumber(parsed, "pid"), + idleExpiresAt: readOptionalNumber(parsed, "idleExpiresAt"), + totalRequests: readOptionalNumber(parsed, "totalRequests"), + rotations: readOptionalNumber(parsed, "rotations"), + lastAccountIndex: readOptionalNumber(parsed, "lastAccountIndex"), + 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 formatAppRuntimeHelperStatus(now: number): string { + const status = readAppRuntimeHelperStatus(); + if (!status) 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}`); + if (status.lastAccountIndex !== null) { + parts.push(`lastAccount=${status.lastAccountIndex + 1}`); + } + if (status.idleExpiresAt !== null && status.idleExpiresAt > now) { + parts.push(`idle-expires=${formatWaitTime(status.idleExpiresAt - now)}`); + } + return `Codex app helper: ${parts.join(", ")}`; +} + async function printRotationStatus(deps: RotationCommandDeps): Promise { const logInfo = deps.logInfo ?? console.log; deps.setStoragePath(null); @@ -65,6 +146,7 @@ async function printRotationStatus(deps: RotationCommandDeps): Promise { `Stored setting: ${config.codexRuntimeRotationProxy === true ? "enabled" : "disabled"}`, ); logInfo(`Env override: ${formatEnvOverride()}`); + logInfo(formatAppRuntimeHelperStatus(now)); logInfo(`Storage: ${deps.getStoragePath()}`); if (!storage || storage.accounts.length === 0) { diff --git a/scripts/codex.js b/scripts/codex.js index a7680efc..e41252b4 100755 --- a/scripts/codex.js +++ b/scripts/codex.js @@ -29,6 +29,12 @@ const RETRYABLE_SHADOW_HOME_CLEANUP_CODES = new Set(["EBUSY", "EPERM", "ENOTEMPT const SHADOW_HOME_CLEANUP_BACKOFF_MS = [20, 60, 120]; const SHADOW_HOME_STATE_FILES = ["auth.json", "accounts.json", ".codex-global-state.json"]; const RUNTIME_ROTATION_PROXY_PROVIDER_ID = "codex-multi-auth-runtime-proxy"; +const INTERNAL_RUNTIME_ROTATION_APP_HELPER_ARG = + "--codex-multi-auth-runtime-app-helper"; +const APP_RUNTIME_HELPER_STATUS_FILE = "runtime-rotation-app-helper.json"; +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; let shadowHomeCleanupBusyFailuresRemaining = Number.parseInt( process.env.CODEX_MULTI_AUTH_TEST_SHADOW_CLEANUP_BUSY_FAILURES ?? "0", 10, @@ -417,7 +423,10 @@ async function forwardToRealCodex(codexBin, rawArgs, baseEnv = process.env) { runtimeProxyContext.env, runtimeProxyContext.cleanup, { - captureOutput: shouldCaptureForwardedCodexOutput(runtimeProxyContext.env), + captureOutput: shouldCaptureForwardedOutputForArgs( + rawArgs, + runtimeProxyContext.env, + ), }, ); lastExitCode = result.exitCode; @@ -1053,7 +1062,7 @@ async function isRuntimeRotationProxyEnabled(rawArgs, baseEnv = process.env) { if ((baseEnv.CODEX_MULTI_AUTH_BYPASS ?? "").trim() === "1") { return false; } - if (!shouldTrackForwardedRuntimeObservability(rawArgs)) { + if (!shouldUseRuntimeRoutingForForwardedArgs(rawArgs)) { return false; } @@ -1243,6 +1252,316 @@ function createRuntimeRotationProxyCodexHome(baseEnv, proxyBaseUrl, clientApiKey }; } +function resolveRuntimeRotationAppHelperStatusPath(env = process.env) { + const multiAuthDir = + resolveOriginalMultiAuthDir(env) ?? join(resolveCodexHomeDir(env), "multi-auth"); + return join(multiAuthDir, APP_RUNTIME_HELPER_STATUS_FILE); +} + +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 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, + }; + 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); + mkdirSync(dirname(statusPath), { recursive: true }); + writeFileSync(statusPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); + } 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() : {}; + 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: proxyStatus.lastAccountIndex ?? null, + lastError: proxyStatus.lastError ?? null, + }; +} + +async function runRuntimeRotationAppHelper() { + let proxyServer = null; + let shadowContext = null; + let statusTimer = null; + let closing = false; + const startedAt = Date.now(); + const idleTimeoutMs = resolveRuntimeRotationAppHelperIdleMs(); + let lastActivityAt = startedAt; + let lastRequestCount = 0; + + const publishStatus = (state) => { + writeRuntimeRotationAppHelperStatus( + createRuntimeRotationAppHelperStatus({ + proxyServer, + startedAt, + idleTimeoutMs, + lastActivityAt, + state, + }), + ); + }; + + const cleanup = async (state = "stopped") => { + if (closing) return; + closing = true; + if (statusTimer) { + clearInterval(statusTimer); + } + try { + shadowContext?.cleanup?.(); + } finally { + try { + await proxyServer?.close?.(); + } finally { + publishStatus(state); + } + } + }; + + 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 clientApiKey = createRuntimeRotationProxyClientApiKey(); + proxyServer = await proxyModule.startRuntimeRotationProxy({ clientApiKey }); + shadowContext = createRuntimeRotationProxyCodexHome( + process.env, + proxyServer.baseUrl, + clientApiKey, + ); + 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 requestCount = proxyServer?.getStatus?.().totalRequests ?? 0; + if (requestCount !== lastRequestCount) { + lastRequestCount = requestCount; + lastActivityAt = Date.now(); + } + publishStatus("running"); + if (Date.now() - 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); + }); +} + +function stopRuntimeRotationAppHelper(helper) { + if (!helper || helper.killed) { + return Promise.resolve(); + } + try { + helper.kill("SIGTERM"); + } catch { + return Promise.resolve(); + } + return waitForRuntimeRotationAppHelperExit(helper); +} + +function startRuntimeRotationAppHelper(baseContext) { + 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, + 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()}`, + ), + ); + }); + }); +} + +async function createRuntimeRotationAppHelperContext(baseContext) { + const startedAt = Date.now(); + const { helper, message } = await startRuntimeRotationAppHelper(baseContext); + const helperEnv = message.env ?? {}; + const detachGraceMs = resolveRuntimeRotationAppHelperDetachGraceMs(baseContext.env); + let helperDetached = false; + + const cleanup = async () => { + const livedMs = Date.now() - startedAt; + if (livedMs < detachGraceMs) { + helperDetached = true; + helper.stdout?.destroy(); + helper.stderr?.destroy(); + helper.unref(); + return; + } + await stopRuntimeRotationAppHelper(helper); + }; + + return { + args: [ + ...baseContext.args, + "-c", + `model_provider=${tomlStringLiteral(RUNTIME_ROTATION_PROXY_PROVIDER_ID)}`, + ], + env: { + ...baseContext.env, + ...helperEnv, + }, + cleanup: async () => { + try { + await cleanup(); + } finally { + if (!helperDetached) { + baseContext.cleanup?.(); + } + } + }, + }; +} + async function createRuntimeRotationProxyContextIfEnabled( baseContext, rawArgs, @@ -1252,6 +1571,10 @@ async function createRuntimeRotationProxyContextIfEnabled( return baseContext; } + if (isCodexAppCommand(rawArgs)) { + return createRuntimeRotationAppHelperContext(baseContext); + } + const proxyModule = await loadRuntimeRotationProxyModule(); if (!proxyModule) { baseContext.cleanup?.(); @@ -1342,8 +1665,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", @@ -1365,7 +1697,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; } @@ -1373,7 +1773,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", @@ -1381,7 +1786,6 @@ function shouldTrackForwardedRuntimeObservability(rawArgs) { "logout", "mcp", "mcp-server", - "app-server", "sandbox", "debug", "apply", @@ -1390,33 +1794,39 @@ 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, @@ -1931,9 +2341,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"; diff --git a/test/codex-bin-wrapper.test.ts b/test/codex-bin-wrapper.test.ts index 496a3d40..de1edd3a 100644 --- a/test/codex-bin-wrapper.test.ts +++ b/test/codex-bin-wrapper.test.ts @@ -629,6 +629,122 @@ describe("codex bin wrapper", () => { ); }); + 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 ?? ""}`);', + '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="codex-multi-auth-runtime-proxy"', + ); + expect(output).toMatch(/^OPENAI_API_KEY:[0-9a-f]{64}$/m); + expect(output).toContain('wire_api = "responses"'); + expect(readFileSync(markerPath, "utf8")).toBe( + "start:http://127.0.0.1:4567\nclose\n", + ); + }); + + 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 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 ?? ""}`);', + '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"); + + 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: "80", + 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 . -c cli_auth_credentials_store="file" -c model_provider="codex-multi-auth-runtime-proxy"', + ); + expect(output).toMatch(/^OPENAI_API_KEY:[0-9a-f]{64}$/m); + expect(output).toContain('wire_api = "responses"'); + const shadowHomeMatch = output.match(/^CODEX_HOME:(.+)$/m); + expect(shadowHomeMatch?.[1]).toBeTruthy(); + + await sleep(250); + + 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 }; + expect(helperStatus.state).toBe("idle-timeout"); + expect(helperStatus.totalRequests).toBe(0); + if (shadowHomeMatch?.[1]) { + expect(existsSync(shadowHomeMatch[1])).toBe(false); + } + }); + it("records forwarded exec traffic in runtime observability when the child process does not update it", () => { const fixtureRoot = createWrapperFixture(); createRuntimeObservabilityFixtureModule(fixtureRoot); From dfabd420e19650088fa5a30138a82ad01e5e6dcf Mon Sep 17 00:00:00 2001 From: ndycode Date: Fri, 24 Apr 2026 22:06:25 +0800 Subject: [PATCH 07/42] Install managed Codex app launcher --- README.md | 1 + docs/configuration.md | 2 + docs/reference/commands.md | 4 +- docs/releases/v1.3.1.md | 1 + package.json | 9 +- scripts/codex-app-launcher.js | 369 ++++++++++++++++++++++++++++++++ scripts/codex.js | 35 +++ test/codex-bin-wrapper.test.ts | 8 + test/documentation.test.ts | 1 + test/install-codex-auth.test.ts | 68 ++++++ test/package-bin.test.ts | 1 + 11 files changed, 494 insertions(+), 5 deletions(-) create mode 100644 scripts/codex-app-launcher.js diff --git a/README.md b/README.md index 6b2ef3f0..ba83d62f 100644 --- a/README.md +++ b/README.md @@ -235,6 +235,7 @@ Selected runtime/environment overrides: | `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_LAUNCHER_INSTALL=0/1` | Opt out/in of installing the managed Codex app launcher 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 | diff --git a/docs/configuration.md b/docs/configuration.md index 85555f9d..08fbdf90 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -109,6 +109,8 @@ The proxy preserves request bodies and streaming responses, replaces outbound au For `codex app`, 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 installs a user-level Codex app launcher where the platform supports it. That launcher points normal app opens at `codex app` through `codex-multi-auth`, so the app loads the same runtime rotation path instead of bypassing the wrapper. Set `CODEX_MULTI_AUTH_APP_LAUNCHER_INSTALL=0` before enabling rotation to skip this best-effort launcher install, or run `codex-multi-auth-app-launcher --remove` to remove the managed launcher later. + --- ## Shipped Templates diff --git a/docs/reference/commands.md b/docs/reference/commands.md index d8740890..b7204387 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -165,13 +165,15 @@ codex auth rotation status Behavior: -- `enable` persists `codexRuntimeRotationProxy=true`. +- `enable` persists `codexRuntimeRotationProxy=true` and installs a user-level Codex app launcher when possible. - `disable` persists `codexRuntimeRotationProxy=false`. - `status` prints the effective setting, environment override state, automatic Codex app helper state, account count, current account, disabled accounts, cooldowns, and rate-limit waits. - `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. +The managed app launcher is also available directly as `codex-multi-auth-app-launcher`. Use `codex-multi-auth-app-launcher --remove` to remove it. + --- ## `codex auth verify` diff --git a/docs/releases/v1.3.1.md b/docs/releases/v1.3.1.md index 1af05fcb..dbebd826 100644 --- a/docs/releases/v1.3.1.md +++ b/docs/releases/v1.3.1.md @@ -29,6 +29,7 @@ This patch release finalizes the GPT-5.5 runtime rollout work for `codex-multi-a - 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 installs a managed user-level Codex app launcher so normal app opens can enter the wrapper-backed `codex app` path ## Validation diff --git a/package.json b/package.json index 056dfc06..100ada00 100644 --- a/package.json +++ b/package.json @@ -104,10 +104,11 @@ "prepublishOnly": "npm run build", "prepare": "husky" }, - "bin": { - "codex": "scripts/codex.js", - "codex-multi-auth": "scripts/codex-multi-auth.js" - }, + "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/codex-app-launcher.js b/scripts/codex-app-launcher.js new file mode 100644 index 00000000..2301e4f6 --- /dev/null +++ b/scripts/codex-app-launcher.js @@ -0,0 +1,369 @@ +#!/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 LAUNCHER_NAME = "Codex"; +const WINDOWS_SHORTCUT_NAME = `${LAUNCHER_NAME}.lnk`; +const LINUX_DESKTOP_FILE_NAME = "codex.desktop"; +const MACOS_APP_NAME = `${LAUNCHER_NAME}.app`; + +/** + * @param {string} value + */ +function quotePowerShellSingle(value) { + return `'${value.replace(/'/g, "''")}'`; +} + +/** + * @param {string} value + */ +function quoteDesktopExec(value) { + return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; +} + +/** + * @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 + * @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 {{ + * 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; + + if (platform === "win32") { + const shortcutPath = join(resolveWindowsStartMenuDir(env, home), WINDOWS_SHORTCUT_NAME); + return { + platform, + launcherPath: shortcutPath, + commandPath: nodePath, + commandArgs: `"${codexScriptPath}" app`, + workingDirectory: home, + iconPath: nodePath, + }; + } + + if (platform === "darwin") { + const appPath = join(resolveMacApplicationsDir(env, home), MACOS_APP_NAME); + return { + platform, + launcherPath: appPath, + commandPath: nodePath, + commandArgs: `"${codexScriptPath}" app`, + workingDirectory: home, + iconPath: nodePath, + }; + } + + const desktopPath = join(resolveLinuxApplicationsDir(env, home), LINUX_DESKTOP_FILE_NAME); + return { + platform, + launcherPath: desktopPath, + commandPath: nodePath, + commandArgs: `"${codexScriptPath}" app %F`, + workingDirectory: home, + iconPath: "utilities-terminal", + }; +} + +/** + * @param {ReturnType} plan + */ +export function createWindowsShortcutPowerShellScript(plan) { + return [ + "$ErrorActionPreference = 'Stop'", + `$ShortcutPath = ${quotePowerShellSingle(plan.launcherPath)}`, + `$TargetPath = ${quotePowerShellSingle(plan.commandPath)}`, + `$Arguments = ${quotePowerShellSingle(plan.commandArgs)}`, + `$WorkingDirectory = ${quotePowerShellSingle(plan.workingDirectory)}`, + `$IconLocation = ${quotePowerShellSingle(plan.iconPath)}`, + "New-Item -ItemType Directory -Force -Path (Split-Path -Parent $ShortcutPath) | Out-Null", + "$Shell = New-Object -ComObject WScript.Shell", + "$Shortcut = $Shell.CreateShortcut($ShortcutPath)", + "$Shortcut.TargetPath = $TargetPath", + "$Shortcut.Arguments = $Arguments", + "$Shortcut.WorkingDirectory = $WorkingDirectory", + "$Shortcut.IconLocation = $IconLocation", + "$Shortcut.Description = 'Launch Codex through codex-multi-auth runtime rotation'", + "$Shortcut.Save()", + ].join("\r\n"); +} + +/** + * @param {ReturnType} plan + */ +function createLinuxDesktopFile(plan) { + return [ + "[Desktop Entry]", + "Type=Application", + `Name=${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", + ` ${LAUNCHER_NAME}`, + " CFBundlePackageType", + " APPL", + "", + "", + "", + ].join("\n"); +} + +/** + * @param {ReturnType} plan + */ +function createMacLauncherScript(plan) { + return [ + "#!/bin/sh", + `cd ${JSON.stringify(plan.workingDirectory)}`, + `exec ${JSON.stringify(plan.commandPath)} ${plan.commandArgs}`, + "", + ].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 }} options + */ +async function installWindowsShortcut(plan, options) { + const script = createWindowsShortcutPowerShellScript(plan); + const powershell = + (options.env.SystemRoot ?? options.env.SYSTEMROOT ?? "C:\\Windows") + + "\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"; + await runCommand( + powershell, + [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + script, + ], + options.env, + ); +} + +/** + * @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 (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 Codex app launcher: ${plan.launcherPath}`); + return plan; + } + + if (options.dryRun) { + log(`[dry-run] Would install Codex app launcher: ${plan.launcherPath}`); + log(`[dry-run] Target: ${plan.commandPath} ${plan.commandArgs}`); + return plan; + } + + if (plan.platform === "win32") { + await installWindowsShortcut(plan, { env }); + } else if (plan.platform === "darwin") { + await installMacAppBundle(plan); + } else { + await installLinuxDesktopFile(plan); + } + log(`Installed Codex app launcher: ${plan.launcherPath}`); + return plan; +} + +function printHelp() { + console.log( + [ + "Usage: codex-multi-auth-app-launcher [--remove] [--dry-run]", + "", + "Installs a user-level Codex 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 install failed: ${error instanceof Error ? error.message : String(error)}`, + ); + process.exit(1); + }); +} diff --git a/scripts/codex.js b/scripts/codex.js index e41252b4..a814d921 100755 --- a/scripts/codex.js +++ b/scripts/codex.js @@ -89,6 +89,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 install Codex app launcher: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + async function loadRunCodexMultiAuthCli() { try { const mod = await import("../dist/lib/codex-manager.js"); @@ -2359,6 +2390,10 @@ async function main() { return 1; } const exitCode = await runCodexMultiAuthCli(normalizedArgs); + await maybeInstallCodexAppLauncherAfterRotationEnable( + normalizedArgs, + normalizeExitCode(exitCode), + ); return normalizeExitCode(exitCode); } catch (error) { console.error( diff --git a/test/codex-bin-wrapper.test.ts b/test/codex-bin-wrapper.test.ts index de1edd3a..c5c31ec4 100644 --- a/test/codex-bin-wrapper.test.ts +++ b/test/codex-bin-wrapper.test.ts @@ -72,6 +72,14 @@ 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", "install-codex-auth-utils.js"), + join(scriptDir, "install-codex-auth-utils.js"), + ); return fixtureRoot; } 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..0d3af96c 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,13 @@ import { resolveInstallPaths, withFileOperationRetry, } from "../scripts/install-codex-auth-utils.js"; +import { + createWindowsShortcutPowerShellScript, + resolveAppLauncherPlan, +} from "../scripts/codex-app-launcher.js"; const scriptPath = "scripts/install-codex-auth.js"; +const appLauncherScriptPath = "scripts/codex-app-launcher.js"; const tempRoots: string[] = []; const execFileAsync = promisify(execFile); @@ -190,3 +196,65 @@ describe("install-codex-auth script", () => { expect(operation).toHaveBeenCalledTimes(1); }); }); + +describe("codex app launcher installer", () => { + it("resolves a Windows Start Menu launcher that points 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.commandPath).toBe(process.execPath); + expect(plan.commandArgs).toContain("scripts\\codex.js"); + expect(plan.commandArgs).toContain(" app"); + + const psScript = createWindowsShortcutPowerShellScript(plan); + expect(psScript).toContain("$Shortcut.TargetPath = $TargetPath"); + expect(psScript).toContain("Launch Codex through codex-multi-auth"); + }); + + 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.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] Would install Codex app launcher"); + expect(existsSync(path.join(dataHome, "applications", "codex.desktop"))).toBe(false); + }); +}); 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/"])); From a0c281d1c5fc81b8ca576845de6314fe15cb0d24 Mon Sep 17 00:00:00 2001 From: ndycode Date: Fri, 24 Apr 2026 22:18:46 +0800 Subject: [PATCH 08/42] Clarify app launcher routing limits --- README.md | 2 +- docs/configuration.md | 4 +- docs/reference/commands.md | 6 +- docs/releases/v1.3.1.md | 2 +- scripts/codex-app-launcher.js | 263 ++++++++++++++++++++++++++++---- scripts/codex.js | 2 +- test/install-codex-auth.test.ts | 61 +++++++- 7 files changed, 299 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index ba83d62f..70d54e9b 100644 --- a/README.md +++ b/README.md @@ -235,7 +235,7 @@ Selected runtime/environment overrides: | `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_LAUNCHER_INSTALL=0/1` | Opt out/in of installing the managed Codex app launcher during 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 | diff --git a/docs/configuration.md b/docs/configuration.md index 08fbdf90..0b73b60a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -109,7 +109,9 @@ The proxy preserves request bodies and streaming responses, replaces outbound au For `codex app`, 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 installs a user-level Codex app launcher where the platform supports it. That launcher points normal app opens at `codex app` through `codex-multi-auth`, so the app loads the same runtime rotation path instead of bypassing the wrapper. Set `CODEX_MULTI_AUTH_APP_LAUNCHER_INSTALL=0` before enabling rotation to skip this best-effort launcher install, or run `codex-multi-auth-app-launcher --remove` to remove the managed launcher later. +`codex auth rotation enable` also routes supported user-level app launchers where the platform supports it. On Windows, it finds existing `Codex` Start Menu, Desktop, and taskbar `.lnk` entries, backs up their original target under the multi-auth directory, and retargets those same icons to `codex app` through `codex-multi-auth`. On macOS, Dock entries cannot safely target a shell command directly, so the helper creates a user-level managed wrapper app that runs the same `codex app` path without a background daemon. The official app files are not patched on either platform; routed launchers still open the official app UI through the wrapper. Set `CODEX_MULTI_AUTH_APP_LAUNCHER_INSTALL=0` before enabling rotation to skip this best-effort launcher 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 are detected and reported, but they cannot be retargeted like `.lnk` files without switching to a persistent background router that rewrites real Codex config. --- diff --git a/docs/reference/commands.md b/docs/reference/commands.md index b7204387..ab017379 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -165,14 +165,16 @@ codex auth rotation status Behavior: -- `enable` persists `codexRuntimeRotationProxy=true` and installs a user-level Codex app launcher when possible. +- `enable` persists `codexRuntimeRotationProxy=true` and routes supported user-level app shortcuts when possible. - `disable` persists `codexRuntimeRotationProxy=false`. - `status` prints the effective setting, environment override state, automatic Codex app helper state, account count, current account, disabled accounts, cooldowns, and rate-limit waits. - `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. -The managed app launcher is also available directly as `codex-multi-auth-app-launcher`. Use `codex-multi-auth-app-launcher --remove` to remove 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, the helper reports it but does not retarget it. Packaged app entries require a persistent background router instead of shortcut rewriting. --- diff --git a/docs/releases/v1.3.1.md b/docs/releases/v1.3.1.md index dbebd826..81d15f3f 100644 --- a/docs/releases/v1.3.1.md +++ b/docs/releases/v1.3.1.md @@ -29,7 +29,7 @@ This patch release finalizes the GPT-5.5 runtime rollout work for `codex-multi-a - 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 installs a managed user-level Codex app launcher so normal app opens can enter the wrapper-backed `codex app` path +- 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 diff --git a/scripts/codex-app-launcher.js b/scripts/codex-app-launcher.js index 2301e4f6..0d91cef9 100644 --- a/scripts/codex-app-launcher.js +++ b/scripts/codex-app-launcher.js @@ -10,10 +10,12 @@ import { fileURLToPath } from "node:url"; import { spawn } from "node:child_process"; import { withFileOperationRetry } from "./install-codex-auth-utils.js"; -const LAUNCHER_NAME = "Codex"; -const WINDOWS_SHORTCUT_NAME = `${LAUNCHER_NAME}.lnk`; -const LINUX_DESKTOP_FILE_NAME = "codex.desktop"; -const MACOS_APP_NAME = `${LAUNCHER_NAME}.app`; +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"; /** * @param {string} value @@ -22,6 +24,23 @@ function quotePowerShellSingle(value) { return `'${value.replace(/'/g, "''")}'`; } +/** + * @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 */ @@ -38,6 +57,37 @@ function resolveWindowsStartMenuDir(env, home) { return join(appData, "Microsoft", "Windows", "Start Menu", "Programs"); } +/** + * @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 {string} home + */ +function resolveWindowsDesktopDir(home) { + return 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 @@ -80,10 +130,17 @@ export function resolveAppLauncherPlan(options = {}) { const nodePath = process.execPath; if (platform === "win32") { - const shortcutPath = join(resolveWindowsStartMenuDir(env, home), WINDOWS_SHORTCUT_NAME); + const startMenuDir = resolveWindowsStartMenuDir(env, home); return { platform, - launcherPath: shortcutPath, + mode: "route-existing", + launcherPath: join(startMenuDir, WINDOWS_SHORTCUT_NAME), + shortcutRoots: [ + startMenuDir, + resolveWindowsTaskbarPinnedDir(env, home), + resolveWindowsDesktopDir(home), + ], + backupPath: join(resolveCodexMultiAuthDir(env, home), WINDOWS_BACKUP_FILE_NAME), commandPath: nodePath, commandArgs: `"${codexScriptPath}" app`, workingDirectory: home, @@ -95,6 +152,7 @@ export function resolveAppLauncherPlan(options = {}) { const appPath = join(resolveMacApplicationsDir(env, home), MACOS_APP_NAME); return { platform, + mode: "create-managed", launcherPath: appPath, commandPath: nodePath, commandArgs: `"${codexScriptPath}" app`, @@ -106,6 +164,7 @@ export function resolveAppLauncherPlan(options = {}) { const desktopPath = join(resolveLinuxApplicationsDir(env, home), LINUX_DESKTOP_FILE_NAME); return { platform, + mode: "create-managed", launcherPath: desktopPath, commandPath: nodePath, commandArgs: `"${codexScriptPath}" app %F`, @@ -116,24 +175,125 @@ export function resolveAppLauncherPlan(options = {}) { /** * @param {ReturnType} plan + * @param {{ dryRun?: boolean, remove?: boolean }} [options] */ -export function createWindowsShortcutPowerShellScript(plan) { +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'", - `$ShortcutPath = ${quotePowerShellSingle(plan.launcherPath)}`, + `$DryRun = ${quotePowerShellBoolean(dryRun)}`, + `$ShortcutRoots = ${quotePowerShellArray(shortcutRoots)}`, + `$BackupPath = ${quotePowerShellSingle(backupPath)}`, + `$ShortcutName = ${quotePowerShellSingle(OFFICIAL_LAUNCHER_NAME)}`, `$TargetPath = ${quotePowerShellSingle(plan.commandPath)}`, `$Arguments = ${quotePowerShellSingle(plan.commandArgs)}`, `$WorkingDirectory = ${quotePowerShellSingle(plan.workingDirectory)}`, - `$IconLocation = ${quotePowerShellSingle(plan.iconPath)}`, - "New-Item -ItemType Directory -Force -Path (Split-Path -Parent $ShortcutPath) | Out-Null", + "$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", - "$Shortcut = $Shell.CreateShortcut($ShortcutPath)", - "$Shortcut.TargetPath = $TargetPath", - "$Shortcut.Arguments = $Arguments", - "$Shortcut.WorkingDirectory = $WorkingDirectory", - "$Shortcut.IconLocation = $IconLocation", - "$Shortcut.Description = 'Launch Codex through codex-multi-auth runtime rotation'", - "$Shortcut.Save()", + "$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", + " }", + " if (-not $BackupByPath.ContainsKey($Path)) {", + " $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", + " $Shortcut.IconLocation = [string]$Backup.IconLocation", + " $Shortcut.Description = 'Launch Codex through codex-multi-auth runtime rotation'", + " $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"); } @@ -144,7 +304,7 @@ function createLinuxDesktopFile(plan) { return [ "[Desktop Entry]", "Type=Application", - `Name=${LAUNCHER_NAME}`, + `Name=${MANAGED_LAUNCHER_NAME}`, "Comment=Launch Codex through codex-multi-auth runtime rotation", `Exec=${quoteDesktopExec(plan.commandPath)} ${plan.commandArgs}`, `Path=${plan.workingDirectory}`, @@ -170,7 +330,7 @@ function createMacInfoPlist(plan) { " CFBundleIdentifier", " com.ndycode.codex-multi-auth.launcher", " CFBundleName", - ` ${LAUNCHER_NAME}`, + ` ${MANAGED_LAUNCHER_NAME}`, " CFBundlePackageType", " APPL", "", @@ -226,14 +386,17 @@ function runCommand(command, args, env) { /** * @param {ReturnType} plan - * @param {{ env: NodeJS.ProcessEnv }} options + * @param {{ env: NodeJS.ProcessEnv, dryRun?: boolean, remove?: boolean }} options */ async function installWindowsShortcut(plan, options) { - const script = createWindowsShortcutPowerShellScript(plan); + 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"; - await runCommand( + const result = await runCommand( powershell, [ "-NoProfile", @@ -244,6 +407,11 @@ async function installWindowsShortcut(plan, options) { ], 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: [] }; + } + return JSON.parse(output); } /** @@ -295,30 +463,64 @@ export async function installCodexAppLauncher(options = {}) { }); 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 Codex app launcher: ${plan.launcherPath}`); + log(`Removed ${MANAGED_LAUNCHER_NAME} app launcher: ${plan.launcherPath}`); return plan; } if (options.dryRun) { - log(`[dry-run] Would install Codex app launcher: ${plan.launcherPath}`); + 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 === "win32") { - await installWindowsShortcut(plan, { env }); - } else if (plan.platform === "darwin") { + if (plan.platform === "darwin") { await installMacAppBundle(plan); } else { await installLinuxDesktopFile(plan); } - log(`Installed Codex app launcher: ${plan.launcherPath}`); + log(`Installed ${MANAGED_LAUNCHER_NAME} app launcher: ${plan.launcherPath}`); return plan; } @@ -327,7 +529,8 @@ function printHelp() { [ "Usage: codex-multi-auth-app-launcher [--remove] [--dry-run]", "", - "Installs a user-level Codex app launcher that runs `codex app` through codex-multi-auth.", + "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", @@ -362,7 +565,7 @@ const isDirectRun = (() => { if (isDirectRun) { main().catch((error) => { console.error( - `Codex app launcher install failed: ${error instanceof Error ? error.message : String(error)}`, + `Codex app launcher routing failed: ${error instanceof Error ? error.message : String(error)}`, ); process.exit(1); }); diff --git a/scripts/codex.js b/scripts/codex.js index a814d921..8088679c 100755 --- a/scripts/codex.js +++ b/scripts/codex.js @@ -115,7 +115,7 @@ async function maybeInstallCodexAppLauncherAfterRotationEnable(args, exitCode) { }); } catch (error) { console.error( - `codex-multi-auth: could not install Codex app launcher: ${error instanceof Error ? error.message : String(error)}`, + `codex-multi-auth: could not route Codex app launchers: ${error instanceof Error ? error.message : String(error)}`, ); } } diff --git a/test/install-codex-auth.test.ts b/test/install-codex-auth.test.ts index 0d3af96c..803c7c6b 100644 --- a/test/install-codex-auth.test.ts +++ b/test/install-codex-auth.test.ts @@ -198,7 +198,7 @@ describe("install-codex-auth script", () => { }); describe("codex app launcher installer", () => { - it("resolves a Windows Start Menu launcher that points at the wrapper app command", () => { + 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({ @@ -209,17 +209,61 @@ describe("codex app launcher installer", () => { }); expect(plan.launcherPath).toBe( - path.join(appData, "Microsoft", "Windows", "Start Menu", "Programs", "Codex.lnk"), + 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(process.execPath); expect(plan.commandArgs).toContain("scripts\\codex.js"); expect(plan.commandArgs).toContain(" app"); const psScript = createWindowsShortcutPowerShellScript(plan); + expect(psScript).toContain("$Candidates"); + expect(psScript).toContain("$BackupPath"); + expect(psScript).toContain("shell:AppsFolder"); expect(psScript).toContain("$Shortcut.TargetPath = $TargetPath"); expect(psScript).toContain("Launch Codex through codex-multi-auth"); }); + 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"; @@ -230,7 +274,9 @@ describe("codex app launcher installer", () => { moduleUrl: pathToFileURL(path.resolve(appLauncherScriptPath)).href, }); - expect(plan.launcherPath).toBe(path.join(dataHome, "applications", "codex.desktop")); + 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"); @@ -254,7 +300,12 @@ describe("codex app launcher installer", () => { ); expect(result.status).toBe(0); - expect(result.stdout).toContain("[dry-run] Would install Codex app launcher"); - expect(existsSync(path.join(dataHome, "applications", "codex.desktop"))).toBe(false); + 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, + ); + } }); }); From 955f8d0c9235980ecdcc367a15f07e3f30bc88fe Mon Sep 17 00:00:00 2001 From: ndycode Date: Fri, 24 Apr 2026 22:33:51 +0800 Subject: [PATCH 09/42] Expose runtime rotation account status --- lib/codex-manager/commands/rotation.ts | 31 +++++- lib/codex-manager/commands/status.ts | 24 +++++ lib/runtime-rotation-proxy.ts | 121 +++++++++++++++++++--- lib/runtime/runtime-observability.ts | 10 ++ scripts/codex.js | 5 +- test/codex-bin-wrapper.test.ts | 50 ++++++++- test/codex-manager-status-command.test.ts | 67 ++++++++++++ test/runtime-observability.test.ts | 2 + test/runtime-rotation-proxy.test.ts | 17 +++ 9 files changed, 307 insertions(+), 20 deletions(-) diff --git a/lib/codex-manager/commands/rotation.ts b/lib/codex-manager/commands/rotation.ts index 99277b8b..21cb3f7c 100644 --- a/lib/codex-manager/commands/rotation.ts +++ b/lib/codex-manager/commands/rotation.ts @@ -15,6 +15,10 @@ interface AppRuntimeHelperStatus { totalRequests: number | null; rotations: number | null; lastAccountIndex: number | null; + lastAccountLabel: string | null; + lastAccountEmail: string | null; + lastAccountId: string | null; + lastAccountUpdatedAt: number | null; updatedAt: number | null; } @@ -95,6 +99,10 @@ function readAppRuntimeHelperStatus(): AppRuntimeHelperStatus | null { totalRequests: readOptionalNumber(parsed, "totalRequests"), rotations: readOptionalNumber(parsed, "rotations"), lastAccountIndex: readOptionalNumber(parsed, "lastAccountIndex"), + lastAccountLabel: readOptionalString(parsed, "lastAccountLabel"), + lastAccountEmail: readOptionalString(parsed, "lastAccountEmail"), + lastAccountId: readOptionalString(parsed, "lastAccountId"), + lastAccountUpdatedAt: readOptionalNumber(parsed, "lastAccountUpdatedAt"), updatedAt: readOptionalNumber(parsed, "updatedAt"), }; } catch { @@ -114,6 +122,24 @@ function isProcessAlive(pid: number | null): boolean { } } +function formatHelperLastAccount(status: AppRuntimeHelperStatus): string | null { + if (status.lastAccountLabel) return status.lastAccountLabel; + if (status.lastAccountEmail) { + return status.lastAccountIndex !== null + ? `Account ${status.lastAccountIndex + 1} (${status.lastAccountEmail})` + : status.lastAccountEmail; + } + 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"; @@ -124,9 +150,8 @@ function formatAppRuntimeHelperStatus(now: number): string { 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}`); - if (status.lastAccountIndex !== null) { - parts.push(`lastAccount=${status.lastAccountIndex + 1}`); - } + 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)}`); } diff --git a/lib/codex-manager/commands/status.ts b/lib/codex-manager/commands/status.ts index aeb8fe45..b22f0002 100644 --- a/lib/codex-manager/commands/status.ts +++ b/lib/codex-manager/commands/status.ts @@ -48,6 +48,26 @@ function readRestoreReason(storage: AccountStorageV3): RestoreReason | undefined : undefined; } +function formatRuntimeLastAccount( + runtimeSnapshot: RuntimeObservabilitySnapshot, +): string | null { + if (runtimeSnapshot.lastAccountLabel) return runtimeSnapshot.lastAccountLabel; + if (runtimeSnapshot.lastAccountEmail) { + return typeof runtimeSnapshot.lastAccountIndex === "number" + ? `Account ${runtimeSnapshot.lastAccountIndex + 1} (${runtimeSnapshot.lastAccountEmail})` + : runtimeSnapshot.lastAccountEmail; + } + 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 { @@ -119,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/runtime-rotation-proxy.ts b/lib/runtime-rotation-proxy.ts index 46592940..5daed242 100644 --- a/lib/runtime-rotation-proxy.ts +++ b/lib/runtime-rotation-proxy.ts @@ -1,5 +1,10 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; -import { AccountManager, extractAccountId, type ManagedAccount } from "./accounts.js"; +import { + AccountManager, + extractAccountId, + formatAccountLabel, + type ManagedAccount, +} from "./accounts.js"; import { getNetworkErrorCooldownMs, getServerErrorCooldownMs, @@ -18,6 +23,7 @@ import { } 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"; @@ -39,6 +45,10 @@ export interface RuntimeRotationProxyStatus { streamsStarted: number; lastError: string | null; lastAccountIndex: number | null; + lastAccountLabel: string | null; + lastAccountEmail: string | null; + lastAccountId: string | null; + lastAccountUpdatedAt: number | null; } export interface RuntimeRotationProxyOptions { @@ -63,6 +73,14 @@ interface RequestContext { type ExhaustionReason = "rate-limit" | "server-error" | "network-error" | "auth-failure" | "no-account"; +interface RuntimeRotationAccountIdentity { + index: number; + label: string; + email: string | null; + 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; @@ -132,12 +150,67 @@ function isAuthorizedClient(headers: Headers, clientApiKey: string | null): bool return headers.get("x-api-key") === clientApiKey; } -function responseHeadersForClient(upstreamHeaders: Headers): Record { +function readTrimmedString(value: string | undefined): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function sanitizeHeaderValue(value: string | null): string | null { + if (!value) return null; + const sanitized = value.replace(/[^\t\x20-\x7e]/g, "").trim(); + return sanitized.length > 0 ? sanitized : null; +} + +function accountIdentityFromAccount( + account: ManagedAccount, + updatedAt: number, +): RuntimeRotationAccountIdentity { + return { + index: account.index, + label: formatAccountLabel(account, account.index), + email: readTrimmedString(account.email), + accountId: readTrimmedString(account.accountId), + updatedAt, + }; +} + +function recordLastRuntimeAccount( + status: RuntimeRotationProxyStatus, + identity: RuntimeRotationAccountIdentity, +): void { + status.lastAccountIndex = identity.index; + status.lastAccountLabel = identity.label; + status.lastAccountEmail = identity.email; + status.lastAccountId = identity.accountId; + status.lastAccountUpdatedAt = identity.updatedAt; + mutateRuntimeObservabilitySnapshot((snapshot) => { + snapshot.lastAccountIndex = identity.index; + snapshot.lastAccountLabel = identity.label; + snapshot.lastAccountEmail = identity.email; + snapshot.lastAccountId = identity.accountId; + snapshot.lastAccountUpdatedAt = identity.updatedAt; + }); +} + +function responseHeadersForClient( + upstreamHeaders: Headers, + accountIdentity?: RuntimeRotationAccountIdentity, +): Record { const headers: Record = {}; for (const [key, value] of upstreamHeaders.entries()) { if (HOP_BY_HOP_HEADERS.has(key.toLowerCase())) continue; headers[key] = value; } + if (accountIdentity) { + headers["x-codex-multi-auth-account-index"] = String(accountIdentity.index + 1); + const label = sanitizeHeaderValue(accountIdentity.label); + if (label) headers["x-codex-multi-auth-account-label"] = label; + const email = sanitizeHeaderValue(accountIdentity.email); + if (email) headers["x-codex-multi-auth-account-email"] = email; + const accountId = sanitizeHeaderValue(accountIdentity.accountId); + if (accountId) headers["x-codex-multi-auth-account-id"] = accountId; + } return headers; } @@ -485,10 +558,14 @@ async function forwardStreamingResponse( upstream: Response, res: ServerResponse, status: RuntimeRotationProxyStatus, + accountIdentity: RuntimeRotationAccountIdentity, onStreamError: () => void, ): Promise { status.streamsStarted += 1; - res.writeHead(upstream.status, responseHeadersForClient(upstream.headers)); + res.writeHead( + upstream.status, + responseHeadersForClient(upstream.headers, accountIdentity), + ); if (!upstream.body) { res.end(); return; @@ -553,6 +630,10 @@ export async function startRuntimeRotationProxy( streamsStarted: 0, lastError: null, lastAccountIndex: null, + lastAccountLabel: null, + lastAccountEmail: null, + lastAccountId: null, + lastAccountUpdatedAt: null, }; const handleRequest = async ( @@ -590,7 +671,6 @@ export async function startRuntimeRotationProxy( }); if (!selected) break; attemptedIndexes.add(selected.index); - status.lastAccountIndex = selected.index; if (!accountManager.consumeToken(selected, context.family, context.model)) { exhaustionReason = "rate-limit"; @@ -629,6 +709,9 @@ export async function startRuntimeRotationProxy( continue; } + const accountIdentity = accountIdentityFromAccount(refreshed.account, now()); + recordLastRuntimeAccount(status, accountIdentity); + const outboundHeaders = createOutboundHeaders( context.headers, refreshed.account, @@ -735,16 +818,26 @@ export async function startRuntimeRotationProxy( ); } - 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(); - }); + await forwardStreamingResponse( + upstream, + res, + status, + accountIdentity, + () => { + accountManager.recordFailure( + refreshed.account, + context.family, + context.model, + ); + accountManager.markAccountCoolingDown( + refreshed.account, + networkErrorCooldownMs, + "network-error", + ); + sessionAffinityStore?.forgetSession(context.sessionKey); + accountManager.saveToDiskDebounced(); + }, + ); return; } 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/scripts/codex.js b/scripts/codex.js index 8088679c..e223d253 100755 --- a/scripts/codex.js +++ b/scripts/codex.js @@ -1354,6 +1354,10 @@ function createRuntimeRotationAppHelperStatus({ retries: proxyStatus.retries ?? 0, rotations: proxyStatus.rotations ?? 0, lastAccountIndex: proxyStatus.lastAccountIndex ?? null, + lastAccountLabel: proxyStatus.lastAccountLabel ?? null, + lastAccountEmail: proxyStatus.lastAccountEmail ?? null, + lastAccountId: proxyStatus.lastAccountId ?? null, + lastAccountUpdatedAt: proxyStatus.lastAccountUpdatedAt ?? null, lastError: proxyStatus.lastError ?? null, }; } @@ -1860,7 +1864,6 @@ function shouldCaptureForwardedOutputForArgs(rawArgs, env) { function createRuntimeSnapshotChangeToken(snapshot) { return JSON.stringify({ - updatedAt: snapshot?.updatedAt ?? null, responsesRequests: snapshot?.responsesRequests ?? null, authRefreshRequests: snapshot?.authRefreshRequests ?? null, diagnosticProbeRequests: snapshot?.diagnosticProbeRequests ?? null, diff --git a/test/codex-bin-wrapper.test.ts b/test/codex-bin-wrapper.test.ts index c5c31ec4..31eea98f 100644 --- a/test/codex-bin-wrapper.test.ts +++ b/test/codex-bin-wrapper.test.ts @@ -183,6 +183,31 @@ function createRuntimeRotationProxyFixtureModule(fixtureRoot: string): string { " 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';", " appendMarker(`start:${baseUrl}`);", @@ -191,7 +216,7 @@ function createRuntimeRotationProxyFixtureModule(fixtureRoot: string): string { " port: 4567,", " baseUrl,", " close: async () => appendMarker('close'),", - " getStatus: () => ({}),", + " getStatus: () => buildStatus(),", " };", "}", ].join("\n"), @@ -725,6 +750,12 @@ describe("codex bin wrapper", () => { CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY: "1", CODEX_MULTI_AUTH_APP_ROTATION_IDLE_MS: "80", 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, }); @@ -745,9 +776,24 @@ describe("codex bin wrapper", () => { ); const helperStatus = JSON.parse( readFileSync(join(multiAuthDir, "runtime-rotation-app-helper.json"), "utf8"), - ) as { state: string; totalRequests: number }; + ) as { + state: string; + totalRequests: number; + lastAccountIndex: number | null; + lastAccountLabel: string | null; + lastAccountEmail: 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 (second@example.com, id:second)", + ); + expect(helperStatus.lastAccountEmail).toBe("second@example.com"); + expect(helperStatus.lastAccountId).toBe("acc_second"); + expect(helperStatus.lastAccountUpdatedAt).toBe(12345); if (shadowHomeMatch?.[1]) { expect(existsSync(shadowHomeMatch[1])).toBe(false); } diff --git a/test/codex-manager-status-command.test.ts b/test/codex-manager-status-command.test.ts index ddeedfdc..0d0e324b 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) }); @@ -137,6 +184,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 (two@example.com, id:acct_2)", + ); + }); }); describe("runFeaturesCommand", () => { 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.test.ts b/test/runtime-rotation-proxy.test.ts index 86e93483..a9327f1a 100644 --- a/test/runtime-rotation-proxy.test.ts +++ b/test/runtime-rotation-proxy.test.ts @@ -195,6 +195,19 @@ describe("runtime rotation proxy", () => { 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")).toBe("1"); + expect(response.headers.get("x-codex-multi-auth-account-email")).toBe( + "account-1@example.com", + ); + expect(response.headers.get("x-codex-multi-auth-account-label")).toBe( + "Account 1 (account-1@example.com, id:acc_1)", + ); + expect(proxy.getStatus()).toMatchObject({ + lastAccountIndex: 0, + lastAccountEmail: "account-1@example.com", + lastAccountLabel: "Account 1 (account-1@example.com, id:acc_1)", + lastAccountId: "acc_1", + }); expect(JSON.parse(calls[0]?.bodyText ?? "{}")).toEqual(requestBody); }); @@ -245,6 +258,10 @@ describe("runtime rotation proxy", () => { "acc_1", "acc_2", ]); + expect(proxy.getStatus()).toMatchObject({ + lastAccountIndex: 1, + lastAccountEmail: "account-2@example.com", + }); expect( accountManager.getAccountByIndex(0)?.rateLimitResetTimes["gpt-5-codex"], ).toBeTypeOf("number"); From dffbe2e6e1ba76b4d1f0893237797753d308815c Mon Sep 17 00:00:00 2001 From: ndycode Date: Fri, 24 Apr 2026 22:57:42 +0800 Subject: [PATCH 10/42] feat: bind Codex app runtime rotation --- README.md | 1 + docs/configuration.md | 8 +- docs/reference/commands.md | 16 +- docs/reference/settings.md | 1 + lib/codex-manager.ts | 8 + lib/codex-manager/commands/rotation.ts | 75 +++ lib/runtime/app-bind.ts | 685 ++++++++++++++++++++ package.json | 13 +- scripts/codex-app-router.js | 139 ++++ scripts/postinstall.js | 203 ++++++ test/app-bind.test.ts | 166 +++++ test/codex-manager-rotation-command.test.ts | 104 +++ test/install-codex-auth.test.ts | 64 ++ 13 files changed, 1469 insertions(+), 14 deletions(-) create mode 100644 lib/runtime/app-bind.ts create mode 100644 scripts/codex-app-router.js create mode 100644 scripts/postinstall.js create mode 100644 test/app-bind.test.ts diff --git a/README.md b/README.md index 70d54e9b..64bc5e8a 100644 --- a/README.md +++ b/README.md @@ -235,6 +235,7 @@ Selected runtime/environment overrides: | `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 | diff --git a/docs/configuration.md b/docs/configuration.md index 0b73b60a..6530cb65 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -107,11 +107,13 @@ Keep these enabled for most environments: 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`, 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. +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 routes supported user-level app launchers where the platform supports it. On Windows, it finds existing `Codex` Start Menu, Desktop, and taskbar `.lnk` entries, backs up their original target under the multi-auth directory, and retargets those same icons to `codex app` through `codex-multi-auth`. On macOS, Dock entries cannot safely target a shell command directly, so the helper creates a user-level managed wrapper app that runs the same `codex app` path without a background daemon. The official app files are not patched on either platform; routed launchers still open the official app UI through the wrapper. Set `CODEX_MULTI_AUTH_APP_LAUNCHER_INSTALL=0` before enabling rotation to skip this best-effort launcher routing, or run `codex-multi-auth-app-launcher --remove` to restore backed-up Windows shortcuts or remove the managed macOS wrapper later. +`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. `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. -Some Windows installs expose Codex only as a packaged `shell:AppsFolder` app entry. Those entries are detected and reported, but they cannot be retargeted like `.lnk` files without switching to a persistent background router that rewrites real Codex config. +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. --- diff --git a/docs/reference/commands.md b/docs/reference/commands.md index ab017379..7a22d80c 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -58,7 +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` | Manage the opt-in runtime Responses proxy for live Codex account rotation | +| `codex auth rotation enable|disable|status|bind-app|unbind-app` | Manage the opt-in runtime Responses proxy for live Codex account rotation | --- @@ -161,20 +161,26 @@ Usage: 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` and routes supported user-level app shortcuts when possible. -- `disable` persists `codexRuntimeRotationProxy=false`. -- `status` prints the effective setting, environment override state, automatic Codex app helper state, account count, current account, disabled accounts, cooldowns, and rate-limit waits. +- `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. 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, the helper reports it but does not retarget it. Packaged app entries require a persistent background router instead of shortcut rewriting. +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. --- diff --git a/docs/reference/settings.md b/docs/reference/settings.md index 99c47e8e..63f07563 100644 --- a/docs/reference/settings.md +++ b/docs/reference/settings.md @@ -186,6 +186,7 @@ Common operator overrides: - `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/lib/codex-manager.ts b/lib/codex-manager.ts index 5322cfd8..e7a85c66 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -90,6 +90,11 @@ import { loadPluginConfig, savePluginConfig, } from "./config.js"; +import { + bindCodexAppRuntimeRotation, + getAppBindStatus, + unbindCodexAppRuntimeRotation, +} from "./runtime/app-bind.js"; import { ACCOUNT_LIMITS } from "./constants.js"; import { type DashboardAccountSortMode, @@ -3393,6 +3398,9 @@ export async function runCodexMultiAuthCli(rawArgs: string[]): Promise { getStoragePath, loadAccounts, resolveActiveIndex, + bindCodexApp: bindCodexAppRuntimeRotation, + unbindCodexApp: unbindCodexAppRuntimeRotation, + getCodexAppBindStatus: getAppBindStatus, }); } if (command === "why-selected") { diff --git a/lib/codex-manager/commands/rotation.ts b/lib/codex-manager/commands/rotation.ts index 21cb3f7c..036be5c6 100644 --- a/lib/codex-manager/commands/rotation.ts +++ b/lib/codex-manager/commands/rotation.ts @@ -2,6 +2,11 @@ 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 type { PluginConfig } from "../../types.js"; import type { AccountStorageV3 } from "../../storage.js"; @@ -30,6 +35,9 @@ export interface RotationCommandDeps { 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; @@ -42,9 +50,12 @@ function printRotationUsage(logInfo: (message: string) => void): void { " 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"), ); @@ -158,6 +169,27 @@ function formatAppRuntimeHelperStatus(now: number): string { 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; deps.setStoragePath(null); @@ -172,6 +204,7 @@ async function printRotationStatus(deps: RotationCommandDeps): Promise { ); logInfo(`Env override: ${formatEnvOverride()}`); logInfo(formatAppRuntimeHelperStatus(now)); + await printCodexAppBindStatus(deps); logInfo(`Storage: ${deps.getStoragePath()}`); if (!storage || storage.accounts.length === 0) { @@ -229,11 +262,53 @@ export async function runRotationCommand( 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; } diff --git a/lib/runtime/app-bind.ts b/lib/runtime/app-bind.ts new file mode 100644 index 00000000..3916180f --- /dev/null +++ b/lib/runtime/app-bind.ts @@ -0,0 +1,685 @@ +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync } from "node:fs"; +import { mkdir, readFile, unlink, writeFile } from "node:fs/promises"; +import { createServer } from "node:net"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; +import { getCodexMultiAuthDir } from "../runtime-paths.js"; + +const RUNTIME_ROTATION_PROXY_PROVIDER_ID = "codex-multi-auth-runtime-proxy"; +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"; + +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; + 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; +} + +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; + spawnDetached?: boolean; + log?: (message: string) => void; +} + +function tomlStringLiteral(value: string): string { + return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; +} + +function removeRuntimeRotationProviderBlock(rawConfig: string): string { + const lines = rawConfig.split(/\r?\n/); + const output: string[] = []; + let skipping = false; + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed === `[model_providers.${RUNTIME_ROTATION_PROXY_PROVIDER_ID}]`) { + skipping = true; + continue; + } + if (skipping && /^\s*\[[^\]]+\]\s*$/.test(line)) { + skipping = false; + } + if (!skipping) output.push(line); + } + return output.join(rawConfig.includes("\r\n") ? "\r\n" : "\n"); +} + +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 = /^\s*\[[^\]]+\]\s*$/.test(line); + 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 (/^\s*\[[^\]]+\]\s*$/.test(line)) return null; + if (/^\s*model_provider\s*=/.test(line)) return line; + } + return null; +} + +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); +} + +function ensureTrailingNewline(value: string): string { + return value.replace(/[\r\n]*$/, "\n"); +} + +function createRuntimeRotationProviderBlock(baseUrl: string): string[] { + return [ + `[model_providers.${RUNTIME_ROTATION_PROXY_PROVIDER_ID}]`, + 'name = "Codex Multi-Auth Runtime Proxy"', + `base_url = ${tomlStringLiteral(baseUrl)}`, + 'wire_api = "responses"', + ]; +} + +export function rewriteConfigTomlForAppBind(rawConfig: string, baseUrl: string): string { + const lineEnding = rawConfig.includes("\r\n") ? "\r\n" : "\n"; + const withoutOldProvider = removeRuntimeRotationProviderBlock(rawConfig).replace( + /[\r\n]*$/, + "", + ); + const withModelProvider = rewriteTopLevelModelProvider(withoutOldProvider).replace( + /[\r\n]*$/, + "", + ); + return `${withModelProvider}${lineEnding}${lineEnding}${createRuntimeRotationProviderBlock(baseUrl).join(lineEnding)}${lineEnding}`; +} + +export function restoreConfigTomlFromAppBind(currentConfig: string, originalConfig: string): string { + const withoutProvider = removeRuntimeRotationProviderBlock(currentConfig); + return ensureTrailingNewline( + restoreTopLevelModelProvider(withoutProvider, originalConfig).replace(/[\r\n]*$/, ""), + ); +} + +function sha256(value: string): string { + return createHash("sha256").update(value).digest("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; +} + +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 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 || + !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, + 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"), + }; +} + +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): string { + if (override) return override; + const candidates = [ + 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; + } + return candidates[0] ?? "codex-app-router.js"; +} + +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), + startupPath: + platform === "win32" ? resolveWindowsStartupPath(env, home) : null, + launchAgentPath: platform === "darwin" ? resolveMacLaunchAgentPath(home) : null, + }; +} + +async function findAvailablePort(host = "127.0.0.1"): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.once("error", reject); + server.listen(0, host, () => { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + server.close((error) => { + if (error) reject(error); + else resolve(port); + }); + }); + }); +} + +function createWindowsStartupCommand(state: AppBindState): string { + return [ + "@echo off", + `"${state.nodePath}" "${state.routerScriptPath}" --port ${state.port} --status "${state.statusPath}" --state "${state.statePath}" >> "${state.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, + ]; + 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 writeFile(state.startupPath, createWindowsStartupCommand(state), "utf8"); + return; + } + if (state.platform === "darwin" && state.launchAgentPath) { + await mkdir(dirname(state.launchAgentPath), { recursive: true }); + await writeFile(state.launchAgentPath, createMacLaunchAgentPlist(state), "utf8"); + } +} + +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 unlink(candidate); + } catch { + // Best-effort cleanup. + } + } +} + +function spawnRouter(state: AppBindState): void { + const child = spawn( + state.nodePath, + [ + state.routerScriptPath, + "--port", + String(state.port), + "--status", + state.statusPath, + "--state", + state.statePath, + ], + { + detached: true, + stdio: "ignore", + windowsHide: true, + }, + ); + child.unref(); +} + +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; +} + +async function waitForRouterStatus(statusPath: string): Promise { + for (let attempt = 0; attempt < 20; attempt += 1) { + const router = await readRouterStatus(statusPath); + if (router?.state === "running" && isProcessAlive(router.pid)) return; + await new Promise((resolve) => setTimeout(resolve, 100)); + } +} + +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 platform = options.platform ?? process.platform; + const now = options.now?.() ?? Date.now(); + const paths = resolveAppBindPaths(options); + const existingState = await readAppBindState(paths.statePath); + const port = existingState?.port ?? (await findAvailablePort()); + const host = existingState?.host ?? "127.0.0.1"; + const baseUrl = `http://${host}:${port}`; + const { existed, content } = await readConfigIfExists(paths.configPath); + const backup = (await readAppBindBackup(paths.backupPath)) ?? { + version: 1, + configPath: paths.configPath, + existed, + content, + createdAt: now, + }; + const boundConfig = rewriteConfigTomlForAppBind(content, baseUrl); + const 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, + 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 writeFile(paths.backupPath, `${JSON.stringify(backup, null, 2)}\n`, "utf8"); + await writeFile(paths.configPath, boundConfig, "utf8"); + await writeFile(paths.statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8"); + await writeAppBindStartup(state); + const startedRouter = await maybeStartRouter(state, options); + if (startedRouter) { + await waitForRouterStatus(state.statusPath); + } + 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); + 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 writeFile( + backup.configPath, + restoreConfigTomlFromAppBind(current.content, backup.content), + "utf8", + ); + } else if (backup.existed) { + await mkdir(dirname(backup.configPath), { recursive: true }); + await writeFile(backup.configPath, backup.content, "utf8"); + } else { + try { + await unlink(backup.configPath); + } catch { + // Missing config is already restored. + } + } + } else if (state) { + const current = await readConfigIfExists(state.configPath); + if (current.existed) { + await writeFile( + state.configPath, + restoreConfigTomlFromAppBind(current.content, ""), + "utf8", + ); + } + } + + for (const candidate of [ + paths.statePath, + paths.backupPath, + paths.statusPath, + ]) { + try { + await unlink(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) { + 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/package.json b/package.json index 100ada00..6f824ba2 100644 --- a/package.json +++ b/package.json @@ -98,12 +98,13 @@ "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" - }, + "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", diff --git a/scripts/codex-app-router.js b/scripts/codex-app-router.js new file mode 100644 index 00000000..4392fc49 --- /dev/null +++ b/scripts/codex-app-router.js @@ -0,0 +1,139 @@ +#!/usr/bin/env node + +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; +import process from "node:process"; + +function parseArgs(argv) { + const result = { + host: "127.0.0.1", + port: 0, + statusPath: "", + statePath: "", + }; + 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 = Number.parseInt(next, 10); + index += 1; + continue; + } + if (arg === "--status") { + result.statusPath = next; + index += 1; + continue; + } + if (arg === "--state") { + result.statePath = next; + index += 1; + } + } + return result; +} + +function readState(path) { + if (!path) return null; + try { + return JSON.parse(readFileSync(path, "utf8")); + } catch { + return null; + } +} + +function writeStatus(statusPath, payload) { + if (!statusPath) return; + try { + mkdirSync(dirname(statusPath), { recursive: true }); + writeFileSync(statusPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); + } catch { + // Status is best-effort. The router should keep serving if telemetry is locked. + } +} + +function createStatusPayload({ state, proxyServer, error, stateRecord }) { + const proxyStatus = + typeof proxyServer?.getStatus === "function" ? proxyServer.getStatus() : {}; + 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: proxyStatus.lastAccountIndex ?? null, + lastAccountLabel: proxyStatus.lastAccountLabel ?? null, + lastAccountEmail: proxyStatus.lastAccountEmail ?? null, + lastAccountId: proxyStatus.lastAccountId ?? null, + lastAccountUpdatedAt: proxyStatus.lastAccountUpdatedAt ?? null, + lastError: error ? (error instanceof Error ? error.message : String(error)) : proxyStatus.lastError ?? null, + }; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const stateRecord = readState(args.statePath); + const host = + typeof stateRecord?.host === "string" && stateRecord.host.trim().length > 0 + ? stateRecord.host.trim() + : args.host; + const port = + typeof stateRecord?.port === "number" && Number.isFinite(stateRecord.port) + ? stateRecord.port + : args.port; + if (!Number.isFinite(port) || port <= 0) { + throw new Error("A positive --port is required for the Codex app runtime router."); + } + + 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 }); + writeCurrentStatus("running"); + const timer = setInterval(() => writeCurrentStatus("running"), 1000); + const cleanup = async (state) => { + clearInterval(timer); + try { + await proxyServer?.close?.(); + } finally { + writeCurrentStatus(state); + } + }; + process.once("SIGINT", () => { + void cleanup("stopped").finally(() => process.exit(130)); + }); + process.once("SIGTERM", () => { + void cleanup("stopped").finally(() => process.exit(0)); + }); + process.once("SIGHUP", () => { + void cleanup("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/postinstall.js b/scripts/postinstall.js new file mode 100644 index 00000000..c1b4d196 --- /dev/null +++ b/scripts/postinstall.js @@ -0,0 +1,203 @@ +#!/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"]); + +/** + * @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 {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; + 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.test.ts b/test/app-bind.test.ts new file mode 100644 index 00000000..6450a8c5 --- /dev/null +++ b/test/app-bind.test.ts @@ -0,0 +1,166 @@ +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 } from "vitest"; +import { + bindCodexAppRuntimeRotation, + resolveAppBindPaths, + restoreConfigTomlFromAppBind, + rewriteConfigTomlForAppBind, + unbindCodexAppRuntimeRotation, +} from "../lib/runtime/app-bind.js"; +import { withFileOperationRetry } from "../scripts/install-codex-auth-utils.js"; + +const tempRoots: string[] = []; + +async function createTempRoot(prefix: string): Promise { + const root = await mkdtemp(join(tmpdir(), prefix)); + tempRoots.push(root); + return root; +} + +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"); + expect(bound).toContain('model_provider = "codex-multi-auth-runtime-proxy"'); + expect(bound).toContain("[model_providers.codex-multi-auth-runtime-proxy]"); + expect(bound).toContain('base_url = "http://127.0.0.1:32123"'); + 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("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, "AppData", "Roaming"); + 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", + ); + + const result = await bindCodexAppRuntimeRotation({ + platform: "win32", + home: root, + env, + nodePath: "node", + routerScriptPath: join(root, "codex-app-router.js"), + 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.codex-multi-auth-runtime-proxy]"); + expect(config).toContain(result.status.state?.baseUrl); + expect(config).not.toContain("env_key"); + const startup = await readFile(result.status.paths.startupPath ?? "", "utf8"); + expect(startup).toContain("--state"); + expect(startup).toContain("runtime-rotation-app-bind.json"); + + 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("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, + }; + + 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("runtime-rotation-app-bind.json"); + }); +}); diff --git a/test/codex-manager-rotation-command.test.ts b/test/codex-manager-rotation-command.test.ts index 41ea14c8..7de58360 100644 --- a/test/codex-manager-rotation-command.test.ts +++ b/test/codex-manager-rotation-command.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 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"; @@ -33,16 +34,45 @@ function createStorage(now: number): AccountStorageV3 { }; } +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 }; +} + 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; @@ -50,11 +80,44 @@ function createDeps(params: { 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", + 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, @@ -68,6 +131,10 @@ function createDeps(params: { 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), @@ -121,6 +188,7 @@ describe("codex auth rotation command", () => { 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)"); @@ -135,4 +203,40 @@ describe("codex auth rotation command", () => { 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/install-codex-auth.test.ts b/test/install-codex-auth.test.ts index 803c7c6b..3e52ffc0 100644 --- a/test/install-codex-auth.test.ts +++ b/test/install-codex-auth.test.ts @@ -16,6 +16,10 @@ import { createWindowsShortcutPowerShellScript, resolveAppLauncherPlan, } from "../scripts/codex-app-launcher.js"; +import { + hasCodexDesktopApp, + shouldAutoBindCodexAppOnInstall, +} from "../scripts/postinstall.js"; const scriptPath = "scripts/install-codex-auth.js"; const appLauncherScriptPath = "scripts/codex-app-launcher.js"; @@ -309,3 +313,63 @@ describe("codex app launcher installer", () => { } }); }); + +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); + }); +}); From 3ec7e6e8569715df3b3b186f01342aede525b70d Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 25 Apr 2026 02:09:45 +0800 Subject: [PATCH 11/42] Fix runtime rotation app bind --- docs/configuration.md | 2 +- docs/development/CONFIG_FIELDS.md | 2 +- docs/features.md | 2 +- docs/reference/commands.md | 4 +- lib/codex-manager/commands/rotation.ts | 4 +- lib/codex-manager/help.ts | 2 +- lib/logger.ts | 1 + lib/runtime-rotation-proxy.ts | 37 +-- lib/runtime/app-bind.ts | 161 ++++++++--- scripts/codex-app-launcher.js | 63 +++- scripts/codex-app-router.js | 37 ++- scripts/codex.js | 300 +++++++++++++++++++- scripts/postinstall.js | 34 +++ test/app-bind.test.ts | 91 +++++- test/codex-bin-wrapper.test.ts | 143 +++++++++- test/codex-manager-rotation-command.test.ts | 1 + test/install-codex-auth.test.ts | 62 ++++ test/runtime-rotation-proxy.test.ts | 19 +- vitest.config.ts | 11 + 19 files changed, 874 insertions(+), 102 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 6530cb65..9f55bb51 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -109,7 +109,7 @@ The proxy preserves request bodies and streaming responses, replaces outbound au 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. `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. +`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. diff --git a/docs/development/CONFIG_FIELDS.md b/docs/development/CONFIG_FIELDS.md index e62b687f..dfeb5ac0 100644 --- a/docs/development/CONFIG_FIELDS.md +++ b/docs/development/CONFIG_FIELDS.md @@ -201,7 +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 | +| `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 62a44f6b..1bfb8c5d 100644 --- a/docs/features.md +++ b/docs/features.md @@ -24,7 +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 | Lets forwarded official Codex CLI/app sessions rotate managed accounts between Responses requests without restarting the session | `codex auth rotation enable` | +| 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 7a22d80c..5d821402 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -58,7 +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 | +| `codex auth rotation enable\|disable\|status\|bind-app\|unbind-app` | Manage the opt-in runtime Responses proxy for live Codex account rotation | --- @@ -176,7 +176,7 @@ Behavior: 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. 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. +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. diff --git a/lib/codex-manager/commands/rotation.ts b/lib/codex-manager/commands/rotation.ts index 036be5c6..88e18aeb 100644 --- a/lib/codex-manager/commands/rotation.ts +++ b/lib/codex-manager/commands/rotation.ts @@ -192,9 +192,11 @@ async function printCodexAppBindStatus(deps: RotationCommandDeps): Promise async function printRotationStatus(deps: RotationCommandDeps): Promise { const logInfo = deps.logInfo ?? console.log; + // Rotation status reports the shared Codex account pool, not a project-scoped override. deps.setStoragePath(null); const config = deps.loadPluginConfig(); - const enabled = deps.getCodexRuntimeRotationProxy(config); + const envOverride = parseBooleanEnv(process.env.CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY); + const enabled = envOverride ?? deps.getCodexRuntimeRotationProxy(config); const storage = await deps.loadAccounts(); const now = deps.getNow?.() ?? Date.now(); diff --git a/lib/codex-manager/help.ts b/lib/codex-manager/help.ts index 1fb94840..4fb4074e 100644 --- a/lib/codex-manager/help.ts +++ b/lib/codex-manager/help.ts @@ -21,7 +21,7 @@ export function printUsage(): void { " codex auth doctor [--json] [--fix] [--dry-run]", "", "Diagnostics:", - " codex auth rotation status", + " codex auth rotation ", " codex auth why-selected [--now | --last] [--json]", "", "Advanced:", 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-rotation-proxy.ts b/lib/runtime-rotation-proxy.ts index 5daed242..45417101 100644 --- a/lib/runtime-rotation-proxy.ts +++ b/lib/runtime-rotation-proxy.ts @@ -95,6 +95,12 @@ const HOP_BY_HOP_HEADERS = new Set([ "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", +]); function isResponsesPath(pathname: string): boolean { return ( @@ -136,9 +142,6 @@ function createOutboundHeaders( 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); - if (account.accountId && account.accountId !== accountId) { - headers.set(OPENAI_HEADERS.ACCOUNT_ID, accountId); - } return headers; } @@ -156,12 +159,6 @@ function readTrimmedString(value: string | undefined): string | null { return trimmed.length > 0 ? trimmed : null; } -function sanitizeHeaderValue(value: string | null): string | null { - if (!value) return null; - const sanitized = value.replace(/[^\t\x20-\x7e]/g, "").trim(); - return sanitized.length > 0 ? sanitized : null; -} - function accountIdentityFromAccount( account: ManagedAccount, updatedAt: number, @@ -193,24 +190,14 @@ function recordLastRuntimeAccount( }); } -function responseHeadersForClient( - upstreamHeaders: Headers, - accountIdentity?: RuntimeRotationAccountIdentity, -): Record { +function responseHeadersForClient(upstreamHeaders: Headers): Record { const headers: Record = {}; for (const [key, value] of upstreamHeaders.entries()) { - if (HOP_BY_HOP_HEADERS.has(key.toLowerCase())) continue; + const normalizedKey = key.toLowerCase(); + if (HOP_BY_HOP_HEADERS.has(normalizedKey)) continue; + if (PRIVATE_CLIENT_RESPONSE_HEADERS.has(normalizedKey)) continue; headers[key] = value; } - if (accountIdentity) { - headers["x-codex-multi-auth-account-index"] = String(accountIdentity.index + 1); - const label = sanitizeHeaderValue(accountIdentity.label); - if (label) headers["x-codex-multi-auth-account-label"] = label; - const email = sanitizeHeaderValue(accountIdentity.email); - if (email) headers["x-codex-multi-auth-account-email"] = email; - const accountId = sanitizeHeaderValue(accountIdentity.accountId); - if (accountId) headers["x-codex-multi-auth-account-id"] = accountId; - } return headers; } @@ -558,13 +545,12 @@ async function forwardStreamingResponse( upstream: Response, res: ServerResponse, status: RuntimeRotationProxyStatus, - accountIdentity: RuntimeRotationAccountIdentity, onStreamError: () => void, ): Promise { status.streamsStarted += 1; res.writeHead( upstream.status, - responseHeadersForClient(upstream.headers, accountIdentity), + responseHeadersForClient(upstream.headers), ); if (!upstream.body) { res.end(); @@ -822,7 +808,6 @@ export async function startRuntimeRotationProxy( upstream, res, status, - accountIdentity, () => { accountManager.recordFailure( refreshed.account, diff --git a/lib/runtime/app-bind.ts b/lib/runtime/app-bind.ts index 3916180f..0613e565 100644 --- a/lib/runtime/app-bind.ts +++ b/lib/runtime/app-bind.ts @@ -1,8 +1,7 @@ import { spawn } from "node:child_process"; -import { createHash } from "node:crypto"; +import { createHash, randomBytes } from "node:crypto"; import { existsSync } from "node:fs"; import { mkdir, readFile, unlink, writeFile } from "node:fs/promises"; -import { createServer } from "node:net"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; import process from "node:process"; @@ -16,6 +15,10 @@ 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 FILE_RETRY_CODES = new Set(["EBUSY", "EPERM", "EAGAIN", "ENOTEMPTY", "EACCES"]); +const FILE_RETRY_MAX_ATTEMPTS = 6; +const FILE_RETRY_BASE_DELAY_MS = 25; +const FILE_RETRY_JITTER_MS = 20; export interface AppBindPaths { codexHome: string; @@ -51,6 +54,7 @@ export interface AppBindState { logPath: string; nodePath: string; routerScriptPath: string; + clientApiKey: string; startupPath: string | null; launchAgentPath: string | null; boundConfigHash: string; @@ -67,6 +71,7 @@ export interface AppBindRouterStatus { lastAccountEmail: string | null; lastAccountId: string | null; updatedAt: number | null; + lastError: string | null; } export interface AppBindStatus { @@ -174,16 +179,29 @@ function ensureTrailingNewline(value: string): string { return value.replace(/[\r\n]*$/, "\n"); } -function createRuntimeRotationProviderBlock(baseUrl: string): string[] { - return [ +function createRuntimeRotationProviderBlock(baseUrl: string, clientApiKey: string): string[] { + const lines = [ `[model_providers.${RUNTIME_ROTATION_PROXY_PROVIDER_ID}]`, - 'name = "Codex Multi-Auth Runtime Proxy"', + '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 rewriteConfigTomlForAppBind(rawConfig: string, baseUrl: string): string { +export function rewriteConfigTomlForAppBind( + rawConfig: string, + baseUrl: string, + clientApiKey = "", +): string { const lineEnding = rawConfig.includes("\r\n") ? "\r\n" : "\n"; const withoutOldProvider = removeRuntimeRotationProviderBlock(rawConfig).replace( /[\r\n]*$/, @@ -193,7 +211,11 @@ export function rewriteConfigTomlForAppBind(rawConfig: string, baseUrl: string): /[\r\n]*$/, "", ); - return `${withModelProvider}${lineEnding}${lineEnding}${createRuntimeRotationProviderBlock(baseUrl).join(lineEnding)}${lineEnding}`; + const providerBlock = createRuntimeRotationProviderBlock( + baseUrl, + clientApiKey, + ).join(lineEnding); + return `${withModelProvider}${lineEnding}${lineEnding}${providerBlock}${lineEnding}`; } export function restoreConfigTomlFromAppBind(currentConfig: string, originalConfig: string): string { @@ -207,6 +229,10 @@ 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; @@ -230,6 +256,46 @@ function readNumber(record: Record, key: string): number | null return typeof value === "number" && Number.isFinite(value) ? value : null; } +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function shouldRetryFileOperation(error: unknown): boolean { + return ( + error instanceof Error && + "code" in error && + typeof error.code === "string" && + FILE_RETRY_CODES.has(error.code) + ); +} + +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); + } + } +} + +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"); @@ -241,6 +307,7 @@ function readAppBindStateRecord(record: Record): AppBindState | 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"); @@ -273,6 +340,7 @@ function readAppBindStateRecord(record: Record): AppBindState | logPath, nodePath, routerScriptPath, + clientApiKey: clientApiKey ?? "", startupPath: readString(record, "startupPath"), launchAgentPath: readString(record, "launchAgentPath"), boundConfigHash, @@ -323,6 +391,7 @@ async function readRouterStatus(path: string): Promise { - return new Promise((resolve, reject) => { - const server = createServer(); - server.once("error", reject); - server.listen(0, host, () => { - const address = server.address(); - const port = typeof address === "object" && address ? address.port : 0; - server.close((error) => { - if (error) reject(error); - else resolve(port); - }); - }); - }); +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 createWindowsStartupCommand(state: AppBindState): string { @@ -474,7 +544,7 @@ async function removeAppBindStartup(state: AppBindState): Promise { ); for (const candidate of candidates) { try { - await unlink(candidate); + await unlinkIfExists(candidate); } catch { // Best-effort cleanup. } @@ -510,12 +580,19 @@ async function maybeStartRouter(state: AppBindState, options: AppBindOptions): P return true; } -async function waitForRouterStatus(statusPath: string): Promise { +async function waitForRouterStatus(statusPath: string): Promise { + let latest: AppBindRouterStatus | null = null; for (let attempt = 0; attempt < 20; attempt += 1) { const router = await readRouterStatus(statusPath); - if (router?.state === "running" && isProcessAlive(router.pid)) return; + 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, 100)); } + return latest; } async function stopRouter(router: AppBindRouterStatus | null): Promise { @@ -559,9 +636,13 @@ export async function bindCodexAppRuntimeRotation( const now = options.now?.() ?? Date.now(); const paths = resolveAppBindPaths(options); const existingState = await readAppBindState(paths.statePath); - const port = existingState?.port ?? (await findAvailablePort()); const host = existingState?.host ?? "127.0.0.1"; - const baseUrl = `http://${host}:${port}`; + 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, @@ -570,8 +651,8 @@ export async function bindCodexAppRuntimeRotation( content, createdAt: now, }; - const boundConfig = rewriteConfigTomlForAppBind(content, baseUrl); - const state: AppBindState = { + let boundConfig = rewriteConfigTomlForAppBind(content, baseUrl, clientApiKey); + let state: AppBindState = { version: 1, platform, host, @@ -584,6 +665,7 @@ export async function bindCodexAppRuntimeRotation( logPath: paths.logPath, nodePath: options.nodePath ?? process.execPath, routerScriptPath: paths.routerScriptPath, + clientApiKey, startupPath: paths.startupPath, launchAgentPath: paths.launchAgentPath, boundConfigHash: sha256(boundConfig), @@ -593,13 +675,24 @@ export async function bindCodexAppRuntimeRotation( await mkdir(paths.bindDir, { recursive: true }); await mkdir(dirname(paths.configPath), { recursive: true }); await writeFile(paths.backupPath, `${JSON.stringify(backup, null, 2)}\n`, "utf8"); - await writeFile(paths.configPath, boundConfig, "utf8"); await writeFile(paths.statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8"); - await writeAppBindStartup(state); const startedRouter = await maybeStartRouter(state, options); if (startedRouter) { - await waitForRouterStatus(state.statusPath); + const router = await waitForRouterStatus(state.statusPath); + port = readPortFromBaseUrl(router?.baseUrl ?? null, port); + baseUrl = router?.baseUrl ?? formatBaseUrl(host, port); + boundConfig = rewriteConfigTomlForAppBind(content, baseUrl, clientApiKey); + state = { + ...state, + port, + baseUrl, + boundConfigHash: sha256(boundConfig), + updatedAt: options.now?.() ?? Date.now(), + }; } + await writeFile(paths.configPath, boundConfig, "utf8"); + await writeFile(paths.statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8"); + await writeAppBindStartup(state); const status = await getAppBindStatus(options); return { status, @@ -631,11 +724,7 @@ export async function unbindCodexAppRuntimeRotation( await mkdir(dirname(backup.configPath), { recursive: true }); await writeFile(backup.configPath, backup.content, "utf8"); } else { - try { - await unlink(backup.configPath); - } catch { - // Missing config is already restored. - } + await unlinkIfExists(backup.configPath); } } else if (state) { const current = await readConfigIfExists(state.configPath); @@ -654,7 +743,7 @@ export async function unbindCodexAppRuntimeRotation( paths.statusPath, ]) { try { - await unlink(candidate); + await unlinkIfExists(candidate); } catch { // Best-effort cleanup. } diff --git a/scripts/codex-app-launcher.js b/scripts/codex-app-launcher.js index 0d91cef9..1e87e16a 100644 --- a/scripts/codex-app-launcher.js +++ b/scripts/codex-app-launcher.js @@ -16,6 +16,8 @@ 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 @@ -48,6 +50,20 @@ 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 @@ -74,10 +90,21 @@ function resolveWindowsTaskbarPinnedDir(env, home) { } /** + * @param {NodeJS.ProcessEnv} env * @param {string} home */ -function resolveWindowsDesktopDir(home) { - return join(home, "Desktop"); +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"), + ]); } /** @@ -128,6 +155,7 @@ export function resolveAppLauncherPlan(options = {}) { 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); @@ -138,11 +166,12 @@ export function resolveAppLauncherPlan(options = {}) { shortcutRoots: [ startMenuDir, resolveWindowsTaskbarPinnedDir(env, home), - resolveWindowsDesktopDir(home), + ...resolveWindowsDesktopDirs(env, home), ], backupPath: join(resolveCodexMultiAuthDir(env, home), WINDOWS_BACKUP_FILE_NAME), commandPath: nodePath, commandArgs: `"${codexScriptPath}" app`, + commandArgv, workingDirectory: home, iconPath: nodePath, }; @@ -156,6 +185,7 @@ export function resolveAppLauncherPlan(options = {}) { launcherPath: appPath, commandPath: nodePath, commandArgs: `"${codexScriptPath}" app`, + commandArgv, workingDirectory: home, iconPath: nodePath, }; @@ -168,6 +198,7 @@ export function resolveAppLauncherPlan(options = {}) { launcherPath: desktopPath, commandPath: nodePath, commandArgs: `"${codexScriptPath}" app %F`, + commandArgv: [codexScriptPath, "app", "%F"], workingDirectory: home, iconPath: "utilities-terminal", }; @@ -221,11 +252,14 @@ export function createWindowsShortcutPowerShellScript(plan, options = {}) { "$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) {", @@ -263,7 +297,8 @@ export function createWindowsShortcutPowerShellScript(plan, options = {}) { " $Skipped += $Path", " continue", " }", - " if (-not $BackupByPath.ContainsKey($Path)) {", + " $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]@{", @@ -282,8 +317,8 @@ export function createWindowsShortcutPowerShellScript(plan, options = {}) { " $Shortcut.TargetPath = $TargetPath", " $Shortcut.Arguments = $Arguments", " $Shortcut.WorkingDirectory = $WorkingDirectory", - " $Shortcut.IconLocation = [string]$Backup.IconLocation", - " $Shortcut.Description = 'Launch Codex through codex-multi-auth runtime rotation'", + " if ($null -ne $Backup -and $null -ne $Backup.IconLocation) { $Shortcut.IconLocation = [string]$Backup.IconLocation }", + " $Shortcut.Description = $ManagedDescription", " $Shortcut.Save()", " }", " $Routed += $Path", @@ -343,10 +378,13 @@ function createMacInfoPlist(plan) { * @param {ReturnType} plan */ function createMacLauncherScript(plan) { + const args = Array.isArray(plan.commandArgv) + ? plan.commandArgv.map(quotePosixShell).join(" ") + : plan.commandArgs; return [ "#!/bin/sh", - `cd ${JSON.stringify(plan.workingDirectory)}`, - `exec ${JSON.stringify(plan.commandPath)} ${plan.commandArgs}`, + `cd ${quotePosixShell(plan.workingDirectory)}`, + `exec ${quotePosixShell(plan.commandPath)} ${args}`, "", ].join("\n"); } @@ -411,7 +449,14 @@ async function installWindowsShortcut(plan, options) { if (!output) { return { action: options.remove ? "restore" : "route", routed: [], restored: [], skipped: [] }; } - return JSON.parse(output); + 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)}`, + ); + } } /** diff --git a/scripts/codex-app-router.js b/scripts/codex-app-router.js index 4392fc49..fa387dcb 100644 --- a/scripts/codex-app-router.js +++ b/scripts/codex-app-router.js @@ -46,6 +46,13 @@ function readState(path) { } } +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; try { @@ -79,6 +86,20 @@ function createStatusPayload({ state, proxyServer, error, stateRecord }) { }; } +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" + ); +} + async function main() { const args = parseArgs(process.argv.slice(2)); const stateRecord = readState(args.statePath); @@ -90,8 +111,14 @@ async function main() { typeof stateRecord?.port === "number" && Number.isFinite(stateRecord.port) ? stateRecord.port : args.port; - if (!Number.isFinite(port) || port <= 0) { - throw new Error("A positive --port is required for the Codex app runtime router."); + const clientApiKey = readTrimmedString(stateRecord, "clientApiKey"); + if (!Number.isFinite(port) || port < 0) { + throw new Error("A non-negative --port 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).", + ); } let proxyServer = null; @@ -104,7 +131,11 @@ async function main() { try { const proxyModule = await import("../dist/lib/runtime-rotation-proxy.js"); - proxyServer = await proxyModule.startRuntimeRotationProxy({ host, port }); + proxyServer = await proxyModule.startRuntimeRotationProxy({ + host, + port, + clientApiKey, + }); writeCurrentStatus("running"); const timer = setInterval(() => writeCurrentStatus("running"), 1000); const cleanup = async (state) => { diff --git a/scripts/codex.js b/scripts/codex.js index e223d253..d002ce7b 100755 --- a/scripts/codex.js +++ b/scripts/codex.js @@ -18,7 +18,8 @@ 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, @@ -29,6 +30,8 @@ const RETRYABLE_SHADOW_HOME_CLEANUP_CODES = new Set(["EBUSY", "EPERM", "ENOTEMPT const SHADOW_HOME_CLEANUP_BACKOFF_MS = [20, 60, 120]; const SHADOW_HOME_STATE_FILES = ["auth.json", "accounts.json", ".codex-global-state.json"]; const RUNTIME_ROTATION_PROXY_PROVIDER_ID = "codex-multi-auth-runtime-proxy"; +const APP_SERVER_ACCOUNT_DISPLAY_NAME = "codex-multi-auth"; +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_STATUS_FILE = "runtime-rotation-app-helper.json"; @@ -346,6 +349,130 @@ 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 createAppServerAccountReadProtocolProxy() { + const pendingAccountReadIds = new Set(); + const inputLines = createProtocolLineAccumulator((line) => { + const { body } = splitProtocolLineEnding(line); + const message = parseJsonObjectLine(body); + if (message?.method !== "account/read" || !Object.hasOwn(message, "id")) { + return; + } + const key = jsonRpcIdKey(message.id); + if (key) { + pendingAccountReadIds.add(key); + } + }); + const outputLines = createProtocolLineAccumulator((line) => { + process.stdout.write( + rewriteAppServerAccountReadResponseLine(line, pendingAccountReadIds), + ); + }); + + return { + observeInput(chunk) { + inputLines.write(chunk); + }, + flushInput() { + inputLines.end(); + }, + writeOutput(chunk) { + outputLines.write(chunk); + }, + flushOutput() { + outputLines.end(); + }, + }; +} + +function rewriteAppServerAccountReadResponseLine(line, pendingAccountReadIds) { + const { body, lineEnding } = splitProtocolLineEnding(line); + const message = parseJsonObjectLine(body); + if (!message || !Object.hasOwn(message, "id") || !Object.hasOwn(message, "result")) { + return line; + } + const key = jsonRpcIdKey(message.id); + if (!key || !pendingAccountReadIds.has(key)) { + return line; + } + pendingAccountReadIds.delete(key); + return `${JSON.stringify({ + ...message, + result: { + account: { + type: "chatgpt", + email: APP_SERVER_ACCOUNT_DISPLAY_NAME, + planType: "unknown", + }, + requiresOpenaiAuth: false, + }, + })}${lineEnding}`; +} + function forwardToRealCodexOnce( codexBin, args, @@ -358,11 +485,19 @@ function forwardToRealCodexOnce( let stdout = ""; let stderr = ""; const captureOutput = options.captureOutput === true; + 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 { await cleanup?.(); } catch { @@ -387,7 +522,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) { @@ -395,7 +534,60 @@ 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.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; @@ -458,6 +650,11 @@ async function forwardToRealCodex(codexBin, rawArgs, baseEnv = process.env) { rawArgs, runtimeProxyContext.env, ), + proxyAppServerAccountRead: + isCodexAppServerCommand(rawArgs) && + (runtimeProxyContext.proxyAppServerAccountRead === true || + (runtimeProxyContext.env[APP_SERVER_ACCOUNT_LABEL_ENV] ?? "").trim() === + "1"), }, ); lastExitCode = result.exitCode; @@ -1164,7 +1361,11 @@ function rewriteTopLevelModelProvider(rawConfig) { return output.join(lineEnding); } -function rewriteConfigTomlForRuntimeRotationProxy(rawConfig, proxyBaseUrl) { +function rewriteConfigTomlForRuntimeRotationProxy( + rawConfig, + proxyBaseUrl, + clientApiKey, +) { const lineEnding = rawConfig.includes("\r\n") ? "\r\n" : "\n"; const withoutOldProvider = removeRuntimeRotationProviderBlock(rawConfig).replace( /[\r\n]*$/, @@ -1176,9 +1377,10 @@ function rewriteConfigTomlForRuntimeRotationProxy(rawConfig, proxyBaseUrl) { ); const providerBlock = [ `[model_providers.${RUNTIME_ROTATION_PROXY_PROVIDER_ID}]`, - 'name = "Codex Multi-Auth Runtime Proxy"', + 'name = "codex-multi-auth"', `base_url = ${tomlStringLiteral(proxyBaseUrl)}`, - 'env_key = "OPENAI_API_KEY"', + "requires_openai_auth = false", + `experimental_bearer_token = ${tomlStringLiteral(clientApiKey)}`, 'wire_api = "responses"', ]; return `${withModelProvider}${lineEnding}${lineEnding}${providerBlock.join(lineEnding)}${lineEnding}`; @@ -1246,6 +1448,7 @@ function createRuntimeRotationProxyCodexHome(baseEnv, proxyBaseUrl, clientApiKey const runtimeConfig = rewriteConfigTomlForRuntimeRotationProxy( rawConfig, proxyBaseUrl, + clientApiKey, ); const runtimeConfigPath = join(shadowCodexHome, "config.toml"); writeFileSync(runtimeConfigPath, runtimeConfig, "utf8"); @@ -1283,6 +1486,78 @@ function createRuntimeRotationProxyCodexHome(baseEnv, proxyBaseUrl, clientApiKey }; } +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 installRuntimeRotationAppServerCliShim(forwardedEnv) { + const shadowCodexHome = forwardedEnv.CODEX_HOME; + if (!shadowCodexHome) { + throw new Error("runtime app-server shim requires CODEX_HOME"); + } + const shimDir = join(shadowCodexHome, "app-server-shim"); + 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"); + copyFileSync(process.execPath, executablePath); + if (process.platform !== "win32") { + chmodSync(executablePath, 0o755); + } + writeFileSync( + preloadPath, + createRuntimeRotationAppServerPreloadSource(fileURLToPath(import.meta.url)), + "utf8", + ); + 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"; +} + function resolveRuntimeRotationAppHelperStatusPath(env = process.env) { const multiAuthDir = resolveOriginalMultiAuthDir(env) ?? join(resolveCodexHomeDir(env), "multi-auth"); @@ -1314,6 +1589,17 @@ function pickRuntimeRotationAppHelperEnv(env) { 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; } @@ -1423,6 +1709,7 @@ async function runRuntimeRotationAppHelper() { proxyServer.baseUrl, clientApiKey, ); + installRuntimeRotationAppServerCliShim(shadowContext.env); lastRequestCount = proxyServer.getStatus?.().totalRequests ?? 0; publishStatus("running"); process.stdout.write( @@ -1659,6 +1946,7 @@ async function createRuntimeRotationProxyContextIfEnabled( ], env: shadowContext.env, cleanup, + proxyAppServerAccountRead: isCodexAppServerCommand(rawArgs), }; } diff --git a/scripts/postinstall.js b/scripts/postinstall.js index c1b4d196..67084c65 100644 --- a/scripts/postinstall.js +++ b/scripts/postinstall.js @@ -10,6 +10,19 @@ 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 @@ -31,6 +44,25 @@ export function isGlobalNpmInstall(env = process.env) { 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 @@ -91,6 +123,8 @@ export function hasCodexDesktopApp(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; diff --git a/test/app-bind.test.ts b/test/app-bind.test.ts index 6450a8c5..be9061db 100644 --- a/test/app-bind.test.ts +++ b/test/app-bind.test.ts @@ -2,6 +2,7 @@ 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 { spawnSync } from "node:child_process"; import { afterEach, describe, expect, it } from "vitest"; import { bindCodexAppRuntimeRotation, @@ -39,10 +40,17 @@ describe("Codex app runtime rotation bind", () => { "", ].join("\n"); - const bound = rewriteConfigTomlForAppBind(original, "http://127.0.0.1:32123"); + const bound = rewriteConfigTomlForAppBind( + original, + "http://127.0.0.1:32123", + "app-secret", + ); expect(bound).toContain('model_provider = "codex-multi-auth-runtime-proxy"'); expect(bound).toContain("[model_providers.codex-multi-auth-runtime-proxy]"); + 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]"); @@ -118,10 +126,15 @@ describe("Codex app runtime rotation bind", () => { const config = await readFile(join(codexHome, "config.toml"), "utf8"); expect(config).toContain("[model_providers.codex-multi-auth-runtime-proxy]"); 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"); const startup = await readFile(result.status.paths.startupPath ?? "", "utf8"); expect(startup).toContain("--state"); expect(startup).toContain("runtime-rotation-app-bind.json"); + expect(startup).not.toContain(result.status.state?.clientApiKey ?? ""); const unbound = await unbindCodexAppRuntimeRotation({ platform: "win32", @@ -137,6 +150,56 @@ describe("Codex app runtime rotation bind", () => { expect(existsSync(result.status.paths.startupPath ?? "")).toBe(false); }); + 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"); + 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("writes a macOS LaunchAgent for login-time router startup", async () => { const root = await createTempRoot("codex-app-bind-mac-"); const multiAuthDir = join(root, "multi-auth"); @@ -162,5 +225,31 @@ describe("Codex app runtime rotation bind", () => { expect(plist).toContain("KeepAlive"); expect(plist).toContain("--state"); 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, + [ + "scripts/codex-app-router.js", + "--host", + "0.0.0.0", + "--port", + "4567", + "--status", + statusPath, + ], + { + encoding: "utf8", + windowsHide: true, + }, + ); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("loopback-only"); + expect(existsSync(statusPath)).toBe(false); }); }); diff --git a/test/codex-bin-wrapper.test.ts b/test/codex-bin-wrapper.test.ts index 31eea98f..7f670477 100644 --- a/test/codex-bin-wrapper.test.ts +++ b/test/codex-bin-wrapper.test.ts @@ -433,6 +433,23 @@ function runWrapper( ); } +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[], @@ -639,15 +656,23 @@ describe("codex bin wrapper", () => { 'FORWARDED:exec status -c cli_auth_credentials_store="file" -c model_provider="codex-multi-auth-runtime-proxy"', ); expect(output).toContain("CODEX_HOME_IS_ORIGINAL:false"); - expect(output).toMatch(/^OPENAI_API_KEY:[0-9a-f]{64}$/m); + const apiKeyMatch = output.match(/^OPENAI_API_KEY:([0-9a-f]{64})$/m); + expect(apiKeyMatch?.[1]).toBeTruthy(); expect(output).toContain( 'model_provider = "codex-multi-auth-runtime-proxy"', ); expect(output).toContain( "[model_providers.codex-multi-auth-runtime-proxy]", ); + 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"'); const shadowHomeMatch = output.match(/^CODEX_HOME:(.+)$/m); expect(shadowHomeMatch?.[1]).toBeTruthy(); @@ -672,6 +697,10 @@ describe("codex bin wrapper", () => { '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);", @@ -694,13 +723,83 @@ describe("codex bin wrapper", () => { expect(output).toContain( 'FORWARDED:app-server --listen stdio:// -c cli_auth_credentials_store="file" -c model_provider="codex-multi-auth-runtime-proxy"', ); - expect(output).toMatch(/^OPENAI_API_KEY:[0-9a-f]{64}$/m); + 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.each([ ["app help", ["app", "--help"]], ["app-server help", ["app-server", "--help"]], @@ -728,11 +827,26 @@ describe("codex bin wrapper", () => { 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");', + '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 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);", @@ -748,7 +862,7 @@ describe("codex bin wrapper", () => { CODEX_HOME: originalHome, CODEX_MULTI_AUTH_DIR: multiAuthDir, CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY: "1", - CODEX_MULTI_AUTH_APP_ROTATION_IDLE_MS: "80", + 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: @@ -760,16 +874,33 @@ describe("codex bin wrapper", () => { }); const output = combinedOutput(result); - expect(result.status).toBe(0); + if (result.status !== 0) { + throw new Error(output); + } expect(output).toContain( 'FORWARDED:app . -c cli_auth_credentials_store="file" -c model_provider="codex-multi-auth-runtime-proxy"', ); - expect(output).toMatch(/^OPENAI_API_KEY:[0-9a-f]{64}$/m); + const apiKeyMatch = output.match(/^OPENAI_API_KEY:([0-9a-f]{64})$/m); + expect(apiKeyMatch?.[1]).toBeTruthy(); + expect(output).toMatch(/^CODEX_CLI_PATH:.+app-server-shim$/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_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(); - await sleep(250); + await sleep(1300); expect(readFileSync(markerPath, "utf8")).toBe( "start:http://127.0.0.1:4567\nclose\n", diff --git a/test/codex-manager-rotation-command.test.ts b/test/codex-manager-rotation-command.test.ts index 7de58360..77d6f73d 100644 --- a/test/codex-manager-rotation-command.test.ts +++ b/test/codex-manager-rotation-command.test.ts @@ -100,6 +100,7 @@ function createDeps(params: { 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", diff --git a/test/install-codex-auth.test.ts b/test/install-codex-auth.test.ts index 3e52ffc0..8f57a391 100644 --- a/test/install-codex-auth.test.ts +++ b/test/install-codex-auth.test.ts @@ -18,6 +18,7 @@ import { } from "../scripts/codex-app-launcher.js"; import { hasCodexDesktopApp, + isCiEnvironment, shouldAutoBindCodexAppOnInstall, } from "../scripts/postinstall.js"; @@ -247,11 +248,35 @@ describe("codex app launcher installer", () => { 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("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({ @@ -372,4 +397,41 @@ describe("codex app bind postinstall gate", () => { }), ).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/runtime-rotation-proxy.test.ts b/test/runtime-rotation-proxy.test.ts index a9327f1a..107f725e 100644 --- a/test/runtime-rotation-proxy.test.ts +++ b/test/runtime-rotation-proxy.test.ts @@ -140,7 +140,13 @@ describe("runtime rotation proxy", () => { const now = Date.now(); const accountManager = new AccountManager(undefined, createStorage(now)); const { calls, fetchImpl } = createRecordingFetch(() => - textEventStream("data: forwarded\n\n"), + 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, @@ -195,13 +201,10 @@ describe("runtime rotation proxy", () => { 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")).toBe("1"); - expect(response.headers.get("x-codex-multi-auth-account-email")).toBe( - "account-1@example.com", - ); - expect(response.headers.get("x-codex-multi-auth-account-label")).toBe( - "Account 1 (account-1@example.com, id: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, lastAccountEmail: "account-1@example.com", diff --git a/vitest.config.ts b/vitest.config.ts index 929cd21d..6075e1a3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -13,6 +13,17 @@ if (forcePlainTestOutput) { } export default defineConfig({ + plugins: [ + { + name: 'strip-script-shebangs-for-vitest', + enforce: 'pre', + transform(code, id) { + if (!id.includes('/scripts/') && !id.includes('\\scripts\\')) return null; + if (!code.startsWith('#!')) return null; + return code.replace(/^#!.*(?:\r?\n|$)/, ''); + }, + }, + ], test: { globals: true, environment: 'node', From 324ca07cca3295f11dff1bca4284f8fda28ca3f3 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 25 Apr 2026 02:26:45 +0800 Subject: [PATCH 12/42] Fix runtime rotation state sync --- lib/codex-manager.ts | 50 ++++++ lib/runtime-rotation-proxy.ts | 19 ++- scripts/codex.js | 249 ++++++++++++++++++---------- test/codex-bin-wrapper.test.ts | 17 ++ test/codex-manager-cli.test.ts | 67 ++++++++ test/runtime-rotation-proxy.test.ts | 44 +++++ 6 files changed, 361 insertions(+), 85 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index e7a85c66..7a2b1417 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -1204,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 { @@ -2737,6 +2786,7 @@ async function runAuthLogin(args: string[]): Promise { } } const flaggedStorage = await loadFlaggedAccounts(); + await syncCodexCliActiveSelectionIfDrifted(currentStorage); const menuResult = await promptLoginMode( toExistingAccountInfo(currentStorage, quotaCache, displaySettings), diff --git a/lib/runtime-rotation-proxy.ts b/lib/runtime-rotation-proxy.ts index 45417101..96c4f0bc 100644 --- a/lib/runtime-rotation-proxy.ts +++ b/lib/runtime-rotation-proxy.ts @@ -21,7 +21,7 @@ import { OPENAI_HEADER_VALUES, URL_PATHS, } from "./constants.js"; -import { getModelFamily, type ModelFamily } from "./prompts/codex.js"; +import { getModelFamily, MODEL_FAMILIES, 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"; @@ -190,6 +190,22 @@ function recordLastRuntimeAccount( }); } +async function persistRuntimeActiveAccount( + accountManager: AccountManager, + account: ManagedAccount, +): Promise { + try { + for (const family of MODEL_FAMILIES) { + accountManager.markSwitched(account, "rotation", family); + } + await accountManager.saveToDisk(); + 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()) { @@ -803,6 +819,7 @@ export async function startRuntimeRotationProxy( now(), ); } + await persistRuntimeActiveAccount(accountManager, refreshed.account); await forwardStreamingResponse( upstream, diff --git a/scripts/codex.js b/scripts/codex.js index d002ce7b..0f7ee3c6 100755 --- a/scripts/codex.js +++ b/scripts/codex.js @@ -5,13 +5,16 @@ import { randomBytes } from "node:crypto"; import { chmodSync, copyFileSync, + cpSync, existsSync, mkdirSync, mkdtempSync, + readdirSync, renameSync, readFileSync, rmSync, statSync, + symlinkSync, writeFileSync, } from "node:fs"; import { createRequire } from "node:module"; @@ -29,11 +32,15 @@ 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_STATE_FILES = ["auth.json", "accounts.json", ".codex-global-state.json"]; +const SHADOW_HOME_STATE_FILE_SET = new Set(SHADOW_HOME_STATE_FILES); +const SHADOW_HOME_CONFIG_FILE = "config.toml"; const RUNTIME_ROTATION_PROXY_PROVIDER_ID = "codex-multi-auth-runtime-proxy"; const APP_SERVER_ACCOUNT_DISPLAY_NAME = "codex-multi-auth"; 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_STATUS_FILE = "runtime-rotation-app-helper.json"; const DEFAULT_APP_RUNTIME_HELPER_IDLE_MS = 12 * 60 * 60 * 1000; const DEFAULT_APP_RUNTIME_HELPER_DETACH_GRACE_MS = 5_000; @@ -1223,6 +1230,127 @@ function syncShadowHomeStateFile( } } +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 { + symlinkSync( + sourcePath, + destinationPath, + process.platform === "win32" ? "junction" : "dir", + ); + return; + } 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, + }); +} + +function createShadowHomeMirror(originalCodexHome, shadowCodexHome, tightenFile) { + const syncFileNames = new Set(SHADOW_HOME_STATE_FILES); + const originalFileStates = new Map(); + 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) { + 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) { + mirrorDirectoryIntoShadowHome(sourcePath, destinationPath); + continue; + } + if (fileLike) { + rememberSyncFile(name); + copyFileSync(sourcePath, destinationPath); + tightenFile(destinationPath); + } + } 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 () => { + for (const name of syncFileNames) { + const shadowPath = join(shadowCodexHome, name); + const shadowState = captureShadowHomeState(shadowPath); + if (!shadowState.exists || shadowState.unreadable) { + continue; + } + + try { + 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; + } + syncShadowHomeStateFile(shadowPath, originalPath, originalSnapshot); + tightenFile(originalPath); + } catch { + // Best-effort only; runtime auth refreshes should not fail cleanup. + } + } + }; +} + function rewriteConfigTomlReasoningEffort(rawConfig, requestedModel) { const lineEnding = rawConfig.includes("\r\n") ? "\r\n" : "\n"; let changed = false; @@ -1393,6 +1521,7 @@ function createRuntimeRotationProxyClientApiKey() { function createRuntimeRotationProxyCodexHome(baseEnv, proxyBaseUrl, clientApiKey) { const originalCodexHome = resolveCodexHomeDir(baseEnv); const shadowCodexHome = mkdtempSync(join(tmpdir(), "codex-multi-auth-runtime-home-")); + let syncShadowHomeStateBack = () => {}; const cleanup = () => { try { removeDirectoryWithRetry(shadowCodexHome); @@ -1407,40 +1536,13 @@ function createRuntimeRotationProxyCodexHome(baseEnv, proxyBaseUrl, clientApiKey // Best-effort only; permission semantics vary by platform. } }; - const originalShadowHomeState = new Map( - SHADOW_HOME_STATE_FILES.map((name) => [ - name, - captureShadowHomeState(join(originalCodexHome, name)), - ]), - ); - 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 originalConfigPath = join(originalCodexHome, "config.toml"); const rawConfig = existsSync(originalConfigPath) ? readFileSync(originalConfigPath, "utf8") @@ -1453,15 +1555,6 @@ function createRuntimeRotationProxyCodexHome(baseEnv, proxyBaseUrl, clientApiKey const runtimeConfigPath = join(shadowCodexHome, "config.toml"); writeFileSync(runtimeConfigPath, runtimeConfig, "utf8"); tightenShadowHomePermissions(runtimeConfigPath); - - 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; @@ -1574,6 +1667,20 @@ function resolveRuntimeRotationAppHelperIdleMs(env = process.env) { : 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 resolveRuntimeRotationAppHelperDetachGraceMs(env = process.env) { const parsed = Number.parseInt( env.CODEX_MULTI_AUTH_APP_ROTATION_DETACH_GRACE_MS ?? "", @@ -1655,6 +1762,7 @@ async function runRuntimeRotationAppHelper() { let closing = false; const startedAt = Date.now(); const idleTimeoutMs = resolveRuntimeRotationAppHelperIdleMs(); + const ownerPid = resolveRuntimeRotationAppHelperOwnerPid(); let lastActivityAt = startedAt; let lastRequestCount = 0; @@ -1723,13 +1831,17 @@ async function runRuntimeRotationAppHelper() { ); statusTimer = setInterval(() => { + const currentTime = Date.now(); const requestCount = proxyServer?.getStatus?.().totalRequests ?? 0; if (requestCount !== lastRequestCount) { lastRequestCount = requestCount; - lastActivityAt = Date.now(); + lastActivityAt = currentTime; + } + if (ownerPid && isProcessAlive(ownerPid)) { + lastActivityAt = currentTime; } publishStatus("running"); - if (Date.now() - lastActivityAt >= idleTimeoutMs) { + if (currentTime - lastActivityAt >= idleTimeoutMs) { exitAfterCleanup("idle-timeout", 0); } }, Math.min(1_000, Math.max(50, Math.floor(idleTimeoutMs / 2)))); @@ -1784,7 +1896,10 @@ function startRuntimeRotationAppHelper(baseContext) { process.execPath, [fileURLToPath(import.meta.url), INTERNAL_RUNTIME_ROTATION_APP_HELPER_ARG], { - env: baseContext.env, + env: { + ...baseContext.env, + [APP_RUNTIME_HELPER_OWNER_PID_ENV]: String(process.pid), + }, stdio: ["ignore", "pipe", "pipe"], detached: true, }, @@ -2233,12 +2348,6 @@ function createCompatibilityCodexHome( if (!existsSync(configPath)) { return { args: processedArgs, env: baseEnv, cleanup: undefined }; } - const originalShadowHomeState = new Map( - SHADOW_HOME_STATE_FILES.map((name) => [ - name, - captureShadowHomeState(join(originalCodexHome, name)), - ]), - ); const rawConfig = readFileSync(configPath, "utf8"); const compatConfig = rewriteConfigTomlReasoningEffort( @@ -2250,6 +2359,7 @@ function createCompatibilityCodexHome( } const shadowCodexHome = mkdtempSync(join(tmpdir(), "codex-multi-auth-home-")); + let syncShadowHomeStateBack = () => {}; const cleanup = () => { try { removeDirectoryWithRetry(shadowCodexHome); @@ -2264,44 +2374,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; diff --git a/test/codex-bin-wrapper.test.ts b/test/codex-bin-wrapper.test.ts index 7f670477..71ba8542 100644 --- a/test/codex-bin-wrapper.test.ts +++ b/test/codex-bin-wrapper.test.ts @@ -614,6 +614,10 @@ describe("codex bin wrapper", () => { '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"))}`);', + 'fs.writeFileSync(path.join(process.env.CODEX_HOME ?? "", "sessions", "runtime-session.jsonl"), "runtime\\n", "utf8");', 'const configPath = path.join(process.env.CODEX_HOME ?? "", "config.toml");', 'console.log("CONFIG_START");', 'console.log(fs.readFileSync(configPath, "utf8").trim());', @@ -623,6 +627,12 @@ describe("codex bin wrapper", () => { 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 }); + 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, "config.toml"), [ @@ -656,6 +666,9 @@ describe("codex bin wrapper", () => { 'FORWARDED:exec status -c cli_auth_credentials_store="file" -c model_provider="codex-multi-auth-runtime-proxy"', ); 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"); const apiKeyMatch = output.match(/^OPENAI_API_KEY:([0-9a-f]{64})$/m); expect(apiKeyMatch?.[1]).toBeTruthy(); expect(output).toContain( @@ -685,6 +698,9 @@ describe("codex bin wrapper", () => { expect(readFileSync(join(originalHome, "config.toml"), "utf8")).toContain( 'model_provider = "openai"', ); + expect( + readFileSync(join(originalHome, "sessions", "runtime-session.jsonl"), "utf8"), + ).toBe("runtime\n"); }); it("starts the opt-in runtime rotation proxy for app-server without capturing protocol stdio", () => { @@ -842,6 +858,7 @@ describe("codex bin wrapper", () => { '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")}`);', + "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}`);', diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index f022ec84..bf9911d5 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -6880,6 +6880,73 @@ 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({ + accountId: "acc_b", + email: "b@example.com", + accessToken: "access-b", + refreshToken: "refresh-b", + expiresAt: now + 3_600_000, + }); + 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/runtime-rotation-proxy.test.ts b/test/runtime-rotation-proxy.test.ts index 107f725e..44bb8609 100644 --- a/test/runtime-rotation-proxy.test.ts +++ b/test/runtime-rotation-proxy.test.ts @@ -214,6 +214,50 @@ describe("runtime rotation proxy", () => { expect(JSON.parse(calls[0]?.bodyText ?? "{}")).toEqual(requestBody); }); + 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"); + expect(calls[0]?.headers.get(OPENAI_HEADERS.ACCOUNT_ID)).toBe("acc_2"); + expect(persisted.at(-1)).toMatchObject({ + activeIndex: 1, + activeIndexByFamily: { 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)); From 98bd520a8fb6a937d5cd0f47b5de59da0fda140d Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 25 Apr 2026 11:09:37 +0800 Subject: [PATCH 13/42] Preserve Codex root state in runtime mirror --- scripts/codex.js | 60 ++++++++++++++++++++++++++++++++-- test/codex-bin-wrapper.test.ts | 24 ++++++++++++++ 2 files changed, 81 insertions(+), 3 deletions(-) diff --git a/scripts/codex.js b/scripts/codex.js index 0f7ee3c6..7956eae3 100755 --- a/scripts/codex.js +++ b/scripts/codex.js @@ -7,6 +7,7 @@ import { copyFileSync, cpSync, existsSync, + linkSync, mkdirSync, mkdtempSync, readdirSync, @@ -1264,6 +1265,52 @@ function mirrorDirectoryIntoShadowHome(sourcePath, destinationPath) { }); } +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 || 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 createShadowHomeMirror(originalCodexHome, shadowCodexHome, tightenFile) { const syncFileNames = new Set(SHADOW_HOME_STATE_FILES); const originalFileStates = new Map(); @@ -1310,8 +1357,12 @@ function createShadowHomeMirror(originalCodexHome, shadowCodexHome, tightenFile) } if (fileLike) { rememberSyncFile(name); - copyFileSync(sourcePath, destinationPath); - tightenFile(destinationPath); + if (isKnownStateFile) { + copyFileSync(sourcePath, destinationPath); + tightenFile(destinationPath); + } else { + mirrorFileIntoShadowHome(sourcePath, destinationPath, tightenFile); + } } } catch (error) { if (isKnownStateFile) { @@ -1324,7 +1375,10 @@ function createShadowHomeMirror(originalCodexHome, shadowCodexHome, tightenFile) } return () => { - for (const name of syncFileNames) { + for (const name of collectShadowHomeSyncFileNames( + shadowCodexHome, + syncFileNames, + )) { const shadowPath = join(shadowCodexHome, name); const shadowState = captureShadowHomeState(shadowPath); if (!shadowState.exists || shadowState.unreadable) { diff --git a/test/codex-bin-wrapper.test.ts b/test/codex-bin-wrapper.test.ts index 71ba8542..c04f293f 100644 --- a/test/codex-bin-wrapper.test.ts +++ b/test/codex-bin-wrapper.test.ts @@ -617,6 +617,12 @@ describe("codex bin wrapper", () => { '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");', 'const configPath = path.join(process.env.CODEX_HOME ?? "", "config.toml");', 'console.log("CONFIG_START");', @@ -630,9 +636,18 @@ describe("codex bin wrapper", () => { 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, "instructions", "profile.md"), + "instruction\n", + "utf8", + ); + writeFileSync(join(originalHome, "state_5.sqlite"), "state\n", "utf8"); writeFileSync( join(originalHome, "config.toml"), [ @@ -669,6 +684,9 @@ describe("codex bin wrapper", () => { 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( @@ -701,6 +719,12 @@ describe("codex bin wrapper", () => { 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", + ); }); it("starts the opt-in runtime rotation proxy for app-server without capturing protocol stdio", () => { From 0706cbf8b9cd1eddd45031911593a16f66cb01cf Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 25 Apr 2026 11:54:40 +0800 Subject: [PATCH 14/42] Fix runtime rotation review regressions --- lib/codex-manager/commands/rotation.ts | 38 ++- lib/codex-manager/commands/status.ts | 10 +- lib/codex-manager/help.ts | 2 +- lib/constants.ts | 1 + lib/runtime-constants.ts | 2 + lib/runtime-rotation-proxy.ts | 209 +++++++++++++--- lib/runtime/app-bind.ts | 176 ++++++++++---- scripts/check-pack-budget-lib.js | 49 +++- scripts/codex-app-router.js | 48 +++- scripts/codex-multi-auth.js | 4 +- scripts/codex.js | 85 +++++-- test/app-bind-io-retry.test.ts | 172 +++++++++++++ test/app-bind.test.ts | 159 +++++++++++- test/codex-bin-wrapper.test.ts | 88 ++++++- test/codex-manager-cli.test.ts | 44 +++- test/codex-manager-rotation-command.test.ts | 109 ++++++++- test/codex-manager-status-command.test.ts | 27 ++- test/logger.test.ts | 14 ++ test/runtime-rotation-proxy.test.ts | 253 +++++++++++++++++++- vitest.config.ts | 5 +- 20 files changed, 1332 insertions(+), 163 deletions(-) create mode 100644 lib/runtime-constants.ts create mode 100644 test/app-bind-io-retry.test.ts diff --git a/lib/codex-manager/commands/rotation.ts b/lib/codex-manager/commands/rotation.ts index 88e18aeb..9ee28df5 100644 --- a/lib/codex-manager/commands/rotation.ts +++ b/lib/codex-manager/commands/rotation.ts @@ -14,6 +14,7 @@ type LoadedStorage = AccountStorageV3 | null; const APP_RUNTIME_HELPER_STATUS_FILE = "runtime-rotation-app-helper.json"; interface AppRuntimeHelperStatus { + kind: string | null; state: string | null; pid: number | null; idleExpiresAt: number | null; @@ -105,13 +106,14 @@ function readAppRuntimeHelperStatus(): AppRuntimeHelperStatus | null { 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: readOptionalString(parsed, "lastAccountEmail"), + lastAccountEmail: null, lastAccountId: readOptionalString(parsed, "lastAccountId"), lastAccountUpdatedAt: readOptionalNumber(parsed, "lastAccountUpdatedAt"), updatedAt: readOptionalNumber(parsed, "updatedAt"), @@ -134,11 +136,8 @@ function isProcessAlive(pid: number | null): boolean { } function formatHelperLastAccount(status: AppRuntimeHelperStatus): string | null { - if (status.lastAccountLabel) return status.lastAccountLabel; - if (status.lastAccountEmail) { - return status.lastAccountIndex !== null - ? `Account ${status.lastAccountIndex + 1} (${status.lastAccountEmail})` - : status.lastAccountEmail; + if (status.lastAccountLabel && !status.lastAccountLabel.includes("@")) { + return status.lastAccountLabel; } if (status.lastAccountId) { return status.lastAccountIndex !== null @@ -154,6 +153,9 @@ function formatHelperLastAccount(status: AppRuntimeHelperStatus): string | 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"; @@ -192,13 +194,23 @@ async function printCodexAppBindStatus(deps: RotationCommandDeps): Promise async function printRotationStatus(deps: RotationCommandDeps): Promise { const logInfo = deps.logInfo ?? console.log; - // Rotation status reports the shared Codex account pool, not a project-scoped override. - deps.setStoragePath(null); - const config = deps.loadPluginConfig(); - const envOverride = parseBooleanEnv(process.env.CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY); - const enabled = envOverride ?? deps.getCodexRuntimeRotationProxy(config); - const storage = await deps.loadAccounts(); + 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( @@ -207,7 +219,7 @@ async function printRotationStatus(deps: RotationCommandDeps): Promise { logInfo(`Env override: ${formatEnvOverride()}`); logInfo(formatAppRuntimeHelperStatus(now)); await printCodexAppBindStatus(deps); - logInfo(`Storage: ${deps.getStoragePath()}`); + logInfo(`Storage: ${storagePath}`); if (!storage || storage.accounts.length === 0) { logInfo("Accounts: none configured"); diff --git a/lib/codex-manager/commands/status.ts b/lib/codex-manager/commands/status.ts index b22f0002..dc7cc3d7 100644 --- a/lib/codex-manager/commands/status.ts +++ b/lib/codex-manager/commands/status.ts @@ -51,11 +51,11 @@ function readRestoreReason(storage: AccountStorageV3): RestoreReason | undefined function formatRuntimeLastAccount( runtimeSnapshot: RuntimeObservabilitySnapshot, ): string | null { - if (runtimeSnapshot.lastAccountLabel) return runtimeSnapshot.lastAccountLabel; - if (runtimeSnapshot.lastAccountEmail) { - return typeof runtimeSnapshot.lastAccountIndex === "number" - ? `Account ${runtimeSnapshot.lastAccountIndex + 1} (${runtimeSnapshot.lastAccountEmail})` - : runtimeSnapshot.lastAccountEmail; + if ( + runtimeSnapshot.lastAccountLabel && + !runtimeSnapshot.lastAccountLabel.includes("@") + ) { + return runtimeSnapshot.lastAccountLabel; } if (runtimeSnapshot.lastAccountId) { return typeof runtimeSnapshot.lastAccountIndex === "number" diff --git a/lib/codex-manager/help.ts b/lib/codex-manager/help.ts index 4fb4074e..01133a49 100644 --- a/lib/codex-manager/help.ts +++ b/lib/codex-manager/help.ts @@ -21,7 +21,7 @@ export function printUsage(): void { " codex auth doctor [--json] [--fix] [--dry-run]", "", "Diagnostics:", - " codex auth rotation ", + " codex auth rotation ", " codex auth why-selected [--now | --last] [--json]", "", "Advanced:", 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/runtime-constants.ts b/lib/runtime-constants.ts new file mode 100644 index 00000000..108b89aa --- /dev/null +++ b/lib/runtime-constants.ts @@ -0,0 +1,2 @@ +export const RUNTIME_ROTATION_PROXY_PROVIDER_ID = + "codex-multi-auth-runtime-proxy" as const; diff --git a/lib/runtime-rotation-proxy.ts b/lib/runtime-rotation-proxy.ts index 96c4f0bc..b9532491 100644 --- a/lib/runtime-rotation-proxy.ts +++ b/lib/runtime-rotation-proxy.ts @@ -1,16 +1,19 @@ +import { timingSafeEqual } from "node:crypto"; import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import { AccountManager, extractAccountId, - formatAccountLabel, type ManagedAccount, } from "./accounts.js"; import { + getFetchTimeoutMs, getNetworkErrorCooldownMs, + getRetryAllAccountsMaxRetries, getServerErrorCooldownMs, getSessionAffinity, getSessionAffinityMaxEntries, getSessionAffinityTtlMs, + getStreamStallTimeoutMs, getTokenRefreshSkewMs, loadPluginConfig, } from "./config.js"; @@ -21,7 +24,7 @@ import { OPENAI_HEADER_VALUES, URL_PATHS, } from "./constants.js"; -import { getModelFamily, MODEL_FAMILIES, type ModelFamily } from "./prompts/codex.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"; @@ -46,7 +49,6 @@ export interface RuntimeRotationProxyStatus { lastError: string | null; lastAccountIndex: number | null; lastAccountLabel: string | null; - lastAccountEmail: string | null; lastAccountId: string | null; lastAccountUpdatedAt: number | null; } @@ -60,6 +62,9 @@ export interface RuntimeRotationProxyOptions { fetchImpl?: typeof fetch; now?: () => number; quotaRemainingPercentThreshold?: number; + maxRequestBodyBytes?: number; + fetchTimeoutMs?: number; + streamStallTimeoutMs?: number; } interface RequestContext { @@ -72,11 +77,14 @@ interface RequestContext { } type ExhaustionReason = "rate-limit" | "server-error" | "network-error" | "auth-failure" | "no-account"; +type RuntimeProxyHttpError = Error & { + statusCode: number; + code: string; +}; interface RuntimeRotationAccountIdentity { index: number; label: string; - email: string | null; accountId: string | null; updatedAt: number; } @@ -84,6 +92,8 @@ interface RuntimeRotationAccountIdentity { 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", @@ -149,8 +159,17 @@ function isAuthorizedClient(headers: Headers, clientApiKey: string | null): bool if (!clientApiKey) return true; const authorization = headers.get("authorization") ?? ""; const bearerMatch = authorization.match(/^Bearer\s+(.+)$/i); - if (bearerMatch?.[1]?.trim() === clientApiKey) return true; - return headers.get("x-api-key") === clientApiKey; + 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"); + if (leftBuffer.length !== rightBuffer.length) return false; + return timingSafeEqual(leftBuffer, rightBuffer); } function readTrimmedString(value: string | undefined): string | null { @@ -165,8 +184,7 @@ function accountIdentityFromAccount( ): RuntimeRotationAccountIdentity { return { index: account.index, - label: formatAccountLabel(account, account.index), - email: readTrimmedString(account.email), + label: `Account ${account.index + 1}`, accountId: readTrimmedString(account.accountId), updatedAt, }; @@ -178,13 +196,12 @@ function recordLastRuntimeAccount( ): void { status.lastAccountIndex = identity.index; status.lastAccountLabel = identity.label; - status.lastAccountEmail = identity.email; status.lastAccountId = identity.accountId; status.lastAccountUpdatedAt = identity.updatedAt; mutateRuntimeObservabilitySnapshot((snapshot) => { snapshot.lastAccountIndex = identity.index; snapshot.lastAccountLabel = identity.label; - snapshot.lastAccountEmail = identity.email; + snapshot.lastAccountEmail = null; snapshot.lastAccountId = identity.accountId; snapshot.lastAccountUpdatedAt = identity.updatedAt; }); @@ -193,12 +210,11 @@ function recordLastRuntimeAccount( async function persistRuntimeActiveAccount( accountManager: AccountManager, account: ManagedAccount, + family: ModelFamily, ): Promise { try { - for (const family of MODEL_FAMILIES) { - accountManager.markSwitched(account, "rotation", family); - } - await accountManager.saveToDisk(); + accountManager.markSwitched(account, "rotation", family); + accountManager.saveToDiskDebounced(); await accountManager.syncCodexCliActiveSelectionForIndex(account.index); } catch { // Runtime forwarding must not fail after a valid upstream response just @@ -217,10 +233,41 @@ function responseHeadersForClient(upstreamHeaders: Headers): Record { +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) { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + 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); } @@ -320,6 +367,39 @@ function isTokenRefreshRetryable(result: Extract> +>(); + +async function commitRefreshedAuthOnce( + accountManager: AccountManager, + account: ManagedAccount, + auth: OAuthAuthDetails, +): Promise { + const key = [ + account.index, + account.accountId ?? "", + account.email ?? "", + account.refreshToken, + auth.access, + auth.refresh, + String(auth.expires), + ].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; @@ -353,8 +433,11 @@ async function ensureFreshAccessToken(params: { expires: refreshResult.expires, }; try { - const updatedAccount = - (await accountManager.commitRefreshedAuth(account, auth)) ?? account; + const updatedAccount = (await commitRefreshedAuthOnce( + accountManager, + account, + auth, + )) ?? account; return { ok: true, accessToken: updatedAccount.access ?? refreshResult.access, @@ -557,11 +640,34 @@ function writePoolExhausted(params: { }); } +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( @@ -581,7 +687,14 @@ async function forwardStreamingResponse( }); try { while (true) { - const { done, value } = await reader.read(); + 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)); @@ -615,6 +728,16 @@ export async function startRuntimeRotationProxy( 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) @@ -633,7 +756,6 @@ export async function startRuntimeRotationProxy( lastError: null, lastAccountIndex: null, lastAccountLabel: null, - lastAccountEmail: null, lastAccountId: null, lastAccountUpdatedAt: null, }; @@ -656,12 +778,19 @@ export async function startRuntimeRotationProxy( } status.totalRequests += 1; - const context = buildRequestContext(req, await readRequestBody(req)); + 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 < Math.max(1, accountManager.getAccountCount())) { + while (attemptedIndexes.size < accountAttemptLimit) { const selected = chooseAccount({ accountManager, sessionAffinityStore, @@ -724,11 +853,18 @@ export async function startRuntimeRotationProxy( let upstream: Response; try { status.upstreamRequests += 1; - upstream = await fetchImpl(upstreamUrl, { - method: "POST", - headers: outboundHeaders, - body: context.body, - }); + 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); @@ -750,6 +886,8 @@ export async function startRuntimeRotationProxy( 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, @@ -767,6 +905,7 @@ export async function startRuntimeRotationProxy( 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, @@ -819,7 +958,11 @@ export async function startRuntimeRotationProxy( now(), ); } - await persistRuntimeActiveAccount(accountManager, refreshed.account); + await persistRuntimeActiveAccount( + accountManager, + refreshed.account, + context.family, + ); await forwardStreamingResponse( upstream, @@ -839,6 +982,7 @@ export async function startRuntimeRotationProxy( sessionAffinityStore?.forgetSession(context.sessionKey); accountManager.saveToDiskDebounced(); }, + streamStallTimeoutMs, ); return; } @@ -853,6 +997,15 @@ export async function startRuntimeRotationProxy( } 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.", diff --git a/lib/runtime/app-bind.ts b/lib/runtime/app-bind.ts index 0613e565..e0856ba4 100644 --- a/lib/runtime/app-bind.ts +++ b/lib/runtime/app-bind.ts @@ -1,14 +1,14 @@ import { spawn } from "node:child_process"; import { createHash, randomBytes } from "node:crypto"; -import { existsSync } from "node:fs"; -import { mkdir, readFile, unlink, writeFile } from "node:fs/promises"; +import { closeSync, existsSync, mkdirSync, openSync } from "node:fs"; +import { mkdir, open, readFile, rename, unlink } from "node:fs/promises"; import { homedir } from "node:os"; -import { dirname, join } from "node:path"; +import { basename, dirname, join } from "node:path"; import process from "node:process"; import { fileURLToPath } from "node:url"; +import { RUNTIME_ROTATION_PROXY_PROVIDER_ID } from "../runtime-constants.js"; import { getCodexMultiAuthDir } from "../runtime-paths.js"; -const RUNTIME_ROTATION_PROXY_PROVIDER_ID = "codex-multi-auth-runtime-proxy"; 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"; @@ -102,17 +102,27 @@ function tomlStringLiteral(value: string): string { return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; } +function readTomlTableName(line: string): string | null { + const match = /^\s*\[{1,2}\s*([^\]]+?)\s*\]{1,2}\s*$/.exec(line); + return match?.[1]?.trim() ?? null; +} + 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 trimmed = line.trim(); if (trimmed === `[model_providers.${RUNTIME_ROTATION_PROXY_PROVIDER_ID}]`) { skipping = true; continue; } - if (skipping && /^\s*\[[^\]]+\]\s*$/.test(line)) { + const tableName = readTomlTableName(line); + if (skipping && tableName) { + if (tableName === providerTable || tableName.startsWith(`${providerTable}.`)) { + continue; + } skipping = false; } if (!skipping) output.push(line); @@ -128,7 +138,7 @@ function rewriteTopLevelModelProvider(rawConfig: string): string { const output: string[] = []; for (const line of lines) { - const isTable = /^\s*\[[^\]]+\]\s*$/.test(line); + const isTable = readTomlTableName(line) !== null; if (!replaced && isTable) { output.push(rewrittenLine); replaced = true; @@ -147,7 +157,7 @@ function rewriteTopLevelModelProvider(rawConfig: string): string { function extractTopLevelModelProviderLine(rawConfig: string): string | null { for (const line of rawConfig.split(/\r?\n/)) { - if (/^\s*\[[^\]]+\]\s*$/.test(line)) return null; + if (readTomlTableName(line) !== null) return null; if (/^\s*model_provider\s*=/.test(line)) return line; } return null; @@ -285,6 +295,51 @@ async function withFileOperationRetry(operation: () => Promise): 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): 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"); + 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)); @@ -529,12 +584,12 @@ function createMacLaunchAgentPlist(state: AppBindState): string { async function writeAppBindStartup(state: AppBindState): Promise { if (state.platform === "win32" && state.startupPath) { await mkdir(dirname(state.startupPath), { recursive: true }); - await writeFile(state.startupPath, createWindowsStartupCommand(state), "utf8"); + await atomicWriteFile(state.startupPath, createWindowsStartupCommand(state)); return; } if (state.platform === "darwin" && state.launchAgentPath) { await mkdir(dirname(state.launchAgentPath), { recursive: true }); - await writeFile(state.launchAgentPath, createMacLaunchAgentPlist(state), "utf8"); + await atomicWriteFile(state.launchAgentPath, createMacLaunchAgentPlist(state)); } } @@ -552,24 +607,30 @@ async function removeAppBindStartup(state: AppBindState): Promise { } function spawnRouter(state: AppBindState): void { - const child = spawn( - state.nodePath, - [ - state.routerScriptPath, - "--port", - String(state.port), - "--status", - state.statusPath, - "--state", - state.statePath, - ], - { - detached: true, - stdio: "ignore", - windowsHide: true, - }, - ); - child.unref(); + mkdirSync(dirname(state.logPath), { recursive: true }); + const logFd = openSync(state.logPath, "a"); + try { + const child = spawn( + state.nodePath, + [ + state.routerScriptPath, + "--port", + String(state.port), + "--status", + state.statusPath, + "--state", + state.statePath, + ], + { + detached: true, + stdio: ["ignore", logFd, logFd], + windowsHide: true, + }, + ); + child.unref(); + } finally { + closeSync(logFd); + } } async function maybeStartRouter(state: AppBindState, options: AppBindOptions): Promise { @@ -592,7 +653,8 @@ async function waitForRouterStatus(statusPath: string): Promise setTimeout(resolve, 100)); } - return latest; + 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 { @@ -674,24 +736,42 @@ export async function bindCodexAppRuntimeRotation( await mkdir(paths.bindDir, { recursive: true }); await mkdir(dirname(paths.configPath), { recursive: true }); - await writeFile(paths.backupPath, `${JSON.stringify(backup, null, 2)}\n`, "utf8"); - await writeFile(paths.statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8"); + 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) + : 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) { - const router = await waitForRouterStatus(state.statusPath); - port = readPortFromBaseUrl(router?.baseUrl ?? null, port); - baseUrl = router?.baseUrl ?? formatBaseUrl(host, port); - boundConfig = rewriteConfigTomlForAppBind(content, baseUrl, clientApiKey); - state = { - ...state, - port, - baseUrl, - boundConfigHash: sha256(boundConfig), - updatedAt: options.now?.() ?? Date.now(), - }; + options.log?.(`Codex app runtime router started on ${baseUrl}`); } - await writeFile(paths.configPath, boundConfig, "utf8"); - await writeFile(paths.statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8"); + await atomicWriteFile(paths.configPath, boundConfig); + await atomicWriteFile(paths.statePath, `${JSON.stringify(state, null, 2)}\n`); await writeAppBindStartup(state); const status = await getAppBindStatus(options); return { @@ -715,24 +795,22 @@ export async function unbindCodexAppRuntimeRotation( if (backup) { const current = await readConfigIfExists(backup.configPath); if (state && current.existed && sha256(current.content) !== state.boundConfigHash) { - await writeFile( + await atomicWriteFile( backup.configPath, restoreConfigTomlFromAppBind(current.content, backup.content), - "utf8", ); } else if (backup.existed) { await mkdir(dirname(backup.configPath), { recursive: true }); - await writeFile(backup.configPath, backup.content, "utf8"); + await atomicWriteFile(backup.configPath, backup.content); } else { await unlinkIfExists(backup.configPath); } } else if (state) { const current = await readConfigIfExists(state.configPath); if (current.existed) { - await writeFile( + await atomicWriteFile( state.configPath, restoreConfigTomlFromAppBind(current.content, ""), - "utf8", ); } } @@ -765,7 +843,7 @@ export function formatAppBindStatus(status: AppBindStatus): string { `port=${status.state.port}`, `config=${status.state.configPath}`, ]; - if (status.router?.lastAccountLabel) { + 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}`); diff --git a/scripts/check-pack-budget-lib.js b/scripts/check-pack-budget-lib.js index bf8a9c30..31f86d81 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; +} + +/** + * @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,27 @@ 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, - })); + }); + stdout = String(result.stdout); } 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-router.js b/scripts/codex-app-router.js index fa387dcb..2b6398f0 100644 --- a/scripts/codex-app-router.js +++ b/scripts/codex-app-router.js @@ -4,6 +4,16 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname } from "node:path"; import process from "node:process"; +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", @@ -20,7 +30,7 @@ function parseArgs(argv) { continue; } if (arg === "--port") { - result.port = Number.parseInt(next, 10); + result.port = parsePort(next); index += 1; continue; } @@ -66,6 +76,14 @@ function writeStatus(statusPath, payload) { 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", @@ -77,9 +95,8 @@ function createStatusPayload({ state, proxyServer, error, stateRecord }) { upstreamRequests: proxyStatus.upstreamRequests ?? 0, retries: proxyStatus.retries ?? 0, rotations: proxyStatus.rotations ?? 0, - lastAccountIndex: proxyStatus.lastAccountIndex ?? null, - lastAccountLabel: proxyStatus.lastAccountLabel ?? null, - lastAccountEmail: proxyStatus.lastAccountEmail ?? null, + lastAccountIndex, + lastAccountLabel, lastAccountId: proxyStatus.lastAccountId ?? null, lastAccountUpdatedAt: proxyStatus.lastAccountUpdatedAt ?? null, lastError: error ? (error instanceof Error ? error.message : String(error)) : proxyStatus.lastError ?? null, @@ -107,13 +124,13 @@ async function main() { typeof stateRecord?.host === "string" && stateRecord.host.trim().length > 0 ? stateRecord.host.trim() : args.host; - const port = - typeof stateRecord?.port === "number" && Number.isFinite(stateRecord.port) - ? stateRecord.port - : args.port; + const statePort = parsePort(stateRecord?.port); + const port = Number.isFinite(statePort) ? statePort : args.port; const clientApiKey = readTrimmedString(stateRecord, "clientApiKey"); - if (!Number.isFinite(port) || port < 0) { - throw new Error("A non-negative --port is required for the Codex app runtime router."); + 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( @@ -138,6 +155,7 @@ async function main() { }); writeCurrentStatus("running"); const timer = setInterval(() => writeCurrentStatus("running"), 1000); + let cleanupPromise = null; const cleanup = async (state) => { clearInterval(timer); try { @@ -146,14 +164,18 @@ async function main() { writeCurrentStatus(state); } }; + const cleanupOnce = (state) => { + cleanupPromise ??= cleanup(state); + return cleanupPromise; + }; process.once("SIGINT", () => { - void cleanup("stopped").finally(() => process.exit(130)); + void cleanupOnce("stopped").finally(() => process.exit(130)); }); process.once("SIGTERM", () => { - void cleanup("stopped").finally(() => process.exit(0)); + void cleanupOnce("stopped").finally(() => process.exit(0)); }); process.once("SIGHUP", () => { - void cleanup("stopped").finally(() => process.exit(0)); + void cleanupOnce("stopped").finally(() => process.exit(0)); }); await new Promise(() => undefined); } catch (error) { 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.js b/scripts/codex.js index 7956eae3..d0b0104d 100755 --- a/scripts/codex.js +++ b/scripts/codex.js @@ -35,8 +35,9 @@ const SHADOW_HOME_CLEANUP_BACKOFF_MS = [20, 60, 120]; const SHADOW_HOME_STATE_FILES = ["auth.json", "accounts.json", ".codex-global-state.json"]; const SHADOW_HOME_STATE_FILE_SET = new Set(SHADOW_HOME_STATE_FILES); const SHADOW_HOME_CONFIG_FILE = "config.toml"; -const RUNTIME_ROTATION_PROXY_PROVIDER_ID = "codex-multi-auth-runtime-proxy"; const APP_SERVER_ACCOUNT_DISPLAY_NAME = "codex-multi-auth"; +const RUNTIME_ROTATION_PROXY_PROVIDER_ID = + await loadRuntimeRotationProxyProviderId(); 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"; @@ -56,6 +57,20 @@ let shadowHomeCleanupPreflightReadBusyFailuresRemaining = Number.parseInt( ); const shadowHomeCleanupRetryMarkerDir = (process.env.CODEX_MULTI_AUTH_TEST_SHADOW_RETRY_MARKER_DIR ?? "").trim(); +let warnedInvalidRuntimeRotationProxyEnv = false; +let warnedPendingAccountReadIdOverflow = false; + +async function loadRuntimeRotationProxyProviderId() { + try { + const mod = await import("../dist/lib/runtime-constants.js"); + if (typeof mod.RUNTIME_ROTATION_PROXY_PROVIDER_ID === "string") { + return mod.RUNTIME_ROTATION_PROXY_PROVIDER_ID; + } + } catch { + // Keep wrapper startup resilient when dist has not been built yet. + } + return `${APP_SERVER_ACCOUNT_DISPLAY_NAME}-runtime-proxy`; +} function isRetryableShadowHomeCleanupError(error) { const code = error && typeof error === "object" && "code" in error ? error.code : undefined; @@ -423,6 +438,7 @@ function createProtocolLineAccumulator(onLine) { } function createAppServerAccountReadProtocolProxy() { + const maxPendingAccountReadIds = 4096; const pendingAccountReadIds = new Set(); const inputLines = createProtocolLineAccumulator((line) => { const { body } = splitProtocolLineEnding(line); @@ -432,6 +448,15 @@ function createAppServerAccountReadProtocolProxy() { } const key = jsonRpcIdKey(message.id); if (key) { + if (pendingAccountReadIds.size >= maxPendingAccountReadIds) { + pendingAccountReadIds.clear(); + if (!warnedPendingAccountReadIdOverflow) { + warnedPendingAccountReadIdOverflow = true; + console.error( + "codex-multi-auth: cleared pending app-server account/read ids after exceeding the safety cap.", + ); + } + } pendingAccountReadIds.add(key); } }); @@ -460,7 +485,7 @@ function createAppServerAccountReadProtocolProxy() { function rewriteAppServerAccountReadResponseLine(line, pendingAccountReadIds) { const { body, lineEnding } = splitProtocolLineEnding(line); const message = parseJsonObjectLine(body); - if (!message || !Object.hasOwn(message, "id") || !Object.hasOwn(message, "result")) { + if (!message || !Object.hasOwn(message, "id")) { return line; } const key = jsonRpcIdKey(message.id); @@ -468,6 +493,9 @@ function rewriteAppServerAccountReadResponseLine(line, pendingAccountReadIds) { return line; } pendingAccountReadIds.delete(key); + if (!Object.hasOwn(message, "result")) { + return line; + } return `${JSON.stringify({ ...message, result: { @@ -1462,9 +1490,12 @@ function parseRuntimeRotationProxyEnv(value) { if (normalized === "0" || normalized === "false" || normalized === "no") { return false; } - console.error( - "codex-multi-auth: ignoring invalid CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY value. Expected 0/1, true/false, or yes/no.", - ); + 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; } @@ -1495,17 +1526,27 @@ function tomlStringLiteral(value) { return `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; } +function readTomlTableName(line) { + const match = /^\s*\[{1,2}\s*([^\]]+?)\s*\]{1,2}\s*$/.exec(line); + return match?.[1]?.trim() ?? null; +} + function removeRuntimeRotationProviderBlock(rawConfig) { const lines = rawConfig.split(/\r?\n/); const output = []; let skipping = false; + const providerTable = `model_providers.${RUNTIME_ROTATION_PROXY_PROVIDER_ID}`; for (const line of lines) { const trimmed = line.trim(); if (trimmed === `[model_providers.${RUNTIME_ROTATION_PROXY_PROVIDER_ID}]`) { skipping = true; continue; } - if (skipping && /^\s*\[[^\]]+\]\s*$/.test(line)) { + const tableName = readTomlTableName(line); + if (skipping && tableName) { + if (tableName === providerTable || tableName.startsWith(`${providerTable}.`)) { + continue; + } skipping = false; } if (!skipping) { @@ -1523,7 +1564,7 @@ function rewriteTopLevelModelProvider(rawConfig) { const output = []; for (const line of lines) { - const isTable = /^\s*\[[^\]]+\]\s*$/.test(line); + const isTable = readTomlTableName(line) !== null; if (!replaced && isTable) { output.push(rewrittenLine); replaced = true; @@ -1786,6 +1827,14 @@ function createRuntimeRotationAppHelperStatus({ }) { 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", @@ -1800,9 +1849,8 @@ function createRuntimeRotationAppHelperStatus({ upstreamRequests: proxyStatus.upstreamRequests ?? 0, retries: proxyStatus.retries ?? 0, rotations: proxyStatus.rotations ?? 0, - lastAccountIndex: proxyStatus.lastAccountIndex ?? null, - lastAccountLabel: proxyStatus.lastAccountLabel ?? null, - lastAccountEmail: proxyStatus.lastAccountEmail ?? null, + lastAccountIndex, + lastAccountLabel, lastAccountId: proxyStatus.lastAccountId ?? null, lastAccountUpdatedAt: proxyStatus.lastAccountUpdatedAt ?? null, lastError: proxyStatus.lastError ?? null, @@ -2017,12 +2065,10 @@ async function createRuntimeRotationAppHelperContext(baseContext) { const { helper, message } = await startRuntimeRotationAppHelper(baseContext); const helperEnv = message.env ?? {}; const detachGraceMs = resolveRuntimeRotationAppHelperDetachGraceMs(baseContext.env); - let helperDetached = false; const cleanup = async () => { const livedMs = Date.now() - startedAt; if (livedMs < detachGraceMs) { - helperDetached = true; helper.stdout?.destroy(); helper.stderr?.destroy(); helper.unref(); @@ -2045,9 +2091,7 @@ async function createRuntimeRotationAppHelperContext(baseContext) { try { await cleanup(); } finally { - if (!helperDetached) { - baseContext.cleanup?.(); - } + baseContext.cleanup?.(); } }, }; @@ -2068,8 +2112,10 @@ async function createRuntimeRotationProxyContextIfEnabled( const proxyModule = await loadRuntimeRotationProxyModule(); if (!proxyModule) { - baseContext.cleanup?.(); - return null; + console.error( + "codex-multi-auth runtime rotation proxy is unavailable; continuing without runtime rotation.", + ); + return baseContext; } let proxyServer; @@ -2088,11 +2134,10 @@ async function createRuntimeRotationProxyContextIfEnabled( } catch { // Best-effort cleanup only. } - baseContext.cleanup?.(); console.error( - `codex-multi-auth runtime rotation proxy failed to start: ${error instanceof Error ? error.message : String(error)}`, + `codex-multi-auth runtime rotation proxy failed to start; continuing without runtime rotation: ${error instanceof Error ? error.message : String(error)}`, ); - return null; + return baseContext; } const cleanup = async () => { diff --git a/test/app-bind-io-retry.test.ts b/test/app-bind-io-retry.test.ts new file mode 100644 index 00000000..95923376 --- /dev/null +++ b/test/app-bind-io-retry.test.ts @@ -0,0 +1,172 @@ +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 type { AppBindPaths } from "../lib/runtime/app-bind.js"; +import { withFileOperationRetry } from "../scripts/install-codex-auth-utils.js"; + +const fsFaults = vi.hoisted(() => ({ + renameFailures: 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); + }), + }; +}); + +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; + 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', + ); + }); +}); diff --git a/test/app-bind.test.ts b/test/app-bind.test.ts index be9061db..408b2924 100644 --- a/test/app-bind.test.ts +++ b/test/app-bind.test.ts @@ -1,8 +1,9 @@ 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 { 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, @@ -14,6 +15,7 @@ import { import { withFileOperationRetry } from "../scripts/install-codex-auth-utils.js"; const tempRoots: string[] = []; +const thisDir = dirname(fileURLToPath(import.meta.url)); async function createTempRoot(prefix: string): Promise { const root = await mkdtemp(join(tmpdir(), prefix)); @@ -21,6 +23,46 @@ async function createTempRoot(prefix: string): Promise { return root; } +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) => @@ -59,6 +101,48 @@ describe("Codex app runtime rotation bind", () => { 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 = "codex-multi-auth-runtime-proxy"')).toBe( + true, + ); + expect(bound.indexOf('model_provider = "codex-multi-auth-runtime-proxy"')).toBeLessThan( + bound.indexOf("[[profiles.experimental]]"), + ); + }); + + it("removes runtime provider subtables when restoring Codex config TOML", () => { + const bound = [ + 'model_provider = "codex-multi-auth-runtime-proxy"', + "", + "[model_providers.codex-multi-auth-runtime-proxy]", + 'name = "codex-multi-auth"', + 'base_url = "http://127.0.0.1:32123"', + "[model_providers.codex-multi-auth-runtime-proxy.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("codex-multi-auth-runtime-proxy"); + expect(restored).not.toContain("Bearer secret"); + expect(restored).toContain("[profiles.default]"); + }); + it("resolves app bind paths from the provided environment", async () => { const root = await createTempRoot("codex-app-bind-paths-"); const multiAuthDir = join(root, "multi-auth"); @@ -107,6 +191,15 @@ describe("Codex app runtime rotation bind", () => { 'model_provider = "openai"\n', "utf8", ); + await seedExistingAppBindState({ + platform: "win32", + home: root, + env, + port: 4567, + baseUrl: "http://127.0.0.1:4567", + nodePath: "node", + routerScriptPath: join(root, "codex-app-router.js"), + }); const result = await bindCodexAppRuntimeRotation({ platform: "win32", @@ -150,6 +243,28 @@ describe("Codex app runtime rotation bind", () => { expect(existsSync(result.status.paths.startupPath ?? "")).toBe(false); }); + 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("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"); @@ -208,6 +323,15 @@ describe("Codex app runtime rotation bind", () => { 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", @@ -234,7 +358,7 @@ describe("Codex app runtime rotation bind", () => { const result = spawnSync( process.execPath, [ - "scripts/codex-app-router.js", + join(thisDir, "..", "scripts", "codex-app-router.js"), "--host", "0.0.0.0", "--port", @@ -248,8 +372,37 @@ describe("Codex app runtime rotation bind", () => { }, ); - expect(result.status).toBe(1); + 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); + }); }); diff --git a/test/codex-bin-wrapper.test.ts b/test/codex-bin-wrapper.test.ts index c04f293f..f9e53ca7 100644 --- a/test/codex-bin-wrapper.test.ts +++ b/test/codex-bin-wrapper.test.ts @@ -727,6 +727,36 @@ describe("codex bin wrapper", () => { ); }); + it("inserts the runtime model provider before TOML array tables", () => { + 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(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 = "codex-multi-auth-runtime-proxy"')).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); @@ -840,6 +870,57 @@ describe("codex bin wrapper", () => { expect(result.stdout).toContain('"ok":true'); }); + it("clears pending app-server account/read ids when the response is an error", () => { + 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: "upstream failed" } }));', + " console.log(JSON.stringify({", + ' jsonrpc: "2.0",', + " id: message.id,", + " result: {", + ' account: { type: "chatgpt", email: "real-user@example.com", planType: "plus" },', + " requiresOpenaiAuth: 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 }, + })}\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('"error":{"code":-32000'); + expect(result.stdout).toContain("real-user@example.com"); + expect(result.stdout).not.toContain("codex-multi-auth"); + }); + it.each([ ["app help", ["app", "--help"]], ["app-server help", ["app-server", "--help"]], @@ -953,17 +1034,14 @@ describe("codex bin wrapper", () => { totalRequests: number; lastAccountIndex: number | null; lastAccountLabel: string | null; - lastAccountEmail: 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 (second@example.com, id:second)", - ); - expect(helperStatus.lastAccountEmail).toBe("second@example.com"); + expect(helperStatus.lastAccountLabel).toBe("Account 2"); + expect(helperStatus).not.toHaveProperty("lastAccountEmail"); expect(helperStatus.lastAccountId).toBe("acc_second"); expect(helperStatus.lastAccountUpdatedAt).toBe(12345); if (shadowHomeMatch?.[1]) { diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index bf9911d5..976714d0 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -968,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", @@ -6932,13 +6956,19 @@ describe("codex manager cli commands", () => { const exitCode = await runCodexMultiAuthCli(["auth", "login"]); expect(exitCode).toBe(0); - expect(setCodexCliActiveSelectionMock).toHaveBeenCalledWith({ - accountId: "acc_b", - email: "b@example.com", - accessToken: "access-b", - refreshToken: "refresh-b", - expiresAt: now + 3_600_000, - }); + 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; diff --git a/test/codex-manager-rotation-command.test.ts b/test/codex-manager-rotation-command.test.ts index 77d6f73d..116bbcc9 100644 --- a/test/codex-manager-rotation-command.test.ts +++ b/test/codex-manager-rotation-command.test.ts @@ -1,12 +1,18 @@ 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 { @@ -60,6 +66,12 @@ function createAppBindResult(message: string, status = createAppBindStatus()): A 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; @@ -150,10 +162,23 @@ beforeEach(() => { afterEach(() => { if (originalRuntimeRotationProxyEnv === undefined) { delete process.env.CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY; - return; + } 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; } - process.env.CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY = - originalRuntimeRotationProxyEnv; +}); + +afterEach(async () => { + await Promise.all( + tempRoots.splice(0).map((root) => + withFileOperationRetry(() => rm(root, { recursive: true, force: true })), + ), + ); }); describe("codex auth rotation command", () => { @@ -194,6 +219,84 @@ describe("codex auth rotation command", () => { 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 () => { diff --git a/test/codex-manager-status-command.test.ts b/test/codex-manager-status-command.test.ts index 0d0e324b..0b262574 100644 --- a/test/codex-manager-status-command.test.ts +++ b/test/codex-manager-status-command.test.ts @@ -124,8 +124,9 @@ describe("runStatusCommand", () => { })), }); - await runStatusCommand(deps); + const result = await runStatusCommand(deps); + expect(result).toBe(0); expect(deps.logInfo).toHaveBeenCalledWith( "No accounts configured. Storage was intentionally reset.", ); @@ -134,6 +135,28 @@ describe("runStatusCommand", () => { ); }); + 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), @@ -201,7 +224,7 @@ describe("runStatusCommand", () => { await runStatusCommand(deps); expect(deps.logInfo).toHaveBeenCalledWith( - "Last runtime account: Account 2 (two@example.com, id:acct_2)", + "Last runtime account: Account 2 (acct_2)", ); }); }); 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/runtime-rotation-proxy.test.ts b/test/runtime-rotation-proxy.test.ts index 44bb8609..c303a9c4 100644 --- a/test/runtime-rotation-proxy.test.ts +++ b/test/runtime-rotation-proxy.test.ts @@ -6,16 +6,30 @@ import { 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 { saveAccountsMock, withAccountStorageTransactionMock } = vi.hoisted( +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 { @@ -32,6 +46,7 @@ interface FetchCall { } const openServers: RuntimeRotationProxyServer[] = []; +const openManagers: AccountManager[] = []; function createStorage(now: number, count = 2): AccountStorageV3 { return { @@ -77,12 +92,15 @@ function createRecordingFetch( 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", quotaRemainingPercentThreshold: 10, + ...params.options, }); openServers.push(proxy); return proxy; @@ -106,6 +124,22 @@ async function postResponses( }); } +async function postRawResponses( + proxy: RuntimeRotationProxyServer, + body: string, + headers: Record = {}, +): Promise { + return fetch(`${proxy.baseUrl}/responses`, { + method: "POST", + headers: { + authorization: "Bearer caller-token", + "content-type": "application/json", + ...headers, + }, + body, + }); +} + function textEventStream(body = "data: {}\n\n", headers?: HeadersInit): Response { return new Response(body, { status: HTTP_STATUS.OK, @@ -119,6 +153,8 @@ function textEventStream(body = "data: {}\n\n", headers?: HeadersInit): Response beforeEach(() => { resetTrackers(); clearCircuitBreakers(); + resetRefreshQueue(); + refreshAccessTokenMock.mockReset(); saveAccountsMock.mockReset(); saveAccountsMock.mockResolvedValue(undefined); withAccountStorageTransactionMock.mockReset(); @@ -131,8 +167,12 @@ 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", () => { @@ -155,6 +195,7 @@ describe("runtime rotation proxy", () => { clientApiKey: "runtime-secret", }); openServers.push(proxy); + openManagers.push(accountManager); const rejected = await postResponses(proxy, { model: "gpt-5-codex" }); @@ -171,6 +212,20 @@ describe("runtime rotation proxy", () => { 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 () => { @@ -207,13 +262,33 @@ describe("runtime rotation proxy", () => { expect(response.headers.get("x-codex-multi-auth-account-id")).toBeNull(); expect(proxy.getStatus()).toMatchObject({ lastAccountIndex: 0, - lastAccountEmail: "account-1@example.com", - lastAccountLabel: "Account 1 (account-1@example.com, id:acc_1)", + lastAccountLabel: "Account 1", lastAccountId: "acc_1", }); + expect(proxy.getStatus()).not.toHaveProperty("lastAccountEmail"); expect(JSON.parse(calls[0]?.bodyText ?? "{}")).toEqual(requestBody); }); + 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"; @@ -243,10 +318,11 @@ describe("runtime rotation proxy", () => { 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: 1, - activeIndexByFamily: { codex: 1 }, + activeIndex: 0, + activeIndexByFamily: { codex: 0, "gpt-5-codex": 1 }, }); expect(persisted.at(-1)?.accounts[1]?.lastSwitchReason).toBe("rotation"); } finally { @@ -307,13 +383,36 @@ describe("runtime rotation proxy", () => { ]); expect(proxy.getStatus()).toMatchObject({ lastAccountIndex: 1, - lastAccountEmail: "account-2@example.com", + 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)); @@ -342,6 +441,55 @@ describe("runtime rotation proxy", () => { 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)); @@ -369,6 +517,60 @@ describe("runtime rotation proxy", () => { expect(accountManager.getAccountByIndex(1)?.cooldownReason).toBe("network-error"); }); + 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("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)); @@ -394,6 +596,45 @@ describe("runtime rotation proxy", () => { 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("server-error"); + 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)); diff --git a/vitest.config.ts b/vitest.config.ts index 6075e1a3..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' || @@ -18,7 +19,9 @@ export default defineConfig({ name: 'strip-script-shebangs-for-vitest', enforce: 'pre', transform(code, id) { - if (!id.includes('/scripts/') && !id.includes('\\scripts\\')) return null; + 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|$)/, ''); }, From 125b78bb0dd6b02e7e3c3ed248a70d6b92f12ad3 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 25 Apr 2026 12:00:52 +0800 Subject: [PATCH 15/42] Tighten runtime review fixes --- lib/runtime-rotation-proxy.ts | 15 ++- scripts/codex.js | 147 +++++++++++++++++++++++----- test/codex-bin-wrapper.test.ts | 6 +- test/runtime-rotation-proxy.test.ts | 2 +- 4 files changed, 139 insertions(+), 31 deletions(-) diff --git a/lib/runtime-rotation-proxy.ts b/lib/runtime-rotation-proxy.ts index b9532491..91768909 100644 --- a/lib/runtime-rotation-proxy.ts +++ b/lib/runtime-rotation-proxy.ts @@ -76,7 +76,13 @@ interface RequestContext { sessionKey: string | null; } -type ExhaustionReason = "rate-limit" | "server-error" | "network-error" | "auth-failure" | "no-account"; +type ExhaustionReason = + | "rate-limit" + | "server-error" + | "network-error" + | "auth-failure" + | "budget" + | "no-account"; type RuntimeProxyHttpError = Error & { statusCode: number; code: string; @@ -987,6 +993,13 @@ export async function startRuntimeRotationProxy( return; } + if ( + attemptedIndexes.size >= accountAttemptLimit && + accountAttemptLimit < accountManager.getAccountCount() + ) { + exhaustionReason = "budget"; + } + writePoolExhausted({ res, accountManager, diff --git a/scripts/codex.js b/scripts/codex.js index d0b0104d..5c6d9bc4 100755 --- a/scripts/codex.js +++ b/scripts/codex.js @@ -35,6 +35,7 @@ const SHADOW_HOME_CLEANUP_BACKOFF_MS = [20, 60, 120]; const SHADOW_HOME_STATE_FILES = ["auth.json", "accounts.json", ".codex-global-state.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 APP_SERVER_ACCOUNT_DISPLAY_NAME = "codex-multi-auth"; const RUNTIME_ROTATION_PROXY_PROVIDER_ID = await loadRuntimeRotationProxyProviderId(); @@ -1236,6 +1237,38 @@ function shadowHomeStateMatches(left, right) { ); } +function acquireShadowHomeSyncLock(originalCodexHome) { + const lockPath = join(originalCodexHome, SHADOW_HOME_SYNC_LOCK_DIR); + mkdirSync(originalCodexHome, { recursive: true }); + for (let attempt = 0; attempt <= SHADOW_HOME_CLEANUP_BACKOFF_MS.length; attempt += 1) { + try { + mkdirSync(lockPath); + writeFileSync( + join(lockPath, "owner.json"), + `${JSON.stringify({ pid: process.pid, createdAt: Date.now() })}\n`, + "utf8", + ); + 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" || attempt === SHADOW_HOME_CLEANUP_BACKOFF_MS.length) { + throw error; + } + sleepSync(SHADOW_HOME_CLEANUP_BACKOFF_MS[attempt]); + } + } + return () => {}; +} + function syncShadowHomeStateFile( sourcePath, destinationPath, @@ -1339,6 +1372,73 @@ function collectShadowHomeSyncFileNames(shadowCodexHome, syncFileNames) { return syncFileNames; } +function syncShadowHomeAuthBundle( + originalCodexHome, + shadowCodexHome, + originalFileStates, + tightenFile, +) { + const plan = []; + for (const name of SHADOW_HOME_STATE_FILES) { + 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)) { + return; + } + if (!shadowHomeStateMatches(shadowState, originalSnapshot)) { + plan.push({ shadowPath, originalPath, originalSnapshot }); + } + } + + for (const entry of plan) { + syncShadowHomeStateFile( + entry.shadowPath, + entry.originalPath, + entry.originalSnapshot, + ); + tightenFile(entry.originalPath); + } +} + +function syncAdditionalShadowHomeFiles( + originalCodexHome, + shadowCodexHome, + names, + originalFileStates, + tightenFile, +) { + for (const name of names) { + if (SHADOW_HOME_STATE_FILE_SET.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; + } + syncShadowHomeStateFile(shadowPath, originalPath, originalSnapshot); + tightenFile(originalPath); + } +} + function createShadowHomeMirror(originalCodexHome, shadowCodexHome, tightenFile) { const syncFileNames = new Set(SHADOW_HOME_STATE_FILES); const originalFileStates = new Map(); @@ -1403,32 +1503,27 @@ function createShadowHomeMirror(originalCodexHome, shadowCodexHome, tightenFile) } return () => { - for (const name of collectShadowHomeSyncFileNames( - shadowCodexHome, - syncFileNames, - )) { - const shadowPath = join(shadowCodexHome, name); - const shadowState = captureShadowHomeState(shadowPath); - if (!shadowState.exists || shadowState.unreadable) { - continue; - } - - try { - 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; - } - syncShadowHomeStateFile(shadowPath, originalPath, originalSnapshot); - tightenFile(originalPath); - } catch { - // Best-effort only; runtime auth refreshes should not fail cleanup. - } + let releaseLock = () => {}; + try { + const names = collectShadowHomeSyncFileNames(shadowCodexHome, syncFileNames); + releaseLock = acquireShadowHomeSyncLock(originalCodexHome); + syncShadowHomeAuthBundle( + originalCodexHome, + shadowCodexHome, + originalFileStates, + tightenFile, + ); + syncAdditionalShadowHomeFiles( + originalCodexHome, + shadowCodexHome, + names, + originalFileStates, + tightenFile, + ); + } catch { + // Best-effort only; runtime auth refreshes should not fail cleanup. + } finally { + releaseLock(); } }; } diff --git a/test/codex-bin-wrapper.test.ts b/test/codex-bin-wrapper.test.ts index f9e53ca7..c87d31c9 100644 --- a/test/codex-bin-wrapper.test.ts +++ b/test/codex-bin-wrapper.test.ts @@ -1351,7 +1351,7 @@ describe("codex bin wrapper", () => { ).toEqual([]); }); - it("does not clobber original auth state that changed while the compatibility shadow was active", () => { + it("does not publish a partial auth bundle when original auth changes during shadow use", () => { const fixtureRoot = createWrapperFixture(); const fakeBin = createCustomFakeCodexBin(fixtureRoot, [ "#!/usr/bin/env node", @@ -1392,8 +1392,8 @@ describe("codex bin wrapper", () => { expect(result.status).toBe(0); expect(readFileSync(join(originalHome, "auth.json"), "utf8").trim()).toBe('{"token":"external"}'); - 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(readFileSync(join(originalHome, "accounts.json"), "utf8").trim()).toBe('{"accounts":["original"]}'); + expect(readFileSync(join(originalHome, ".codex-global-state.json"), "utf8").trim()).toBe('{"last":"original"}'); }); it("does not clobber sync-back files that change during rename retry backoff", () => { diff --git a/test/runtime-rotation-proxy.test.ts b/test/runtime-rotation-proxy.test.ts index c303a9c4..d8dce91e 100644 --- a/test/runtime-rotation-proxy.test.ts +++ b/test/runtime-rotation-proxy.test.ts @@ -608,7 +608,7 @@ describe("runtime rotation proxy", () => { const payload = (await response.json()) as { error: { reason: string } }; expect(response.status).toBe(503); - expect(payload.error.reason).toBe("server-error"); + expect(payload.error.reason).toBe("budget"); expect(calls).toHaveLength(4); }); From 914ad73d82662f63d358d73318e8c8d8feb077ce Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 25 Apr 2026 12:08:20 +0800 Subject: [PATCH 16/42] Harden app bind router token state --- lib/runtime/app-bind.ts | 11 +++- scripts/codex-app-router.js | 5 ++ test/app-bind.test.ts | 123 +++++++++++++++++++++++++++++++++++- 3 files changed, 135 insertions(+), 4 deletions(-) diff --git a/lib/runtime/app-bind.ts b/lib/runtime/app-bind.ts index e0856ba4..6b325df6 100644 --- a/lib/runtime/app-bind.ts +++ b/lib/runtime/app-bind.ts @@ -307,7 +307,11 @@ async function syncDirectoryBestEffort(path: string): Promise { } } -async function atomicWriteFile(target: string, content: string): Promise { +async function atomicWriteFile( + target: string, + content: string, + mode = 0o600, +): Promise { await withFileOperationRetry(async () => { await mkdir(dirname(target), { recursive: true }); const tempPath = join( @@ -323,7 +327,7 @@ async function atomicWriteFile(target: string, content: string): Promise { let moved = false; let handle: Awaited> | null = null; try { - handle = await open(tempPath, "w"); + handle = await open(tempPath, "w", mode); await handle.writeFile(content, "utf8"); await handle.sync(); await handle.close(); @@ -377,6 +381,7 @@ function readAppBindStateRecord(record: Record): AppBindState | !logPath || !nodePath || !routerScriptPath || + !clientApiKey || !boundConfigHash || updatedAt === null ) { @@ -395,7 +400,7 @@ function readAppBindStateRecord(record: Record): AppBindState | logPath, nodePath, routerScriptPath, - clientApiKey: clientApiKey ?? "", + clientApiKey, startupPath: readString(record, "startupPath"), launchAgentPath: readString(record, "launchAgentPath"), boundConfigHash, diff --git a/scripts/codex-app-router.js b/scripts/codex-app-router.js index 2b6398f0..273c8223 100644 --- a/scripts/codex-app-router.js +++ b/scripts/codex-app-router.js @@ -137,6 +137,11 @@ async function main() { "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) => { diff --git a/test/app-bind.test.ts b/test/app-bind.test.ts index 408b2924..51d7a05e 100644 --- a/test/app-bind.test.ts +++ b/test/app-bind.test.ts @@ -1,4 +1,4 @@ -import { existsSync } from "node:fs"; +import { existsSync, statSync } from "node:fs"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; @@ -224,6 +224,10 @@ describe("Codex app runtime rotation bind", () => { `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("runtime-rotation-app-bind.json"); @@ -265,6 +269,53 @@ describe("Codex app runtime rotation bind", () => { ).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"); @@ -315,6 +366,46 @@ describe("Codex app runtime rotation bind", () => { }); }); + 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, + }), + ).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"); @@ -405,4 +496,34 @@ describe("Codex app runtime rotation bind", () => { 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); + }); }); From c1330fc7249a171621e782ac5d7e5919efd4314b Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 25 Apr 2026 12:11:50 +0800 Subject: [PATCH 17/42] Recover stale shadow home sync locks --- scripts/codex.js | 34 ++++++++++++++++++++-- test/codex-bin-wrapper.test.ts | 52 ++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/scripts/codex.js b/scripts/codex.js index 5c6d9bc4..87862005 100755 --- a/scripts/codex.js +++ b/scripts/codex.js @@ -1237,10 +1237,34 @@ function shadowHomeStateMatches(left, right) { ); } +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 removeStaleShadowHomeSyncLock(lockPath) { + const ownerPid = readShadowHomeSyncLockOwnerPid(lockPath); + if (!ownerPid || isProcessAlive(ownerPid)) { + return false; + } + try { + removeDirectoryWithRetry(lockPath); + return true; + } catch { + return false; + } +} + function acquireShadowHomeSyncLock(originalCodexHome) { const lockPath = join(originalCodexHome, SHADOW_HOME_SYNC_LOCK_DIR); mkdirSync(originalCodexHome, { recursive: true }); - for (let attempt = 0; attempt <= SHADOW_HOME_CLEANUP_BACKOFF_MS.length; attempt += 1) { + const lastRetryAttempt = SHADOW_HOME_CLEANUP_BACKOFF_MS.length; + for (let attempt = 0; attempt <= lastRetryAttempt + 1; attempt += 1) { try { mkdirSync(lockPath); writeFileSync( @@ -1260,7 +1284,13 @@ function acquireShadowHomeSyncLock(originalCodexHome) { error && typeof error === "object" && "code" in error ? error.code : undefined; - if (code !== "EEXIST" || attempt === SHADOW_HOME_CLEANUP_BACKOFF_MS.length) { + if (code !== "EEXIST") { + throw error; + } + if (attempt >= lastRetryAttempt) { + if (removeStaleShadowHomeSyncLock(lockPath)) { + continue; + } throw error; } sleepSync(SHADOW_HOME_CLEANUP_BACKOFF_MS[attempt]); diff --git a/test/codex-bin-wrapper.test.ts b/test/codex-bin-wrapper.test.ts index c87d31c9..4b0f126e 100644 --- a/test/codex-bin-wrapper.test.ts +++ b/test/codex-bin-wrapper.test.ts @@ -1351,6 +1351,58 @@ describe("codex bin wrapper", () => { ).toEqual([]); }); + it("removes stale shadow sync locks before publishing refreshed auth state", () => { + 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 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", + ); + + 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("does not publish a partial auth bundle when original auth changes during shadow use", () => { const fixtureRoot = createWrapperFixture(); const fakeBin = createCustomFakeCodexBin(fixtureRoot, [ From dca7ef60a721d09cc41ee1f94a4962c5c200b484 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 25 Apr 2026 12:16:32 +0800 Subject: [PATCH 18/42] Centralize app helper status constant --- lib/codex-manager/commands/rotation.ts | 2 +- lib/runtime-constants.ts | 3 +++ scripts/codex.js | 27 +++++++++++++++++++------- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/lib/codex-manager/commands/rotation.ts b/lib/codex-manager/commands/rotation.ts index 9ee28df5..08be687c 100644 --- a/lib/codex-manager/commands/rotation.ts +++ b/lib/codex-manager/commands/rotation.ts @@ -7,11 +7,11 @@ import { 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; -const APP_RUNTIME_HELPER_STATUS_FILE = "runtime-rotation-app-helper.json"; interface AppRuntimeHelperStatus { kind: string | null; diff --git a/lib/runtime-constants.ts b/lib/runtime-constants.ts index 108b89aa..2bb1ca4c 100644 --- a/lib/runtime-constants.ts +++ b/lib/runtime-constants.ts @@ -1,2 +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/scripts/codex.js b/scripts/codex.js index 87862005..65957de4 100755 --- a/scripts/codex.js +++ b/scripts/codex.js @@ -37,14 +37,16 @@ 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 APP_SERVER_ACCOUNT_DISPLAY_NAME = "codex-multi-auth"; +const RUNTIME_CONSTANTS = await loadRuntimeConstants(); const RUNTIME_ROTATION_PROXY_PROVIDER_ID = - await loadRuntimeRotationProxyProviderId(); + 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_STATUS_FILE = "runtime-rotation-app-helper.json"; +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; @@ -61,16 +63,27 @@ const shadowHomeCleanupRetryMarkerDir = let warnedInvalidRuntimeRotationProxyEnv = false; let warnedPendingAccountReadIdOverflow = false; -async function loadRuntimeRotationProxyProviderId() { +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"); - if (typeof mod.RUNTIME_ROTATION_PROXY_PROVIDER_ID === "string") { - return mod.RUNTIME_ROTATION_PROXY_PROVIDER_ID; - } + 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 `${APP_SERVER_ACCOUNT_DISPLAY_NAME}-runtime-proxy`; + return fallback; } function isRetryableShadowHomeCleanupError(error) { From 9725080cd7e7f5d11e1c4a330be5527a9e1b1322 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 25 Apr 2026 12:24:42 +0800 Subject: [PATCH 19/42] Recover orphaned shadow sync locks --- scripts/codex.js | 2 +- test/codex-bin-wrapper.test.ts | 48 ++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/scripts/codex.js b/scripts/codex.js index 65957de4..f52cc337 100755 --- a/scripts/codex.js +++ b/scripts/codex.js @@ -1262,7 +1262,7 @@ function readShadowHomeSyncLockOwnerPid(lockPath) { function removeStaleShadowHomeSyncLock(lockPath) { const ownerPid = readShadowHomeSyncLockOwnerPid(lockPath); - if (!ownerPid || isProcessAlive(ownerPid)) { + if (ownerPid !== null && isProcessAlive(ownerPid)) { return false; } try { diff --git a/test/codex-bin-wrapper.test.ts b/test/codex-bin-wrapper.test.ts index 4b0f126e..2a58bc36 100644 --- a/test/codex-bin-wrapper.test.ts +++ b/test/codex-bin-wrapper.test.ts @@ -1403,6 +1403,54 @@ describe("codex bin wrapper", () => { expect(existsSync(lockDir)).toBe(false); }); + it.each([ + ["missing owner metadata", undefined], + ["corrupt owner metadata", "{not-json"], + ])("removes orphaned shadow sync locks with %s", (_caseName, ownerContent) => { + 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"); + mkdirSync(lockDir, { recursive: true }); + if (ownerContent !== undefined) { + writeFileSync(join(lockDir, "owner.json"), ownerContent, "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(existsSync(lockDir)).toBe(false); + }); + it("does not publish a partial auth bundle when original auth changes during shadow use", () => { const fixtureRoot = createWrapperFixture(); const fakeBin = createCustomFakeCodexBin(fixtureRoot, [ From 17ce1d223d9a4ab300e0ebb8ca634692da8ef8dd Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 25 Apr 2026 12:42:22 +0800 Subject: [PATCH 20/42] Address runtime rotation review hardening --- lib/fs-retry.ts | 41 +++++++++ lib/runtime-rotation-proxy.ts | 32 +++---- lib/runtime/app-bind.ts | 101 +++++++++++++--------- scripts/check-pack-budget-lib.js | 10 ++- scripts/codex.js | 14 ++++ test/app-bind-io-retry.test.ts | 50 ++++++++++- test/app-bind.test.ts | 124 ++++++++++++++++++++++++---- test/check-pack-budget.test.ts | 16 ++++ test/codex-bin-wrapper.test.ts | 19 +++-- test/runtime-rotation-proxy.test.ts | 115 +++++++++++++++++++++++++- 10 files changed, 438 insertions(+), 84 deletions(-) create mode 100644 lib/fs-retry.ts 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/runtime-rotation-proxy.ts b/lib/runtime-rotation-proxy.ts index 91768909..e356ab19 100644 --- a/lib/runtime-rotation-proxy.ts +++ b/lib/runtime-rotation-proxy.ts @@ -57,7 +57,7 @@ export interface RuntimeRotationProxyOptions { host?: string; port?: number; upstreamBaseUrl?: string; - clientApiKey?: string; + clientApiKey: string; accountManager?: AccountManager; fetchImpl?: typeof fetch; now?: () => number; @@ -117,14 +117,15 @@ const PRIVATE_CLIENT_RESPONSE_HEADERS = new Set([ "x-codex-multi-auth-account-email", "x-codex-multi-auth-account-id", ]); +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 ( - pathname === URL_PATHS.RESPONSES || - pathname === URL_PATHS.CODEX_RESPONSES || - pathname.endsWith(URL_PATHS.RESPONSES) || - pathname.endsWith(URL_PATHS.CODEX_RESPONSES) - ); + return ALLOWED_RESPONSES_PATHS.has(pathname); } function headersFromIncoming(req: IncomingMessage): Headers { @@ -161,8 +162,7 @@ function createOutboundHeaders( return headers; } -function isAuthorizedClient(headers: Headers, clientApiKey: string | null): boolean { - if (!clientApiKey) return true; +function isAuthorizedClient(headers: Headers, clientApiKey: string): boolean { const authorization = headers.get("authorization") ?? ""; const bearerMatch = authorization.match(/^Bearer\s+(.+)$/i); const bearer = bearerMatch?.[1]?.trim(); @@ -388,9 +388,6 @@ async function commitRefreshedAuthOnce( account.accountId ?? "", account.email ?? "", account.refreshToken, - auth.access, - auth.refresh, - String(auth.expires), ].join("\0"); let queue = runtimeRefreshCommitQueues.get(accountManager); if (!queue) { @@ -717,7 +714,7 @@ async function forwardStreamingResponse( } export async function startRuntimeRotationProxy( - options: RuntimeRotationProxyOptions = {}, + options: RuntimeRotationProxyOptions, ): Promise { const pluginConfig = loadPluginConfig(); const accountManager = options.accountManager ?? (await AccountManager.loadFromDisk()); @@ -730,6 +727,9 @@ export async function startRuntimeRotationProxy( 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); @@ -1025,10 +1025,10 @@ export async function startRuntimeRotationProxy( code: "codex_runtime_rotation_proxy_error", }, }); - } else if (!res.destroyed) { - res.destroy(error instanceof Error ? error : undefined); + } else if (!res.destroyed) { + res.destroy(error instanceof Error ? error : undefined); + } } - } }; const server = createServer((req, res) => { diff --git a/lib/runtime/app-bind.ts b/lib/runtime/app-bind.ts index 6b325df6..d4ca72ef 100644 --- a/lib/runtime/app-bind.ts +++ b/lib/runtime/app-bind.ts @@ -6,6 +6,7 @@ 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 { RUNTIME_ROTATION_PROXY_PROVIDER_ID } from "../runtime-constants.js"; import { getCodexMultiAuthDir } from "../runtime-paths.js"; @@ -15,10 +16,7 @@ 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 FILE_RETRY_CODES = new Set(["EBUSY", "EPERM", "EAGAIN", "ENOTEMPTY", "EACCES"]); -const FILE_RETRY_MAX_ATTEMPTS = 6; -const FILE_RETRY_BASE_DELAY_MS = 25; -const FILE_RETRY_JITTER_MS = 20; +const appBindLocks = new Map>(); export interface AppBindPaths { codexHome: string; @@ -94,10 +92,33 @@ export interface AppBindOptions { now?: () => number; nodePath?: string; routerScriptPath?: string; + routerScriptCandidates?: string[]; spawnDetached?: boolean; 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); + } + } +} + function tomlStringLiteral(value: string): string { return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; } @@ -266,35 +287,6 @@ function readNumber(record: Record, key: string): number | null return typeof value === "number" && Number.isFinite(value) ? value : null; } -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -function shouldRetryFileOperation(error: unknown): boolean { - return ( - error instanceof Error && - "code" in error && - typeof error.code === "string" && - FILE_RETRY_CODES.has(error.code) - ); -} - -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); - } - } -} - async function syncDirectoryBestEffort(path: string): Promise { let handle: Awaited> | null = null; try { @@ -484,16 +476,22 @@ function resolveMacLaunchAgentPath(home: string): string { return join(home, "Library", "LaunchAgents", `${MACOS_LAUNCH_AGENT_ID}.plist`); } -function resolveRouterScriptPath(override?: string): string { +function resolveRouterScriptPath( + override?: string, + candidateOverride?: string[], +): string { if (override) return override; - const candidates = [ - fileURLToPath(new URL("../../../scripts/codex-app-router.js", import.meta.url)), - fileURLToPath(new URL("../../scripts/codex-app-router.js", import.meta.url)), - ]; + 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; } - return candidates[0] ?? "codex-app-router.js"; + throw new Error( + `codex-app-router.js not found; checked: ${candidates.join(", ")}`, + ); } export function resolveAppBindPaths(options: AppBindOptions = {}): AppBindPaths { @@ -512,7 +510,10 @@ export function resolveAppBindPaths(options: AppBindOptions = {}): AppBindPaths 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), + routerScriptPath: resolveRouterScriptPath( + options.routerScriptPath, + options.routerScriptCandidates, + ), startupPath: platform === "win32" ? resolveWindowsStartupPath(env, home) : null, launchAgentPath: platform === "darwin" ? resolveMacLaunchAgentPath(home) : null, @@ -698,10 +699,19 @@ export async function getAppBindStatus(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 paths = resolveAppBindPaths(options); const existingState = await readAppBindState(paths.statePath); const host = existingState?.host ?? "127.0.0.1"; let port = existingState && existingState.port > 0 ? existingState.port : 0; @@ -789,6 +799,15 @@ 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) { diff --git a/scripts/check-pack-budget-lib.js b/scripts/check-pack-budget-lib.js index 31f86d81..c6f4741e 100644 --- a/scripts/check-pack-budget-lib.js +++ b/scripts/check-pack-budget-lib.js @@ -39,7 +39,7 @@ export const FORBIDDEN_PREFIXES = [ * @returns {value is Record} */ function isRecord(value) { - return typeof value === "object" && value !== null; + return typeof value === "object" && value !== null && !Array.isArray(value); } /** @@ -127,7 +127,13 @@ export async function runPackBudgetCheck(deps = {}) { windowsHide: true, maxBuffer: 10 * 1024 * 1024, }); - stdout = String(result.stdout); + 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 = diff --git a/scripts/codex.js b/scripts/codex.js index f52cc337..bacb8ad1 100755 --- a/scripts/codex.js +++ b/scripts/codex.js @@ -32,6 +32,7 @@ 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 = 100; const SHADOW_HOME_STATE_FILES = ["auth.json", "accounts.json", ".codex-global-state.json"]; const SHADOW_HOME_STATE_FILE_SET = new Set(SHADOW_HOME_STATE_FILES); const SHADOW_HOME_CONFIG_FILE = "config.toml"; @@ -1260,11 +1261,24 @@ function readShadowHomeSyncLockOwnerPid(lockPath) { } } +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); return true; diff --git a/test/app-bind-io-retry.test.ts b/test/app-bind-io-retry.test.ts index 95923376..47342495 100644 --- a/test/app-bind-io-retry.test.ts +++ b/test/app-bind-io-retry.test.ts @@ -3,11 +3,12 @@ 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"; -import { withFileOperationRetry } from "../scripts/install-codex-auth-utils.js"; const fsFaults = vi.hoisted(() => ({ renameFailures: 0, + unlinkFailures: 0, })); vi.mock("node:fs/promises", async (importOriginal) => { @@ -21,6 +22,13 @@ vi.mock("node:fs/promises", async (importOriginal) => { } 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); + }), }; }); @@ -34,6 +42,7 @@ async function createTempRoot(prefix: string): Promise { afterEach(async () => { fsFaults.renameFailures = 0; + fsFaults.unlinkFailures = 0; await Promise.all( tempRoots.splice(0).map((root) => withFileOperationRetry(() => rm(root, { recursive: true, force: true })), @@ -169,4 +178,43 @@ describe("Codex app bind filesystem retry behavior", () => { '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 index 51d7a05e..0e4c08a2 100644 --- a/test/app-bind.test.ts +++ b/test/app-bind.test.ts @@ -1,4 +1,5 @@ import { existsSync, 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"; @@ -12,7 +13,8 @@ import { rewriteConfigTomlForAppBind, unbindCodexAppRuntimeRotation, } from "../lib/runtime/app-bind.js"; -import { withFileOperationRetry } from "../scripts/install-codex-auth-utils.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)); @@ -23,6 +25,10 @@ async function createTempRoot(prefix: string): Promise { return root; } +function sha256(content: string): string { + return createHash("sha256").update(content).digest("hex"); +} + async function seedExistingAppBindState(params: { platform: NodeJS.Platform; home: string; @@ -87,8 +93,12 @@ describe("Codex app runtime rotation bind", () => { "http://127.0.0.1:32123", "app-secret", ); - expect(bound).toContain('model_provider = "codex-multi-auth-runtime-proxy"'); - expect(bound).toContain("[model_providers.codex-multi-auth-runtime-proxy]"); + 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"); @@ -114,22 +124,24 @@ describe("Codex app runtime rotation bind", () => { "app-secret", ); - expect(bound.startsWith('model_provider = "codex-multi-auth-runtime-proxy"')).toBe( - true, - ); - expect(bound.indexOf('model_provider = "codex-multi-auth-runtime-proxy"')).toBeLessThan( - bound.indexOf("[[profiles.experimental]]"), - ); + 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 = "codex-multi-auth-runtime-proxy"', + `model_provider = "${RUNTIME_ROTATION_PROXY_PROVIDER_ID}"`, "", - "[model_providers.codex-multi-auth-runtime-proxy]", + `[model_providers.${RUNTIME_ROTATION_PROXY_PROVIDER_ID}]`, 'name = "codex-multi-auth"', 'base_url = "http://127.0.0.1:32123"', - "[model_providers.codex-multi-auth-runtime-proxy.http_headers]", + `[model_providers.${RUNTIME_ROTATION_PROXY_PROVIDER_ID}.http_headers]`, 'authorization = "Bearer secret"', "[profiles.default]", 'model = "gpt-5.4"', @@ -138,7 +150,7 @@ describe("Codex app runtime rotation bind", () => { const restored = restoreConfigTomlFromAppBind(bound, 'model_provider = "openai"\n'); - expect(restored).not.toContain("codex-multi-auth-runtime-proxy"); + expect(restored).not.toContain(RUNTIME_ROTATION_PROXY_PROVIDER_ID); expect(restored).not.toContain("Bearer secret"); expect(restored).toContain("[profiles.default]"); }); @@ -217,7 +229,9 @@ describe("Codex app runtime rotation bind", () => { join(multiAuthDir, "app-bind", "runtime-rotation-app-bind.json"), ); const config = await readFile(join(codexHome, "config.toml"), "utf8"); - expect(config).toContain("[model_providers.codex-multi-auth-runtime-proxy]"); + 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( @@ -247,6 +261,88 @@ describe("Codex app runtime rotation bind", () => { 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"); 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-bin-wrapper.test.ts b/test/codex-bin-wrapper.test.ts index 2a58bc36..e2c29afa 100644 --- a/test/codex-bin-wrapper.test.ts +++ b/test/codex-bin-wrapper.test.ts @@ -16,6 +16,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"; @@ -658,7 +659,7 @@ describe("codex bin wrapper", () => { 'name = "Existing"', 'base_url = "https://example.invalid"', "", - "[model_providers.codex-multi-auth-runtime-proxy]", + `[model_providers.${RUNTIME_ROTATION_PROXY_PROVIDER_ID}]`, 'name = "Stale Runtime Proxy"', 'base_url = "http://127.0.0.1:1"', ].join("\n"), @@ -678,7 +679,7 @@ describe("codex bin wrapper", () => { const output = combinedOutput(result); expect(result.status).toBe(0); expect(output).toContain( - 'FORWARDED:exec status -c cli_auth_credentials_store="file" -c model_provider="codex-multi-auth-runtime-proxy"', + `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"); @@ -690,10 +691,10 @@ describe("codex bin wrapper", () => { const apiKeyMatch = output.match(/^OPENAI_API_KEY:([0-9a-f]{64})$/m); expect(apiKeyMatch?.[1]).toBeTruthy(); expect(output).toContain( - 'model_provider = "codex-multi-auth-runtime-proxy"', + `model_provider = "${RUNTIME_ROTATION_PROXY_PROVIDER_ID}"`, ); expect(output).toContain( - "[model_providers.codex-multi-auth-runtime-proxy]", + `[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"'); @@ -752,7 +753,11 @@ describe("codex bin wrapper", () => { }); expect(result.status).toBe(0); - expect(result.stdout.indexOf('model_provider = "codex-multi-auth-runtime-proxy"')).toBeLessThan( + expect( + result.stdout.indexOf( + `model_provider = "${RUNTIME_ROTATION_PROXY_PROVIDER_ID}"`, + ), + ).toBeLessThan( result.stdout.indexOf("[[profiles.experimental]]"), ); }); @@ -791,7 +796,7 @@ describe("codex bin wrapper", () => { 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="codex-multi-auth-runtime-proxy"', + `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(); @@ -1000,7 +1005,7 @@ describe("codex bin wrapper", () => { throw new Error(output); } expect(output).toContain( - 'FORWARDED:app . -c cli_auth_credentials_store="file" -c model_provider="codex-multi-auth-runtime-proxy"', + `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(); diff --git a/test/runtime-rotation-proxy.test.ts b/test/runtime-rotation-proxy.test.ts index d8dce91e..7796c4f4 100644 --- a/test/runtime-rotation-proxy.test.ts +++ b/test/runtime-rotation-proxy.test.ts @@ -47,6 +47,7 @@ interface FetchCall { const openServers: RuntimeRotationProxyServer[] = []; const openManagers: AccountManager[] = []; +const DEFAULT_CLIENT_API_KEY = "runtime-secret"; function createStorage(now: number, count = 2): AccountStorageV3 { return { @@ -99,6 +100,7 @@ async function startProxy(params: { accountManager: params.accountManager, fetchImpl: params.fetchImpl, upstreamBaseUrl: "https://example.test/backend-api", + clientApiKey: DEFAULT_CLIENT_API_KEY, quotaRemainingPercentThreshold: 10, ...params.options, }); @@ -115,7 +117,7 @@ async function postResponses( return fetch(`${proxy.baseUrl}${path}`, { method: "POST", headers: { - authorization: "Bearer caller-token", + authorization: `Bearer ${DEFAULT_CLIENT_API_KEY}`, "content-type": "application/json", "x-api-key": "caller-key", ...headers, @@ -132,7 +134,7 @@ async function postRawResponses( return fetch(`${proxy.baseUrl}/responses`, { method: "POST", headers: { - authorization: "Bearer caller-token", + authorization: `Bearer ${DEFAULT_CLIENT_API_KEY}`, "content-type": "application/json", ...headers, }, @@ -176,6 +178,20 @@ afterEach(async () => { }); 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("rejects unauthenticated local clients when a wrapper token is configured", async () => { const now = Date.now(); const accountManager = new AccountManager(undefined, createStorage(now)); @@ -197,7 +213,15 @@ describe("runtime rotation proxy", () => { openServers.push(proxy); openManagers.push(accountManager); - const rejected = await postResponses(proxy, { model: "gpt-5-codex" }); + 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); @@ -269,6 +293,24 @@ describe("runtime rotation proxy", () => { expect(JSON.parse(calls[0]?.bodyText ?? "{}")).toEqual(requestBody); }); + 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)); @@ -571,6 +613,73 @@ describe("runtime rotation proxy", () => { 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)); From 0f23ecfbfa52818975ae7a856d1c41db172c928b Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 25 Apr 2026 12:56:49 +0800 Subject: [PATCH 21/42] Fix app helper lifecycle review issues --- scripts/codex-app-router.js | 5 +- scripts/codex.js | 37 +++++++++-- test/codex-bin-wrapper.test.ts | 114 +++++++++++++++++++++++++++++++++ 3 files changed, 150 insertions(+), 6 deletions(-) diff --git a/scripts/codex-app-router.js b/scripts/codex-app-router.js index 273c8223..d3073fae 100644 --- a/scripts/codex-app-router.js +++ b/scripts/codex-app-router.js @@ -67,7 +67,10 @@ function writeStatus(statusPath, payload) { if (!statusPath) return; try { mkdirSync(dirname(statusPath), { recursive: true }); - writeFileSync(statusPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); + writeFileSync(statusPath, `${JSON.stringify(payload, null, 2)}\n`, { + encoding: "utf8", + mode: 0o600, + }); } catch { // Status is best-effort. The router should keep serving if telemetry is locked. } diff --git a/scripts/codex.js b/scripts/codex.js index bacb8ad1..6deecc65 100755 --- a/scripts/codex.js +++ b/scripts/codex.js @@ -46,6 +46,8 @@ 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; @@ -1765,8 +1767,13 @@ function createRuntimeRotationProxyClientApiKey() { return randomBytes(32).toString("hex"); } +function resolveRuntimeRotationProxyOriginalCodexHome(baseEnv) { + const override = (baseEnv[APP_RUNTIME_HELPER_REAL_CODEX_HOME_ENV] ?? "").trim(); + return override || resolveCodexHomeDir(baseEnv); +} + function createRuntimeRotationProxyCodexHome(baseEnv, proxyBaseUrl, clientApiKey) { - const originalCodexHome = resolveCodexHomeDir(baseEnv); + const originalCodexHome = resolveRuntimeRotationProxyOriginalCodexHome(baseEnv); const shadowCodexHome = mkdtempSync(join(tmpdir(), "codex-multi-auth-runtime-home-")); let syncShadowHomeStateBack = () => {}; const cleanup = () => { @@ -2142,6 +2149,9 @@ function stopRuntimeRotationAppHelper(helper) { } function startRuntimeRotationAppHelper(baseContext) { + const realCodexHome = + baseContext.originalCodexHome ?? + resolveRuntimeRotationProxyOriginalCodexHome(baseContext.env); return new Promise((resolve, reject) => { let stdoutBuffer = ""; let stderrBuffer = ""; @@ -2153,6 +2163,7 @@ function startRuntimeRotationAppHelper(baseContext) { 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, @@ -2590,14 +2601,24 @@ 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 rawConfig = readFileSync(configPath, "utf8"); @@ -2606,7 +2627,12 @@ 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-")); @@ -2656,6 +2682,7 @@ function createCompatibilityCodexHome( args: processedArgs, env: forwardedEnv, cleanup: cleanupWithSync, + originalCodexHome, }; } diff --git a/test/codex-bin-wrapper.test.ts b/test/codex-bin-wrapper.test.ts index e2c29afa..4d99d14b 100644 --- a/test/codex-bin-wrapper.test.ts +++ b/test/codex-bin-wrapper.test.ts @@ -9,6 +9,7 @@ import { readdirSync, readFileSync, rmSync, + statSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; @@ -77,6 +78,10 @@ function createWrapperFixture(): string { 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"), @@ -211,6 +216,10 @@ function createRuntimeRotationProxyFixtureModule(fixtureRoot: string): string { "", "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(`start:${baseUrl}`);", " return {", " host: '127.0.0.1',", @@ -1054,6 +1063,111 @@ describe("codex bin wrapper", () => { } }); + 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(1300); + + 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); + }); + } + }); + it("records forwarded exec traffic in runtime observability when the child process does not update it", () => { const fixtureRoot = createWrapperFixture(); createRuntimeObservabilityFixtureModule(fixtureRoot); From 17524118b9bc55063fb4eceb8a86f5df38007354 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 25 Apr 2026 13:08:47 +0800 Subject: [PATCH 22/42] Fix remaining runtime review findings --- lib/runtime-rotation-proxy.ts | 8 +- scripts/codex.js | 2 +- test/codex-bin-wrapper.test.ts | 53 ++++++++++++- .../runtime-rotation-proxy-safe-equal.test.ts | 79 +++++++++++++++++++ 4 files changed, 138 insertions(+), 4 deletions(-) create mode 100644 test/runtime-rotation-proxy-safe-equal.test.ts diff --git a/lib/runtime-rotation-proxy.ts b/lib/runtime-rotation-proxy.ts index e356ab19..1ed0f01a 100644 --- a/lib/runtime-rotation-proxy.ts +++ b/lib/runtime-rotation-proxy.ts @@ -174,8 +174,12 @@ function isAuthorizedClient(headers: Headers, clientApiKey: string): boolean { function safeEqual(left: string, right: string): boolean { const leftBuffer = Buffer.from(left, "utf8"); const rightBuffer = Buffer.from(right, "utf8"); - if (leftBuffer.length !== rightBuffer.length) return false; - return timingSafeEqual(leftBuffer, rightBuffer); + 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 { diff --git a/scripts/codex.js b/scripts/codex.js index 6deecc65..49a63e74 100755 --- a/scripts/codex.js +++ b/scripts/codex.js @@ -32,7 +32,7 @@ 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 = 100; +const SHADOW_HOME_ORPHAN_LOCK_STALE_AGE_MS = 2_000; const SHADOW_HOME_STATE_FILES = ["auth.json", "accounts.json", ".codex-global-state.json"]; const SHADOW_HOME_STATE_FILE_SET = new Set(SHADOW_HOME_STATE_FILES); const SHADOW_HOME_CONFIG_FILE = "config.toml"; diff --git a/test/codex-bin-wrapper.test.ts b/test/codex-bin-wrapper.test.ts index 4d99d14b..5384d89b 100644 --- a/test/codex-bin-wrapper.test.ts +++ b/test/codex-bin-wrapper.test.ts @@ -10,6 +10,7 @@ import { readFileSync, rmSync, statSync, + utimesSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; @@ -25,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)) { @@ -443,6 +445,12 @@ function runWrapper( ); } +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[], @@ -1525,7 +1533,7 @@ describe("codex bin wrapper", () => { it.each([ ["missing owner metadata", undefined], ["corrupt owner metadata", "{not-json"], - ])("removes orphaned shadow sync locks with %s", (_caseName, ownerContent) => { + ])("removes orphaned shadow sync locks with %s", async (_caseName, ownerContent) => { const fixtureRoot = createWrapperFixture(); const fakeBin = createCustomFakeCodexBin(fixtureRoot, [ "#!/usr/bin/env node", @@ -1550,6 +1558,7 @@ describe("codex bin wrapper", () => { if (ownerContent !== undefined) { writeFileSync(join(lockDir, "owner.json"), ownerContent, "utf8"); } + await ageShadowSyncLockForSteal(lockDir); const result = runWrapper( fixtureRoot, @@ -1570,6 +1579,48 @@ describe("codex bin wrapper", () => { expect(existsSync(lockDir)).toBe(false); }); + it("does not steal fresh orphaned shadow sync locks", () => { + 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"); + mkdirSync(lockDir, { recursive: true }); + + 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":"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(true); + }); + it("does not publish a partial auth bundle when original auth changes during shadow use", () => { const fixtureRoot = createWrapperFixture(); const fakeBin = createCustomFakeCodexBin(fixtureRoot, [ 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); + }); +}); From 7a3dc67e38cc6cf581a686cdda8d2e67bff0a873 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 25 Apr 2026 13:24:49 +0800 Subject: [PATCH 23/42] Fix app helper review findings --- scripts/codex.js | 30 ++++++---- test/codex-bin-wrapper.test.ts | 105 ++++++++++++++++++++++++++++++++- 2 files changed, 122 insertions(+), 13 deletions(-) diff --git a/scripts/codex.js b/scripts/codex.js index 49a63e74..e4ac82fe 100755 --- a/scripts/codex.js +++ b/scripts/codex.js @@ -552,7 +552,7 @@ function forwardToRealCodexOnce( cleanupProtocolProxy(); protocolProxy?.flushOutput(); try { - await cleanup?.(); + await cleanup?.({ exitCode }); } catch { // Best-effort cleanup only. } @@ -690,9 +690,6 @@ async function forwardToRealCodex(codexBin, rawArgs, baseEnv = process.env) { compatibility, rawArgs, ); - if (!runtimeProxyContext) { - return 1; - } const result = await forwardToRealCodexOnce( codexBin, runtimeProxyContext.args, @@ -1935,6 +1932,15 @@ function isProcessAlive(pid) { } } +function isRuntimeRotationAppHelperOwnerAlive(pid) { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + function resolveRuntimeRotationAppHelperDetachGraceMs(env = process.env) { const parsed = Number.parseInt( env.CODEX_MULTI_AUTH_APP_ROTATION_DETACH_GRACE_MS ?? "", @@ -1971,7 +1977,11 @@ function writeRuntimeRotationAppHelperStatus(payload, env = process.env) { try { const statusPath = resolveRuntimeRotationAppHelperStatusPath(env); mkdirSync(dirname(statusPath), { recursive: true }); - writeFileSync(statusPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); + writeFileSync(statusPath, `${JSON.stringify(payload, null, 2)}\n`, { + encoding: "utf8", + mode: 0o600, + }); + chmodSync(statusPath, 0o600); } catch { // Best-effort status only; the helper must not fail because telemetry is unavailable. } @@ -2098,7 +2108,7 @@ async function runRuntimeRotationAppHelper() { lastRequestCount = requestCount; lastActivityAt = currentTime; } - if (ownerPid && isProcessAlive(ownerPid)) { + if (ownerPid && isRuntimeRotationAppHelperOwnerAlive(ownerPid)) { lastActivityAt = currentTime; } publishStatus("running"); @@ -2229,9 +2239,9 @@ async function createRuntimeRotationAppHelperContext(baseContext) { const helperEnv = message.env ?? {}; const detachGraceMs = resolveRuntimeRotationAppHelperDetachGraceMs(baseContext.env); - const cleanup = async () => { + const cleanup = async ({ exitCode } = {}) => { const livedMs = Date.now() - startedAt; - if (livedMs < detachGraceMs) { + if (exitCode === 0 && livedMs < detachGraceMs) { helper.stdout?.destroy(); helper.stderr?.destroy(); helper.unref(); @@ -2250,9 +2260,9 @@ async function createRuntimeRotationAppHelperContext(baseContext) { ...baseContext.env, ...helperEnv, }, - cleanup: async () => { + cleanup: async (details) => { try { - await cleanup(); + await cleanup(details); } finally { baseContext.cleanup?.(); } diff --git a/test/codex-bin-wrapper.test.ts b/test/codex-bin-wrapper.test.ts index 5384d89b..e67a2b96 100644 --- a/test/codex-bin-wrapper.test.ts +++ b/test/codex-bin-wrapper.test.ts @@ -222,7 +222,7 @@ function createRuntimeRotationProxyFixtureModule(fixtureRoot: string): string { " appendMarker(`codex-home-env:${process.env.CODEX_HOME ?? ''}`);", " appendMarker(`real-home-env:${process.env.CODEX_MULTI_AUTH_REAL_CODEX_HOME ?? ''}`);", " }", - " appendMarker(`start:${baseUrl}`);", + " 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,", @@ -445,6 +445,17 @@ 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); @@ -1044,7 +1055,7 @@ describe("codex bin wrapper", () => { const shadowHomeMatch = output.match(/^CODEX_HOME:(.+)$/m); expect(shadowHomeMatch?.[1]).toBeTruthy(); - await sleep(1300); + await sleep(2200); expect(readFileSync(markerPath, "utf8")).toBe( "start:http://127.0.0.1:4567\nclose\n", @@ -1066,11 +1077,99 @@ describe("codex bin wrapper", () => { 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); } }); + 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); @@ -1105,7 +1204,7 @@ describe("codex bin wrapper", () => { } expect(output).toContain("FORWARDED:app . --model gpt-5.1"); - await sleep(1300); + await sleep(2200); const marker = readFileSync(markerPath, "utf8"); expect(marker).toContain(`real-home-env:${originalHome}\n`); From ef45d7daddd7527bef21e8c6b11125b41e21500e Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 25 Apr 2026 13:38:57 +0800 Subject: [PATCH 24/42] Preserve concurrent shadow sync updates --- scripts/codex.js | 18 +++++++----------- test/codex-bin-wrapper.test.ts | 6 +++--- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/scripts/codex.js b/scripts/codex.js index e4ac82fe..46a904cc 100755 --- a/scripts/codex.js +++ b/scripts/codex.js @@ -1434,7 +1434,6 @@ function syncShadowHomeAuthBundle( originalFileStates, tightenFile, ) { - const plan = []; for (const name of SHADOW_HOME_STATE_FILES) { const shadowPath = join(shadowCodexHome, name); const shadowState = captureShadowHomeState(shadowPath); @@ -1446,20 +1445,17 @@ function syncShadowHomeAuthBundle( originalFileStates.get(name) ?? { exists: false, content: null }; const currentOriginalState = captureShadowHomeState(originalPath); if (!shadowHomeStateMatches(currentOriginalState, originalSnapshot)) { - return; + continue; } - if (!shadowHomeStateMatches(shadowState, originalSnapshot)) { - plan.push({ shadowPath, originalPath, originalSnapshot }); + if (shadowHomeStateMatches(shadowState, originalSnapshot)) { + continue; } - } - - for (const entry of plan) { syncShadowHomeStateFile( - entry.shadowPath, - entry.originalPath, - entry.originalSnapshot, + shadowPath, + originalPath, + originalSnapshot, ); - tightenFile(entry.originalPath); + tightenFile(originalPath); } } diff --git a/test/codex-bin-wrapper.test.ts b/test/codex-bin-wrapper.test.ts index e67a2b96..1013abdd 100644 --- a/test/codex-bin-wrapper.test.ts +++ b/test/codex-bin-wrapper.test.ts @@ -1720,7 +1720,7 @@ describe("codex bin wrapper", () => { expect(existsSync(lockDir)).toBe(true); }); - it("does not publish a partial auth bundle when original auth changes during shadow use", () => { + it("syncs unchanged auth bundle files when a sibling changes during shadow use", () => { const fixtureRoot = createWrapperFixture(); const fakeBin = createCustomFakeCodexBin(fixtureRoot, [ "#!/usr/bin/env node", @@ -1761,8 +1761,8 @@ describe("codex bin wrapper", () => { expect(result.status).toBe(0); expect(readFileSync(join(originalHome, "auth.json"), "utf8").trim()).toBe('{"token":"external"}'); - 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(readFileSync(join(originalHome, "accounts.json"), "utf8").trim()).toBe('{"accounts":["shadow"]}'); + expect(readFileSync(join(originalHome, ".codex-global-state.json"), "utf8").trim()).toBe('{"last":"shadow"}'); }); it("does not clobber sync-back files that change during rename retry backoff", () => { From 1c7540df66656137d3884436f561aab2470c801c Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 25 Apr 2026 13:53:35 +0800 Subject: [PATCH 25/42] Harden app router review gaps --- lib/runtime/app-bind.ts | 2 +- scripts/codex-app-router.js | 3 +- scripts/codex.js | 27 +++- test/app-bind.test.ts | 3 + test/codex-app-router.test.ts | 253 +++++++++++++++++++++++++++++++++ test/codex-bin-wrapper.test.ts | 22 ++- 6 files changed, 304 insertions(+), 6 deletions(-) create mode 100644 test/codex-app-router.test.ts diff --git a/lib/runtime/app-bind.ts b/lib/runtime/app-bind.ts index d4ca72ef..ff263ebf 100644 --- a/lib/runtime/app-bind.ts +++ b/lib/runtime/app-bind.ts @@ -614,7 +614,7 @@ async function removeAppBindStartup(state: AppBindState): Promise { function spawnRouter(state: AppBindState): void { mkdirSync(dirname(state.logPath), { recursive: true }); - const logFd = openSync(state.logPath, "a"); + const logFd = openSync(state.logPath, "a", 0o600); try { const child = spawn( state.nodePath, diff --git a/scripts/codex-app-router.js b/scripts/codex-app-router.js index d3073fae..a26ef68f 100644 --- a/scripts/codex-app-router.js +++ b/scripts/codex-app-router.js @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname } from "node:path"; import process from "node:process"; @@ -71,6 +71,7 @@ function writeStatus(statusPath, payload) { encoding: "utf8", mode: 0o600, }); + chmodSync(statusPath, 0o600); } catch { // Status is best-effort. The router should keep serving if telemetry is locked. } diff --git a/scripts/codex.js b/scripts/codex.js index 46a904cc..b64688ce 100755 --- a/scripts/codex.js +++ b/scripts/codex.js @@ -1875,20 +1875,41 @@ function installRuntimeRotationAppServerCliShim(forwardedEnv) { if (!shadowCodexHome) { throw new Error("runtime app-server shim requires CODEX_HOME"); } - const shimDir = join(shadowCodexHome, "app-server-shim"); + const multiAuthDir = + resolveOriginalMultiAuthDir(forwardedEnv) ?? + join(resolveRuntimeRotationProxyOriginalCodexHome(forwardedEnv), "multi-auth"); + const shimDir = join( + multiAuthDir, + "app-server-shims", + `helper-${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"); - copyFileSync(process.execPath, executablePath); + 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)), - "utf8", + { encoding: "utf8", mode: 0o600 }, ); + try { + chmodSync(preloadPath, 0o600); + } catch { + // Best-effort only; permission semantics vary by platform. + } forwardedEnv.CODEX_CLI_PATH = shimDir; forwardedEnv.NODE_OPTIONS = appendNodeImportOption( forwardedEnv.NODE_OPTIONS, diff --git a/test/app-bind.test.ts b/test/app-bind.test.ts index 0e4c08a2..e3bf8a52 100644 --- a/test/app-bind.test.ts +++ b/test/app-bind.test.ts @@ -449,6 +449,9 @@ describe("Codex app runtime rotation bind", () => { 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( 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 1013abdd..08b4715a 100644 --- a/test/codex-bin-wrapper.test.ts +++ b/test/codex-bin-wrapper.test.ts @@ -1037,7 +1037,7 @@ describe("codex bin wrapper", () => { ); const apiKeyMatch = output.match(/^OPENAI_API_KEY:([0-9a-f]{64})$/m); expect(apiKeyMatch?.[1]).toBeTruthy(); - expect(output).toMatch(/^CODEX_CLI_PATH:.+app-server-shim$/m); + 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"); @@ -1054,6 +1054,11 @@ describe("codex bin wrapper", () => { 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); @@ -1086,6 +1091,21 @@ describe("codex bin wrapper", () => { if (shadowHomeMatch?.[1]) { expect(existsSync(shadowHomeMatch[1])).toBe(false); } + if (cliPathMatch?.[1]) { + expect( + existsSync( + join( + cliPathMatch[1], + process.platform === "win32" ? "codex.exe" : "codex", + ), + ), + ).toBe(true); + expect( + existsSync( + join(cliPathMatch[1], "codex-multi-auth-app-server-preload.mjs"), + ), + ).toBe(true); + } }); it("stops failed app helpers before unsupported-model retries", async () => { From 14e3c180e5415b2974dccd9d64413d9517553b54 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 25 Apr 2026 14:13:05 +0800 Subject: [PATCH 26/42] Fix auth command wrapper routing --- scripts/codex-routing.js | 5 +++++ test/codex-routing.test.ts | 34 +++++++++++++++++++++++++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/scripts/codex-routing.js b/scripts/codex-routing.js index 913d325f..2c7835f5 100644 --- a/scripts/codex-routing.js +++ b/scripts/codex-routing.js @@ -7,11 +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/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); + } + }); }); From 6110f65337fe7f865a9d07a180c34597b32d0e8e Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 25 Apr 2026 14:16:42 +0800 Subject: [PATCH 27/42] Fix stale shadow sync lock retry edge --- scripts/codex.js | 28 ++++++++++++++++-- test/codex-bin-wrapper.test.ts | 54 ++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 3 deletions(-) diff --git a/scripts/codex.js b/scripts/codex.js index b64688ce..05bf534b 100755 --- a/scripts/codex.js +++ b/scripts/codex.js @@ -61,6 +61,10 @@ 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, +); const shadowHomeCleanupRetryMarkerDir = (process.env.CODEX_MULTI_AUTH_TEST_SHADOW_RETRY_MARKER_DIR ?? "").trim(); let warnedInvalidRuntimeRotationProxyEnv = false; @@ -1280,6 +1284,15 @@ function removeStaleShadowHomeSyncLock(lockPath) { } try { removeDirectoryWithRetry(lockPath); + if (shadowHomeSyncLockRecreateStaleCount > 0) { + shadowHomeSyncLockRecreateStaleCount -= 1; + mkdirSync(lockPath, { recursive: true }); + writeFileSync( + join(lockPath, "owner.json"), + `${JSON.stringify({ pid: 2_147_483_647, createdAt: 1 })}\n`, + "utf8", + ); + } return true; } catch { return false; @@ -1290,7 +1303,10 @@ function acquireShadowHomeSyncLock(originalCodexHome) { const lockPath = join(originalCodexHome, SHADOW_HOME_SYNC_LOCK_DIR); mkdirSync(originalCodexHome, { recursive: true }); const lastRetryAttempt = SHADOW_HOME_CLEANUP_BACKOFF_MS.length; - for (let attempt = 0; attempt <= lastRetryAttempt + 1; attempt += 1) { + const maxStaleRecoveries = SHADOW_HOME_CLEANUP_BACKOFF_MS.length + 1; + let staleRecoveries = 0; + let attempt = 0; + while (attempt <= lastRetryAttempt) { try { mkdirSync(lockPath); writeFileSync( @@ -1314,15 +1330,21 @@ function acquireShadowHomeSyncLock(originalCodexHome) { throw error; } if (attempt >= lastRetryAttempt) { - if (removeStaleShadowHomeSyncLock(lockPath)) { + if ( + staleRecoveries < maxStaleRecoveries && + removeStaleShadowHomeSyncLock(lockPath) + ) { + staleRecoveries += 1; + attempt = 0; continue; } throw error; } sleepSync(SHADOW_HOME_CLEANUP_BACKOFF_MS[attempt]); + attempt += 1; } } - return () => {}; + throw new Error("Failed to acquire shadow home sync lock"); } function syncShadowHomeStateFile( diff --git a/test/codex-bin-wrapper.test.ts b/test/codex-bin-wrapper.test.ts index 08b4715a..0e868102 100644 --- a/test/codex-bin-wrapper.test.ts +++ b/test/codex-bin-wrapper.test.ts @@ -356,6 +356,12 @@ function injectShadowPreflightReadBusyFailures( }; } +function injectShadowLockRecreatedStaleCount(count = 2): NodeJS.ProcessEnv { + return { + CODEX_MULTI_AUTH_TEST_SHADOW_LOCK_RECREATE_STALE_COUNT: String(count), + }; +} + function createFakeGlobalCodexInstall(rootDir: string): string { const fakeBin = join(rootDir, "@openai", "codex", "bin", "codex.js"); mkdirSync(dirname(fakeBin), { recursive: true }); @@ -1698,6 +1704,54 @@ describe("codex bin wrapper", () => { expect(existsSync(lockDir)).toBe(false); }); + 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");', + '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"); + mkdirSync(lockDir, { recursive: true }); + writeFileSync( + 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, + TMP: controlledTmp, + TEMP: controlledTmp, + TMPDIR: controlledTmp, + ...injectShadowLockRecreatedStaleCount(2), + }, + ); + + 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("does not steal fresh orphaned shadow sync locks", () => { const fixtureRoot = createWrapperFixture(); const fakeBin = createCustomFakeCodexBin(fixtureRoot, [ From 4ed2bbac7d8254430a360e9eb8ba551d3e3c30a4 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 25 Apr 2026 14:22:17 +0800 Subject: [PATCH 28/42] Fix post-runtime Codex active selection sync --- scripts/codex.js | 10 +++++++--- test/codex-bin-wrapper.test.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/scripts/codex.js b/scripts/codex.js index 05bf534b..53e96a2e 100755 --- a/scripts/codex.js +++ b/scripts/codex.js @@ -3115,9 +3115,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/test/codex-bin-wrapper.test.ts b/test/codex-bin-wrapper.test.ts index 0e868102..14136c79 100644 --- a/test/codex-bin-wrapper.test.ts +++ b/test/codex-bin-wrapper.test.ts @@ -2883,6 +2883,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"); From 461790131bf92aae7e96a582e68a945daf8fee73 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 25 Apr 2026 14:34:15 +0800 Subject: [PATCH 29/42] Fix app helper EPERM owner liveness --- scripts/codex.js | 7 +-- test/codex-bin-wrapper.test.ts | 105 +++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 6 deletions(-) diff --git a/scripts/codex.js b/scripts/codex.js index 53e96a2e..84d77865 100755 --- a/scripts/codex.js +++ b/scripts/codex.js @@ -1972,12 +1972,7 @@ function isProcessAlive(pid) { } function isRuntimeRotationAppHelperOwnerAlive(pid) { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } + return isProcessAlive(pid); } function resolveRuntimeRotationAppHelperDetachGraceMs(env = process.env) { diff --git a/test/codex-bin-wrapper.test.ts b/test/codex-bin-wrapper.test.ts index 14136c79..7c697054 100644 --- a/test/codex-bin-wrapper.test.ts +++ b/test/codex-bin-wrapper.test.ts @@ -1114,6 +1114,111 @@ describe("codex bin wrapper", () => { } }); + 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); From d156018ceab593550748429ae4e6dbe52c3463e3 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 25 Apr 2026 14:45:03 +0800 Subject: [PATCH 30/42] Clean up app helper shim directories --- scripts/codex.js | 63 ++++++++++++++++++++++------------ test/codex-bin-wrapper.test.ts | 14 +------- 2 files changed, 42 insertions(+), 35 deletions(-) diff --git a/scripts/codex.js b/scripts/codex.js index 84d77865..6beb5905 100755 --- a/scripts/codex.js +++ b/scripts/codex.js @@ -1910,27 +1910,36 @@ function installRuntimeRotationAppServerCliShim(forwardedEnv) { const executablePath = join(shimDir, executableName); const preloadPath = join(shimDir, "codex-multi-auth-app-server-preload.mjs"); 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. + 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( @@ -1939,6 +1948,7 @@ function installRuntimeRotationAppServerCliShim(forwardedEnv) { ); forwardedEnv.CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY = "0"; forwardedEnv[APP_SERVER_ACCOUNT_LABEL_ENV] = "1"; + return shimDir; } function resolveRuntimeRotationAppHelperStatusPath(env = process.env) { @@ -2063,6 +2073,7 @@ function createRuntimeRotationAppHelperStatus({ async function runRuntimeRotationAppHelper() { let proxyServer = null; let shadowContext = null; + let appServerShimDir = null; let statusTimer = null; let closing = false; const startedAt = Date.now(); @@ -2090,6 +2101,14 @@ async function runRuntimeRotationAppHelper() { clearInterval(statusTimer); } try { + if (appServerShimDir) { + try { + removeDirectoryWithRetry(appServerShimDir); + } catch { + // Best-effort shim cleanup only. + } + appServerShimDir = null; + } shadowContext?.cleanup?.(); } finally { try { @@ -2122,7 +2141,7 @@ async function runRuntimeRotationAppHelper() { proxyServer.baseUrl, clientApiKey, ); - installRuntimeRotationAppServerCliShim(shadowContext.env); + appServerShimDir = installRuntimeRotationAppServerCliShim(shadowContext.env); lastRequestCount = proxyServer.getStatus?.().totalRequests ?? 0; publishStatus("running"); process.stdout.write( diff --git a/test/codex-bin-wrapper.test.ts b/test/codex-bin-wrapper.test.ts index 7c697054..c2c2ea98 100644 --- a/test/codex-bin-wrapper.test.ts +++ b/test/codex-bin-wrapper.test.ts @@ -1098,19 +1098,7 @@ describe("codex bin wrapper", () => { expect(existsSync(shadowHomeMatch[1])).toBe(false); } if (cliPathMatch?.[1]) { - expect( - existsSync( - join( - cliPathMatch[1], - process.platform === "win32" ? "codex.exe" : "codex", - ), - ), - ).toBe(true); - expect( - existsSync( - join(cliPathMatch[1], "codex-multi-auth-app-server-preload.mjs"), - ), - ).toBe(true); + expect(existsSync(cliPathMatch[1])).toBe(false); } }); From 9e7bf5db72f15cf921974e2429795e42efe5e920 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 25 Apr 2026 15:02:24 +0800 Subject: [PATCH 31/42] Harden app rotation auth surfaces --- lib/runtime/app-bind.ts | 11 ++- scripts/codex.js | 147 +++++++++++++++++++++++++++------ test/app-bind.test.ts | 22 +++-- test/codex-bin-wrapper.test.ts | 117 +++++++++++++++++++++++--- 4 files changed, 249 insertions(+), 48 deletions(-) diff --git a/lib/runtime/app-bind.ts b/lib/runtime/app-bind.ts index ff263ebf..ef792071 100644 --- a/lib/runtime/app-bind.ts +++ b/lib/runtime/app-bind.ts @@ -536,10 +536,19 @@ function readPortFromBaseUrl(baseUrl: string | null, fallback: number): number { } } +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", - `"${state.nodePath}" "${state.routerScriptPath}" --port ${state.port} --status "${state.statusPath}" --state "${state.statePath}" >> "${state.logPath}" 2>&1`, + `"${nodePath}" "${routerScriptPath}" --port ${state.port} --status "${statusPath}" --state "${statePath}" >> "${logPath}" 2>&1`, "", ].join("\r\n"); } diff --git a/scripts/codex.js b/scripts/codex.js index 6beb5905..02bfbc98 100755 --- a/scripts/codex.js +++ b/scripts/codex.js @@ -34,6 +34,10 @@ const RETRYABLE_SHADOW_HOME_CLEANUP_CODES = new Set(["EBUSY", "EPERM", "ENOTEMPT const SHADOW_HOME_CLEANUP_BACKOFF_MS = [20, 60, 120]; const SHADOW_HOME_ORPHAN_LOCK_STALE_AGE_MS = 2_000; 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"; @@ -458,32 +462,73 @@ function createProtocolLineAccumulator(onLine) { }; } +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 maxPendingAccountReadIds = 4096; - const pendingAccountReadIds = new Set(); + const maxPendingAuthRequestIds = 4096; + const pendingAuthRequestMethodsById = new Map(); const inputLines = createProtocolLineAccumulator((line) => { const { body } = splitProtocolLineEnding(line); const message = parseJsonObjectLine(body); - if (message?.method !== "account/read" || !Object.hasOwn(message, "id")) { + if ( + ![ + "account/read", + "account/rateLimits/read", + "getAuthStatus", + ].includes(message?.method) || + !Object.hasOwn(message, "id") + ) { return; } const key = jsonRpcIdKey(message.id); if (key) { - if (pendingAccountReadIds.size >= maxPendingAccountReadIds) { - pendingAccountReadIds.clear(); + if (pendingAuthRequestMethodsById.size >= maxPendingAuthRequestIds) { + pendingAuthRequestMethodsById.clear(); if (!warnedPendingAccountReadIdOverflow) { warnedPendingAccountReadIdOverflow = true; console.error( - "codex-multi-auth: cleared pending app-server account/read ids after exceeding the safety cap.", + "codex-multi-auth: cleared pending app-server auth request ids after exceeding the safety cap.", ); } } - pendingAccountReadIds.add(key); + pendingAuthRequestMethodsById.set(key, message.method); } }); const outputLines = createProtocolLineAccumulator((line) => { process.stdout.write( - rewriteAppServerAccountReadResponseLine(line, pendingAccountReadIds), + rewriteAppServerAccountReadResponseLine(line, pendingAuthRequestMethodsById), ); }); @@ -503,30 +548,33 @@ function createAppServerAccountReadProtocolProxy() { }; } -function rewriteAppServerAccountReadResponseLine(line, pendingAccountReadIds) { +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 || !pendingAccountReadIds.has(key)) { + if (!key || !pendingAuthRequestMethodsById.has(key)) { return line; } - pendingAccountReadIds.delete(key); - if (!Object.hasOwn(message, "result")) { + 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({ - ...message, - result: { - account: { - type: "chatgpt", - email: APP_SERVER_ACCOUNT_DISPLAY_NAME, - planType: "unknown", - }, - requiresOpenaiAuth: false, - }, + jsonrpc: typeof message.jsonrpc === "string" ? message.jsonrpc : "2.0", + id: message.id, + result, })}${lineEnding}`; } @@ -1782,6 +1830,24 @@ 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); @@ -1812,6 +1878,7 @@ function createRuntimeRotationProxyCodexHome(baseEnv, proxyBaseUrl, clientApiKey shadowCodexHome, tightenShadowHomePermissions, ); + omitRuntimeRotationShadowHomeStateFiles(shadowCodexHome); const originalConfigPath = join(originalCodexHome, "config.toml"); const rawConfig = existsSync(originalConfigPath) ? readFileSync(originalConfigPath, "utf8") @@ -1957,6 +2024,37 @@ function resolveRuntimeRotationAppHelperStatusPath(env = process.env) { return join(multiAuthDir, APP_RUNTIME_HELPER_STATUS_FILE); } +function writeOwnerOnlyJsonFileAtomicSync(targetPath, payload) { + const targetDir = dirname(targetPath); + mkdirSync(targetDir, { recursive: true }); + 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); + renameSync(tempPath, targetPath); + chmodSync(targetPath, 0o600); + } catch (error) { + try { + rmSync(tempPath, { force: true }); + } catch { + // Preserve the original write failure. + } + throw error; + } +} + function resolveRuntimeRotationAppHelperIdleMs(env = process.env) { const parsed = Number.parseInt( env.CODEX_MULTI_AUTH_APP_ROTATION_IDLE_MS ?? "", @@ -2020,12 +2118,7 @@ function pickRuntimeRotationAppHelperEnv(env) { function writeRuntimeRotationAppHelperStatus(payload, env = process.env) { try { const statusPath = resolveRuntimeRotationAppHelperStatusPath(env); - mkdirSync(dirname(statusPath), { recursive: true }); - writeFileSync(statusPath, `${JSON.stringify(payload, null, 2)}\n`, { - encoding: "utf8", - mode: 0o600, - }); - chmodSync(statusPath, 0o600); + writeOwnerOnlyJsonFileAtomicSync(statusPath, payload); } catch { // Best-effort status only; the helper must not fail because telemetry is unavailable. } diff --git a/test/app-bind.test.ts b/test/app-bind.test.ts index e3bf8a52..c12b53b4 100644 --- a/test/app-bind.test.ts +++ b/test/app-bind.test.ts @@ -189,9 +189,11 @@ describe("Codex app runtime rotation bind", () => { 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, "AppData", "Roaming"); + 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, @@ -209,16 +211,16 @@ describe("Codex app runtime rotation bind", () => { env, port: 4567, baseUrl: "http://127.0.0.1:4567", - nodePath: "node", - routerScriptPath: join(root, "codex-app-router.js"), + nodePath, + routerScriptPath, }); const result = await bindCodexAppRuntimeRotation({ platform: "win32", home: root, env, - nodePath: "node", - routerScriptPath: join(root, "codex-app-router.js"), + nodePath, + routerScriptPath, spawnDetached: false, now: () => 123, }); @@ -245,6 +247,12 @@ describe("Codex app runtime rotation bind", () => { const startup = await readFile(result.status.paths.startupPath ?? "", "utf8"); expect(startup).toContain("--state"); 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({ diff --git a/test/codex-bin-wrapper.test.ts b/test/codex-bin-wrapper.test.ts index c2c2ea98..5a88448b 100644 --- a/test/codex-bin-wrapper.test.ts +++ b/test/codex-bin-wrapper.test.ts @@ -909,7 +909,7 @@ describe("codex bin wrapper", () => { expect(result.stdout).toContain('"ok":true'); }); - it("clears pending app-server account/read ids when the response is an error", () => { + it("suppresses app-server account/read errors with a synthetic multi-auth account", () => { const fixtureRoot = createWrapperFixture(); createRuntimeRotationProxyFixtureModule(fixtureRoot); const fakeBin = createCustomFakeCodexBin(fixtureRoot, [ @@ -919,15 +919,7 @@ describe("codex bin wrapper", () => { '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: "upstream failed" } }));', - " console.log(JSON.stringify({", - ' jsonrpc: "2.0",', - " id: message.id,", - " result: {", - ' account: { type: "chatgpt", email: "real-user@example.com", planType: "plus" },', - " requiresOpenaiAuth: true,", - " },", - " }));", + ' 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));', @@ -955,9 +947,78 @@ describe("codex bin wrapper", () => { ); expect(result.status).toBe(0); - expect(result.stdout).toContain('"error":{"code":-32000'); - expect(result.stdout).toContain("real-user@example.com"); - expect(result.stdout).not.toContain("codex-multi-auth"); + 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([ @@ -1002,6 +1063,12 @@ describe("codex bin wrapper", () => { '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")}`);', + '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 });', @@ -1017,6 +1084,24 @@ describe("codex bin wrapper", () => { 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, @@ -1047,6 +1132,12 @@ describe("codex bin wrapper", () => { 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("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", From b22f79ea883202da18af3b0b74d5c27786b92445 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 25 Apr 2026 15:10:47 +0800 Subject: [PATCH 32/42] Harden app router and launcher review fixes --- scripts/codex-app-launcher.js | 40 ++++++++++++++++++++++++++-- scripts/codex-app-router.js | 46 ++++++++++++++++++++++++++++----- test/codex-bin-wrapper.test.ts | 5 ++++ test/install-codex-auth.test.ts | 44 ++++++++++++++++++++++++++++--- 4 files changed, 123 insertions(+), 12 deletions(-) diff --git a/scripts/codex-app-launcher.js b/scripts/codex-app-launcher.js index 1e87e16a..baf2f8fb 100644 --- a/scripts/codex-app-launcher.js +++ b/scripts/codex-app-launcher.js @@ -26,6 +26,13 @@ function quotePowerShellSingle(value) { return `'${value.replace(/'/g, "''")}'`; } +/** + * @param {string} value + */ +function encodePowerShellCommand(value) { + return Buffer.from(value, "utf16le").toString("base64"); +} + /** * @param {boolean} value */ @@ -73,6 +80,15 @@ function resolveWindowsStartMenuDir(env, home) { 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 @@ -139,6 +155,22 @@ 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, @@ -169,8 +201,12 @@ export function resolveAppLauncherPlan(options = {}) { ...resolveWindowsDesktopDirs(env, home), ], backupPath: join(resolveCodexMultiAuthDir(env, home), WINDOWS_BACKUP_FILE_NAME), - commandPath: nodePath, - commandArgs: `"${codexScriptPath}" app`, + commandPath: resolveWindowsPowerShellPath(env), + commandArgs: createWindowsLauncherCommandArgs({ + nodePath, + codexScriptPath, + workingDirectory: home, + }), commandArgv, workingDirectory: home, iconPath: nodePath, diff --git a/scripts/codex-app-router.js b/scripts/codex-app-router.js index a26ef68f..7203b580 100644 --- a/scripts/codex-app-router.js +++ b/scripts/codex-app-router.js @@ -1,7 +1,16 @@ #!/usr/bin/env node -import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { dirname } from "node:path"; +import { + chmodSync, + closeSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { basename, dirname, join } from "node:path"; import process from "node:process"; function parsePort(value) { @@ -65,15 +74,38 @@ function readTrimmedString(record, key) { 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(dirname(statusPath), { recursive: true }); - writeFileSync(statusPath, `${JSON.stringify(payload, null, 2)}\n`, { - encoding: "utf8", - mode: 0o600, - }); + 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. + } } } diff --git a/test/codex-bin-wrapper.test.ts b/test/codex-bin-wrapper.test.ts index 5a88448b..48515529 100644 --- a/test/codex-bin-wrapper.test.ts +++ b/test/codex-bin-wrapper.test.ts @@ -1483,6 +1483,11 @@ describe("codex bin wrapper", () => { 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", () => { diff --git a/test/install-codex-auth.test.ts b/test/install-codex-auth.test.ts index 8f57a391..7cb4292c 100644 --- a/test/install-codex-auth.test.ts +++ b/test/install-codex-auth.test.ts @@ -44,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"); @@ -241,9 +247,20 @@ describe("codex app launcher installer", () => { path.join(home, "Desktop"), ]), ); - expect(plan.commandPath).toBe(process.execPath); - expect(plan.commandArgs).toContain("scripts\\codex.js"); - expect(plan.commandArgs).toContain(" app"); + 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"); @@ -255,6 +272,27 @@ describe("codex app launcher installer", () => { 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"); From 503ba9c8249c94b99be1d5c61ae3251a90d176b0 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 25 Apr 2026 15:25:20 +0800 Subject: [PATCH 33/42] Fix shadow sync and proxy error review findings --- lib/runtime-rotation-proxy.ts | 4 +++ scripts/codex.js | 32 ++++++++++++++--- test/codex-bin-wrapper.test.ts | 54 ++++++++++++++++++++++++++++- test/runtime-rotation-proxy.test.ts | 48 +++++++++++++++++++++++++ 4 files changed, 133 insertions(+), 5 deletions(-) diff --git a/lib/runtime-rotation-proxy.ts b/lib/runtime-rotation-proxy.ts index 1ed0f01a..6dc14230 100644 --- a/lib/runtime-rotation-proxy.ts +++ b/lib/runtime-rotation-proxy.ts @@ -1038,6 +1038,9 @@ export async function startRuntimeRotationProxy( const server = createServer((req, res) => { void handleRequest(req, res); }); + const onPostStartupServerError = (error: Error): void => { + status.lastError = error.message; + }; await new Promise((resolve, reject) => { const onError = (error: Error): void => { @@ -1052,6 +1055,7 @@ export async function startRuntimeRotationProxy( server.once("listening", onListening); server.listen(port, host); }); + server.on("error", onPostStartupServerError); const address = server.address(); const resolvedPort = diff --git a/scripts/codex.js b/scripts/codex.js index 02bfbc98..fd4947ca 100755 --- a/scripts/codex.js +++ b/scripts/codex.js @@ -1418,6 +1418,26 @@ function syncShadowHomeStateFile( } } +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(); @@ -1520,12 +1540,12 @@ function syncShadowHomeAuthBundle( if (shadowHomeStateMatches(shadowState, originalSnapshot)) { continue; } - syncShadowHomeStateFile( + syncShadowHomeStateFileBestEffort( shadowPath, originalPath, originalSnapshot, + tightenFile, ); - tightenFile(originalPath); } } @@ -1556,8 +1576,12 @@ function syncAdditionalShadowHomeFiles( if (shadowHomeStateMatches(shadowState, originalSnapshot)) { continue; } - syncShadowHomeStateFile(shadowPath, originalPath, originalSnapshot); - tightenFile(originalPath); + syncShadowHomeStateFileBestEffort( + shadowPath, + originalPath, + originalSnapshot, + tightenFile, + ); } } diff --git a/test/codex-bin-wrapper.test.ts b/test/codex-bin-wrapper.test.ts index 48515529..dfc12b8a 100644 --- a/test/codex-bin-wrapper.test.ts +++ b/test/codex-bin-wrapper.test.ts @@ -1789,7 +1789,59 @@ describe("codex bin wrapper", () => { readdirSync(controlledTmp).filter((entry) => entry.startsWith("codex-multi-auth-home-"), ), - ).toEqual([]); + ).toEqual([]); + }); + + 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("removes stale shadow sync locks before publishing refreshed auth state", () => { diff --git a/test/runtime-rotation-proxy.test.ts b/test/runtime-rotation-proxy.test.ts index 7796c4f4..43c733d2 100644 --- a/test/runtime-rotation-proxy.test.ts +++ b/test/runtime-rotation-proxy.test.ts @@ -142,6 +142,42 @@ async function postRawResponses( }); } +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, @@ -192,6 +228,18 @@ describe("runtime rotation proxy", () => { ).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("rejects unauthenticated local clients when a wrapper token is configured", async () => { const now = Date.now(); const accountManager = new AccountManager(undefined, createStorage(now)); From c781818e0da98008004a786e6353cce1ae826048 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 25 Apr 2026 15:56:09 +0800 Subject: [PATCH 34/42] Fix Greptile runtime rotation cleanup items --- lib/runtime/app-bind.ts | 135 ++------------------------ lib/runtime/config-toml.ts | 150 +++++++++++++++++++++++++++++ scripts/codex.js | 167 +++++++++++++-------------------- test/codex-bin-wrapper.test.ts | 121 ++++++++++++++++++++++++ 4 files changed, 345 insertions(+), 228 deletions(-) create mode 100644 lib/runtime/config-toml.ts diff --git a/lib/runtime/app-bind.ts b/lib/runtime/app-bind.ts index ef792071..0d95e371 100644 --- a/lib/runtime/app-bind.ts +++ b/lib/runtime/app-bind.ts @@ -7,8 +7,11 @@ import { basename, dirname, join } from "node:path"; import process from "node:process"; import { fileURLToPath } from "node:url"; import { withFileOperationRetry } from "../fs-retry.js"; -import { RUNTIME_ROTATION_PROXY_PROVIDER_ID } from "../runtime-constants.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"; @@ -119,140 +122,22 @@ async function withAppBindLock( } } -function tomlStringLiteral(value: string): string { - return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; -} - -function readTomlTableName(line: string): string | null { - const match = /^\s*\[{1,2}\s*([^\]]+?)\s*\]{1,2}\s*$/.exec(line); - return match?.[1]?.trim() ?? null; -} - -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 trimmed = line.trim(); - if (trimmed === `[model_providers.${RUNTIME_ROTATION_PROXY_PROVIDER_ID}]`) { - skipping = true; - continue; - } - const tableName = readTomlTableName(line); - 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 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; -} - -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); -} - -function ensureTrailingNewline(value: string): string { - return value.replace(/[\r\n]*$/, "\n"); -} - -function createRuntimeRotationProviderBlock(baseUrl: string, clientApiKey: string): 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 rewriteConfigTomlForAppBind( 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( + return rewriteConfigTomlForRuntimeRotationProvider( + rawConfig, baseUrl, clientApiKey, - ).join(lineEnding); - return `${withModelProvider}${lineEnding}${lineEnding}${providerBlock}${lineEnding}`; + ); } export function restoreConfigTomlFromAppBind(currentConfig: string, originalConfig: string): string { - const withoutProvider = removeRuntimeRotationProviderBlock(currentConfig); - return ensureTrailingNewline( - restoreTopLevelModelProvider(withoutProvider, originalConfig).replace(/[\r\n]*$/, ""), + return restoreConfigTomlFromRuntimeRotationProvider( + currentConfig, + originalConfig, ); } diff --git a/lib/runtime/config-toml.ts b/lib/runtime/config-toml.ts new file mode 100644 index 00000000..30094fef --- /dev/null +++ b/lib/runtime/config-toml.ts @@ -0,0 +1,150 @@ +import { RUNTIME_ROTATION_PROXY_PROVIDER_ID } from "../runtime-constants.js"; + +export function tomlStringLiteral(value: string): string { + return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; +} + +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 trimmed = line.trim(); + if (trimmed === `[model_providers.${RUNTIME_ROTATION_PROXY_PROVIDER_ID}]`) { + skipping = true; + continue; + } + const tableName = readTomlTableName(line); + 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/scripts/codex.js b/scripts/codex.js index fd4947ca..b6ebdbc6 100755 --- a/scripts/codex.js +++ b/scripts/codex.js @@ -237,6 +237,24 @@ async function loadRuntimeRotationConfigModule() { } } +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; @@ -1335,11 +1353,10 @@ function removeStaleShadowHomeSyncLock(lockPath) { if (shadowHomeSyncLockRecreateStaleCount > 0) { shadowHomeSyncLockRecreateStaleCount -= 1; mkdirSync(lockPath, { recursive: true }); - writeFileSync( - join(lockPath, "owner.json"), - `${JSON.stringify({ pid: 2_147_483_647, createdAt: 1 })}\n`, - "utf8", - ); + writeShadowHomeSyncLockOwner(lockPath, { + pid: 2_147_483_647, + createdAt: 1, + }); } return true; } catch { @@ -1347,6 +1364,19 @@ function removeStaleShadowHomeSyncLock(lockPath) { } } +function writeShadowHomeSyncLockOwner(lockPath, owner) { + const ownerPath = join(lockPath, "owner.json"); + writeFileSync(ownerPath, `${JSON.stringify(owner)}\n`, { + encoding: "utf8", + mode: 0o600, + }); + try { + chmodSync(ownerPath, 0o600); + } catch { + // Best-effort only; permission semantics vary by platform. + } +} + function acquireShadowHomeSyncLock(originalCodexHome) { const lockPath = join(originalCodexHome, SHADOW_HOME_SYNC_LOCK_DIR); mkdirSync(originalCodexHome, { recursive: true }); @@ -1357,11 +1387,10 @@ function acquireShadowHomeSyncLock(originalCodexHome) { while (attempt <= lastRetryAttempt) { try { mkdirSync(lockPath); - writeFileSync( - join(lockPath, "owner.json"), - `${JSON.stringify({ pid: process.pid, createdAt: Date.now() })}\n`, - "utf8", - ); + writeShadowHomeSyncLockOwner(lockPath, { + pid: process.pid, + createdAt: Date.now(), + }); return () => { try { removeDirectoryWithRetry(lockPath); @@ -1763,93 +1792,6 @@ async function isRuntimeRotationProxyEnabled(rawArgs, baseEnv = process.env) { return configModule.getCodexRuntimeRotationProxy(pluginConfig) === true; } -function tomlStringLiteral(value) { - return `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; -} - -function readTomlTableName(line) { - const match = /^\s*\[{1,2}\s*([^\]]+?)\s*\]{1,2}\s*$/.exec(line); - return match?.[1]?.trim() ?? null; -} - -function removeRuntimeRotationProviderBlock(rawConfig) { - const lines = rawConfig.split(/\r?\n/); - const output = []; - let skipping = false; - const providerTable = `model_providers.${RUNTIME_ROTATION_PROXY_PROVIDER_ID}`; - for (const line of lines) { - const trimmed = line.trim(); - if (trimmed === `[model_providers.${RUNTIME_ROTATION_PROXY_PROVIDER_ID}]`) { - skipping = true; - continue; - } - const tableName = readTomlTableName(line); - 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 rewriteTopLevelModelProvider(rawConfig) { - 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 = []; - - 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 rewriteConfigTomlForRuntimeRotationProxy( - rawConfig, - proxyBaseUrl, - clientApiKey, -) { - 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 = [ - `[model_providers.${RUNTIME_ROTATION_PROXY_PROVIDER_ID}]`, - 'name = "codex-multi-auth"', - `base_url = ${tomlStringLiteral(proxyBaseUrl)}`, - "requires_openai_auth = false", - `experimental_bearer_token = ${tomlStringLiteral(clientApiKey)}`, - 'wire_api = "responses"', - ]; - return `${withModelProvider}${lineEnding}${lineEnding}${providerBlock.join(lineEnding)}${lineEnding}`; -} - function createRuntimeRotationProxyClientApiKey() { return randomBytes(32).toString("hex"); } @@ -1877,7 +1819,12 @@ function resolveRuntimeRotationProxyOriginalCodexHome(baseEnv) { return override || resolveCodexHomeDir(baseEnv); } -function createRuntimeRotationProxyCodexHome(baseEnv, proxyBaseUrl, clientApiKey) { +function createRuntimeRotationProxyCodexHome( + baseEnv, + proxyBaseUrl, + clientApiKey, + configTomlModule, +) { const originalCodexHome = resolveRuntimeRotationProxyOriginalCodexHome(baseEnv); const shadowCodexHome = mkdtempSync(join(tmpdir(), "codex-multi-auth-runtime-home-")); let syncShadowHomeStateBack = () => {}; @@ -1907,7 +1854,7 @@ function createRuntimeRotationProxyCodexHome(baseEnv, proxyBaseUrl, clientApiKey const rawConfig = existsSync(originalConfigPath) ? readFileSync(originalConfigPath, "utf8") : ""; - const runtimeConfig = rewriteConfigTomlForRuntimeRotationProxy( + const runtimeConfig = configTomlModule.rewriteConfigTomlForRuntimeRotationProvider( rawConfig, proxyBaseUrl, clientApiKey, @@ -2251,12 +2198,17 @@ async function runRuntimeRotationAppHelper() { 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; @@ -2403,7 +2355,7 @@ function startRuntimeRotationAppHelper(baseContext) { }); } -async function createRuntimeRotationAppHelperContext(baseContext) { +async function createRuntimeRotationAppHelperContext(baseContext, configTomlModule) { const startedAt = Date.now(); const { helper, message } = await startRuntimeRotationAppHelper(baseContext); const helperEnv = message.env ?? {}; @@ -2424,7 +2376,7 @@ async function createRuntimeRotationAppHelperContext(baseContext) { args: [ ...baseContext.args, "-c", - `model_provider=${tomlStringLiteral(RUNTIME_ROTATION_PROXY_PROVIDER_ID)}`, + `model_provider=${configTomlModule.tomlStringLiteral(RUNTIME_ROTATION_PROXY_PROVIDER_ID)}`, ], env: { ...baseContext.env, @@ -2449,8 +2401,16 @@ async function createRuntimeRotationProxyContextIfEnabled( return baseContext; } + 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); + return createRuntimeRotationAppHelperContext(baseContext, configTomlModule); } const proxyModule = await loadRuntimeRotationProxyModule(); @@ -2470,6 +2430,7 @@ async function createRuntimeRotationProxyContextIfEnabled( baseContext.env, proxyServer.baseUrl, clientApiKey, + configTomlModule, ); } catch (error) { try { @@ -2499,7 +2460,7 @@ async function createRuntimeRotationProxyContextIfEnabled( args: [ ...baseContext.args, "-c", - `model_provider=${tomlStringLiteral(RUNTIME_ROTATION_PROXY_PROVIDER_ID)}`, + `model_provider=${configTomlModule.tomlStringLiteral(RUNTIME_ROTATION_PROXY_PROVIDER_ID)}`, ], env: shadowContext.env, cleanup, diff --git a/test/codex-bin-wrapper.test.ts b/test/codex-bin-wrapper.test.ts index dfc12b8a..e6fd64f1 100644 --- a/test/codex-bin-wrapper.test.ts +++ b/test/codex-bin-wrapper.test.ts @@ -174,7 +174,74 @@ 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) {", + " return `\"${String(value).replace(/\\\\/g, '\\\\\\\\').replace(/\"/g, '\\\\\"')}\"`;", + "}", + "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) {", + " if (line.trim() === `[model_providers.${providerId}]`) { skipping = true; continue; }", + " const tableName = readTomlTableName(line);", + " 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"); @@ -1051,6 +1118,7 @@ describe("codex bin wrapper", () => { '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 ?? ""}`);', @@ -1063,6 +1131,10 @@ describe("codex bin wrapper", () => { '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"))}`);', @@ -1132,6 +1204,8 @@ describe("codex bin wrapper", () => { 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"); @@ -1993,6 +2067,53 @@ describe("codex bin wrapper", () => { expect(existsSync(lockDir)).toBe(false); }); + it("writes shadow sync lock owner metadata with owner-only permissions", () => { + const fixtureRoot = createWrapperFixture(); + 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"), '{"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(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, + TMP: controlledTmp, + TEMP: controlledTmp, + TMPDIR: controlledTmp, + ...injectShadowLockRecreatedStaleCount(99), + }, + ); + + 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("does not steal fresh orphaned shadow sync locks", () => { const fixtureRoot = createWrapperFixture(); const fakeBin = createCustomFakeCodexBin(fixtureRoot, [ From 61472eb16c208847fc502f752167a008ddb25849 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 25 Apr 2026 16:14:38 +0800 Subject: [PATCH 35/42] Harden runtime rotation shutdown and sync --- lib/runtime-rotation-proxy.ts | 19 ++++++++++-- lib/runtime/app-bind.ts | 25 ++++++++++++--- scripts/codex.js | 20 ++++++++++-- test/app-bind.test.ts | 48 +++++++++++++++++++++++++++++ test/codex-bin-wrapper.test.ts | 23 ++++++++++++++ test/runtime-rotation-proxy.test.ts | 46 +++++++++++++++++++++++++++ 6 files changed, 172 insertions(+), 9 deletions(-) diff --git a/lib/runtime-rotation-proxy.ts b/lib/runtime-rotation-proxy.ts index 6dc14230..4ca177b3 100644 --- a/lib/runtime-rotation-proxy.ts +++ b/lib/runtime-rotation-proxy.ts @@ -1,5 +1,6 @@ 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, @@ -1038,6 +1039,13 @@ export async function startRuntimeRotationProxy( 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; }; @@ -1066,16 +1074,16 @@ export async function startRuntimeRotationProxy( port: resolvedPort, baseUrl: `http://${host}:${resolvedPort}`, close: async () => { + await closeServer(server, sockets); await accountManager.flushPendingSave(); - await closeServer(server); }, getStatus: () => ({ ...status }), }; } -async function closeServer(server: Server): Promise { +async function closeServer(server: Server, sockets: Set): Promise { if (!server.listening) return; - await new Promise((resolve, reject) => { + const closed = new Promise((resolve, reject) => { server.close((error) => { if (error) { reject(error); @@ -1084,4 +1092,9 @@ async function closeServer(server: Server): Promise { 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 index 0d95e371..cfb7d1a1 100644 --- a/lib/runtime/app-bind.ts +++ b/lib/runtime/app-bind.ts @@ -19,6 +19,8 @@ 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 appBindLocks = new Map>(); export interface AppBindPaths { @@ -97,6 +99,7 @@ export interface AppBindOptions { routerScriptPath?: string; routerScriptCandidates?: string[]; spawnDetached?: boolean; + routerReadyTimeoutMs?: number; log?: (message: string) => void; } @@ -541,9 +544,20 @@ async function maybeStartRouter(state: AppBindState, options: AppBindOptions): P return true; } -async function waitForRouterStatus(statusPath: string): Promise { +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; - for (let attempt = 0; attempt < 20; attempt += 1) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { const router = await readRouterStatus(statusPath); latest = router ?? latest; if (router?.state === "error") { @@ -551,7 +565,7 @@ async function waitForRouterStatus(statusPath: string): Promise setTimeout(resolve, 100)); + 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}`); @@ -649,7 +663,10 @@ async function bindCodexAppRuntimeRotationLocked( await atomicWriteFile(paths.statePath, `${JSON.stringify(state, null, 2)}\n`); const startedRouter = await maybeStartRouter(state, options); const router = startedRouter - ? await waitForRouterStatus(state.statusPath) + ? await waitForRouterStatus( + state.statusPath, + resolveRouterReadyTimeoutMs(options), + ) : await readRouterStatus(state.statusPath); const routerBaseUrl = router?.baseUrl ?? null; const routerIsUsable = diff --git a/scripts/codex.js b/scripts/codex.js index b6ebdbc6..f2888072 100755 --- a/scripts/codex.js +++ b/scripts/codex.js @@ -1552,8 +1552,12 @@ function syncShadowHomeAuthBundle( shadowCodexHome, originalFileStates, tightenFile, + skipSyncBackNames = new Set(), ) { 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) { @@ -1584,9 +1588,10 @@ function syncAdditionalShadowHomeFiles( names, originalFileStates, tightenFile, + skipSyncBackNames = new Set(), ) { for (const name of names) { - if (SHADOW_HOME_STATE_FILE_SET.has(name)) { + if (SHADOW_HOME_STATE_FILE_SET.has(name) || skipSyncBackNames.has(name)) { continue; } const shadowPath = join(shadowCodexHome, name); @@ -1614,8 +1619,14 @@ function syncAdditionalShadowHomeFiles( } } -function createShadowHomeMirror(originalCodexHome, shadowCodexHome, 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 rememberSyncFile = (name) => { if (!originalFileStates.has(name)) { @@ -1687,6 +1698,7 @@ function createShadowHomeMirror(originalCodexHome, shadowCodexHome, tightenFile) shadowCodexHome, originalFileStates, tightenFile, + skipSyncBackNames, ); syncAdditionalShadowHomeFiles( originalCodexHome, @@ -1694,6 +1706,7 @@ function createShadowHomeMirror(originalCodexHome, shadowCodexHome, tightenFile) names, originalFileStates, tightenFile, + skipSyncBackNames, ); } catch { // Best-effort only; runtime auth refreshes should not fail cleanup. @@ -1848,6 +1861,9 @@ function createRuntimeRotationProxyCodexHome( originalCodexHome, shadowCodexHome, tightenShadowHomePermissions, + { + skipSyncBackNames: RUNTIME_ROTATION_SHADOW_HOME_OMIT_STATE_FILES, + }, ); omitRuntimeRotationShadowHomeStateFiles(shadowCodexHome); const originalConfigPath = join(originalCodexHome, "config.toml"); diff --git a/test/app-bind.test.ts b/test/app-bind.test.ts index c12b53b4..b41ec224 100644 --- a/test/app-bind.test.ts +++ b/test/app-bind.test.ts @@ -473,6 +473,53 @@ describe("Codex app runtime rotation bind", () => { }); }); + 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"); @@ -506,6 +553,7 @@ describe("Codex app runtime rotation bind", () => { env, nodePath: process.execPath, routerScriptPath, + routerReadyTimeoutMs: 500, }), ).rejects.toThrow("did not report ready"); await expect(readFile(join(codexHome, "config.toml"), "utf8")).resolves.toBe( diff --git a/test/codex-bin-wrapper.test.ts b/test/codex-bin-wrapper.test.ts index e6fd64f1..6fb817bc 100644 --- a/test/codex-bin-wrapper.test.ts +++ b/test/codex-bin-wrapper.test.ts @@ -726,6 +726,9 @@ describe("codex bin wrapper", () => { '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());', @@ -744,6 +747,17 @@ describe("codex bin wrapper", () => { 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", @@ -827,6 +841,15 @@ describe("codex bin wrapper", () => { 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("inserts the runtime model provider before TOML array tables", () => { diff --git a/test/runtime-rotation-proxy.test.ts b/test/runtime-rotation-proxy.test.ts index 43c733d2..6414b396 100644 --- a/test/runtime-rotation-proxy.test.ts +++ b/test/runtime-rotation-proxy.test.ts @@ -90,6 +90,12 @@ function createRecordingFetch( 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; @@ -240,6 +246,46 @@ describe("runtime rotation proxy", () => { 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)); From 7de04e0a5ab985a1e24ba758eeb1b2d8e1af2ad8 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 25 Apr 2026 16:38:49 +0800 Subject: [PATCH 36/42] Fix concurrent shadow sync and shim cleanup --- scripts/codex.js | 154 +++++++++++++++++++++++++++++--- test/codex-bin-wrapper.test.ts | 158 +++++++++++++++++++++++++++++++++ 2 files changed, 301 insertions(+), 11 deletions(-) diff --git a/scripts/codex.js b/scripts/codex.js index f2888072..22ef75c1 100755 --- a/scripts/codex.js +++ b/scripts/codex.js @@ -1,7 +1,7 @@ #!/usr/bin/env node import { spawn } from "node:child_process"; -import { randomBytes } from "node:crypto"; +import { createHash, randomBytes } from "node:crypto"; import { chmodSync, copyFileSync, @@ -41,6 +41,7 @@ const RUNTIME_ROTATION_SHADOW_HOME_OMIT_STATE_FILES = new Set([ 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 = @@ -57,6 +58,8 @@ const 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, @@ -1320,6 +1323,79 @@ 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")); @@ -1529,7 +1605,11 @@ function collectShadowHomeSyncFileNames(shadowCodexHome, syncFileNames) { try { for (const entry of readdirSync(shadowCodexHome, { withFileTypes: true })) { const name = entry.name; - if (name === SHADOW_HOME_CONFIG_FILE || syncFileNames.has(name)) { + if ( + name === SHADOW_HOME_CONFIG_FILE || + name === SHADOW_HOME_SYNC_STATE_FILE || + syncFileNames.has(name) + ) { continue; } const shadowPath = join(shadowCodexHome, name); @@ -1554,6 +1634,7 @@ function syncShadowHomeAuthBundle( tightenFile, skipSyncBackNames = new Set(), ) { + const syncState = readShadowHomeSyncState(originalCodexHome); for (const name of SHADOW_HOME_STATE_FILES) { if (skipSyncBackNames.has(name)) { continue; @@ -1567,18 +1648,40 @@ function syncShadowHomeAuthBundle( const originalSnapshot = originalFileStates.get(name) ?? { exists: false, content: null }; const currentOriginalState = captureShadowHomeState(originalPath); + let expectedDestinationState = originalSnapshot; if (!shadowHomeStateMatches(currentOriginalState, originalSnapshot)) { - continue; + if ( + !canRebaseShadowHomeSyncState( + syncState, + name, + originalSnapshot, + currentOriginalState, + ) + ) { + continue; + } + expectedDestinationState = currentOriginalState; } - if (shadowHomeStateMatches(shadowState, originalSnapshot)) { + if ( + expectedDestinationState.unreadable || + shadowHomeStateMatches(shadowState, expectedDestinationState) + ) { continue; } - syncShadowHomeStateFileBestEffort( + if (syncShadowHomeStateFileBestEffort( shadowPath, originalPath, - originalSnapshot, + expectedDestinationState, tightenFile, - ); + )) { + rememberShadowHomeSyncState( + originalCodexHome, + syncState, + name, + originalSnapshot, + shadowState, + ); + } } } @@ -1644,7 +1747,11 @@ function createShadowHomeMirror( if (existsSync(originalCodexHome)) { for (const entry of readdirSync(originalCodexHome, { withFileTypes: true })) { const name = entry.name; - if (name === SHADOW_HOME_CONFIG_FILE) { + 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); @@ -1946,6 +2053,30 @@ function createRuntimeRotationAppServerPreloadSource(wrapperScriptPath) { ].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) { @@ -1954,10 +2085,11 @@ function installRuntimeRotationAppServerCliShim(forwardedEnv) { const multiAuthDir = resolveOriginalMultiAuthDir(forwardedEnv) ?? join(resolveRuntimeRotationProxyOriginalCodexHome(forwardedEnv), "multi-auth"); + const shimRootDir = join(multiAuthDir, APP_SERVER_SHIM_DIR_NAME); + sweepStaleRuntimeRotationAppServerShimDirs(shimRootDir); const shimDir = join( - multiAuthDir, - "app-server-shims", - `helper-${process.pid}`, + shimRootDir, + `${APP_SERVER_SHIM_HELPER_PREFIX}${process.pid}`, ); mkdirSync(shimDir, { recursive: true }); const executableName = process.platform === "win32" ? "codex.exe" : "codex"; diff --git a/test/codex-bin-wrapper.test.ts b/test/codex-bin-wrapper.test.ts index 6fb817bc..cb9d0c93 100644 --- a/test/codex-bin-wrapper.test.ts +++ b/test/codex-bin-wrapper.test.ts @@ -616,6 +616,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 { @@ -1290,6 +1320,56 @@ describe("codex bin wrapper", () => { } }); + 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); @@ -1889,6 +1969,84 @@ describe("codex bin wrapper", () => { ).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", + }, + ); + 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, [ From b9bc1789254008e2ea7b91253c5d9af508b9d04f Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 25 Apr 2026 17:10:32 +0800 Subject: [PATCH 37/42] Fix runtime rotation review regressions --- lib/runtime-rotation-proxy.ts | 1 + lib/runtime/config-toml.ts | 5 ++-- scripts/codex.js | 34 ++++++++++++++++++++++-- test/codex-bin-wrapper.test.ts | 40 +++++++++++++++++++++++++++-- test/runtime-rotation-proxy.test.ts | 2 ++ 5 files changed, 75 insertions(+), 7 deletions(-) diff --git a/lib/runtime-rotation-proxy.ts b/lib/runtime-rotation-proxy.ts index 4ca177b3..e989c431 100644 --- a/lib/runtime-rotation-proxy.ts +++ b/lib/runtime-rotation-proxy.ts @@ -885,6 +885,7 @@ export async function startRuntimeRotationProxy( networkErrorCooldownMs, "network-error", ); + accountManager.saveToDiskDebounced(); exhaustionReason = "network-error"; status.retries += 1; status.rotations += 1; diff --git a/lib/runtime/config-toml.ts b/lib/runtime/config-toml.ts index 30094fef..38d0fe51 100644 --- a/lib/runtime/config-toml.ts +++ b/lib/runtime/config-toml.ts @@ -15,12 +15,11 @@ export function removeRuntimeRotationProviderBlock(rawConfig: string): string { let skipping = false; const providerTable = `model_providers.${RUNTIME_ROTATION_PROXY_PROVIDER_ID}`; for (const line of lines) { - const trimmed = line.trim(); - if (trimmed === `[model_providers.${RUNTIME_ROTATION_PROXY_PROVIDER_ID}]`) { + const tableName = readTomlTableName(line); + if (tableName === providerTable) { skipping = true; continue; } - const tableName = readTomlTableName(line); if (skipping && tableName) { if (tableName === providerTable || tableName.startsWith(`${providerTable}.`)) { continue; diff --git a/scripts/codex.js b/scripts/codex.js index 22ef75c1..7a5edfb5 100755 --- a/scripts/codex.js +++ b/scripts/codex.js @@ -1561,12 +1561,15 @@ function isFileLike(path) { 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; + return "linked"; } catch { // Fall back to a copy when links are unavailable. Directory links are // preferred because they keep sessions, plugins, and skills live. @@ -1575,6 +1578,7 @@ function mirrorDirectoryIntoShadowHome(sourcePath, destinationPath) { recursive: true, dereference: false, }); + return "copied"; } function linkFileIntoShadowHome(sourcePath, destinationPath) { @@ -1627,6 +1631,24 @@ function collectShadowHomeSyncFileNames(shadowCodexHome, syncFileNames) { 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, @@ -1731,6 +1753,7 @@ function createShadowHomeMirror( 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( @@ -1773,7 +1796,9 @@ function createShadowHomeMirror( throw new Error(`Expected ${name} to be a file`); } if (directoryLike) { - mirrorDirectoryIntoShadowHome(sourcePath, destinationPath); + if (mirrorDirectoryIntoShadowHome(sourcePath, destinationPath) === "copied") { + copiedDirectoryNames.add(name); + } continue; } if (fileLike) { @@ -1807,6 +1832,11 @@ function createShadowHomeMirror( tightenFile, skipSyncBackNames, ); + syncCopiedShadowHomeDirectories( + originalCodexHome, + shadowCodexHome, + copiedDirectoryNames, + ); syncAdditionalShadowHomeFiles( originalCodexHome, shadowCodexHome, diff --git a/test/codex-bin-wrapper.test.ts b/test/codex-bin-wrapper.test.ts index cb9d0c93..d389db1d 100644 --- a/test/codex-bin-wrapper.test.ts +++ b/test/codex-bin-wrapper.test.ts @@ -195,8 +195,8 @@ function createRuntimeConfigTomlFixtureModule(fixtureRoot: string): string { " let skipping = false;", " const providerTable = `model_providers.${providerId}`;", " for (const line of lines) {", - " if (line.trim() === `[model_providers.${providerId}]`) { skipping = true; continue; }", " const tableName = readTomlTableName(line);", + " if (tableName === providerTable) { skipping = true; continue; }", " if (skipping && tableName) {", " if (tableName === providerTable || tableName.startsWith(`${providerTable}.`)) continue;", " skipping = false;", @@ -804,7 +804,7 @@ describe("codex bin wrapper", () => { 'name = "Existing"', 'base_url = "https://example.invalid"', "", - `[model_providers.${RUNTIME_ROTATION_PROXY_PROVIDER_ID}]`, + `[ model_providers.${RUNTIME_ROTATION_PROXY_PROVIDER_ID} ]`, 'name = "Stale Runtime Proxy"', 'base_url = "http://127.0.0.1:1"', ].join("\n"), @@ -851,6 +851,7 @@ describe("codex bin wrapper", () => { 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]) { @@ -1924,6 +1925,41 @@ describe("codex bin wrapper", () => { ).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, [ diff --git a/test/runtime-rotation-proxy.test.ts b/test/runtime-rotation-proxy.test.ts index 6414b396..330bb036 100644 --- a/test/runtime-rotation-proxy.test.ts +++ b/test/runtime-rotation-proxy.test.ts @@ -629,6 +629,7 @@ describe("runtime rotation proxy", () => { 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 }); @@ -651,6 +652,7 @@ describe("runtime rotation proxy", () => { ]); 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 () => { From 85d9c9433dddec0a8d842f24dfc44635b22ec800 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 25 Apr 2026 17:30:13 +0800 Subject: [PATCH 38/42] Fix app router review regressions --- lib/runtime/app-bind.ts | 11 ++++- lib/runtime/config-toml.ts | 21 +++++++- scripts/codex-app-router.js | 89 ++++++++++++++++++++++++++++++++++ scripts/codex.js | 73 +++++++++++++++++++--------- test/app-bind.test.ts | 85 +++++++++++++++++++++++++++++++- test/codex-bin-wrapper.test.ts | 25 +++++++++- 6 files changed, 277 insertions(+), 27 deletions(-) diff --git a/lib/runtime/app-bind.ts b/lib/runtime/app-bind.ts index cfb7d1a1..e000d206 100644 --- a/lib/runtime/app-bind.ts +++ b/lib/runtime/app-bind.ts @@ -21,6 +21,7 @@ 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 { @@ -436,7 +437,7 @@ function createWindowsStartupCommand(state: AppBindState): string { const logPath = escapeWindowsBatchPath(state.logPath); return [ "@echo off", - `"${nodePath}" "${routerScriptPath}" --port ${state.port} --status "${statusPath}" --state "${statePath}" >> "${logPath}" 2>&1`, + `"${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"); } @@ -458,6 +459,10 @@ function createMacLaunchAgentPlist(state: AppBindState): string { state.statusPath, "--state", state.statePath, + "--log", + state.logPath, + "--max-log-bytes", + String(APP_ROUTER_MAX_LOG_BYTES), ]; return [ '', @@ -523,6 +528,10 @@ function spawnRouter(state: AppBindState): void { state.statusPath, "--state", state.statePath, + "--log", + state.logPath, + "--max-log-bytes", + String(APP_ROUTER_MAX_LOG_BYTES), ], { detached: true, diff --git a/lib/runtime/config-toml.ts b/lib/runtime/config-toml.ts index 38d0fe51..02a36019 100644 --- a/lib/runtime/config-toml.ts +++ b/lib/runtime/config-toml.ts @@ -1,7 +1,26 @@ import { RUNTIME_ROTATION_PROXY_PROVIDER_ID } from "../runtime-constants.js"; export function tomlStringLiteral(value: string): string { - return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; + 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 { diff --git a/scripts/codex-app-router.js b/scripts/codex-app-router.js index 7203b580..d79766e6 100644 --- a/scripts/codex-app-router.js +++ b/scripts/codex-app-router.js @@ -3,16 +3,24 @@ 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(); @@ -29,6 +37,8 @@ function parseArgs(argv) { port: 0, statusPath: "", statePath: "", + logPath: "", + maxLogBytes: DEFAULT_MAX_LOG_BYTES, }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; @@ -51,6 +61,20 @@ function parseArgs(argv) { 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; @@ -153,9 +177,74 @@ function isLoopbackHost(host) { ); } +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() diff --git a/scripts/codex.js b/scripts/codex.js index 7a5edfb5..4868c925 100755 --- a/scripts/codex.js +++ b/scripts/codex.js @@ -72,6 +72,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, +); const shadowHomeCleanupRetryMarkerDir = (process.env.CODEX_MULTI_AUTH_TEST_SHADOW_RETRY_MARKER_DIR ?? "").trim(); let warnedInvalidRuntimeRotationProxyEnv = false; @@ -926,6 +930,18 @@ 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 writeShadowHomeCleanupRetryMarker(destinationPath, attempt) { if (shadowHomeCleanupRetryMarkerDir.length === 0) { return; @@ -2176,31 +2192,42 @@ function resolveRuntimeRotationAppHelperStatusPath(env = process.env) { function writeOwnerOnlyJsonFileAtomicSync(targetPath, payload) { const targetDir = dirname(targetPath); mkdirSync(targetDir, { recursive: true }); - 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); - renameSync(tempPath, targetPath); - chmodSync(targetPath, 0o600); - } catch (error) { + 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 { - rmSync(tempPath, { force: true }); - } catch { - // Preserve the original write failure. + 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; } - throw error; } } diff --git a/test/app-bind.test.ts b/test/app-bind.test.ts index b41ec224..2789e96c 100644 --- a/test/app-bind.test.ts +++ b/test/app-bind.test.ts @@ -1,4 +1,4 @@ -import { existsSync, statSync } from "node:fs"; +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"; @@ -13,6 +13,7 @@ import { 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"; @@ -155,6 +156,16 @@ describe("Codex app runtime rotation bind", () => { 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"); @@ -246,6 +257,8 @@ describe("Codex app runtime rotation bind", () => { } 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"); @@ -594,6 +607,9 @@ describe("Codex app runtime rotation bind", () => { 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 ?? ""); }); @@ -681,4 +697,71 @@ describe("Codex app runtime rotation bind", () => { 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/codex-bin-wrapper.test.ts b/test/codex-bin-wrapper.test.ts index d389db1d..86333226 100644 --- a/test/codex-bin-wrapper.test.ts +++ b/test/codex-bin-wrapper.test.ts @@ -183,7 +183,19 @@ function createRuntimeConfigTomlFixtureModule(fixtureRoot: string): string { [ `const providerId = ${JSON.stringify(RUNTIME_ROTATION_PROXY_PROVIDER_ID)};`, "export function tomlStringLiteral(value) {", - " return `\"${String(value).replace(/\\\\/g, '\\\\\\\\').replace(/\"/g, '\\\\\"')}\"`;", + " 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);", @@ -423,6 +435,16 @@ 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), @@ -2050,6 +2072,7 @@ describe("codex bin wrapper", () => { { ...commonEnv, CODEX_MULTI_AUTH_TEST_SESSION_ID: "first", + ...injectShadowSyncMetadataBusyFailures(), }, ); const second = runWrapperAsync( From 4c80bbb6793783eebde74c508a1cd116e1a0676f Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 25 Apr 2026 17:42:26 +0800 Subject: [PATCH 39/42] Strip expect header in runtime proxy --- lib/runtime-rotation-proxy.ts | 1 + test/runtime-rotation-proxy.test.ts | 59 +++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/lib/runtime-rotation-proxy.ts b/lib/runtime-rotation-proxy.ts index e989c431..f21fb800 100644 --- a/lib/runtime-rotation-proxy.ts +++ b/lib/runtime-rotation-proxy.ts @@ -104,6 +104,7 @@ 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", diff --git a/test/runtime-rotation-proxy.test.ts b/test/runtime-rotation-proxy.test.ts index 330bb036..61679bec 100644 --- a/test/runtime-rotation-proxy.test.ts +++ b/test/runtime-rotation-proxy.test.ts @@ -1,4 +1,5 @@ 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 { @@ -148,6 +149,45 @@ async function postRawResponses( }); } +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[]; } @@ -499,6 +539,25 @@ describe("runtime rotation proxy", () => { 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)); From 55596f77304100163e958c566e61fc336013d01a Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 25 Apr 2026 18:06:43 +0800 Subject: [PATCH 40/42] Fix shadow sync orphan lock wait --- scripts/codex.js | 33 ++++++++++++++++++++------------- test/codex-bin-wrapper.test.ts | 12 +++++++----- 2 files changed, 27 insertions(+), 18 deletions(-) diff --git a/scripts/codex.js b/scripts/codex.js index 4868c925..b34e2561 100755 --- a/scripts/codex.js +++ b/scripts/codex.js @@ -33,6 +33,9 @@ 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", @@ -1472,11 +1475,11 @@ function writeShadowHomeSyncLockOwner(lockPath, owner) { function acquireShadowHomeSyncLock(originalCodexHome) { const lockPath = join(originalCodexHome, SHADOW_HOME_SYNC_LOCK_DIR); mkdirSync(originalCodexHome, { recursive: true }); - const lastRetryAttempt = SHADOW_HOME_CLEANUP_BACKOFF_MS.length; const maxStaleRecoveries = SHADOW_HOME_CLEANUP_BACKOFF_MS.length + 1; let staleRecoveries = 0; let attempt = 0; - while (attempt <= lastRetryAttempt) { + const deadline = Date.now() + SHADOW_HOME_SYNC_LOCK_WAIT_TIMEOUT_MS; + while (true) { try { mkdirSync(lockPath); writeShadowHomeSyncLockOwner(lockPath, { @@ -1498,22 +1501,26 @@ function acquireShadowHomeSyncLock(originalCodexHome) { if (code !== "EEXIST") { throw error; } - if (attempt >= lastRetryAttempt) { - if ( - staleRecoveries < maxStaleRecoveries && - removeStaleShadowHomeSyncLock(lockPath) - ) { - staleRecoveries += 1; - attempt = 0; - continue; - } + if ( + staleRecoveries < maxStaleRecoveries && + removeStaleShadowHomeSyncLock(lockPath) + ) { + staleRecoveries += 1; + attempt = 0; + continue; + } + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) { throw error; } - sleepSync(SHADOW_HOME_CLEANUP_BACKOFF_MS[attempt]); + 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; } } - throw new Error("Failed to acquire shadow home sync lock"); } function syncShadowHomeStateFile( diff --git a/test/codex-bin-wrapper.test.ts b/test/codex-bin-wrapper.test.ts index 86333226..1d0c8fcd 100644 --- a/test/codex-bin-wrapper.test.ts +++ b/test/codex-bin-wrapper.test.ts @@ -2354,7 +2354,7 @@ describe("codex bin wrapper", () => { } }); - it("does not steal fresh orphaned shadow sync locks", () => { + it("waits for fresh orphaned shadow sync locks to become stale before stealing", () => { const fixtureRoot = createWrapperFixture(); const fakeBin = createCustomFakeCodexBin(fixtureRoot, [ "#!/usr/bin/env node", @@ -2377,6 +2377,7 @@ describe("codex bin wrapper", () => { 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"], @@ -2390,10 +2391,11 @@ describe("codex bin wrapper", () => { ); 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":["original"]}'); - expect(readFileSync(join(originalHome, ".codex-global-state.json"), "utf8").trim()).toBe('{"last":"original"}'); - expect(existsSync(lockDir)).toBe(true); + 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(existsSync(lockDir)).toBe(false); }); it("syncs unchanged auth bundle files when a sibling changes during shadow use", () => { From 9ff882cf18963d80383c400724349bbec669d3f7 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 25 Apr 2026 19:09:02 +0800 Subject: [PATCH 41/42] Fix runtime rotation review issues --- lib/runtime-rotation-proxy.ts | 5 ++ scripts/codex.js | 48 +++++++++++++-- test/codex-bin-wrapper.test.ts | 94 +++++++++++++++++++++++++++++ test/runtime-rotation-proxy.test.ts | 26 ++++++++ 4 files changed, 169 insertions(+), 4 deletions(-) diff --git a/lib/runtime-rotation-proxy.ts b/lib/runtime-rotation-proxy.ts index f21fb800..1bc4d048 100644 --- a/lib/runtime-rotation-proxy.ts +++ b/lib/runtime-rotation-proxy.ts @@ -119,6 +119,10 @@ const PRIVATE_CLIENT_RESPONSE_HEADERS = new Set([ "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, @@ -240,6 +244,7 @@ function responseHeadersForClient(upstreamHeaders: Headers): Record 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; @@ -1461,6 +1474,7 @@ function removeStaleShadowHomeSyncLock(lockPath) { function writeShadowHomeSyncLockOwner(lockPath, owner) { const ownerPath = join(lockPath, "owner.json"); + maybeThrowSimulatedShadowHomeSyncLockOwnerWriteError(); writeFileSync(ownerPath, `${JSON.stringify(owner)}\n`, { encoding: "utf8", mode: 0o600, @@ -1472,6 +1486,23 @@ function writeShadowHomeSyncLockOwner(lockPath, owner) { } } +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 }); @@ -1482,10 +1513,19 @@ function acquireShadowHomeSyncLock(originalCodexHome) { while (true) { try { mkdirSync(lockPath); - writeShadowHomeSyncLockOwner(lockPath, { - pid: process.pid, - createdAt: Date.now(), - }); + 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); diff --git a/test/codex-bin-wrapper.test.ts b/test/codex-bin-wrapper.test.ts index 1d0c8fcd..ff838c90 100644 --- a/test/codex-bin-wrapper.test.ts +++ b/test/codex-bin-wrapper.test.ts @@ -451,6 +451,16 @@ function injectShadowLockRecreatedStaleCount(count = 2): NodeJS.ProcessEnv { }; } +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 }); @@ -2158,6 +2168,90 @@ describe("codex bin wrapper", () => { 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 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); + 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("removes stale shadow sync locks before publishing refreshed auth state", () => { const fixtureRoot = createWrapperFixture(); const fakeBin = createCustomFakeCodexBin(fixtureRoot, [ diff --git a/test/runtime-rotation-proxy.test.ts b/test/runtime-rotation-proxy.test.ts index 61679bec..8d09feef 100644 --- a/test/runtime-rotation-proxy.test.ts +++ b/test/runtime-rotation-proxy.test.ts @@ -427,6 +427,32 @@ describe("runtime rotation proxy", () => { 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)); From 5ef57968e8aa7378324bb09d1537aeb02a57a69e Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 25 Apr 2026 19:19:28 +0800 Subject: [PATCH 42/42] Resume stdin after app protocol cleanup --- scripts/codex.js | 1 + test/codex-bin-wrapper.test.ts | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/scripts/codex.js b/scripts/codex.js index d4e4a0aa..e7b8b48d 100755 --- a/scripts/codex.js +++ b/scripts/codex.js @@ -715,6 +715,7 @@ function forwardToRealCodexOnce( 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); diff --git a/test/codex-bin-wrapper.test.ts b/test/codex-bin-wrapper.test.ts index ff838c90..9bbfce5e 100644 --- a/test/codex-bin-wrapper.test.ts +++ b/test/codex-bin-wrapper.test.ts @@ -1062,6 +1062,18 @@ describe("codex bin wrapper", () => { 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);