From 7a37b6ad7191ec0bf12c8e322b024e1a4bb43929 Mon Sep 17 00:00:00 2001 From: ndycode Date: Tue, 3 Mar 2026 20:11:56 +0800 Subject: [PATCH 01/88] fix: harden wrapper exits and matrix regressions Co-authored-by: Codex --- scripts/codex-multi-auth.js | 12 + scripts/codex.js | 317 +++++++++++++++++++++- scripts/test-model-matrix.js | 21 +- test/codex-bin-wrapper.test.ts | 142 +++++++++- test/codex-multi-auth-bin-wrapper.test.ts | 108 ++++++++ test/test-model-matrix-script.test.ts | 55 ++++ 6 files changed, 644 insertions(+), 11 deletions(-) create mode 100644 test/codex-multi-auth-bin-wrapper.test.ts diff --git a/scripts/codex-multi-auth.js b/scripts/codex-multi-auth.js index d67358669..3634ef30e 100644 --- a/scripts/codex-multi-auth.js +++ b/scripts/codex-multi-auth.js @@ -1,6 +1,18 @@ #!/usr/bin/env node +import { createRequire } from "node:module"; import { runCodexMultiAuthCli } from "../dist/lib/codex-manager.js"; +try { + const require = createRequire(import.meta.url); + const pkg = require("../package.json"); + const version = typeof pkg?.version === "string" ? pkg.version.trim() : ""; + if (version.length > 0) { + process.env.CODEX_MULTI_AUTH_CLI_VERSION = version; + } +} catch { + // Best effort only. +} + const exitCode = await runCodexMultiAuthCli(process.argv.slice(2)); process.exitCode = Number.isInteger(exitCode) ? exitCode : 1; diff --git a/scripts/codex.js b/scripts/codex.js index 133eae267..14487b384 100644 --- a/scripts/codex.js +++ b/scripts/codex.js @@ -1,13 +1,26 @@ #!/usr/bin/env node import { spawn, spawnSync } from "node:child_process"; -import { existsSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { createRequire } from "node:module"; -import { dirname, join } from "node:path"; +import { basename, delimiter, dirname, join, resolve as resolvePath } from "node:path"; import process from "node:process"; import { fileURLToPath } from "node:url"; import { normalizeAuthAlias, shouldHandleMultiAuthAuth } from "./codex-routing.js"; +function hydrateCliVersionEnv() { + try { + const require = createRequire(import.meta.url); + const pkg = require("../package.json"); + const version = typeof pkg?.version === "string" ? pkg.version.trim() : ""; + if (version.length > 0) { + process.env.CODEX_MULTI_AUTH_CLI_VERSION = version; + } + } catch { + // Best effort only. + } +} + async function loadRunCodexMultiAuthCli() { try { const mod = await import("../dist/lib/codex-manager.js"); @@ -178,7 +191,307 @@ function normalizeExitCode(value) { return 1; } +const WINDOWS_SHIM_MARKER = "codex-multi-auth windows shim guardian v1"; +const POWERSHELL_PROFILE_MARKER_START = "# >>> codex-multi-auth shell guard >>>"; +const POWERSHELL_PROFILE_MARKER_END = "# <<< codex-multi-auth shell guard <<<"; + +function shouldInstallWindowsBatchShimGuard() { + if (process.platform !== "win32") return false; + const override = (process.env.CODEX_MULTI_AUTH_WINDOWS_BATCH_SHIM_GUARD ?? "1").trim(); + return override !== "0"; +} + +function splitPathEntries(pathValue) { + if (typeof pathValue !== "string" || pathValue.trim().length === 0) { + return []; + } + return pathValue + .split(delimiter) + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); +} + +function resolveWindowsShimDirectoryFromInvocation() { + const invokedScript = (process.argv[1] ?? "").trim(); + if (invokedScript.length === 0) return null; + const resolvedScript = resolvePath(invokedScript); + const scriptDir = dirname(resolvedScript); + const packageRoot = dirname(scriptDir); + const nodeModulesDir = dirname(packageRoot); + if (basename(nodeModulesDir).toLowerCase() !== "node_modules") { + return null; + } + const shimDir = dirname(nodeModulesDir); + if (existsSync(join(shimDir, "codex-multi-auth.cmd"))) { + return shimDir; + } + return null; +} + +function resolveWindowsShimDirectoryFromPath() { + const fromInvocation = resolveWindowsShimDirectoryFromInvocation(); + if (fromInvocation) { + return fromInvocation; + } + const pathEntries = splitPathEntries(process.env.PATH ?? process.env.Path ?? ""); + for (const entry of pathEntries) { + if (existsSync(join(entry, "codex-multi-auth.cmd"))) { + return entry; + } + } + return null; +} + +function buildWindowsBatchShimContent() { + return [ + "@ECHO off", + `:: ${WINDOWS_SHIM_MARKER}`, + "GOTO start", + ":find_dp0", + "SET dp0=%~dp0", + "EXIT /b", + ":start", + "SETLOCAL", + "CALL :find_dp0", + "", + 'IF EXIST "%dp0%\\node.exe" (', + ' SET "_prog=%dp0%\\node.exe"', + ") ELSE (", + ' SET "_prog=node"', + ' SET PATHEXT=%PATHEXT:;.JS;=%', + ")", + "", + 'endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\\node_modules\\codex-multi-auth\\scripts\\codex.js" %*', + ].join("\r\n"); +} + +function buildWindowsCmdShimContent() { + return [ + "@ECHO off", + `:: ${WINDOWS_SHIM_MARKER}`, + "GOTO start", + ":find_dp0", + "SET dp0=%~dp0", + "EXIT /b", + ":start", + "SETLOCAL", + "CALL :find_dp0", + "", + 'IF EXIST "%dp0%\\node.exe" (', + ' SET "_prog=%dp0%\\node.exe"', + ") ELSE (", + ' SET "_prog=node"', + ' SET PATHEXT=%PATHEXT:;.JS;=%', + ")", + "", + 'endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\\node_modules\\codex-multi-auth\\scripts\\codex.js" %*', + ].join("\r\n"); +} + +function buildWindowsPowerShellShimContent() { + return [ + `# ${WINDOWS_SHIM_MARKER}`, + "$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent", + "", + '$exe=""', + 'if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {', + ' $exe=".exe"', + "}", + "$ret=0", + 'if (Test-Path "$basedir/node$exe") {', + " if ($MyInvocation.ExpectingInput) {", + ' $input | & "$basedir/node$exe" "$basedir/node_modules/codex-multi-auth/scripts/codex.js" $args', + " } else {", + ' & "$basedir/node$exe" "$basedir/node_modules/codex-multi-auth/scripts/codex.js" $args', + " }", + " $ret=$LASTEXITCODE", + "} else {", + " if ($MyInvocation.ExpectingInput) {", + ' $input | & "node$exe" "$basedir/node_modules/codex-multi-auth/scripts/codex.js" $args', + " } else {", + ' & "node$exe" "$basedir/node_modules/codex-multi-auth/scripts/codex.js" $args', + " }", + " $ret=$LASTEXITCODE", + "}", + "if ($null -eq $ret) {", + " exit 0", + "}", + "exit $ret", + ].join("\r\n"); +} + +function ensureWindowsShellShim(filePath, desiredContent, options = {}) { + const { + overwriteCustomShim = false, + shimMarker = WINDOWS_SHIM_MARKER, + } = options; + + let currentContent = ""; + if (existsSync(filePath)) { + try { + currentContent = readFileSync(filePath, "utf8"); + } catch { + return false; + } + if (currentContent === desiredContent || currentContent.includes(shimMarker)) { + if (currentContent !== desiredContent) { + try { + writeFileSync(filePath, desiredContent, { encoding: "utf8", mode: 0o755 }); + return true; + } catch { + return false; + } + } + return false; + } + const looksLikeStockOpenAiShim = + currentContent.includes("node_modules\\@openai\\codex\\bin\\codex.js") || + currentContent.includes("node_modules/@openai/codex/bin/codex.js"); + if (looksLikeStockOpenAiShim) { + try { + writeFileSync(filePath, desiredContent, { encoding: "utf8", mode: 0o755 }); + return true; + } catch { + return false; + } + } + if (!overwriteCustomShim) { + return false; + } + } + + try { + writeFileSync(filePath, desiredContent, { encoding: "utf8", mode: 0o755 }); + return true; + } catch { + return false; + } +} + +function shouldInstallPowerShellProfileGuard() { + if (process.platform !== "win32") return false; + const override = (process.env.CODEX_MULTI_AUTH_PWSH_PROFILE_GUARD ?? "1").trim(); + return override !== "0"; +} + +function resolveWindowsUserHomeDir() { + const userProfile = (process.env.USERPROFILE ?? "").trim(); + if (userProfile.length > 0) return userProfile; + const homeDrive = (process.env.HOMEDRIVE ?? "").trim(); + const homePath = (process.env.HOMEPATH ?? "").trim(); + if (homeDrive.length > 0 && homePath.length > 0) { + return `${homeDrive}${homePath}`; + } + const home = (process.env.HOME ?? "").trim(); + return home; +} + +function buildPowerShellProfileGuardBlock(shimDirectory) { + const codexBatchPath = join(shimDirectory, "codex.bat").replace(/\\/g, "\\\\"); + return [ + POWERSHELL_PROFILE_MARKER_START, + `$CodexMultiAuthShim = "${codexBatchPath}"`, + "if (Test-Path $CodexMultiAuthShim) {", + " function global:codex {", + " & $CodexMultiAuthShim @args", + " }", + "}", + POWERSHELL_PROFILE_MARKER_END, + ].join("\r\n"); +} + +function upsertPowerShellProfileGuard(profilePath, guardBlock) { + let content = ""; + if (existsSync(profilePath)) { + try { + content = readFileSync(profilePath, "utf8"); + } catch { + return false; + } + } + const normalizedCurrentContent = content.replace(/\r?\n$/, ""); + + const startIndex = content.indexOf(POWERSHELL_PROFILE_MARKER_START); + const endIndex = content.indexOf(POWERSHELL_PROFILE_MARKER_END); + let nextContent; + if (startIndex >= 0 && endIndex >= startIndex) { + const endWithMarker = endIndex + POWERSHELL_PROFILE_MARKER_END.length; + const prefix = content.slice(0, startIndex).replace(/\s*$/, ""); + const suffix = content.slice(endWithMarker).replace(/^\s*/, ""); + nextContent = `${prefix}\r\n\r\n${guardBlock}\r\n\r\n${suffix}`.trimEnd(); + } else if (normalizedCurrentContent.trim().length === 0) { + nextContent = guardBlock; + } else { + nextContent = `${normalizedCurrentContent.replace(/\s*$/, "")}\r\n\r\n${guardBlock}`; + } + + if (nextContent === normalizedCurrentContent) { + return false; + } + + try { + mkdirSync(dirname(profilePath), { recursive: true }); + writeFileSync(profilePath, `${nextContent}\r\n`, { encoding: "utf8", mode: 0o644 }); + return true; + } catch { + return false; + } +} + +function ensurePowerShellProfileGuard(shimDirectory) { + if (!shouldInstallPowerShellProfileGuard()) return false; + const homeDir = resolveWindowsUserHomeDir(); + if (!homeDir) return false; + const guardBlock = buildPowerShellProfileGuardBlock(shimDirectory); + const profilePaths = [ + join(homeDir, "Documents", "PowerShell", "Microsoft.PowerShell_profile.ps1"), + join(homeDir, "Documents", "WindowsPowerShell", "Microsoft.PowerShell_profile.ps1"), + ]; + let changed = false; + for (const profilePath of profilePaths) { + changed = upsertPowerShellProfileGuard(profilePath, guardBlock) || changed; + } + return changed; +} + +function ensureWindowsShellShimGuards() { + if (!shouldInstallWindowsBatchShimGuard()) return; + const shimDirectory = resolveWindowsShimDirectoryFromPath(); + if (!shimDirectory) return; + + const codexMultiAuthShimPath = join(shimDirectory, "codex-multi-auth.cmd"); + if (!existsSync(codexMultiAuthShimPath)) return; + + const overwriteCustomShim = + (process.env.CODEX_MULTI_AUTH_OVERWRITE_CUSTOM_BATCH_SHIM ?? "0").trim() === "1"; + const installedBatch = ensureWindowsShellShim( + join(shimDirectory, "codex.bat"), + buildWindowsBatchShimContent(), + { overwriteCustomShim }, + ); + const installedCmd = ensureWindowsShellShim( + join(shimDirectory, "codex.cmd"), + buildWindowsCmdShimContent(), + { overwriteCustomShim }, + ); + const installedPs1 = ensureWindowsShellShim( + join(shimDirectory, "codex.ps1"), + buildWindowsPowerShellShimContent(), + { overwriteCustomShim }, + ); + const installedAny = installedBatch || installedCmd || installedPs1; + const installedProfileGuard = ensurePowerShellProfileGuard(shimDirectory); + if (installedAny || installedProfileGuard) { + console.error( + "codex-multi-auth: installed Windows shell guards to keep multi-auth routing after codex npm updates.", + ); + } +} + async function main() { + hydrateCliVersionEnv(); + ensureWindowsShellShimGuards(); + const rawArgs = process.argv.slice(2); const normalizedArgs = normalizeAuthAlias(rawArgs); const bypass = (process.env.CODEX_MULTI_AUTH_BYPASS ?? "").trim() === "1"; diff --git a/scripts/test-model-matrix.js b/scripts/test-model-matrix.js index b7a34dd9c..15d7c452f 100644 --- a/scripts/test-model-matrix.js +++ b/scripts/test-model-matrix.js @@ -130,6 +130,9 @@ function toFileUri(pathValue) { return `file:///${normalized}`; } +let stopCodexServersQueue = Promise.resolve(); +const spawnedCodexPids = new Set(); + function runQuiet(command, commandArgs) { try { spawnSync(command, commandArgs, { @@ -141,9 +144,6 @@ function runQuiet(command, commandArgs) { } } -let stopCodexServersQueue = Promise.resolve(); -const spawnedCodexPids = new Set(); - export function registerSpawnedCodex(pid) { if (!Number.isInteger(pid) || pid <= 0) return; spawnedCodexPids.add(pid); @@ -244,12 +244,11 @@ function enumerateCases(models, smoke, maxCases) { return selected; } -function executeModelCase(caseInfo, index) { +function buildModelCaseArgs(caseInfo, index) { const token = `MODEL_MATRIX_OK_${index}`; - const message = token; const args = [ "exec", - message, + token, "--model", caseInfo.model, "--json", @@ -258,6 +257,15 @@ function executeModelCase(caseInfo, index) { if (caseInfo.variant) { args.push("-c", `model_reasoning_effort="${caseInfo.variant}"`); } + return { token, args }; +} + +export function __buildModelCaseArgsForTests(caseInfo, index) { + return buildModelCaseArgs(caseInfo, index); +} + +function executeModelCase(caseInfo, index) { + const { token, args } = buildModelCaseArgs(caseInfo, index); const timeoutMs = resolveMatrixTimeoutMs(); const commandArgs = [...(CodexExecutable.prefixArgs ?? []), ...args]; @@ -441,7 +449,6 @@ async function main() { smoke, maxCases, pluginRef, - portStart: 47000 + i * 500, }); allResults.push(...scenarioResults.map((item) => ({ ...item, scenario }))); } diff --git a/test/codex-bin-wrapper.test.ts b/test/codex-bin-wrapper.test.ts index f68d3f17c..34385fb4d 100644 --- a/test/codex-bin-wrapper.test.ts +++ b/test/codex-bin-wrapper.test.ts @@ -1,7 +1,7 @@ import { spawn, spawnSync, type SpawnSyncReturns } from "node:child_process"; -import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; +import { delimiter, dirname, join } from "node:path"; import process from "node:process"; import { fileURLToPath } from "node:url"; import { afterEach, describe, expect, it } from "vitest"; @@ -79,6 +79,20 @@ function runWrapper( ); } +function runWrapperScript( + scriptPath: string, + args: string[], + extraEnv: NodeJS.ProcessEnv = {}, +): SpawnSyncReturns { + return spawnSync(process.execPath, [scriptPath, ...args], { + encoding: "utf8", + env: { + ...process.env, + ...extraEnv, + }, + }); +} + type WrapperAsyncResult = { status: number | null; signal: NodeJS.Signals | null; @@ -171,6 +185,109 @@ describe("codex bin wrapper", () => { expect(result.stdout).toContain("FORWARDED:--version"); }); + it("installs Windows codex shell guards to survive shim takeover", () => { + if (process.platform !== "win32") { + return; + } + + const fixtureRoot = createWrapperFixture(); + const fakeBin = createFakeCodexBin(fixtureRoot); + const shimDir = join(fixtureRoot, "shim-bin"); + mkdirSync(shimDir, { recursive: true }); + writeFileSync( + join(shimDir, "codex-multi-auth.cmd"), + "@ECHO OFF\r\nREM fixture codex-multi-auth shim\r\n", + "utf8", + ); + writeFileSync( + join(shimDir, "codex.cmd"), + '@ECHO OFF\r\necho "%dp0%\\node_modules\\@openai\\codex\\bin\\codex.js"\r\n', + "utf8", + ); + writeFileSync( + join(shimDir, "codex.ps1"), + 'Write-Output "$basedir/node_modules/@openai/codex/bin/codex.js"' + "\r\n", + "utf8", + ); + + const result = runWrapper(fixtureRoot, ["--version"], { + CODEX_MULTI_AUTH_REAL_CODEX_BIN: fakeBin, + CODEX_MULTI_AUTH_WINDOWS_BATCH_SHIM_GUARD: "1", + PATH: `${shimDir}${delimiter}${process.env.PATH ?? ""}`, + USERPROFILE: fixtureRoot, + HOME: fixtureRoot, + }); + expect(result.status).toBe(0); + + const codexBatchPath = join(shimDir, "codex.bat"); + expect(readFileSync(codexBatchPath, "utf8")).toContain( + "codex-multi-auth windows shim guardian v1", + ); + const codexCmdPath = join(shimDir, "codex.cmd"); + expect(readFileSync(codexCmdPath, "utf8")).toContain( + "codex-multi-auth windows shim guardian v1", + ); + expect(readFileSync(codexCmdPath, "utf8")).toContain( + "node_modules\\codex-multi-auth\\scripts\\codex.js", + ); + const codexPs1Path = join(shimDir, "codex.ps1"); + expect(readFileSync(codexPs1Path, "utf8")).toContain( + "codex-multi-auth windows shim guardian v1", + ); + expect(readFileSync(codexPs1Path, "utf8")).toContain( + "node_modules/codex-multi-auth/scripts/codex.js", + ); + const pwshProfilePath = join( + fixtureRoot, + "Documents", + "PowerShell", + "Microsoft.PowerShell_profile.ps1", + ); + expect(readFileSync(pwshProfilePath, "utf8")).toContain( + "# >>> codex-multi-auth shell guard >>>", + ); + expect(readFileSync(pwshProfilePath, "utf8")).toContain("CodexMultiAuthShim"); + }); + + it("prefers invocation-derived shim directory over PATH-decoy shim entries", () => { + if (process.platform !== "win32") { + return; + } + + const fixtureRoot = mkdtempSync(join(tmpdir(), "codex-wrapper-invoke-fixture-")); + createdDirs.push(fixtureRoot); + const globalShimDir = join(fixtureRoot, "global-bin"); + const scriptDir = join(globalShimDir, "node_modules", "codex-multi-auth", "scripts"); + mkdirSync(scriptDir, { recursive: true }); + copyFileSync(join(repoRootDir, "scripts", "codex.js"), join(scriptDir, "codex.js")); + copyFileSync(join(repoRootDir, "scripts", "codex-routing.js"), join(scriptDir, "codex-routing.js")); + writeFileSync( + join(globalShimDir, "codex-multi-auth.cmd"), + "@ECHO OFF\r\nREM real shim\r\n", + "utf8", + ); + const decoyShimDir = join(fixtureRoot, "decoy-bin"); + mkdirSync(decoyShimDir, { recursive: true }); + writeFileSync( + join(decoyShimDir, "codex-multi-auth.cmd"), + "@ECHO OFF\r\nREM decoy shim\r\n", + "utf8", + ); + const fakeBin = createFakeCodexBin(fixtureRoot); + const scriptPath = join(scriptDir, "codex.js"); + const result = runWrapperScript(scriptPath, ["--version"], { + CODEX_MULTI_AUTH_REAL_CODEX_BIN: fakeBin, + PATH: `${decoyShimDir}${delimiter}${globalShimDir}${delimiter}${process.env.PATH ?? ""}`, + USERPROFILE: fixtureRoot, + HOME: fixtureRoot, + }); + expect(result.status).toBe(0); + expect(readFileSync(join(globalShimDir, "codex.bat"), "utf8")).toContain( + "codex-multi-auth windows shim guardian v1", + ); + expect(() => readFileSync(join(decoyShimDir, "codex.bat"), "utf8")).toThrow(); + }); + it("honors bypass for auth commands and forwards to the real CLI", () => { const fixtureRoot = createWrapperFixture(); const fakeBin = createFakeCodexBin(fixtureRoot); @@ -228,6 +345,27 @@ describe("codex bin wrapper", () => { expect(output).not.toContain("codex-multi-auth runner failed:"); }); + it("propagates numeric-string multi-auth exit codes", () => { + const fixtureRoot = createWrapperFixture(); + const distLibDir = join(fixtureRoot, "dist", "lib"); + mkdirSync(distLibDir, { recursive: true }); + writeFileSync( + join(distLibDir, "codex-manager.js"), + [ + "export async function runCodexMultiAuthCli() {", + '\treturn "7";', + "}", + ].join("\n"), + "utf8", + ); + + const result = runWrapper(fixtureRoot, ["auth", "status"], { + CODEX_MULTI_AUTH_BYPASS: "", + CODEX_MULTI_AUTH_REAL_CODEX_BIN: "", + }); + expect(result.status).toBe(7); + }); + it("prints actionable guidance when real codex bin cannot be found", () => { const fixtureRoot = createWrapperFixture(); const missingOverride = join(fixtureRoot, "missing", "codex.js"); diff --git a/test/codex-multi-auth-bin-wrapper.test.ts b/test/codex-multi-auth-bin-wrapper.test.ts new file mode 100644 index 000000000..2a5c45622 --- /dev/null +++ b/test/codex-multi-auth-bin-wrapper.test.ts @@ -0,0 +1,108 @@ +import { spawnSync } from "node:child_process"; +import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; +import { sleep } from "../lib/utils.js"; + +const createdDirs: string[] = []; +const testFileDir = dirname(fileURLToPath(import.meta.url)); +const repoRootDir = join(testFileDir, ".."); + +function isRetriableFsError(error: unknown): boolean { + if (!error || typeof error !== "object" || !("code" in error)) { + return false; + } + const { code } = error as { code?: unknown }; + return code === "EBUSY" || code === "EPERM"; +} + +async function removeDirectoryWithRetry(dir: string): Promise { + const backoffMs = [20, 60, 120]; + let lastError: unknown; + for (let attempt = 0; attempt <= backoffMs.length; attempt += 1) { + try { + rmSync(dir, { recursive: true, force: true }); + return; + } catch (error) { + lastError = error; + if (!isRetriableFsError(error) || attempt === backoffMs.length) { + break; + } + await sleep(backoffMs[attempt]); + } + } + throw lastError; +} + +function createWrapperFixture(): string { + const fixtureRoot = mkdtempSync(join(tmpdir(), "codex-multi-auth-wrapper-fixture-")); + createdDirs.push(fixtureRoot); + const scriptDir = join(fixtureRoot, "scripts"); + mkdirSync(scriptDir, { recursive: true }); + copyFileSync( + join(repoRootDir, "scripts", "codex-multi-auth.js"), + join(scriptDir, "codex-multi-auth.js"), + ); + return fixtureRoot; +} + +function runWrapper(fixtureRoot: string, args: string[] = []) { + return spawnSync( + process.execPath, + [join(fixtureRoot, "scripts", "codex-multi-auth.js"), ...args], + { + encoding: "utf8", + env: { + ...process.env, + }, + }, + ); +} + +afterEach(async () => { + for (const dir of createdDirs.splice(0, createdDirs.length)) { + await removeDirectoryWithRetry(dir); + } +}); + +describe("codex-multi-auth bin wrapper", () => { + it("propagates integer exit codes", () => { + const fixtureRoot = createWrapperFixture(); + const distLibDir = join(fixtureRoot, "dist", "lib"); + mkdirSync(distLibDir, { recursive: true }); + writeFileSync( + join(distLibDir, "codex-manager.js"), + [ + "export async function runCodexMultiAuthCli(args) {", + '\tif (!Array.isArray(args) || args[0] !== "auth") throw new Error("bad args");', + "\treturn 5;", + "}", + ].join("\n"), + "utf8", + ); + + const result = runWrapper(fixtureRoot, ["auth", "status"]); + expect(result.status).toBe(5); + }); + + it("normalizes non-integer exit codes to 1", () => { + const fixtureRoot = createWrapperFixture(); + const distLibDir = join(fixtureRoot, "dist", "lib"); + mkdirSync(distLibDir, { recursive: true }); + writeFileSync( + join(distLibDir, "codex-manager.js"), + [ + "export async function runCodexMultiAuthCli() {", + '\treturn "ok";', + "}", + ].join("\n"), + "utf8", + ); + + const result = runWrapper(fixtureRoot, ["auth", "status"]); + expect(result.status).toBe(1); + }); +}); diff --git a/test/test-model-matrix-script.test.ts b/test/test-model-matrix-script.test.ts index 3f2baf5df..2a1348a4d 100644 --- a/test/test-model-matrix-script.test.ts +++ b/test/test-model-matrix-script.test.ts @@ -63,6 +63,61 @@ describe("test-model-matrix script helpers", () => { } }); + it("falls back to shell mode when .cmd wrapper cannot be parsed", async () => { + const fixtureRoot = mkdtempSync(join(tmpdir(), "matrix-cmd-fallback-")); + try { + const cmdPath = join(fixtureRoot, "Codex.cmd"); + writeFileSync( + cmdPath, + [ + "@ECHO off", + "REM deliberately no %dp0% JS wrapper path for parser", + "echo hello", + ].join("\r\n"), + "utf8", + ); + vi.stubEnv("CODEX_BIN", cmdPath); + + const mod = await import("../scripts/test-model-matrix.js"); + expect(mod.resolveCodexExecutable()).toEqual({ + command: cmdPath, + shell: true, + }); + } finally { + rmSync(fixtureRoot, { recursive: true, force: true }); + } + }); + + it("builds matrix exec args with JSON + git-check skip and optional variant config", async () => { + const mod = await import("../scripts/test-model-matrix.js"); + + expect(mod.__buildModelCaseArgsForTests({ model: "gpt-5.2" }, 3)).toEqual({ + token: "MODEL_MATRIX_OK_3", + args: [ + "exec", + "MODEL_MATRIX_OK_3", + "--model", + "gpt-5.2", + "--json", + "--skip-git-repo-check", + ], + }); + + expect(mod.__buildModelCaseArgsForTests({ model: "gpt-5.2", variant: "high" }, 4)).toEqual({ + token: "MODEL_MATRIX_OK_4", + args: [ + "exec", + "MODEL_MATRIX_OK_4", + "--model", + "gpt-5.2", + "--json", + "--skip-git-repo-check", + "-c", + 'model_reasoning_effort="high"', + ], + }); + }); + it("falls back to default timeout when CODEX_MATRIX_TIMEOUT_MS is invalid", async () => { vi.stubEnv("CODEX_MATRIX_TIMEOUT_MS", "abc"); const mod = await import("../scripts/test-model-matrix.js"); From 8ae183fa32ceb44e055845f81fe1f38fc9dce24e Mon Sep 17 00:00:00 2001 From: ndycode Date: Tue, 3 Mar 2026 20:22:07 +0800 Subject: [PATCH 02/88] fix: add windows fs retry guards for shim writes Co-authored-by: Codex --- scripts/codex.js | 89 ++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 74 insertions(+), 15 deletions(-) diff --git a/scripts/codex.js b/scripts/codex.js index 14487b384..6d2ee8b0b 100644 --- a/scripts/codex.js +++ b/scripts/codex.js @@ -194,6 +194,53 @@ function normalizeExitCode(value) { const WINDOWS_SHIM_MARKER = "codex-multi-auth windows shim guardian v1"; const POWERSHELL_PROFILE_MARKER_START = "# >>> codex-multi-auth shell guard >>>"; const POWERSHELL_PROFILE_MARKER_END = "# <<< codex-multi-auth shell guard <<<"; +const RETRYABLE_WINDOWS_FS_CODES = new Set(["EBUSY", "EPERM", "EACCES"]); + +function sleep(ms) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +function getFsErrorCode(error) { + if (!error || typeof error !== "object" || !("code" in error)) { + return undefined; + } + const code = error.code; + return typeof code === "string" ? code : undefined; +} + +async function runWithWindowsFsRetry(operation, options = {}) { + const { + maxAttempts = 4, + backoffMs = 50, + } = options; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + return operation(); + } catch (error) { + const code = getFsErrorCode(error); + const shouldRetry = code !== undefined && RETRYABLE_WINDOWS_FS_CODES.has(code); + if (!shouldRetry || attempt === maxAttempts) { + throw error; + } + await sleep(backoffMs * (2 ** (attempt - 1))); + } + } + return undefined; +} + +async function writeFileSyncWithWindowsRetry(filePath, content, options) { + await runWithWindowsFsRetry(() => { + writeFileSync(filePath, content, options); + }, { maxAttempts: 4, backoffMs: 50 }); +} + +async function mkdirSyncWithWindowsRetry(dirPath, options) { + await runWithWindowsFsRetry(() => { + mkdirSync(dirPath, options); + }, { maxAttempts: 4, backoffMs: 50 }); +} function shouldInstallWindowsBatchShimGuard() { if (process.platform !== "win32") return false; @@ -320,7 +367,7 @@ function buildWindowsPowerShellShimContent() { ].join("\r\n"); } -function ensureWindowsShellShim(filePath, desiredContent, options = {}) { +async function ensureWindowsShellShim(filePath, desiredContent, options = {}) { const { overwriteCustomShim = false, shimMarker = WINDOWS_SHIM_MARKER, @@ -336,7 +383,10 @@ function ensureWindowsShellShim(filePath, desiredContent, options = {}) { if (currentContent === desiredContent || currentContent.includes(shimMarker)) { if (currentContent !== desiredContent) { try { - writeFileSync(filePath, desiredContent, { encoding: "utf8", mode: 0o755 }); + await writeFileSyncWithWindowsRetry(filePath, desiredContent, { + encoding: "utf8", + mode: 0o755, + }); return true; } catch { return false; @@ -349,7 +399,10 @@ function ensureWindowsShellShim(filePath, desiredContent, options = {}) { currentContent.includes("node_modules/@openai/codex/bin/codex.js"); if (looksLikeStockOpenAiShim) { try { - writeFileSync(filePath, desiredContent, { encoding: "utf8", mode: 0o755 }); + await writeFileSyncWithWindowsRetry(filePath, desiredContent, { + encoding: "utf8", + mode: 0o755, + }); return true; } catch { return false; @@ -361,7 +414,10 @@ function ensureWindowsShellShim(filePath, desiredContent, options = {}) { } try { - writeFileSync(filePath, desiredContent, { encoding: "utf8", mode: 0o755 }); + await writeFileSyncWithWindowsRetry(filePath, desiredContent, { + encoding: "utf8", + mode: 0o755, + }); return true; } catch { return false; @@ -400,7 +456,7 @@ function buildPowerShellProfileGuardBlock(shimDirectory) { ].join("\r\n"); } -function upsertPowerShellProfileGuard(profilePath, guardBlock) { +async function upsertPowerShellProfileGuard(profilePath, guardBlock) { let content = ""; if (existsSync(profilePath)) { try { @@ -430,15 +486,18 @@ function upsertPowerShellProfileGuard(profilePath, guardBlock) { } try { - mkdirSync(dirname(profilePath), { recursive: true }); - writeFileSync(profilePath, `${nextContent}\r\n`, { encoding: "utf8", mode: 0o644 }); + await mkdirSyncWithWindowsRetry(dirname(profilePath), { recursive: true }); + await writeFileSyncWithWindowsRetry(profilePath, `${nextContent}\r\n`, { + encoding: "utf8", + mode: 0o644, + }); return true; } catch { return false; } } -function ensurePowerShellProfileGuard(shimDirectory) { +async function ensurePowerShellProfileGuard(shimDirectory) { if (!shouldInstallPowerShellProfileGuard()) return false; const homeDir = resolveWindowsUserHomeDir(); if (!homeDir) return false; @@ -449,12 +508,12 @@ function ensurePowerShellProfileGuard(shimDirectory) { ]; let changed = false; for (const profilePath of profilePaths) { - changed = upsertPowerShellProfileGuard(profilePath, guardBlock) || changed; + changed = (await upsertPowerShellProfileGuard(profilePath, guardBlock)) || changed; } return changed; } -function ensureWindowsShellShimGuards() { +async function ensureWindowsShellShimGuards() { if (!shouldInstallWindowsBatchShimGuard()) return; const shimDirectory = resolveWindowsShimDirectoryFromPath(); if (!shimDirectory) return; @@ -464,23 +523,23 @@ function ensureWindowsShellShimGuards() { const overwriteCustomShim = (process.env.CODEX_MULTI_AUTH_OVERWRITE_CUSTOM_BATCH_SHIM ?? "0").trim() === "1"; - const installedBatch = ensureWindowsShellShim( + const installedBatch = await ensureWindowsShellShim( join(shimDirectory, "codex.bat"), buildWindowsBatchShimContent(), { overwriteCustomShim }, ); - const installedCmd = ensureWindowsShellShim( + const installedCmd = await ensureWindowsShellShim( join(shimDirectory, "codex.cmd"), buildWindowsCmdShimContent(), { overwriteCustomShim }, ); - const installedPs1 = ensureWindowsShellShim( + const installedPs1 = await ensureWindowsShellShim( join(shimDirectory, "codex.ps1"), buildWindowsPowerShellShimContent(), { overwriteCustomShim }, ); const installedAny = installedBatch || installedCmd || installedPs1; - const installedProfileGuard = ensurePowerShellProfileGuard(shimDirectory); + const installedProfileGuard = await ensurePowerShellProfileGuard(shimDirectory); if (installedAny || installedProfileGuard) { console.error( "codex-multi-auth: installed Windows shell guards to keep multi-auth routing after codex npm updates.", @@ -490,7 +549,7 @@ function ensureWindowsShellShimGuards() { async function main() { hydrateCliVersionEnv(); - ensureWindowsShellShimGuards(); + await ensureWindowsShellShimGuards(); const rawArgs = process.argv.slice(2); const normalizedArgs = normalizeAuthAlias(rawArgs); From 4e6e7bee8b23f85585588d68363e2a6a1599f42a Mon Sep 17 00:00:00 2001 From: ndycode Date: Tue, 3 Mar 2026 21:16:30 +0800 Subject: [PATCH 03/88] fix: harden oauth code exchange timeout handling Add timeout and abort boundary for authorization code exchange. Wire fetch timeout config into oauth exchange callsites. Add tests for network and timeout failure behavior. Co-authored-by: Codex --- index.ts | 6 ++ lib/auth/auth.ts | 151 +++++++++++++++++++++++++++++--------- lib/codex-manager.ts | 10 ++- test/auth-logging.test.ts | 43 ++++++++++- test/auth.test.ts | 58 +++++++++++++++ 5 files changed, 230 insertions(+), 38 deletions(-) diff --git a/index.ts b/index.ts index 7db88088a..e8e57f60c 100644 --- a/index.ts +++ b/index.ts @@ -460,10 +460,13 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { message: "OAuth state mismatch. Restart login and try again.", }; } + const authPluginConfig = loadPluginConfig(); + const oauthFetchTimeoutMs = getFetchTimeoutMs(authPluginConfig); const tokens = await exchangeAuthorizationCode( parsed.code, pkce.verifier, REDIRECT_URI, + { timeoutMs: oauthFetchTimeoutMs }, ); if (tokens?.type === "success") { const resolved = resolveAccountSelection(tokens); @@ -509,10 +512,13 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { return { type: "failed" as const, reason: "unknown" as const, message: "OAuth callback timeout or cancelled" }; } + const authPluginConfig = loadPluginConfig(); + const oauthFetchTimeoutMs = getFetchTimeoutMs(authPluginConfig); return await exchangeAuthorizationCode( result.code, pkce.verifier, REDIRECT_URI, + { timeoutMs: oauthFetchTimeoutMs }, ); }; diff --git a/lib/auth/auth.ts b/lib/auth/auth.ts index 591a68ec2..38692f8a4 100644 --- a/lib/auth/auth.ts +++ b/lib/auth/auth.ts @@ -11,6 +11,7 @@ export const AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize"; export const TOKEN_URL = "https://auth.openai.com/oauth/token"; export const REDIRECT_URI = "http://localhost:1455/auth/callback"; export const SCOPE = "openid profile email offline_access"; +const DEFAULT_OAUTH_EXCHANGE_TIMEOUT_MS = 60_000; const OAUTH_SENSITIVE_QUERY_PARAMS = [ "state", @@ -111,50 +112,128 @@ export function parseAuthorizationInput(input: string): ParsedAuthInput { * @param redirectUri - OAuth redirect URI * @returns Token result */ +export type ExchangeAuthorizationCodeOptions = { + signal?: AbortSignal; + timeoutMs?: number; +}; + +function resolveExchangeTimeoutMs(timeoutMs: number | undefined): number { + if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs)) { + return DEFAULT_OAUTH_EXCHANGE_TIMEOUT_MS; + } + return Math.max(1_000, Math.floor(timeoutMs)); +} + +function createAbortError(message: string): Error & { code?: string } { + const error = new Error(message) as Error & { code?: string }; + error.name = "AbortError"; + error.code = "ABORT_ERR"; + return error; +} + +function buildExchangeAbortContext( + options: ExchangeAuthorizationCodeOptions, +): { signal: AbortSignal; cleanup: () => void } { + const controller = new AbortController(); + const timeoutMs = resolveExchangeTimeoutMs(options.timeoutMs); + const upstreamSignal = options.signal; + let timeoutId: ReturnType | undefined; + + const onUpstreamAbort = () => { + const reason = upstreamSignal?.reason; + controller.abort( + reason instanceof Error ? reason : createAbortError("Request aborted"), + ); + }; + + if (upstreamSignal?.aborted) { + onUpstreamAbort(); + } else if (upstreamSignal) { + upstreamSignal.addEventListener("abort", onUpstreamAbort, { once: true }); + } + + if (!controller.signal.aborted) { + timeoutId = setTimeout(() => { + controller.abort( + createAbortError( + `OAuth token exchange timed out after ${timeoutMs}ms`, + ), + ); + }, timeoutMs); + } + + return { + signal: controller.signal, + cleanup: () => { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + if (upstreamSignal) { + upstreamSignal.removeEventListener("abort", onUpstreamAbort); + } + }, + }; +} + export async function exchangeAuthorizationCode( code: string, verifier: string, redirectUri: string = REDIRECT_URI, + options: ExchangeAuthorizationCodeOptions = {}, ): Promise { - const res = await fetch(TOKEN_URL, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - grant_type: "authorization_code", - client_id: CLIENT_ID, - code, - code_verifier: verifier, - redirect_uri: redirectUri, - }), - }); - if (!res.ok) { - const text = await res.text().catch(() => ""); - logError(`code->token failed: ${res.status} ${text}`); - return { type: "failed", reason: "http_error", statusCode: res.status, message: text || undefined }; - } - const rawJson = (await res.json()) as unknown; - const json = safeParseOAuthTokenResponse(rawJson); - if (!json) { - logError("token response validation failed", getOAuthResponseLogMetadata(rawJson)); - return { type: "failed", reason: "invalid_response", message: "Response failed schema validation" }; - } - if (!json.refresh_token || json.refresh_token.trim().length === 0) { - logError("token response missing refresh token", getOAuthResponseLogMetadata(rawJson)); + const abortContext = buildExchangeAbortContext(options); + try { + const res = await fetch(TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + signal: abortContext.signal, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: CLIENT_ID, + code, + code_verifier: verifier, + redirect_uri: redirectUri, + }), + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + logError(`code->token failed: ${res.status} ${text}`); + return { type: "failed", reason: "http_error", statusCode: res.status, message: text || undefined }; + } + const rawJson = (await res.json()) as unknown; + const json = safeParseOAuthTokenResponse(rawJson); + if (!json) { + logError("token response validation failed", getOAuthResponseLogMetadata(rawJson)); + return { type: "failed", reason: "invalid_response", message: "Response failed schema validation" }; + } + if (!json.refresh_token || json.refresh_token.trim().length === 0) { + logError("token response missing refresh token", getOAuthResponseLogMetadata(rawJson)); + return { + type: "failed", + reason: "invalid_response", + message: "Missing refresh token in authorization code exchange response", + }; + } + const normalizedRefreshToken = json.refresh_token.trim(); return { - type: "failed", - reason: "invalid_response", - message: "Missing refresh token in authorization code exchange response", + type: "success", + access: json.access_token, + refresh: normalizedRefreshToken, + expires: Date.now() + json.expires_in * 1000, + idToken: json.id_token, + multiAccount: true, }; + } catch (error) { + const err = error as Error; + if (isAbortError(err)) { + logError("code->token aborted", { message: err?.message ?? "Request aborted" }); + return { type: "failed", reason: "unknown", message: err?.message ?? "Request aborted" }; + } + logError("code->token error", { message: err?.message ?? String(err) }); + return { type: "failed", reason: "network_error", message: err?.message ?? "Network request failed" }; + } finally { + abortContext.cleanup(); } - const normalizedRefreshToken = json.refresh_token.trim(); - return { - type: "success", - access: json.access_token, - refresh: normalizedRefreshToken, - expires: Date.now() + json.expires_in * 1000, - idToken: json.id_token, - multiAccount: true, - }; } /** diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 794eb7c65..ee4c22ce5 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -23,6 +23,7 @@ import { selectBestAccountCandidate, } from "./accounts.js"; import { ACCOUNT_LIMITS } from "./constants.js"; +import { getFetchTimeoutMs, loadPluginConfig } from "./config.js"; import { loadDashboardDisplaySettings, DEFAULT_DASHBOARD_DISPLAY_SETTINGS, @@ -1251,7 +1252,14 @@ async function runOAuthFlow(forceNewLogin: boolean): Promise { message: UI_COPY.oauth.cancelled, }; } - return exchangeAuthorizationCode(code, pkce.verifier, REDIRECT_URI); + const authPluginConfig = loadPluginConfig(); + const oauthFetchTimeoutMs = getFetchTimeoutMs(authPluginConfig); + return exchangeAuthorizationCode( + code, + pkce.verifier, + REDIRECT_URI, + { timeoutMs: oauthFetchTimeoutMs }, + ); } async function persistAccountPool( diff --git a/test/auth-logging.test.ts b/test/auth-logging.test.ts index e34e5dc39..d7215138d 100644 --- a/test/auth-logging.test.ts +++ b/test/auth-logging.test.ts @@ -5,7 +5,7 @@ vi.mock('../lib/logger.js', () => ({ })); import { logError } from '../lib/logger.js'; -import { exchangeAuthorizationCode } from '../lib/auth/auth.js'; +import { exchangeAuthorizationCode, REDIRECT_URI } from '../lib/auth/auth.js'; describe('OAuth auth logging', () => { afterEach(() => { @@ -63,4 +63,45 @@ describe('OAuth auth logging', () => { globalThis.fetch = originalFetch; } }); + + it('logs timeout metadata when token exchange aborts', async () => { + const originalFetch = globalThis.fetch; + vi.useFakeTimers(); + globalThis.fetch = vi.fn((_url, init) => + new Promise((_resolve, reject) => { + const signal = init?.signal as AbortSignal | undefined; + if (signal?.aborted) { + reject(signal.reason); + return; + } + signal?.addEventListener( + 'abort', + () => { + reject(signal.reason); + }, + { once: true }, + ); + }), + ) as never; + + try { + const resultPromise = exchangeAuthorizationCode( + 'auth-code', + 'verifier-123', + REDIRECT_URI, + { timeoutMs: 1000 }, + ); + await vi.advanceTimersByTimeAsync(1000); + const result = await resultPromise; + expect(result.type).toBe('failed'); + + expect(vi.mocked(logError)).toHaveBeenCalledWith( + 'code->token aborted', + { message: 'OAuth token exchange timed out after 1000ms' }, + ); + } finally { + globalThis.fetch = originalFetch; + vi.useRealTimers(); + } + }); }); diff --git a/test/auth.test.ts b/test/auth.test.ts index fe7affad3..e1e1e5320 100644 --- a/test/auth.test.ts +++ b/test/auth.test.ts @@ -347,6 +347,64 @@ describe('Auth Module', () => { } }); + it('returns failed for network errors during code exchange', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = vi.fn(async () => { + throw new Error('Network failed'); + }) as never; + + try { + const result = await exchangeAuthorizationCode('code', 'verifier'); + expect(result.type).toBe('failed'); + if (result.type === 'failed') { + expect(result.reason).toBe('network_error'); + expect(result.message).toBe('Network failed'); + } + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('returns failed when code exchange times out', async () => { + const originalFetch = globalThis.fetch; + vi.useFakeTimers(); + globalThis.fetch = vi.fn((_url, init) => + new Promise((_resolve, reject) => { + const signal = init?.signal as AbortSignal | undefined; + if (signal?.aborted) { + reject(signal.reason); + return; + } + signal?.addEventListener( + 'abort', + () => { + reject(signal.reason); + }, + { once: true }, + ); + }), + ) as never; + + try { + const resultPromise = exchangeAuthorizationCode( + 'code', + 'verifier', + REDIRECT_URI, + { timeoutMs: 1000 }, + ); + await vi.advanceTimersByTimeAsync(1000); + const result = await resultPromise; + expect(result.type).toBe('failed'); + if (result.type === 'failed') { + expect(result.reason).toBe('unknown'); + expect(result.message).toContain('timed out'); + } + } finally { + globalThis.fetch = originalFetch; + vi.useRealTimers(); + } + }); + it('returns failed with undefined message when text read fails', async () => { const originalFetch = globalThis.fetch; const mockResponse = { From 50f3ea0545179fb4e5b64924f6450ad9a333b00e Mon Sep 17 00:00:00 2001 From: ndycode Date: Tue, 3 Mar 2026 22:16:25 +0800 Subject: [PATCH 04/88] feat: implement enterprise hardening baseline Add enterprise-grade hardening across runtime, CLI, storage, CI, and docs.\n\n- Add cross-process file locking for settings/quota persistence\n- Add at-rest secret encryption with rotation command and idempotency support\n- Add RBAC/ABAC-style command authorization, JSON redaction, and retention policies\n- Add background retry + dead-letter queue for async persistence failures\n- Add list JSON pagination standard and schemaVersion contract updates\n- Add CI security gates: secret scan, supply-chain/SCA/license checks, SBOM, required checks policy\n- Add operations and incident response runbooks\n- Add/extend tests for new security/reliability primitives and CLI behaviors\n\nValidated with:\n- npm run typecheck\n- npm run lint\n- npm run build && npm test\n- npm run coverage\n- npm run audit:ci\n- npm run license:check\n- npm run clean:repo:check Co-authored-by: Codex --- .github/settings.yml | 23 ++ .github/workflows/ci.yml | 69 +++- .github/workflows/secret-scan.yml | 28 ++ .github/workflows/supply-chain.yml | 78 +++++ README.md | 1 + docs/README.md | 1 + docs/configuration.md | 6 + docs/development/CONFIG_FIELDS.md | 10 + docs/development/TESTING.md | 9 +- docs/index.md | 3 +- docs/privacy.md | 29 ++ docs/reference/commands.md | 7 +- docs/reference/error-contracts.md | 7 + docs/reference/public-api.md | 18 + docs/reference/settings.md | 8 +- docs/reference/storage-paths.md | 1 + docs/runbooks/README.md | 12 + docs/runbooks/incident-response.md | 97 ++++++ docs/runbooks/operations.md | 85 +++++ index.ts | 82 ++++- lib/accounts.ts | 13 +- lib/authorization.ts | 42 +++ lib/background-jobs.ts | 115 ++++++ lib/codex-manager.ts | 537 +++++++++++++++++++++++++++-- lib/data-redaction.ts | 50 +++ lib/data-retention.ts | 122 +++++++ lib/file-lock.ts | 181 ++++++++++ lib/idempotency.ts | 125 +++++++ lib/index.ts | 7 + lib/quota-cache.ts | 52 +-- lib/secrets-crypto.ts | 101 ++++++ lib/storage.ts | 140 +++++++- lib/unified-settings.ts | 94 +++-- package.json | 2 + scripts/license-policy-check.js | 65 ++++ test/authorization.test.ts | 60 ++++ test/background-jobs.test.ts | 89 +++++ test/codex-manager-cli.test.ts | 127 +++++++ test/data-redaction.test.ts | 34 ++ test/data-retention.test.ts | 79 +++++ test/file-lock.test.ts | 60 ++++ test/idempotency.test.ts | 51 +++ test/quota-cache.test.ts | 5 +- test/secrets-crypto.test.ts | 62 ++++ 44 files changed, 2673 insertions(+), 114 deletions(-) create mode 100644 .github/settings.yml create mode 100644 .github/workflows/secret-scan.yml create mode 100644 .github/workflows/supply-chain.yml create mode 100644 docs/runbooks/README.md create mode 100644 docs/runbooks/incident-response.md create mode 100644 docs/runbooks/operations.md create mode 100644 lib/authorization.ts create mode 100644 lib/background-jobs.ts create mode 100644 lib/data-redaction.ts create mode 100644 lib/data-retention.ts create mode 100644 lib/file-lock.ts create mode 100644 lib/idempotency.ts create mode 100644 lib/secrets-crypto.ts create mode 100644 scripts/license-policy-check.js create mode 100644 test/authorization.test.ts create mode 100644 test/background-jobs.test.ts create mode 100644 test/data-redaction.test.ts create mode 100644 test/data-retention.test.ts create mode 100644 test/file-lock.test.ts create mode 100644 test/idempotency.test.ts create mode 100644 test/secrets-crypto.test.ts diff --git a/.github/settings.yml b/.github/settings.yml new file mode 100644 index 000000000..c3a0d1243 --- /dev/null +++ b/.github/settings.yml @@ -0,0 +1,23 @@ +branches: + - name: main + protection: + required_pull_request_reviews: + required_approving_review_count: 1 + require_code_owner_reviews: false + dismiss_stale_reviews: true + required_status_checks: + strict: true + checks: + - context: "CI / Test on Node.js 20.x" + - context: "CI / Test on Node.js 22.x" + - context: "CI / Coverage Gate" + - context: "CI / Lint" + - context: "CI / Codex Compatibility Smoke" + - context: "CI / Cross-Platform Smoke (windows-latest)" + - context: "CI / Cross-Platform Smoke (macos-latest)" + - context: "CodeQL / Analyze" + - context: "Secret Scan / Gitleaks" + - context: "Supply Chain / Dependency Review" + - context: "Supply Chain / SCA and License Gate" + enforce_admins: true + restrictions: null diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3c4f0b99..8679f4494 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,12 +6,17 @@ on: pull_request: branches: [main] +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: test: name: Test on Node.js ${{ matrix.node-version }} runs-on: ubuntu-latest strategy: + fail-fast: false matrix: node-version: [20.x, 22.x] @@ -23,7 +28,7 @@ jobs: uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} - cache: 'npm' + cache: npm - name: Install dependencies run: npm ci @@ -44,15 +49,34 @@ jobs: - name: Run type check run: npm run typecheck - - name: Run tests with coverage - run: npm run coverage - - name: Build run: npm run build + - name: Run tests + run: npm test + + coverage-gate: + name: Coverage Gate + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20.x + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run tests with coverage threshold gate + run: npm run coverage + lint: name: Lint - runs-on: ubuntu-latest steps: @@ -63,7 +87,7 @@ jobs: uses: actions/setup-node@v4 with: node-version: 20.x - cache: 'npm' + cache: npm - name: Install dependencies run: npm ci @@ -83,10 +107,41 @@ jobs: uses: actions/setup-node@v4 with: node-version: 20.x - cache: 'npm' + cache: npm - name: Install dependencies run: npm ci - name: Run Codex compatibility tests run: npm run test -- test/codex.test.ts test/host-codex-prompt.test.ts test/request-transformer.test.ts test/fetch-helpers.test.ts + + cross-platform-smoke: + name: Cross-Platform Smoke (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + os: [windows-latest, macos-latest] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20.x + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run smoke typecheck + run: npm run typecheck + + - name: Build + run: npm run build + + - name: Run smoke tests + run: npm run test -- test/runtime-paths.test.ts test/codex-bin-wrapper.test.ts diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml new file mode 100644 index 000000000..c5967419f --- /dev/null +++ b/.github/workflows/secret-scan.yml @@ -0,0 +1,28 @@ +name: Secret Scan + +on: + pull_request: + branches: [main] + push: + branches: [main] + schedule: + - cron: "0 5 * * 1" + +jobs: + gitleaks: + name: Gitleaks + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Run gitleaks scanner + uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/supply-chain.yml b/.github/workflows/supply-chain.yml new file mode 100644 index 000000000..b2d94a48e --- /dev/null +++ b/.github/workflows/supply-chain.yml @@ -0,0 +1,78 @@ +name: Supply Chain + +on: + pull_request: + branches: [main] + push: + branches: [main] + schedule: + - cron: "0 4 * * 1" + +jobs: + dependency-review: + name: Dependency Review + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Dependency review + uses: actions/dependency-review-action@v4 + with: + fail-on-severity: high + fail-on-scopes: runtime + deny-licenses: GPL-2.0, GPL-3.0, AGPL-3.0 + + sca-and-license: + name: SCA and License Gate + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20.x + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run vulnerability policy gate + run: npm run audit:ci + + - name: Run license policy gate + run: npm run license:check + + sbom: + name: Generate SBOM + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20.x + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Generate CycloneDX SBOM + run: npx --yes @cyclonedx/cyclonedx-npm --output-file sbom.cdx.json --omit dev + + - name: Upload SBOM artifact + uses: actions/upload-artifact@v4 + with: + name: sbom-cyclonedx + path: sbom.cdx.json diff --git a/README.md b/README.md index e254c6a65..a3685af48 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,7 @@ codex auth doctor --fix | `codex auth fix --dry-run` | Preview safe repairs | | `codex auth fix --live --model gpt-5-codex` | Run repairs with live probe model | | `codex auth doctor --fix` | Diagnose and apply safe fixes | +| `codex auth rotate-secrets --json` | Re-encrypt stored secrets and return rotation summary | --- diff --git a/docs/README.md b/docs/README.md index 2accdd99f..2cb64b5b4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -59,6 +59,7 @@ Canonical documentation map for `codex-multi-auth`. | [development/REPOSITORY_SCOPE.md](development/REPOSITORY_SCOPE.md) | Ownership map by repository path | | [development/TESTING.md](development/TESTING.md) | Validation gates and test matrix | | [development/TUI_PARITY_CHECKLIST.md](development/TUI_PARITY_CHECKLIST.md) | Dashboard UX parity checklist | +| [runbooks/README.md](runbooks/README.md) | Operations and incident response playbooks | | [benchmarks/code-edit-format-benchmark.md](benchmarks/code-edit-format-benchmark.md) | Benchmark methodology and outputs | --- diff --git a/docs/configuration.md b/docs/configuration.md index 172296c74..632abf0f7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -68,6 +68,9 @@ These are safe for most operators and frequently used in day-to-day workflows. | `CODEX_TUI_GLYPHS=ascii|unicode|auto` | Glyph mode selection | | `CODEX_AUTH_FETCH_TIMEOUT_MS=` | HTTP request timeout override | | `CODEX_AUTH_STREAM_STALL_TIMEOUT_MS=` | Stream stall timeout override | +| `CODEX_AUTH_ENCRYPTION_KEY=` | Enable at-rest encryption for stored account secrets | +| `CODEX_AUTH_PREVIOUS_ENCRYPTION_KEY=` | Fallback key for staged secret rotation | +| `CODEX_AUTH_ROLE=admin|operator|viewer` | CLI authorization role baseline | --- @@ -81,6 +84,9 @@ Use these only when debugging, controlled benchmarking, or maintainer workflows. - `CODEX_CLI_ACCOUNTS_PATH` - `CODEX_CLI_AUTH_PATH` - refresh lease tuning variables (`CODEX_AUTH_REFRESH_LEASE*`) +- `CODEX_AUTH_BREAK_GLASS` +- `CODEX_AUTH_REDACT_JSON_OUTPUT` +- retention tuning variables (`CODEX_AUTH_RETENTION_*`) Full inventory: [development/CONFIG_FIELDS.md](development/CONFIG_FIELDS.md) diff --git a/docs/development/CONFIG_FIELDS.md b/docs/development/CONFIG_FIELDS.md index 9a3ee4cf3..190c79d48 100644 --- a/docs/development/CONFIG_FIELDS.md +++ b/docs/development/CONFIG_FIELDS.md @@ -195,6 +195,16 @@ Used only for host plugin mode through the host runtime config file. | `CODEX_TUI_GLYPHS` | TUI glyph mode | | `CODEX_AUTH_FETCH_TIMEOUT_MS` | Request timeout override | | `CODEX_AUTH_STREAM_STALL_TIMEOUT_MS` | Stream stall timeout override | +| `CODEX_AUTH_ENCRYPTION_KEY` | Primary key for at-rest secret encryption | +| `CODEX_AUTH_PREVIOUS_ENCRYPTION_KEY` | Previous key for staged secret rotation | +| `CODEX_AUTH_ROLE` | Authorization role baseline (`admin`, `operator`, `viewer`) | +| `CODEX_AUTH_BREAK_GLASS` | Emergency authorization bypass toggle | +| `CODEX_AUTH_REDACT_JSON_OUTPUT` | Redact sensitive values in JSON command output | +| `CODEX_AUTH_RETENTION_LOG_DAYS` | Log retention window | +| `CODEX_AUTH_RETENTION_CACHE_DAYS` | Cache retention window | +| `CODEX_AUTH_RETENTION_FLAGGED_DAYS` | Flagged-account file retention window | +| `CODEX_AUTH_RETENTION_QUOTA_CACHE_DAYS` | Quota cache retention window | +| `CODEX_AUTH_RETENTION_DLQ_DAYS` | Dead-letter queue retention window | | `CODEX_MULTI_AUTH_SYNC_CODEX_CLI` | Toggle Codex CLI state sync | | `CODEX_MULTI_AUTH_REAL_CODEX_BIN` | Force official Codex binary path | | `CODEX_MULTI_AUTH_BYPASS` | Bypass local auth handling | diff --git a/docs/development/TESTING.md b/docs/development/TESTING.md index 9292b9065..5b2953485 100644 --- a/docs/development/TESTING.md +++ b/docs/development/TESTING.md @@ -24,6 +24,8 @@ npm run typecheck npm run lint npm test npm run build +npm run audit:ci +npm run license:check ``` Optional: @@ -42,8 +44,11 @@ npm run bench:edit-formats:smoke 1. `npm run typecheck` 2. `npm run lint` 3. `npm test` -4. `npm run build` -5. run docs command checks for newly documented command paths +4. `npm run coverage` +5. `npm run build` +6. `npm run audit:ci` +7. `npm run license:check` +8. run docs command checks for newly documented command paths * * * diff --git a/docs/index.md b/docs/index.md index 9a59b2d86..ecc3dbf0a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -46,4 +46,5 @@ Legacy package/path guidance is documented in [upgrade.md](upgrade.md) and [refe - Command flags and hotkeys: [reference/commands.md](reference/commands.md) - Settings and overrides: [reference/settings.md](reference/settings.md) - Storage path matrix: [reference/storage-paths.md](reference/storage-paths.md) -- Full docs portal: [README.md](README.md) \ No newline at end of file +- Operations runbooks: [runbooks/README.md](runbooks/README.md) +- Full docs portal: [README.md](README.md) diff --git a/docs/privacy.md b/docs/privacy.md index 4fa153420..8c19e3845 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -20,6 +20,7 @@ | Accounts | `~/.codex/multi-auth/openai-codex-accounts.json` | Primary saved account pool | | Flagged accounts | `~/.codex/multi-auth/openai-codex-flagged-accounts.json` | Accounts with hard auth failures | | Quota cache | `~/.codex/multi-auth/quota-cache.json` | Cached quota snapshots | +| Background DLQ | `~/.codex/multi-auth/background-job-dlq.jsonl` | Failed background jobs after retry exhaustion | | Logs | `~/.codex/multi-auth/logs/codex-plugin/` | Optional diagnostics | | Prompt/cache files | `~/.codex/multi-auth/cache/` | Cached prompt/template metadata | | Codex CLI state | `~/.codex/accounts.json`, `~/.codex/auth.json` | Official Codex CLI files | @@ -48,6 +49,34 @@ Current external destinations: Raw body logs may contain sensitive payload text. Treat logs as sensitive data and rotate/delete as needed. +`CODEX_AUTH_REDACT_JSON_OUTPUT=1` redacts sensitive values from JSON command output for automation logs. + +--- + +## Secret Encryption and Rotation + +- Account refresh/access tokens can be encrypted at rest when `CODEX_AUTH_ENCRYPTION_KEY` is set. +- Key rotation supports staged migration with `CODEX_AUTH_PREVIOUS_ENCRYPTION_KEY`. +- Rotation command: + +```bash +codex auth rotate-secrets --json +``` + +Store encryption keys in a secret manager or CI secret store, not in repository files. + +--- + +## Retention + +Startup retention cleanup removes expired local artifacts based on: + +- `CODEX_AUTH_RETENTION_LOG_DAYS` +- `CODEX_AUTH_RETENTION_CACHE_DAYS` +- `CODEX_AUTH_RETENTION_FLAGGED_DAYS` +- `CODEX_AUTH_RETENTION_QUOTA_CACHE_DAYS` +- `CODEX_AUTH_RETENTION_DLQ_DAYS` + --- ## Data Cleanup diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 43c877fae..26742ebed 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -38,6 +38,7 @@ Compatibility aliases are supported: | `codex auth report` | Generate full health report | | `codex auth fix` | Apply safe account storage fixes | | `codex auth doctor` | Run diagnostics and optional repairs | +| `codex auth rotate-secrets` | Re-encrypt account secrets using current encryption key | --- @@ -45,13 +46,16 @@ Compatibility aliases are supported: | Flag | Applies to | Meaning | | --- | --- | --- | -| `--json` | verify-flagged, forecast, report, fix, doctor | Print machine-readable output | +| `--json` | verify-flagged, forecast, report, fix, doctor, rotate-secrets | Print machine-readable output | | `--live` | forecast, report, fix | Use live probe before decisions/output | | `--dry-run` | verify-flagged, fix, doctor | Preview without writing storage | | `--model ` | forecast, report, fix | Specify model for live probe paths | | `--out ` | report | Write report output to file | | `--fix` | doctor | Apply safe repairs | | `--no-restore` | verify-flagged | Verify only; do not restore healthy flagged accounts | +| `--page-size ` | list, status (`--json`) | Page size for JSON list output (1-200) | +| `--cursor ` | list, status (`--json`) | Cursor token for JSON list pagination | +| `--idempotency-key ` | rotate-secrets | Safe retry key for automation | --- @@ -108,6 +112,7 @@ Repair and recovery: codex auth fix --dry-run codex auth fix --live --model gpt-5-codex codex auth doctor --fix +codex auth rotate-secrets --json ``` --- diff --git a/docs/reference/error-contracts.md b/docs/reference/error-contracts.md index 62694d4f6..2196f1a7c 100644 --- a/docs/reference/error-contracts.md +++ b/docs/reference/error-contracts.md @@ -31,17 +31,24 @@ Examples: The following commands support `--json` and produce pretty-printed JSON objects: +- `codex auth list --json` - `codex auth forecast --json` - `codex auth report --json` - `codex auth fix --json` - `codex auth doctor --json` - `codex auth verify-flagged --json` +- `codex auth rotate-secrets --json` Compatibility guarantees: - Output is valid JSON. - `command` field identifies the command family. +- `schemaVersion` is required for machine-consumable contracts. - Documented top-level sections remain stable unless a migration note is provided. +- Optional redaction mode (`CODEX_AUTH_REDACT_JSON_OUTPUT=1`) masks sensitive fields without changing schema shape. +- Paginated list output uses `pagination.{cursor,nextCursor,hasMore,pageSize}`. + +For `rotate-secrets`, automation may provide `--idempotency-key ` to avoid duplicate side effects on retried runs. --- diff --git a/docs/reference/public-api.md b/docs/reference/public-api.md index 865189ff9..d916a379c 100644 --- a/docs/reference/public-api.md +++ b/docs/reference/public-api.md @@ -63,6 +63,24 @@ Positional signatures are preserved for backward compatibility. --- +## API Standards Baseline + +Where this repository exposes machine-readable command output or future HTTP endpoints, use these defaults: + +- Versioning: + - include `schemaVersion` in JSON command output. + - increment schema version only when contract shape changes. +- Idempotency: + - mutating automation flows should support caller-provided idempotency keys. + - repeated requests with the same idempotency key should not duplicate side effects. +- Pagination: + - list-style payloads should prefer cursor-based pagination (`nextCursor`, `hasMore`) over offset-only paging. + - response envelopes should include stable paging metadata even for empty result sets. + +This project currently applies the versioning baseline to JSON command outputs and documents idempotency/pagination standards for future API expansion. + +--- + ## Semver Guidance - Breaking Tier A change: `MAJOR` diff --git a/docs/reference/settings.md b/docs/reference/settings.md index 1466374b9..1d6d46ea7 100644 --- a/docs/reference/settings.md +++ b/docs/reference/settings.md @@ -128,6 +128,9 @@ Common operator overrides: - `CODEX_TUI_GLYPHS` - `CODEX_AUTH_FETCH_TIMEOUT_MS` - `CODEX_AUTH_STREAM_STALL_TIMEOUT_MS` +- `CODEX_AUTH_ENCRYPTION_KEY` +- `CODEX_AUTH_PREVIOUS_ENCRYPTION_KEY` +- `CODEX_AUTH_ROLE` --- @@ -141,6 +144,9 @@ Maintainer/debug-focused overrides include: - `CODEX_CLI_ACCOUNTS_PATH` - `CODEX_CLI_AUTH_PATH` - refresh lease controls (`CODEX_AUTH_REFRESH_LEASE*`) +- `CODEX_AUTH_BREAK_GLASS` +- `CODEX_AUTH_REDACT_JSON_OUTPUT` +- retention controls (`CODEX_AUTH_RETENTION_*`) Full inventory: [../development/CONFIG_FIELDS.md](../development/CONFIG_FIELDS.md) @@ -175,4 +181,4 @@ codex auth forecast --live - [commands.md](commands.md) - [storage-paths.md](storage-paths.md) -- [../configuration.md](../configuration.md) \ No newline at end of file +- [../configuration.md](../configuration.md) diff --git a/docs/reference/storage-paths.md b/docs/reference/storage-paths.md index bae76b844..ca409f884 100644 --- a/docs/reference/storage-paths.md +++ b/docs/reference/storage-paths.md @@ -26,6 +26,7 @@ Override root: | Accounts WAL | `~/.codex/multi-auth/openai-codex-accounts.json.wal` | | Flagged accounts | `~/.codex/multi-auth/openai-codex-flagged-accounts.json` | | Quota cache | `~/.codex/multi-auth/quota-cache.json` | +| Background job DLQ | `~/.codex/multi-auth/background-job-dlq.jsonl` | | Logs | `~/.codex/multi-auth/logs/codex-plugin/` | | Cache | `~/.codex/multi-auth/cache/` | | Codex CLI accounts | `~/.codex/accounts.json` | diff --git a/docs/runbooks/README.md b/docs/runbooks/README.md new file mode 100644 index 000000000..7b7d5302c --- /dev/null +++ b/docs/runbooks/README.md @@ -0,0 +1,12 @@ +# Runbooks + +Operational runbooks for `codex-multi-auth`. + +## Runbook Index + +- [operations.md](operations.md): routine operational checks, release gates, and maintenance tasks. +- [incident-response.md](incident-response.md): severity model, containment flow, and post-incident process. + +## Scope + +These runbooks cover plugin-owned local state under `~/.codex/multi-auth` (or `CODEX_MULTI_AUTH_DIR`) and repository-level CI/security controls. diff --git a/docs/runbooks/incident-response.md b/docs/runbooks/incident-response.md new file mode 100644 index 000000000..2ce2d0c72 --- /dev/null +++ b/docs/runbooks/incident-response.md @@ -0,0 +1,97 @@ +# Incident Response Playbook + +Incident response workflow for `codex-multi-auth`. + +--- + +## Severity Levels + +- `SEV-1`: active secret exposure, auth bypass, or broad production outage. +- `SEV-2`: major functionality degraded, high failure rate, or persistent data corruption risk. +- `SEV-3`: contained bug with workaround, no ongoing security impact. + +--- + +## Response Timeline + +### 1. Detect and Declare (0-15 min) + +1. Open an internal incident channel. +2. Assign incident commander and communications lead. +3. Record: + - first detection timestamp + - affected command flows + - impacted storage paths/environment variables + +### 2. Contain (15-60 min) + +1. For credential exposure: + - rotate affected OAuth/session credentials + - set new `CODEX_AUTH_ENCRYPTION_KEY` + - run `codex auth rotate-secrets` +2. For unauthorized command execution: + - downgrade role to `CODEX_AUTH_ROLE=viewer` where possible + - reserve `CODEX_AUTH_BREAK_GLASS=1` for explicit emergency changes +3. For filesystem instability: + - pause mutation commands (`login`, `switch`, `fix`) + - inspect lock files and dead-letter entries + +### 3. Eradicate and Recover (within 24h) + +1. Patch root cause and merge behind required CI checks. +2. Validate: + - `npm run typecheck` + - `npm run lint` + - `npm test` + - `npm run audit:ci` +3. Re-enable normal command paths and monitor audit logs. + +--- + +## Communication Template + +Use this internal status template: + +```text +Incident: +Severity: +Start Time: +Current Status: +Impact: +Mitigation: +Next Update: