From 7a37b6ad7191ec0bf12c8e322b024e1a4bb43929 Mon Sep 17 00:00:00 2001 From: ndycode Date: Tue, 3 Mar 2026 20:11:56 +0800 Subject: [PATCH 1/3] 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 d673586..3634ef3 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 133eae2..14487b3 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 b7a34dd..15d7c45 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 f68d3f1..34385fb 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 0000000..2a5c456 --- /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 3f2baf5..2a1348a 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/3] 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 14487b3..6d2ee8b 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/3] 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 3634ef3..3cf9fb2 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 34385fb..7027ae5 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 2a5c456..02afa30 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); + }); });