#!/usr/bin/env node import { execFile } from "node:child_process"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; const execFileAsync = promisify(execFile); const DEFAULT_TOKENS_DIR = path.resolve(process.cwd(), "acc_pool"); const DEFAULT_CODEX_HOME = path.join(os.homedir(), ".codex"); const DEFAULT_AUTH_PATH = path.join(DEFAULT_CODEX_HOME, "auth.json"); const DEFAULT_CONFIG_PATH = path.join(DEFAULT_CODEX_HOME, "config.toml"); const DEFAULT_BACKUP_DIR = path.join(DEFAULT_CODEX_HOME, "backups", "switch-codex-account"); const DEFAULT_VALIDATE_URL = "https://api.openai.com/v1/models"; const DEFAULT_MODEL = "gpt-5.4"; const DEFAULT_TIMEOUT_SECONDS = 20; function parseArgs(argv) { const args = {}; for (const part of argv) { if (!part.startsWith("--")) continue; const raw = part.slice(2); const eqIndex = raw.indexOf("="); if (eqIndex === -1) { args[raw] = "true"; continue; } const key = raw.slice(0, eqIndex); const value = raw.slice(eqIndex + 1); args[key] = value; } return args; } function printUsage() { console.log(`Usage: node src/scripts/switch-codex-account.mjs node src/scripts/switch-codex-account.mjs --tokens-dir=acc_pool --model=gpt-5.4 Options: --tokens-dir=PATH Token JSON directory. Default: acc_pool --auth-path=PATH Target Codex auth.json path --config-path=PATH Target Codex config.toml path --backup-dir=PATH Backup directory --validate-url=URL Validation URL. Default: https://api.openai.com/v1/models --model=NAME model / review_model value. Default: gpt-5.4 --timeout=SECONDS Curl timeout. Default: 20 --dry-run Validate only, do not write files --help Show this help `); } function nowStamp() { const date = new Date(); const pad = (value) => String(value).padStart(2, "0"); return [ date.getFullYear(), pad(date.getMonth() + 1), pad(date.getDate()), "-", pad(date.getHours()), pad(date.getMinutes()), pad(date.getSeconds()), ].join(""); } function maskToken(value) { if (!value) return ""; if (value.length <= 12) return "*".repeat(value.length); return `${value.slice(0, 8)}...${value.slice(-4)}`; } function toIsoString(value) { const date = new Date(value); if (Number.isNaN(date.getTime())) return null; return date.toISOString(); } function decodeJwtPayload(token) { try { const parts = String(token || "").split("."); if (parts.length < 2) return null; return JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")); } catch { return null; } } function isFutureDate(value) { const date = new Date(value); return !Number.isNaN(date.getTime()) && date.getTime() > Date.now(); } function buildCodexAuthJson(entry) { const tokenSource = entry.tokens && typeof entry.tokens === "object" ? entry.tokens : entry; return { OPENAI_API_KEY: "", auth_mode: "chatgpt", last_refresh: toIsoString(entry.last_refresh) || new Date().toISOString(), tokens: { access_token: tokenSource.access_token, account_id: tokenSource.account_id, id_token: tokenSource.id_token, refresh_token: tokenSource.refresh_token, }, }; } function buildCodexConfigToml(model) { return [ 'model_provider = "OpenAI"', `model = "${model}"`, `review_model = "${model}"`, "disable_response_storage = true", 'network_access = "enabled"', "windows_wsl_setup_acknowledged = true", "model_context_window = 1000000", "model_auto_compact_token_limit = 900000", 'model_reasoning_effort = "medium"', "", "[model_providers]", "[model_providers.OpenAI]", 'name = "OpenAI"', 'wire_api = "responses"', "requires_openai_auth = true", "", ].join("\n"); } async function readJsonFile(filePath) { return JSON.parse(await fs.readFile(filePath, "utf8")); } function normalizeEntries(raw) { if (Array.isArray(raw)) { return raw.filter((entry) => entry && typeof entry === "object"); } return []; } async function listTokenFiles(tokensDir) { return [path.join(tokensDir, "pool.json")]; } async function listTokenEntries(tokensDir) { const files = await listTokenFiles(tokensDir); const collected = []; for (const filePath of files) { const raw = await readJsonFile(filePath); for (const [index, entry] of normalizeEntries(raw).entries()) { collected.push({ filePath, fileName: index > 0 ? `${path.basename(filePath)}#${index + 1}` : path.basename(filePath), entry, }); } } return collected; } function precheckEntry(entry) { if (!entry || typeof entry !== "object") { return { ok: false, reason: "invalid-json" }; } const tokenSource = entry.tokens && typeof entry.tokens === "object" ? entry.tokens : entry; if (entry.disabled) { return { ok: false, reason: "disabled" }; } if ( !tokenSource.access_token || !tokenSource.account_id || !tokenSource.id_token || !tokenSource.refresh_token ) { return { ok: false, reason: "missing-required-fields" }; } if (entry.expired && !isFutureDate(entry.expired)) { return { ok: false, reason: "expired-field" }; } const payload = decodeJwtPayload(tokenSource.access_token); if (!payload) { return { ok: false, reason: "invalid-access-token-jwt" }; } if (typeof payload.exp === "number" && payload.exp * 1000 <= Date.now()) { return { ok: false, reason: "expired-jwt" }; } return { ok: true, reason: "precheck-passed", payload, }; } async function validateAccessToken(accessToken, validateUrl, timeoutSeconds) { try { const { stdout } = await execFileAsync( "curl", [ "-sS", "-D", "-", "-o", "/dev/null", "-m", String(timeoutSeconds), "-H", `Authorization: Bearer ${accessToken}`, validateUrl, ], { maxBuffer: 1024 * 1024 * 2 }, ); const match = stdout.match(/^HTTP\/\S+\s+(\d+)/m); const status = match ? Number(match[1]) : 0; const ok = status >= 200 && status < 300; return { ok, status, reason: ok ? "validated" : `http-${status || "unknown"}`, }; } catch (error) { const detail = [error?.stdout, error?.stderr, error?.message] .filter(Boolean) .join(" ") .trim(); return { ok: false, status: 0, reason: detail || "curl-failed", }; } } async function ensureDir(dirPath) { await fs.mkdir(dirPath, { recursive: true }); } async function backupFileIfExists(sourcePath, backupDir) { try { await fs.access(sourcePath); } catch { return null; } await ensureDir(backupDir); const targetPath = path.join( backupDir, `${path.basename(sourcePath)}.${nowStamp()}.bak`, ); await fs.copyFile(sourcePath, targetPath); return targetPath; } async function writeSelectedAccount({ selectedFile, selectedEntry, authPath, configPath, backupDir, model, dryRun, }) { const authJson = buildCodexAuthJson(selectedEntry); const configToml = buildCodexConfigToml(model); if (dryRun) { return { authBackupPath: null, configBackupPath: null, wrote: false, authJson, configToml, }; } const authBackupPath = await backupFileIfExists(authPath, backupDir); const configBackupPath = await backupFileIfExists(configPath, backupDir); await ensureDir(path.dirname(authPath)); await ensureDir(path.dirname(configPath)); await fs.writeFile(authPath, `${JSON.stringify(authJson, null, 2)}\n`, "utf8"); await fs.writeFile(configPath, configToml, "utf8"); return { authBackupPath, configBackupPath, wrote: true, authJson, configToml, selectedFile, }; } async function main() { const args = parseArgs(process.argv.slice(2)); if (args.help === "true") { printUsage(); return; } const tokensDir = path.resolve(args["tokens-dir"] || DEFAULT_TOKENS_DIR); const authPath = path.resolve(args["auth-path"] || DEFAULT_AUTH_PATH); const configPath = path.resolve(args["config-path"] || DEFAULT_CONFIG_PATH); const backupDir = path.resolve(args["backup-dir"] || DEFAULT_BACKUP_DIR); const validateUrl = args["validate-url"] || DEFAULT_VALIDATE_URL; const model = args.model || DEFAULT_MODEL; const timeoutSeconds = Number(args.timeout || DEFAULT_TIMEOUT_SECONDS); const dryRun = args["dry-run"] === "true"; const tokenEntries = await listTokenEntries(tokensDir); if (tokenEntries.length === 0) { throw new Error(`No usable entries found in ${path.join(tokensDir, "pool.json")}`); } console.log(`Scanning pool file: ${path.join(tokensDir, "pool.json")}`); console.log(`Validation URL: ${validateUrl}`); console.log(`Dry run: ${dryRun ? "yes" : "no"}`); const failures = []; let selected = null; for (const { filePath, fileName, entry } of tokenEntries) { const precheck = precheckEntry(entry); if (!precheck.ok) { failures.push({ file: fileName, stage: "precheck", reason: precheck.reason }); console.log(`skip ${fileName}: ${precheck.reason}`); continue; } const tokenSource = entry.tokens && typeof entry.tokens === "object" ? entry.tokens : entry; console.log(`validate ${fileName}: ${maskToken(tokenSource.access_token)}`); const validation = await validateAccessToken( tokenSource.access_token, validateUrl, timeoutSeconds, ); if (!validation.ok) { failures.push({ file: fileName, stage: "validate", reason: validation.reason }); console.log(`invalid ${fileName}: ${validation.reason}`); continue; } selected = { filePath, fileName, entry, validation, payload: precheck.payload, }; break; } if (!selected) { console.error("No usable account found."); if (failures.length > 0) { console.error("Recent failures:"); for (const failure of failures.slice(0, 10)) { console.error(`- ${failure.file} [${failure.stage}] ${failure.reason}`); } } process.exitCode = 1; return; } const writeResult = await writeSelectedAccount({ selectedFile: selected.fileName, selectedEntry: selected.entry, authPath, configPath, backupDir, model, dryRun, }); console.log(""); console.log(`Selected account: ${selected.fileName}`); console.log(`Email: ${selected.entry.email || "(unknown)"}`); const selectedTokenSource = selected.entry.tokens && typeof selected.entry.tokens === "object" ? selected.entry.tokens : selected.entry; console.log(`Account ID: ${selectedTokenSource.account_id}`); console.log(`Plan: ${selected.payload?.["https://api.openai.com/auth"]?.chatgpt_plan_type || "(unknown)"}`); console.log(`Auth path: ${authPath}`); console.log(`Config path: ${configPath}`); if (writeResult.wrote) { console.log(`Auth backup: ${writeResult.authBackupPath || "(none)"}`); console.log(`Config backup: ${writeResult.configBackupPath || "(none)"}`); console.log("Codex auth.json and config.toml updated."); } else { console.log("Dry run only. Files were not written."); } } main().catch((error) => { console.error(error.message || error); process.exitCode = 1; });