diff --git a/scripts/codex-multi-auth.js b/scripts/codex-multi-auth.js index d67358669..a0ce730c4 100644 --- a/scripts/codex-multi-auth.js +++ b/scripts/codex-multi-auth.js @@ -1,6 +1,34 @@ #!/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. +} + +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)); -process.exitCode = Number.isInteger(exitCode) ? exitCode : 1; +process.exitCode = parseCliExitCode(exitCode); diff --git a/scripts/codex.js b/scripts/codex.js index 133eae267..addaf23df 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,352 @@ 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 <<<"; +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))); + } + } +} + +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; + 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 buildWindowsCmdLikeShimContent() { + 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 buildWindowsBatchShimContent() { + return buildWindowsCmdLikeShimContent(); +} + +function buildWindowsCmdShimContent() { + return buildWindowsCmdLikeShimContent(); +} + +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"); +} + +async 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 { + await writeFileSyncWithWindowsRetry(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 { + await writeFileSyncWithWindowsRetry(filePath, desiredContent, { + encoding: "utf8", + mode: 0o755, + }); + return true; + } catch { + return false; + } + } + if (!overwriteCustomShim) { + return false; + } + } + + try { + await writeFileSyncWithWindowsRetry(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"); +} + +async 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*/, ""); + 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 { + nextContent = `${normalizedCurrentContent.replace(/\s*$/, "")}\r\n\r\n${guardBlock}`; + } + + if (nextContent === normalizedCurrentContent) { + return false; + } + + try { + await mkdirSyncWithWindowsRetry(dirname(profilePath), { recursive: true }); + await writeFileSyncWithWindowsRetry(profilePath, `${nextContent}\r\n`, { + encoding: "utf8", + mode: 0o644, + }); + return true; + } catch { + return false; + } +} + +async 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 = (await upsertPowerShellProfileGuard(profilePath, guardBlock)) || changed; + } + return changed; +} + +async 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 = await ensureWindowsShellShim( + join(shimDirectory, "codex.bat"), + buildWindowsBatchShimContent(), + { overwriteCustomShim }, + ); + const installedCmd = await ensureWindowsShellShim( + join(shimDirectory, "codex.cmd"), + buildWindowsCmdShimContent(), + { overwriteCustomShim }, + ); + const installedPs1 = await ensureWindowsShellShim( + join(shimDirectory, "codex.ps1"), + buildWindowsPowerShellShimContent(), + { overwriteCustomShim }, + ); + const installedAny = installedBatch || installedCmd || installedPs1; + 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.", + ); + } +} + async function main() { + hydrateCliVersionEnv(); + await 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..4cac29aec 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"; @@ -66,17 +66,29 @@ 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", + env: { + ...process.env, + ...extraEnv, }, - ); + }); } type WrapperAsyncResult = { @@ -171,6 +183,225 @@ describe("codex bin wrapper", () => { expect(result.stdout).toContain("FORWARDED:--version"); }); + 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"); + 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.skipIf(process.platform !== "win32")( + "retries transient shim write failures and still installs guards", + () => { + 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", + ); + } + }, + ); + + 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); + 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 +459,48 @@ 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("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"); 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..801fe69b8 --- /dev/null +++ b/test/codex-multi-auth-bin-wrapper.test.ts @@ -0,0 +1,248 @@ +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" || code === "EACCES"; +} + +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 () => { + let firstCleanupError: unknown; + for (const dir of createdDirs.splice(0, createdDirs.length)) { + try { + await removeDirectoryWithRetry(dir); + } catch (error) { + if (firstCleanupError === undefined) { + firstCleanupError = error; + } + } + } + if (firstCleanupError !== undefined) { + throw firstCleanupError; + } +}); + +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); + }); + + it("propagates decimal 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); + }); + + 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(); + 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 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"); + 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); + }); + + 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); + }); +}); 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");