From 7a37b6ad7191ec0bf12c8e322b024e1a4bb43929 Mon Sep 17 00:00:00 2001 From: ndycode Date: Tue, 3 Mar 2026 20:11:56 +0800 Subject: [PATCH 1/9] 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 2/9] 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 0231eab73cd8b91e21c6fcb710ef9c17e3df89c2 Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 4 Mar 2026 17:09:02 +0800 Subject: [PATCH 3/9] fix(wrapper): normalize exit code parsing and harden wrapper tests Co-authored-by: Codex --- scripts/codex-multi-auth.js | 4 +++- test/codex-bin-wrapper.test.ts | 12 ++---------- test/codex-multi-auth-bin-wrapper.test.ts | 20 +++++++++++++++++++- 3 files changed, 24 insertions(+), 12 deletions(-) diff --git a/scripts/codex-multi-auth.js b/scripts/codex-multi-auth.js index 3634ef30e..3cf9fb29a 100644 --- a/scripts/codex-multi-auth.js +++ b/scripts/codex-multi-auth.js @@ -15,4 +15,6 @@ try { } const exitCode = await runCodexMultiAuthCli(process.argv.slice(2)); -process.exitCode = Number.isInteger(exitCode) ? exitCode : 1; +const parsedExitCode = + typeof exitCode === "number" && Number.isInteger(exitCode) ? exitCode : Number(exitCode); +process.exitCode = Number.isInteger(parsedExitCode) ? parsedExitCode : 1; diff --git a/test/codex-bin-wrapper.test.ts b/test/codex-bin-wrapper.test.ts index 34385fb4d..7027ae5d8 100644 --- a/test/codex-bin-wrapper.test.ts +++ b/test/codex-bin-wrapper.test.ts @@ -185,11 +185,7 @@ 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; - } - + it.skipIf(process.platform !== "win32")("installs Windows codex shell guards to survive shim takeover", () => { const fixtureRoot = createWrapperFixture(); const fakeBin = createFakeCodexBin(fixtureRoot); const shimDir = join(fixtureRoot, "shim-bin"); @@ -249,11 +245,7 @@ describe("codex bin wrapper", () => { expect(readFileSync(pwshProfilePath, "utf8")).toContain("CodexMultiAuthShim"); }); - it("prefers invocation-derived shim directory over PATH-decoy shim entries", () => { - if (process.platform !== "win32") { - return; - } - + it.skipIf(process.platform !== "win32")("prefers invocation-derived shim directory over PATH-decoy shim entries", () => { const fixtureRoot = mkdtempSync(join(tmpdir(), "codex-wrapper-invoke-fixture-")); createdDirs.push(fixtureRoot); const globalShimDir = join(fixtureRoot, "global-bin"); diff --git a/test/codex-multi-auth-bin-wrapper.test.ts b/test/codex-multi-auth-bin-wrapper.test.ts index 2a5c45622..02afa30b4 100644 --- a/test/codex-multi-auth-bin-wrapper.test.ts +++ b/test/codex-multi-auth-bin-wrapper.test.ts @@ -16,7 +16,7 @@ function isRetriableFsError(error: unknown): boolean { return false; } const { code } = error as { code?: unknown }; - return code === "EBUSY" || code === "EPERM"; + return code === "EBUSY" || code === "EPERM" || code === "EACCES"; } async function removeDirectoryWithRetry(dir: string): Promise { @@ -105,4 +105,22 @@ describe("codex-multi-auth bin wrapper", () => { const result = runWrapper(fixtureRoot, ["auth", "status"]); expect(result.status).toBe(1); }); + + it("propagates numeric-string 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"]); + expect(result.status).toBe(7); + }); }); From a6ccb6cccb8bd042fb18e60e929698c92c2a922f Mon Sep 17 00:00:00 2001 From: ndycode Date: Thu, 5 Mar 2026 11:06:25 +0800 Subject: [PATCH 4/9] test: dedupe codex wrapper spawn helpers - extract shared runNodeScript helper for wrapper test process spawning - route runWrapper and runWrapperScript through the shared helper to avoid drift Co-authored-by: Codex --- test/codex-bin-wrapper.test.ts | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/test/codex-bin-wrapper.test.ts b/test/codex-bin-wrapper.test.ts index 7027ae5d8..fdd6ede02 100644 --- a/test/codex-bin-wrapper.test.ts +++ b/test/codex-bin-wrapper.test.ts @@ -66,23 +66,21 @@ function runWrapper( args: string[], extraEnv: NodeJS.ProcessEnv = {}, ): SpawnSyncReturns { - return spawnSync( - process.execPath, - [join(fixtureRoot, "scripts", "codex.js"), ...args], - { - encoding: "utf8", - env: { - ...process.env, - ...extraEnv, - }, - }, - ); + return runNodeScript(join(fixtureRoot, "scripts", "codex.js"), args, extraEnv); } function runWrapperScript( scriptPath: string, args: string[], extraEnv: NodeJS.ProcessEnv = {}, +): SpawnSyncReturns { + return runNodeScript(scriptPath, args, extraEnv); +} + +function runNodeScript( + scriptPath: string, + args: string[], + extraEnv: NodeJS.ProcessEnv = {}, ): SpawnSyncReturns { return spawnSync(process.execPath, [scriptPath, ...args], { encoding: "utf8", From 0a7d9eafe4e12e95ff173f628edb1f849712649d Mon Sep 17 00:00:00 2001 From: ndycode Date: Thu, 5 Mar 2026 11:14:50 +0800 Subject: [PATCH 5/9] fix: harden windows profile-guard idempotency and retry regression - prevent extra leading separators when rewriting existing PowerShell guard blocks - add deterministic Windows retry regression for transient shim write failure - add repeated-startup idempotency regression for PowerShell profile guard content Co-authored-by: Codex --- scripts/codex.js | 4 +- test/codex-bin-wrapper.test.ts | 122 +++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 1 deletion(-) diff --git a/scripts/codex.js b/scripts/codex.js index 6d2ee8b0b..cced0a0b9 100644 --- a/scripts/codex.js +++ b/scripts/codex.js @@ -474,7 +474,9 @@ async function upsertPowerShellProfileGuard(profilePath, guardBlock) { 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(); + const prefixPart = prefix.length > 0 ? `${prefix}\r\n\r\n` : ""; + const suffixPart = suffix.length > 0 ? `\r\n\r\n${suffix}` : ""; + nextContent = `${prefixPart}${guardBlock}${suffixPart}`.trimEnd(); } else if (normalizedCurrentContent.trim().length === 0) { nextContent = guardBlock; } else { diff --git a/test/codex-bin-wrapper.test.ts b/test/codex-bin-wrapper.test.ts index fdd6ede02..e149a9db0 100644 --- a/test/codex-bin-wrapper.test.ts +++ b/test/codex-bin-wrapper.test.ts @@ -243,6 +243,128 @@ describe("codex bin wrapper", () => { expect(readFileSync(pwshProfilePath, "utf8")).toContain("CodexMultiAuthShim"); }); + it.skipIf(process.platform !== "win32")( + "retries transient shim write failures and still installs guards", + () => { + 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 hookPath = join(fixtureRoot, "fail-once-shim-write.cjs"); + writeFileSync( + hookPath, + [ + 'const fs = require("node:fs");', + 'const { syncBuiltinESMExports } = require("node:module");', + "const originalWriteFileSync = fs.writeFileSync;", + "let injected = false;", + "fs.writeFileSync = (...args) => {", + ' const target = String(args[0] ?? "");', + " if (!injected && /codex\\.(bat|cmd|ps1)$/i.test(target)) {", + " injected = true;", + ' const error = new Error(\"busy\");', + ' error.code = \"EBUSY\";', + " throw error;", + " }", + " return originalWriteFileSync(...args);", + "};", + "syncBuiltinESMExports();", + ].join("\n"), + "utf8", + ); + + const existingNodeOptions = (process.env.NODE_OPTIONS ?? "").trim(); + const preloadOption = `--require=${hookPath}`; + const nodeOptions = + existingNodeOptions.length > 0 + ? `${existingNodeOptions} ${preloadOption}` + : preloadOption; + 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, + NODE_OPTIONS: nodeOptions, + }); + expect(result.status).toBe(0); + expect(readFileSync(join(shimDir, "codex.bat"), "utf8")).toContain( + "codex-multi-auth windows shim guardian v1", + ); + expect(readFileSync(join(shimDir, "codex.cmd"), "utf8")).toContain( + "codex-multi-auth windows shim guardian v1", + ); + expect(readFileSync(join(shimDir, "codex.ps1"), "utf8")).toContain( + "codex-multi-auth windows shim guardian v1", + ); + }, + ); + + it.skipIf(process.platform !== "win32")( + "keeps PowerShell profile guard idempotent on repeated startup", + () => { + 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 env = { + 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(runWrapper(fixtureRoot, ["--version"], env).status).toBe(0); + const pwshProfilePath = join( + fixtureRoot, + "Documents", + "PowerShell", + "Microsoft.PowerShell_profile.ps1", + ); + const firstContent = readFileSync(pwshProfilePath, "utf8"); + + expect(runWrapper(fixtureRoot, ["--version"], env).status).toBe(0); + const secondContent = readFileSync(pwshProfilePath, "utf8"); + + expect(secondContent).toBe(firstContent); + expect(secondContent.startsWith("\r\n")).toBe(false); + expect( + (secondContent.match(/# >>> codex-multi-auth shell guard >>>/g) ?? []).length, + ).toBe(1); + }, + ); + it.skipIf(process.platform !== "win32")("prefers invocation-derived shim directory over PATH-decoy shim entries", () => { const fixtureRoot = mkdtempSync(join(tmpdir(), "codex-wrapper-invoke-fixture-")); createdDirs.push(fixtureRoot); From 155f38c11c47de7759d60552d4dda3f3241e9f15 Mon Sep 17 00:00:00 2001 From: ndycode Date: Thu, 5 Mar 2026 11:21:35 +0800 Subject: [PATCH 6/9] fix: fail closed for null or empty wrapper exit codes - treat only integer numbers or non-empty numeric strings as valid exit codes - prevent null/empty-string coercion to success via Number() - add regressions for null and empty-string exit code normalization Co-authored-by: Codex --- scripts/codex-multi-auth.js | 6 +++- test/codex-multi-auth-bin-wrapper.test.ts | 36 +++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/scripts/codex-multi-auth.js b/scripts/codex-multi-auth.js index 3cf9fb29a..de9347f14 100644 --- a/scripts/codex-multi-auth.js +++ b/scripts/codex-multi-auth.js @@ -16,5 +16,9 @@ try { const exitCode = await runCodexMultiAuthCli(process.argv.slice(2)); const parsedExitCode = - typeof exitCode === "number" && Number.isInteger(exitCode) ? exitCode : Number(exitCode); + typeof exitCode === "number" && Number.isInteger(exitCode) + ? exitCode + : typeof exitCode === "string" && exitCode.trim().length > 0 + ? Number(exitCode) + : Number.NaN; process.exitCode = Number.isInteger(parsedExitCode) ? parsedExitCode : 1; diff --git a/test/codex-multi-auth-bin-wrapper.test.ts b/test/codex-multi-auth-bin-wrapper.test.ts index 02afa30b4..fce293d18 100644 --- a/test/codex-multi-auth-bin-wrapper.test.ts +++ b/test/codex-multi-auth-bin-wrapper.test.ts @@ -123,4 +123,40 @@ describe("codex-multi-auth bin wrapper", () => { const result = runWrapper(fixtureRoot, ["auth", "status"]); expect(result.status).toBe(7); }); + + it("normalizes null exit codes to failure", () => { + const fixtureRoot = createWrapperFixture(); + const distLibDir = join(fixtureRoot, "dist", "lib"); + mkdirSync(distLibDir, { recursive: true }); + writeFileSync( + join(distLibDir, "codex-manager.js"), + [ + "export async function runCodexMultiAuthCli() {", + "\treturn null;", + "}", + ].join("\n"), + "utf8", + ); + + const result = runWrapper(fixtureRoot, ["auth", "status"]); + expect(result.status).toBe(1); + }); + + it("normalizes empty-string exit codes to failure", () => { + const fixtureRoot = createWrapperFixture(); + const distLibDir = join(fixtureRoot, "dist", "lib"); + mkdirSync(distLibDir, { recursive: true }); + writeFileSync( + join(distLibDir, "codex-manager.js"), + [ + "export async function runCodexMultiAuthCli() {", + '\treturn "";', + "}", + ].join("\n"), + "utf8", + ); + + const result = runWrapper(fixtureRoot, ["auth", "status"]); + expect(result.status).toBe(1); + }); }); From e08c072362694795a4abb4a08e09043b3eaa13cd Mon Sep 17 00:00:00 2001 From: ndycode Date: Thu, 5 Mar 2026 11:35:18 +0800 Subject: [PATCH 7/9] test: close remaining wrapper review gaps Deduplicate identical Windows cmd/batch shim content builders, expand transient shim-write retry regression coverage to EBUSY/EPERM/EACCES, and add numeric-string "0" clean-exit coverage. Co-authored-by: Codex --- scripts/codex.js | 27 ++---- test/codex-bin-wrapper.test.ts | 155 +++++++++++++++++++-------------- 2 files changed, 95 insertions(+), 87 deletions(-) diff --git a/scripts/codex.js b/scripts/codex.js index cced0a0b9..edfa55f3a 100644 --- a/scripts/codex.js +++ b/scripts/codex.js @@ -289,7 +289,7 @@ function resolveWindowsShimDirectoryFromPath() { return null; } -function buildWindowsBatchShimContent() { +function buildWindowsCmdLikeShimContent() { return [ "@ECHO off", `:: ${WINDOWS_SHIM_MARKER}`, @@ -312,27 +312,12 @@ function buildWindowsBatchShimContent() { ].join("\r\n"); } +function buildWindowsBatchShimContent() { + return buildWindowsCmdLikeShimContent(); +} + 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"); + return buildWindowsCmdLikeShimContent(); } function buildWindowsPowerShellShimContent() { diff --git a/test/codex-bin-wrapper.test.ts b/test/codex-bin-wrapper.test.ts index e149a9db0..4cac29aec 100644 --- a/test/codex-bin-wrapper.test.ts +++ b/test/codex-bin-wrapper.test.ts @@ -246,72 +246,74 @@ describe("codex bin wrapper", () => { it.skipIf(process.platform !== "win32")( "retries transient shim write failures and still installs guards", () => { - 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 hookPath = join(fixtureRoot, "fail-once-shim-write.cjs"); - writeFileSync( - hookPath, - [ - 'const fs = require("node:fs");', - 'const { syncBuiltinESMExports } = require("node:module");', - "const originalWriteFileSync = fs.writeFileSync;", - "let injected = false;", - "fs.writeFileSync = (...args) => {", - ' const target = String(args[0] ?? "");', - " if (!injected && /codex\\.(bat|cmd|ps1)$/i.test(target)) {", - " injected = true;", - ' const error = new Error(\"busy\");', - ' error.code = \"EBUSY\";', - " throw error;", - " }", - " return originalWriteFileSync(...args);", - "};", - "syncBuiltinESMExports();", - ].join("\n"), - "utf8", - ); - - const existingNodeOptions = (process.env.NODE_OPTIONS ?? "").trim(); - const preloadOption = `--require=${hookPath}`; - const nodeOptions = - existingNodeOptions.length > 0 - ? `${existingNodeOptions} ${preloadOption}` - : preloadOption; - 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, - NODE_OPTIONS: nodeOptions, - }); - expect(result.status).toBe(0); - expect(readFileSync(join(shimDir, "codex.bat"), "utf8")).toContain( - "codex-multi-auth windows shim guardian v1", - ); - expect(readFileSync(join(shimDir, "codex.cmd"), "utf8")).toContain( - "codex-multi-auth windows shim guardian v1", - ); - expect(readFileSync(join(shimDir, "codex.ps1"), "utf8")).toContain( - "codex-multi-auth windows shim guardian v1", - ); + for (const retryCode of ["EBUSY", "EPERM", "EACCES"]) { + 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 hookPath = join(fixtureRoot, `fail-once-shim-write-${retryCode}.cjs`); + writeFileSync( + hookPath, + [ + 'const fs = require("node:fs");', + 'const { syncBuiltinESMExports } = require("node:module");', + "const originalWriteFileSync = fs.writeFileSync;", + "let injected = false;", + "fs.writeFileSync = (...args) => {", + ' const target = String(args[0] ?? "");', + " if (!injected && /codex\\.(bat|cmd|ps1)$/i.test(target)) {", + " injected = true;", + ' const error = new Error("busy");', + ` error.code = "${retryCode}";`, + " throw error;", + " }", + " return originalWriteFileSync(...args);", + "};", + "syncBuiltinESMExports();", + ].join("\n"), + "utf8", + ); + + const existingNodeOptions = (process.env.NODE_OPTIONS ?? "").trim(); + const preloadOption = `--require=${hookPath}`; + const nodeOptions = + existingNodeOptions.length > 0 + ? `${existingNodeOptions} ${preloadOption}` + : preloadOption; + 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, + NODE_OPTIONS: nodeOptions, + }); + expect(result.status).toBe(0); + expect(readFileSync(join(shimDir, "codex.bat"), "utf8")).toContain( + "codex-multi-auth windows shim guardian v1", + ); + expect(readFileSync(join(shimDir, "codex.cmd"), "utf8")).toContain( + "codex-multi-auth windows shim guardian v1", + ); + expect(readFileSync(join(shimDir, "codex.ps1"), "utf8")).toContain( + "codex-multi-auth windows shim guardian v1", + ); + } }, ); @@ -478,6 +480,27 @@ describe("codex bin wrapper", () => { expect(result.status).toBe(7); }); + it("treats numeric-string zero multi-auth exit code as success", () => { + const fixtureRoot = createWrapperFixture(); + const distLibDir = join(fixtureRoot, "dist", "lib"); + mkdirSync(distLibDir, { recursive: true }); + writeFileSync( + join(distLibDir, "codex-manager.js"), + [ + "export async function runCodexMultiAuthCli() {", + '\treturn "0";', + "}", + ].join("\n"), + "utf8", + ); + + const result = runWrapper(fixtureRoot, ["auth", "status"], { + CODEX_MULTI_AUTH_BYPASS: "", + CODEX_MULTI_AUTH_REAL_CODEX_BIN: "", + }); + expect(result.status).toBe(0); + }); + it("prints actionable guidance when real codex bin cannot be found", () => { const fixtureRoot = createWrapperFixture(); const missingOverride = join(fixtureRoot, "missing", "codex.js"); From fe6ff870fa5d775ff5e5c274d0eb8a103991d467 Mon Sep 17 00:00:00 2001 From: ndycode Date: Thu, 5 Mar 2026 11:43:22 +0800 Subject: [PATCH 8/9] fix: restrict wrapper string exit codes to decimal integers Prevent codex-multi-auth wrapper from accepting hex/octal-like string exit codes via Number coercion. Parse only signed decimal digit strings and keep non-decimal strings fail-closed. Add regressions for 0xff and 0o7 inputs. Co-authored-by: Codex --- scripts/codex-multi-auth.js | 24 ++++++++++++++++------- test/codex-multi-auth-bin-wrapper.test.ts | 22 ++++++++++++++++++++- 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/scripts/codex-multi-auth.js b/scripts/codex-multi-auth.js index de9347f14..1c1b55422 100644 --- a/scripts/codex-multi-auth.js +++ b/scripts/codex-multi-auth.js @@ -14,11 +14,21 @@ try { // Best effort only. } +function parseCliExitCode(value) { + if (typeof value === "number" && Number.isInteger(value)) { + return value; + } + if (typeof value === "string") { + const trimmed = value.trim(); + if (/^[+-]?\d+$/.test(trimmed)) { + const parsed = Number.parseInt(trimmed, 10); + if (Number.isInteger(parsed)) { + return parsed; + } + } + } + return 1; +} + const exitCode = await runCodexMultiAuthCli(process.argv.slice(2)); -const parsedExitCode = - typeof exitCode === "number" && Number.isInteger(exitCode) - ? exitCode - : typeof exitCode === "string" && exitCode.trim().length > 0 - ? Number(exitCode) - : Number.NaN; -process.exitCode = Number.isInteger(parsedExitCode) ? parsedExitCode : 1; +process.exitCode = parseCliExitCode(exitCode); diff --git a/test/codex-multi-auth-bin-wrapper.test.ts b/test/codex-multi-auth-bin-wrapper.test.ts index fce293d18..ee6397d36 100644 --- a/test/codex-multi-auth-bin-wrapper.test.ts +++ b/test/codex-multi-auth-bin-wrapper.test.ts @@ -106,7 +106,7 @@ describe("codex-multi-auth bin wrapper", () => { expect(result.status).toBe(1); }); - it("propagates numeric-string exit codes", () => { + it("propagates decimal numeric-string exit codes", () => { const fixtureRoot = createWrapperFixture(); const distLibDir = join(fixtureRoot, "dist", "lib"); mkdirSync(distLibDir, { recursive: true }); @@ -124,6 +124,26 @@ describe("codex-multi-auth bin wrapper", () => { expect(result.status).toBe(7); }); + it("normalizes non-decimal numeric-string exit codes to failure", () => { + for (const encoded of ["0xff", "0o7"]) { + const fixtureRoot = createWrapperFixture(); + const distLibDir = join(fixtureRoot, "dist", "lib"); + mkdirSync(distLibDir, { recursive: true }); + writeFileSync( + join(distLibDir, "codex-manager.js"), + [ + "export async function runCodexMultiAuthCli() {", + `\treturn "${encoded}";`, + "}", + ].join("\n"), + "utf8", + ); + + const result = runWrapper(fixtureRoot, ["auth", "status"]); + expect(result.status).toBe(1); + } + }); + it("normalizes null exit codes to failure", () => { const fixtureRoot = createWrapperFixture(); const distLibDir = join(fixtureRoot, "dist", "lib"); From cee89c793e3f134290c6389429f509206ad0d28e Mon Sep 17 00:00:00 2001 From: ndycode Date: Thu, 5 Mar 2026 11:51:38 +0800 Subject: [PATCH 9/9] fix: harden wrapper exit parsing and cleanup resilience Restrict codex-multi-auth string exit parsing to unsigned decimal values, add regressions for signed/hex/octal/whitespace edge cases, and keep zero-string success coverage. Also ensure test fixture cleanup attempts all directories even when one removal fails, and remove unreachable retry helper dead code. Co-authored-by: Codex --- scripts/codex-multi-auth.js | 2 +- scripts/codex.js | 1 - test/codex-multi-auth-bin-wrapper.test.ts | 68 ++++++++++++++++++++++- 3 files changed, 68 insertions(+), 3 deletions(-) diff --git a/scripts/codex-multi-auth.js b/scripts/codex-multi-auth.js index 1c1b55422..a0ce730c4 100644 --- a/scripts/codex-multi-auth.js +++ b/scripts/codex-multi-auth.js @@ -20,7 +20,7 @@ function parseCliExitCode(value) { } if (typeof value === "string") { const trimmed = value.trim(); - if (/^[+-]?\d+$/.test(trimmed)) { + if (/^\d+$/.test(trimmed)) { const parsed = Number.parseInt(trimmed, 10); if (Number.isInteger(parsed)) { return parsed; diff --git a/scripts/codex.js b/scripts/codex.js index edfa55f3a..addaf23df 100644 --- a/scripts/codex.js +++ b/scripts/codex.js @@ -227,7 +227,6 @@ async function runWithWindowsFsRetry(operation, options = {}) { await sleep(backoffMs * (2 ** (attempt - 1))); } } - return undefined; } async function writeFileSyncWithWindowsRetry(filePath, content, options) { diff --git a/test/codex-multi-auth-bin-wrapper.test.ts b/test/codex-multi-auth-bin-wrapper.test.ts index ee6397d36..801fe69b8 100644 --- a/test/codex-multi-auth-bin-wrapper.test.ts +++ b/test/codex-multi-auth-bin-wrapper.test.ts @@ -63,8 +63,18 @@ function runWrapper(fixtureRoot: string, args: string[] = []) { } afterEach(async () => { + let firstCleanupError: unknown; for (const dir of createdDirs.splice(0, createdDirs.length)) { - await removeDirectoryWithRetry(dir); + try { + await removeDirectoryWithRetry(dir); + } catch (error) { + if (firstCleanupError === undefined) { + firstCleanupError = error; + } + } + } + if (firstCleanupError !== undefined) { + throw firstCleanupError; } }); @@ -124,6 +134,24 @@ describe("codex-multi-auth bin wrapper", () => { expect(result.status).toBe(7); }); + it("propagates zero numeric-string exit codes as success", () => { + const fixtureRoot = createWrapperFixture(); + const distLibDir = join(fixtureRoot, "dist", "lib"); + mkdirSync(distLibDir, { recursive: true }); + writeFileSync( + join(distLibDir, "codex-manager.js"), + [ + "export async function runCodexMultiAuthCli() {", + '\treturn "0";', + "}", + ].join("\n"), + "utf8", + ); + + const result = runWrapper(fixtureRoot, ["auth", "status"]); + expect(result.status).toBe(0); + }); + it("normalizes non-decimal numeric-string exit codes to failure", () => { for (const encoded of ["0xff", "0o7"]) { const fixtureRoot = createWrapperFixture(); @@ -144,6 +172,26 @@ describe("codex-multi-auth bin wrapper", () => { } }); + it("normalizes signed numeric-string exit codes to failure", () => { + for (const encoded of ["+0", "-0"]) { + const fixtureRoot = createWrapperFixture(); + const distLibDir = join(fixtureRoot, "dist", "lib"); + mkdirSync(distLibDir, { recursive: true }); + writeFileSync( + join(distLibDir, "codex-manager.js"), + [ + "export async function runCodexMultiAuthCli() {", + `\treturn "${encoded}";`, + "}", + ].join("\n"), + "utf8", + ); + + const result = runWrapper(fixtureRoot, ["auth", "status"]); + expect(result.status).toBe(1); + } + }); + it("normalizes null exit codes to failure", () => { const fixtureRoot = createWrapperFixture(); const distLibDir = join(fixtureRoot, "dist", "lib"); @@ -179,4 +227,22 @@ describe("codex-multi-auth bin wrapper", () => { const result = runWrapper(fixtureRoot, ["auth", "status"]); expect(result.status).toBe(1); }); + + it("normalizes whitespace-only exit codes to failure", () => { + const fixtureRoot = createWrapperFixture(); + const distLibDir = join(fixtureRoot, "dist", "lib"); + mkdirSync(distLibDir, { recursive: true }); + writeFileSync( + join(distLibDir, "codex-manager.js"), + [ + "export async function runCodexMultiAuthCli() {", + '\treturn " ";', + "}", + ].join("\n"), + "utf8", + ); + + const result = runWrapper(fixtureRoot, ["auth", "status"]); + expect(result.status).toBe(1); + }); });