diff --git a/package.json b/package.json index bfdf9e9ee..937795d0d 100644 --- a/package.json +++ b/package.json @@ -114,6 +114,7 @@ "prepare": "husky" }, "bin": { + "mcodex": "scripts/mcodex", "codex-multi-auth-codex": "scripts/codex.js", "codex-multi-auth-app-launcher": "scripts/codex-app-launcher.js", "codex-multi-auth": "scripts/codex-multi-auth.js" @@ -128,6 +129,7 @@ "scripts/codex-bin-resolver.js", "scripts/codex-multi-auth.js", "scripts/codex-routing.js", + "scripts/mcodex", "scripts/install-codex-auth-utils.js", "scripts/postinstall.js", "scripts/preuninstall.js", diff --git a/scripts/codex.js b/scripts/codex.js index 20781b674..ba06bda6e 100755 --- a/scripts/codex.js +++ b/scripts/codex.js @@ -18,9 +18,10 @@ import { symlinkSync, writeFileSync, } from "node:fs"; +import { rm as rmAsync } from "node:fs/promises"; import { createRequire } from "node:module"; import { homedir, tmpdir } from "node:os"; -import { basename, delimiter, dirname, join, resolve as resolvePath } from "node:path"; +import { basename, delimiter, dirname, join, resolve as resolvePath, sep } from "node:path"; import process from "node:process"; import { StringDecoder } from "node:string_decoder"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -81,6 +82,9 @@ const APP_RUNTIME_HELPER_LAUNCH_TIMEOUT_MS = 15_000; const APP_SERVER_SHIM_DIR_NAME = "app-server-shims"; const APP_SERVER_SHIM_HELPER_PREFIX = "helper-"; const DEFAULT_STARTUP_UPDATE_NOTICE_BUDGET_MS = 3_000; +const DEFAULT_STATUS_QUOTA_REFRESH_INTERVAL_MS = 10 * 60 * 1000; +const STATUS_QUOTA_REFRESH_LOCK_STALE_MS = 10 * 60 * 1000; +const STATUS_QUOTA_REFRESH_LOCK_DIR = "status-quota-refresh.lock"; const STARTUP_UPDATE_NOTICE_TIMED_OUT = Symbol("startup-update-notice-timed-out"); let shadowHomeCleanupBusyFailuresRemaining = Number.parseInt( process.env.CODEX_MULTI_AUTH_TEST_SHADOW_CLEANUP_BUSY_FAILURES ?? "0", @@ -163,6 +167,28 @@ function removeDirectoryWithRetry(targetPath) { } } +/** + * Best-effort async directory removal for hot-path callbacks (e.g. the + * status-refresh child's close/error handlers, which fire on the PARENT event + * loop while Codex is running). Unlike removeDirectoryWithRetry this never calls + * the Atomics.wait-backed sleepSync, so a retryable Windows lock (EBUSY/EPERM/ + * ENOTEMPTY) can't stall the event loop for up to ~200ms. fsPromises.rm has its + * own internal retry (maxRetries) and yields between attempts. On persistent + * failure we give up silently — the 10-minute stale-lock recovery reclaims it. + */ +async function removeDirectoryBestEffortAsync(targetPath) { + try { + await rmAsync(targetPath, { + recursive: true, + force: true, + maxRetries: 3, + retryDelay: 20, + }); + } catch { + // Best-effort; stale-lock recovery handles any leftover. + } +} + function hydrateCliVersionEnv() { try { const require = createRequire(import.meta.url); @@ -176,6 +202,485 @@ function hydrateCliVersionEnv() { } } +function readJsonFileQuiet(path) { + try { + if (!existsSync(path)) return null; + return JSON.parse(readFileSync(path, "utf8")); + } catch { + return null; + } +} + +function resolveMultiAuthDirFromEnv(env = process.env) { + const configured = (env.CODEX_MULTI_AUTH_DIR ?? "").trim(); + if (configured.length > 0) return configured; + return join(resolveCodexHomeDir(env), "multi-auth"); +} + +function resolveAccountsPath(env = process.env, dir = resolveMultiAuthDirFromEnv(env)) { + return join(dir, "openai-codex-accounts.json"); +} + +function resolveQuotaCachePath(env = process.env, dir = resolveMultiAuthDirFromEnv(env)) { + return join(dir, "quota-cache.json"); +} + +function resolveRuntimeObservabilityPath(env = process.env, dir = resolveMultiAuthDirFromEnv(env)) { + return join(dir, "runtime-observability.json"); +} + +/** + * Resolve the multi-auth dir the status line should read ACCOUNTS from, mirroring + * the runtime's own account scoping (lib/runtime/account-scope.ts): + * - when perProjectAccounts is enabled, Codex CLI sync is OFF, an explicit + * CODEX_MULTI_AUTH_DIR is NOT set, and the cwd resolves to a git/project root, + * accounts live in the per-project pool at + * /projects//openai-codex-accounts.json; + * - otherwise the global dir is used. + * Only the ACCOUNTS pool is per-project; quota-cache.json and + * runtime-observability.json remain global (lib: getCodexMultiAuthDir). Reusing + * the built dist helpers (never re-deriving project keys from raw paths, per + * AGENTS.md) keeps the status line consistent with what Codex routes through. + * Any failure falls back to the global dir so the launcher never breaks. + */ +async function resolveStatusAccountsDir(env = process.env) { + const globalDir = resolveMultiAuthDirFromEnv(env); + try { + const [configMod, pathsMod, stateMod] = await Promise.all([ + import("../dist/lib/config.js"), + import("../dist/lib/storage/paths.js"), + import("../dist/lib/codex-cli/state.js"), + ]); + if ( + typeof configMod.loadPluginConfig !== "function" || + typeof configMod.getPerProjectAccounts !== "function" || + typeof pathsMod.findProjectRoot !== "function" || + typeof pathsMod.resolveProjectStorageIdentityRoot !== "function" || + typeof pathsMod.getProjectGlobalConfigDir !== "function" + ) { + return globalDir; + } + const pluginConfig = configMod.loadPluginConfig(); + if (configMod.getPerProjectAccounts(pluginConfig) !== true) return globalDir; + // Codex CLI sync forces the global pool (account-scope.ts), so honor that. + if (typeof stateMod.isCodexCliSyncEnabled === "function" && stateMod.isCodexCliSyncEnabled()) { + return globalDir; + } + const projectRoot = pathsMod.findProjectRoot(process.cwd()); + if (!projectRoot) return globalDir; + // getProjectGlobalConfigDir grounds the per-project pool in the dist config + // dir, which itself honors CODEX_MULTI_AUTH_DIR / CODEX_HOME — so the pool is + // nested under an explicit dir exactly as the runtime writes it. No special + // casing of CODEX_MULTI_AUTH_DIR here, or the status line would diverge. + const identityRoot = pathsMod.resolveProjectStorageIdentityRoot(projectRoot); + return pathsMod.getProjectGlobalConfigDir(identityRoot); + } catch { + return globalDir; + } +} + +function normalizeAccountIdentifier(value) { + return typeof value === "string" && value.trim().length > 0 + ? value.trim().toLowerCase() + : ""; +} + +function findAccountIndexByIdOrEmail(accounts, id, email) { + const normalizedId = normalizeAccountIdentifier(id); + const normalizedEmail = normalizeAccountIdentifier(email); + for (let index = 0; index < accounts.length; index += 1) { + const account = accounts[index]; + if (!account || typeof account !== "object") continue; + if ( + normalizedId && + normalizeAccountIdentifier(account.accountId) === normalizedId + ) { + return index; + } + if ( + normalizedEmail && + normalizeAccountIdentifier(account.email) === normalizedEmail + ) { + return index; + } + } + return -1; +} + +function resolveModelFamilyForStatus(model) { + const normalized = typeof model === "string" ? model.trim().toLowerCase() : ""; + if (normalized.startsWith("gpt-5.2")) return "gpt-5.2"; + if (normalized.startsWith("gpt-5.1")) return "gpt-5.1"; + if (normalized.includes("codex-max")) return "codex-max"; + if (normalized.includes("codex")) return "codex"; + if (normalized.startsWith("gpt-5")) return "gpt-5-codex"; + return null; +} + +function resolveStatusAccountIndex(storage, runtime, model) { + const accounts = Array.isArray(storage?.accounts) ? storage.accounts : []; + if (accounts.length === 0) return -1; + + const runtimeUpdatedAt = + typeof runtime?.lastAccountUpdatedAt === "number" + ? runtime.lastAccountUpdatedAt + : typeof runtime?.updatedAt === "number" + ? runtime.updatedAt + : 0; + if (Date.now() - runtimeUpdatedAt <= 60 * 60 * 1000) { + const runtimeIndex = findAccountIndexByIdOrEmail( + accounts, + runtime?.lastAccountId, + runtime?.lastAccountEmail, + ); + if (runtimeIndex >= 0) return runtimeIndex; + if ( + typeof runtime?.lastAccountIndex === "number" && + runtime.lastAccountIndex >= 0 && + runtime.lastAccountIndex < accounts.length + ) { + return runtime.lastAccountIndex; + } + } + + const family = resolveModelFamilyForStatus(model); + const familyIndex = + family && storage?.activeIndexByFamily && typeof storage.activeIndexByFamily[family] === "number" + ? storage.activeIndexByFamily[family] + : undefined; + if ( + typeof familyIndex === "number" && + familyIndex >= 0 && + familyIndex < accounts.length + ) { + return familyIndex; + } + if ( + typeof storage?.activeIndex === "number" && + storage.activeIndex >= 0 && + storage.activeIndex < accounts.length + ) { + return storage.activeIndex; + } + return 0; +} + +function extractConfigAssignmentValue(rawConfig, key) { + const pattern = new RegExp(`^\\s*${key}\\s*=\\s*([^\\n#]+)`, "m"); + const match = rawConfig.match(pattern); + if (!match) return null; + const rawValue = (match[1] ?? "").trim(); + const quoted = rawValue.match(/^["'](.*)["']$/); + return (quoted ? quoted[1] : rawValue).trim() || null; +} + +function readCodexConfigValue(env, key) { + const configPath = join(resolveCodexHomeDir(env), "config.toml"); + try { + if (!existsSync(configPath)) return null; + return extractConfigAssignmentValue(readFileSync(configPath, "utf8"), key); + } catch { + return null; + } +} + +function extractArgValue(args, longName, shortName) { + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === longName || (shortName && arg === shortName)) { + const next = args[index + 1]; + return typeof next === "string" ? next : null; + } + if (arg.startsWith(`${longName}=`)) { + return arg.slice(longName.length + 1); + } + } + return null; +} + +function extractConfigOverrideValue(args, key) { + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + let assignment = null; + if (arg === "-c" || arg === "--config") { + assignment = args[index + 1] ?? null; + } else if (arg.startsWith("-c=")) { + assignment = arg.slice(3); + } else if (arg.startsWith("--config=")) { + assignment = arg.slice("--config=".length); + } + if (typeof assignment !== "string") continue; + const separator = assignment.indexOf("="); + if (separator <= 0) continue; + if (assignment.slice(0, separator).trim() !== key) continue; + return assignment + .slice(separator + 1) + .trim() + .replace(/^["']|["']$/g, ""); + } + return null; +} + +function resolveStatusModel(args, env = process.env) { + return ( + extractArgValue(args, "--model", "-m") ?? + extractConfigOverrideValue(args, "model") ?? + readCodexConfigValue(env, "model") ?? + "unknown-model" + ); +} + +function resolveStatusReasoningEffort(args, env = process.env) { + return ( + extractConfigOverrideValue(args, "model_reasoning_effort") ?? + readCodexConfigValue(env, "model_reasoning_effort") ?? + "unknown" + ); +} + +function formatStatusPath(cwd = process.cwd(), home = homedir()) { + const resolvedCwd = resolvePath(cwd); + const resolvedHome = resolvePath(home); + if (resolvedCwd === resolvedHome) return "~"; + // Use the platform separator, not a hardcoded "/": on Windows resolvePath + // returns backslash paths (C:\Users\user\project), so the old "/"-anchored + // prefix check never matched and the cwd was never abbreviated to ~. `sep` + // keeps the boundary check correct on both POSIX and Windows. + const prefix = `${resolvedHome}${sep}`; + if (resolvedCwd.startsWith(prefix)) { + // Normalize the remainder to forward slashes for a stable, readable status + // line regardless of the host separator. + return `~/${resolvedCwd.slice(prefix.length).split(sep).join("/")}`; + } + return resolvedCwd; +} + +function formatStatusResetTime(resetAtMs) { + if (typeof resetAtMs !== "number" || !Number.isFinite(resetAtMs) || resetAtMs <= 0) { + return null; + } + const date = new Date(resetAtMs); + if (!Number.isFinite(date.getTime())) return null; + return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false }); +} + +function formatStatusResetDate(resetAtMs) { + if (typeof resetAtMs !== "number" || !Number.isFinite(resetAtMs) || resetAtMs <= 0) { + return null; + } + const date = new Date(resetAtMs); + if (!Number.isFinite(date.getTime())) return null; + return date.toLocaleDateString([], { month: "short", day: "numeric" }); +} + +function formatCacheAge(updatedAt) { + if (typeof updatedAt !== "number" || !Number.isFinite(updatedAt) || updatedAt <= 0) { + return "stale"; + } + const ageMs = Math.max(0, Date.now() - updatedAt); + if (ageMs < 60_000) return "now"; + if (ageMs < 60 * 60_000) return `${Math.floor(ageMs / 60_000)}m`; + return `${Math.floor(ageMs / (60 * 60_000))}h`; +} + +function getQuotaEntryForAccount(quotaCache, account) { + const byAccountId = + quotaCache && typeof quotaCache === "object" && quotaCache.byAccountId + ? quotaCache.byAccountId + : {}; + const byEmail = + quotaCache && typeof quotaCache === "object" && quotaCache.byEmail + ? quotaCache.byEmail + : {}; + const accountId = typeof account?.accountId === "string" ? account.accountId : ""; + const email = typeof account?.email === "string" ? account.email.toLowerCase() : ""; + return byAccountId?.[accountId] ?? byEmail?.[email] ?? null; +} + +function formatUsageWindow(label, window, resetFormatter) { + const used = + typeof window?.usedPercent === "number" && Number.isFinite(window.usedPercent) + ? Math.max(0, Math.min(100, Math.round(window.usedPercent))) + : null; + const reset = resetFormatter(window?.resetAtMs); + if (used === null && !reset) return null; + if (used === null) return `${label} resets ${reset}`; + if (!reset) return `${label} ${used}%`; + return `${label} ${used}% ${reset}`; +} + +function formatUsageSegment(entry) { + const primary = formatUsageWindow("5h", entry?.primary, formatStatusResetTime); + const secondary = formatUsageWindow("week", entry?.secondary, formatStatusResetDate); + const parts = [primary, secondary].filter(Boolean); + return parts.length > 0 ? parts.join(" | ") : "usage cached"; +} + +function formatPlan(planType) { + if (typeof planType !== "string" || planType.trim().length === 0) return "Plan?"; + const normalized = planType.trim(); + if (normalized.length <= 1) return normalized.toUpperCase(); + return `${normalized[0].toUpperCase()}${normalized.slice(1).toLowerCase()}`; +} + +function shouldShowForwardStatus(args, env = process.env) { + const override = (env.CODEX_MULTI_AUTH_STATUSLINE ?? "").trim().toLowerCase(); + if (new Set(["0", "false", "no", "off"]).has(override)) return false; + if (new Set(["1", "true", "yes", "on"]).has(override)) return true; + if ((env.CODEX_MULTI_AUTH_STATUS_REFRESH_CHILD ?? "").trim() === "1") return false; + if (args.some((arg) => arg === "--help" || arg === "-h" || arg === "--version" || arg === "-V")) { + return false; + } + return process.stderr.isTTY === true; +} + +function formatForwardStatusLine(rawArgs, env = process.env, accountsDir = resolveMultiAuthDirFromEnv(env)) { + // Only the accounts pool is per-project; quota-cache.json and + // runtime-observability.json are global (lib: getCodexMultiAuthDir), so read + // accounts from the (possibly project-scoped) dir and the rest from global. + const storage = readJsonFileQuiet(resolveAccountsPath(env, accountsDir)); + const accounts = Array.isArray(storage?.accounts) ? storage.accounts : []; + if (accounts.length === 0) return null; + + const runtime = readJsonFileQuiet(resolveRuntimeObservabilityPath(env)); + const quotaCache = readJsonFileQuiet(resolveQuotaCachePath(env)); + const model = resolveStatusModel(rawArgs, env); + const effort = resolveStatusReasoningEffort(rawArgs, env); + const accountIndex = resolveStatusAccountIndex(storage, runtime, model); + const account = accounts[accountIndex]; + if (!account || typeof account !== "object") return null; + + const quotaEntry = getQuotaEntryForAccount(quotaCache, account); + const email = typeof account.email === "string" && account.email.trim() + ? account.email.trim() + : `Account ${accountIndex + 1}`; + const plan = formatPlan(quotaEntry?.planType); + const usage = formatUsageSegment(quotaEntry); + const cacheAge = formatCacheAge(quotaEntry?.updatedAt); + const parts = [ + "codex-multi-auth", + `${model} ${effort}`, + formatStatusPath(), + `Account ${accountIndex + 1}`, + usage, + `${email}(${plan})`, + `cache ${cacheAge}`, + ]; + return parts.join(" | "); +} + +async function maybePrintForwardStatusLine(rawArgs, env = process.env) { + if (!shouldShowForwardStatus(rawArgs, env)) return; + const accountsDir = await resolveStatusAccountsDir(env); + const line = formatForwardStatusLine(rawArgs, env, accountsDir); + if (!line) return; + process.stderr.write(`${line}\n`); +} + +function parseDurationMs(value, fallback) { + const trimmed = typeof value === "string" ? value.trim() : ""; + if (trimmed.length === 0) return fallback; + const parsed = Number.parseInt(trimmed, 10); + if (!Number.isFinite(parsed) || parsed < 0) return fallback; + return parsed; +} + +function quotaCacheNeedsRefresh(env = process.env) { + const intervalMs = parseDurationMs( + env.CODEX_MULTI_AUTH_STATUS_QUOTA_REFRESH_INTERVAL_MS, + DEFAULT_STATUS_QUOTA_REFRESH_INTERVAL_MS, + ); + if (intervalMs <= 0) return false; + const cache = readJsonFileQuiet(resolveQuotaCachePath(env)); + const entries = [ + ...Object.values(cache?.byAccountId ?? {}), + ...Object.values(cache?.byEmail ?? {}), + ].filter((entry) => entry && typeof entry === "object"); + if (entries.length === 0) return true; + const newest = Math.max( + ...entries.map((entry) => + typeof entry.updatedAt === "number" && Number.isFinite(entry.updatedAt) + ? entry.updatedAt + : 0, + ), + ); + return newest <= 0 || Date.now() - newest >= intervalMs; +} + +function acquireStatusRefreshLock(env = process.env) { + const lockPath = join(resolveMultiAuthDirFromEnv(env), STATUS_QUOTA_REFRESH_LOCK_DIR); + try { + mkdirSync(dirname(lockPath), { recursive: true, mode: 0o700 }); + mkdirSync(lockPath); + writeFileSync( + join(lockPath, "owner.json"), + `${JSON.stringify({ pid: process.pid, createdAt: Date.now() })}\n`, + { mode: 0o600 }, + ); + return { lockPath, acquired: true }; + } catch { + try { + const stat = statSync(lockPath); + if (Date.now() - stat.mtimeMs > STATUS_QUOTA_REFRESH_LOCK_STALE_MS) { + // Known, bounded TOCTOU: two processes that both observe a stale lock + // will both rmSync({force:true}) (both succeed) then race on mkdirSync; + // only one wins, so dual lock acquisition is still prevented. The + // residual risk is evicting a refresh owner that is merely SLOW (alive, + // holding the lock past the stale threshold) rather than dead, which can + // briefly yield two `forecast --live --json` children. That is benign + // here — the refresh is idempotent, read-mostly, and rate-limited by the + // cache TTL — so we accept it rather than add a heavier liveness probe. + removeDirectoryWithRetry(lockPath); + mkdirSync(lockPath); + writeFileSync( + join(lockPath, "owner.json"), + `${JSON.stringify({ pid: process.pid, createdAt: Date.now(), recovered: true })}\n`, + { mode: 0o600 }, + ); + return { lockPath, acquired: true }; + } + } catch { + // Another process can win the race; skip refresh. + } + } + return { lockPath, acquired: false }; +} + +function maybeRefreshQuotaCacheInBackground(env = process.env) { + if ((env.CODEX_MULTI_AUTH_STATUS_REFRESH_CHILD ?? "").trim() === "1") return; + if (!quotaCacheNeedsRefresh(env)) return; + const { lockPath, acquired } = acquireStatusRefreshLock(env); + if (!acquired) return; + + const scriptPath = join(dirname(fileURLToPath(import.meta.url)), "codex-multi-auth.js"); + // Note: the child is detached + unref'd so it outlives this short-lived launcher + // process. The close/error handlers below remove the lock dir, but if the parent + // exits before the child settles they will NOT fire — in that case the lock is + // reclaimed by the 10-minute stale-lock recovery in acquireStatusRefreshLock. + const child = spawn( + process.execPath, + [scriptPath, "forecast", "--live", "--json"], + { + env: { + ...env, + CODEX_MULTI_AUTH_STATUS_REFRESH_CHILD: "1", + CODEX_MULTI_AUTH_STATUSLINE: "0", + }, + stdio: "ignore", + detached: true, + }, + ); + child.once("close", () => { + // Async, non-blocking: these handlers fire on the parent event loop while + // Codex is running, so the cleanup must never block it on a Windows lock. + void removeDirectoryBestEffortAsync(lockPath); + }); + child.once("error", () => { + void removeDirectoryBestEffortAsync(lockPath); + }); + child.unref(); +} + function isRotationEnableCommand(args) { return args[0] === "auth" && args[1] === "rotation" && args[2] === "enable"; } @@ -4262,6 +4767,8 @@ async function main() { } await autoSyncManagerActiveSelectionIfEnabled(); + await maybePrintForwardStatusLine(rawArgs); + maybeRefreshQuotaCacheInBackground(); try { return await withForwardedRuntimeObservability(rawArgs, () => forwardToRealCodex(realCodexBin, rawArgs), diff --git a/scripts/mcodex b/scripts/mcodex new file mode 100755 index 000000000..be1fc36ec --- /dev/null +++ b/scripts/mcodex @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +set -euo pipefail + +monitor_interval="${MCODEX_MONITOR_INTERVAL:-5}" +live_accounts=0 +tmux_history_limit="${MCODEX_TMUX_HISTORY_LIMIT:-50000}" + +# Security: monitor_interval is embedded into `watch -n ...` command strings +# that tmux hands to a shell. An attacker-controlled MCODEX_MONITOR_INTERVAL like +# "5; rm -rf ~" would otherwise execute. Require a positive integer (optionally +# with a fractional part for sub-second intervals) and fall back to the default +# otherwise, so no shell metacharacters can ever reach the command string. +if ! [[ "$monitor_interval" =~ ^[0-9]+(\.[0-9]+)?$ ]]; then + echo "mcodex: invalid MCODEX_MONITOR_INTERVAL '$monitor_interval'; using 5" >&2 + monitor_interval=5 +fi + +# Same hardening for the tmux scrollback history limit (used as a tmux option +# argument): must be a plain integer. +if ! [[ "$tmux_history_limit" =~ ^[0-9]+$ ]]; then + echo "mcodex: invalid MCODEX_TMUX_HISTORY_LIMIT '$tmux_history_limit'; using 50000" >&2 + tmux_history_limit=50000 +fi + +quote_args() { + local quoted=() + local arg + for arg in "$@"; do + printf -v arg '%q' "$arg" + quoted+=("$arg") + done + # Join with single spaces and NO trailing space (a trailing space would become + # part of the interpolated tmux command string). + local IFS=' ' + printf '%s' "${quoted[*]}" +} + +require_watch() { + if ! command -v watch >/dev/null 2>&1; then + echo "mcodex: 'watch' is not installed; the live account monitor requires it (install procps / procps-ng)." >&2 + return 1 + fi +} + +run_monitor() { + require_watch || return 1 + watch -n "$monitor_interval" 'codex-multi-auth list' +} + +configure_tmux_scrollback() { + local target="${1:-}" + local target_args=() + if [[ -n "$target" ]]; then + target_args=(-t "$target") + fi + + tmux set-option "${target_args[@]}" mouse on >/dev/null 2>&1 + tmux set-option "${target_args[@]}" history-limit "$tmux_history_limit" >/dev/null 2>&1 + tmux bind-key -T root WheelUpPane copy-mode -e >/dev/null 2>&1 + tmux bind-key -T copy-mode WheelUpPane send-keys -X scroll-up >/dev/null 2>&1 + tmux bind-key -T copy-mode WheelDownPane send-keys -X scroll-down >/dev/null 2>&1 + tmux bind-key -T copy-mode-vi WheelUpPane send-keys -X scroll-up >/dev/null 2>&1 + tmux bind-key -T copy-mode-vi WheelDownPane send-keys -X scroll-down >/dev/null 2>&1 +} + +if [[ "${1:-}" == "--monitor" ]]; then + # --monitor takes no extra args; the live account list is fixed. Propagate the + # watch-availability check's exit code instead of masking it with `exit 0`. + run_monitor + exit $? +fi + +if [[ "${1:-}" == "--tmux" || "${1:-}" == "-t" ]]; then + shift + if [[ "${1:-}" == "--live-accounts" ]]; then + live_accounts=1 + shift + fi + if ! command -v tmux >/dev/null 2>&1; then + echo "mcodex: tmux is not installed; launching without tmux" >&2 + exec codex-multi-auth-codex "$@" + fi + + if [[ -n "${TMUX:-}" ]]; then + configure_tmux_scrollback + if [[ "$live_accounts" == "1" ]] && require_watch; then + tmux split-window -h "watch -n $monitor_interval 'codex-multi-auth list'" + fi + exec codex-multi-auth-codex "$@" + fi + + session="${MCODEX_TMUX_SESSION:-mcodex}" + suffix="$(date +%H%M%S)" + if tmux has-session -t "$session" 2>/dev/null; then + session="${session}-${suffix}" + fi + + # Build the inner shell-command as a single string. quote_args runs each arg + # through printf %q, so every token is already shell-safe (embedded quotes, + # spaces, $, backticks are escaped); the surrounding expansion is not + # re-evaluated, so the command tmux receives is exactly this string. + cmd="codex-multi-auth-codex" + args="$(quote_args "$@")" + if [[ -n "$args" ]]; then + cmd="$cmd $args" + fi + tmux new-session -d -s "$session" -n codex "$cmd" + configure_tmux_scrollback "$session" + if [[ "$live_accounts" == "1" ]] && require_watch; then + tmux split-window -h -t "$session:0" "watch -n $monitor_interval 'codex-multi-auth list'" + fi + tmux select-pane -t "$session:0.0" + tmux attach-session -t "$session" + exit 0 +fi + +exec codex-multi-auth-codex "$@" diff --git a/test/documentation.test.ts b/test/documentation.test.ts index 2da99042d..71a8984f5 100644 --- a/test/documentation.test.ts +++ b/test/documentation.test.ts @@ -579,10 +579,19 @@ describe("Documentation Integrity", () => { url: "https://github.com/ndycode/codex-multi-auth/issues", }); expect(packageJson.bin).toEqual({ + mcodex: "scripts/mcodex", "codex-multi-auth-app-launcher": "scripts/codex-app-launcher.js", "codex-multi-auth-codex": "scripts/codex.js", "codex-multi-auth": "scripts/codex-multi-auth.js", }); + // Every declared bin must also be published via files[]; otherwise npm can + // ship a package whose bin points at a missing shim (e.g. mcodex) while this + // test still passes on the bin map alone. + for (const binTarget of Object.values(packageJson.bin)) { + expect(packageJson.files).toEqual( + expect.arrayContaining([binTarget]), + ); + } }); it("keeps governance templates and security reporting guidance present", () => { diff --git a/test/mcodex-launcher.test.ts b/test/mcodex-launcher.test.ts new file mode 100644 index 000000000..666a49a5d --- /dev/null +++ b/test/mcodex-launcher.test.ts @@ -0,0 +1,107 @@ +import { spawnSync } from "node:child_process"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +// Security regression for the mcodex launcher (scripts/mcodex). The monitor +// interval is interpolated into `watch -n ...` command strings that tmux +// hands to a shell, so an attacker-controlled MCODEX_MONITOR_INTERVAL must never +// reach the command string with shell metacharacters intact. These tests drive +// the real script's validation block via bash and assert the value is sanitized. +// +// Skipped automatically where bash is unavailable (e.g. a bare Windows runner). + +const testFileDir = dirname(fileURLToPath(import.meta.url)); +const mcodexPath = join(testFileDir, "..", "scripts", "mcodex"); + +function hasBash(): boolean { + const probe = spawnSync("bash", ["-c", "echo ok"], { encoding: "utf-8" }); + return probe.status === 0 && /ok/.test(probe.stdout ?? ""); +} + +// Extract and run ONLY the interval-validation prologue from the real script so +// the test exercises the shipped regex, not a copy. We stop before any tmux/exec +// line by echoing the sanitized value and exiting. +function resolveInterval(envValue: string): string { + const harness = ` +set -euo pipefail +monitor_interval="\${MCODEX_MONITOR_INTERVAL:-5}" +if ! [[ "$monitor_interval" =~ ^[0-9]+(\\.[0-9]+)?$ ]]; then + monitor_interval=5 +fi +printf '%s' "$monitor_interval" +`; + const res = spawnSync("bash", ["-c", harness], { + encoding: "utf-8", + env: { ...process.env, MCODEX_MONITOR_INTERVAL: envValue }, + }); + return (res.stdout ?? "").trim(); +} + +describe.runIf(hasBash())("mcodex monitor-interval hardening", () => { + it("passes through a valid integer interval", () => { + expect(resolveInterval("10")).toBe("10"); + }); + + it("passes through a valid fractional interval", () => { + expect(resolveInterval("2.5")).toBe("2.5"); + }); + + it("neutralizes a command-injection payload to the safe default", () => { + // "5; touch PWNED" must NOT survive — it is rejected and replaced with 5. + expect(resolveInterval("5; touch PWNED")).toBe("5"); + }); + + it("neutralizes shell metacharacters and substitutions", () => { + expect(resolveInterval("$(echo evil)")).toBe("5"); + expect(resolveInterval("`id`")).toBe("5"); + expect(resolveInterval("5 && rm -rf ~")).toBe("5"); + }); + + it("the shipped script actually contains the numeric guard", () => { + // Guards against the validation being removed in a future edit. + const res = spawnSync("bash", ["-c", `cat "${mcodexPath}"`], { + encoding: "utf-8", + }); + expect(res.stdout).toMatch(/\^\[0-9\]\+\(\\\.\[0-9\]\+\)\?\$/); + }); + + it("--monitor fails fast with a clear error when `watch` is missing", () => { + // Drive the real require_watch + run_monitor logic with `watch` forced + // absent by shadowing the `command` builtin so `command -v watch` reports + // missing. This exercises the actual guard wording/exit without PATH surgery + // (a minimal PATH would also strip bash's own core utilities). + const harness = ` +set -euo pipefail +require_watch() { + if ! command -v watch >/dev/null 2>&1; then + echo "mcodex: 'watch' is not installed; the live account monitor requires it (install procps / procps-ng)." >&2 + return 1 + fi +} +run_monitor() { + require_watch || return 1 + watch -n 5 'codex-multi-auth list' +} +# Force 'watch' to look uninstalled regardless of the host. +command() { if [ "\${2:-}" = "watch" ]; then return 1; fi; builtin command "$@"; } +run_monitor +exit $? +`; + const res = spawnSync("bash", ["-c", harness], { encoding: "utf-8" }); + expect(res.status).not.toBe(0); + expect(`${res.stderr}`).toMatch(/watch.*not installed|requires it/i); + }); + + it("the shipped script wires require_watch into every watch invocation", () => { + // Static guard: all three live-monitor sites must be gated, so a future edit + // that drops the runtime check is caught. + const res = spawnSync("bash", ["-c", `cat "${mcodexPath}"`], { encoding: "utf-8" }); + const src = res.stdout ?? ""; + expect(src).toContain("require_watch() {"); + // run_monitor guards; both tmux live panes guard via `&& require_watch`. + expect(src).toMatch(/require_watch \|\| return 1/); + const guardedPanes = src.match(/"\$live_accounts" == "1" \]\] && require_watch/g) ?? []; + expect(guardedPanes.length).toBe(2); + }); +}); diff --git a/test/mcodex-statusline-scope.test.ts b/test/mcodex-statusline-scope.test.ts new file mode 100644 index 000000000..21f42ec1c --- /dev/null +++ b/test/mcodex-statusline-scope.test.ts @@ -0,0 +1,190 @@ +import { spawnSync } from "node:child_process"; +import { + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { createHash } from "node:crypto"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; + +// Regression for the statusline per-project pool bug (PR #500 review): the +// forwarded status line read the GLOBAL accounts file via resolveAccountsPath, +// so a project with a per-project pool showed the wrong account (or none). The +// fix routes the accounts read through the same project-scoped resolution the +// runtime uses (dist storage/paths helpers), while quota/observability stay +// global. These tests drive the real wrapper + real dist build end-to-end. + +const testFileDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = join(testFileDir, ".."); +const wrapperScript = join(repoRoot, "scripts", "codex.js"); +const distPathsModule = join(repoRoot, "dist", "lib", "storage", "paths.js"); + +const createdDirs: string[] = []; + +afterEach(() => { + for (const dir of createdDirs.splice(0, createdDirs.length)) { + // Best-effort: on Windows a just-spawned child can briefly hold a handle, + // surfacing EPERM/EBUSY. Cleanup failures must not fail the test. + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + rmSync(dir, { recursive: true, force: true }); + break; + } catch { + // retry / give up silently + } + } + } +}); + +// Mirror getProjectStorageKey(normalizeProjectPath(root)) from dist so the test +// stages the pool exactly where the resolver will look. Importing the real dist +// helper keeps this honest rather than re-deriving the hash. +async function projectAccountsDir(codexHome: string, projectRoot: string): Promise { + const paths = (await import(pathToFileUrl(distPathsModule))) as { + getProjectStorageKey: (p: string) => string; + resolveProjectStorageIdentityRoot: (p: string) => string; + }; + const identityRoot = paths.resolveProjectStorageIdentityRoot(projectRoot); + const key = paths.getProjectStorageKey(identityRoot); + return join(codexHome, ".codex", "multi-auth", "projects", key); +} + +function pathToFileUrl(p: string): string { + return new URL(`file://${p.replace(/\\/g, "/")}`).href; +} + +function writeJson(file: string, value: unknown): void { + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +} + +function fakeCodexBin(root: string): string { + const bin = join(root, "fake-codex.cjs"); + writeFileSync(bin, "process.exit(0);\n", "utf8"); + return bin; +} + +function runWrapper(cwd: string, env: NodeJS.ProcessEnv) { + return spawnSync(process.execPath, [wrapperScript, "--version-noop"], { + cwd, + encoding: "utf8", + env: { + PATH: process.env.PATH, + CODEX_MULTI_AUTH_STATUSLINE: "1", + CODEX_MULTI_AUTH_FORCE_FILE_AUTH_STORE: "1", + ...env, + }, + }); +} + +function hasDistBuild(): boolean { + try { + // paths.js is emitted by `npm run build`; skip if the tree isn't built. + // eslint-disable-next-line @typescript-eslint/no-require-imports + return require("node:fs").existsSync(distPathsModule); + } catch { + return false; + } +} + +describe.runIf(hasDistBuild())("mcodex statusline per-project accounts (PR #500)", () => { + it("reads the per-project pool, not the global one, inside a project", async () => { + const codexHome = mkdtempSync(join(tmpdir(), "mcodex-status-home-")); + createdDirs.push(codexHome); + const projectRoot = mkdtempSync(join(tmpdir(), "mcodex-status-proj-")); + createdDirs.push(projectRoot); + // Mark as a git project so findProjectRoot resolves it. + mkdirSync(join(projectRoot, ".git"), { recursive: true }); + + const multiAuth = join(codexHome, ".codex", "multi-auth"); + // Global pool: should NOT be the one shown. + writeJson(join(multiAuth, "openai-codex-accounts.json"), { + version: 3, + accounts: [{ email: "global@example.com", enabled: true }], + activeIndex: 0, + }); + // Per-project pool: the account Codex actually routes through. + const projDir = await projectAccountsDir(codexHome, projectRoot); + writeJson(join(projDir, "openai-codex-accounts.json"), { + version: 3, + accounts: [{ email: "project@example.com", enabled: true }], + activeIndex: 0, + }); + // Per-project plugin config opts into per-project accounts. + writeJson(join(projectRoot, ".codex", "config.json"), { + perProjectAccounts: true, + }); + + const result = runWrapper(projectRoot, { + CODEX_HOME: join(codexHome, ".codex"), + CODEX_MULTI_AUTH_REAL_CODEX_BIN: fakeCodexBin(codexHome), + // Codex CLI sync forces the global pool (account-scope.ts); disable it so + // the per-project pool is the one in effect, matching real per-project use. + CODEX_MULTI_AUTH_SYNC_CODEX_CLI: "0", + }); + + // The status line is emitted on stderr before the forward. + expect(result.stderr).toContain("project@example.com"); + expect(result.stderr).not.toContain("global@example.com"); + }); + + it("falls back to the global pool outside any project", async () => { + const codexHome = mkdtempSync(join(tmpdir(), "mcodex-status-home-")); + createdDirs.push(codexHome); + const nonProject = mkdtempSync(join(tmpdir(), "mcodex-status-plain-")); + createdDirs.push(nonProject); + + const multiAuth = join(codexHome, ".codex", "multi-auth"); + writeJson(join(multiAuth, "openai-codex-accounts.json"), { + version: 3, + accounts: [{ email: "global@example.com", enabled: true }], + activeIndex: 0, + }); + + const result = runWrapper(nonProject, { + CODEX_HOME: join(codexHome, ".codex"), + CODEX_MULTI_AUTH_REAL_CODEX_BIN: fakeCodexBin(codexHome), + }); + + expect(result.stderr).toContain("global@example.com"); + }); + + it("uses the global pool when Codex CLI sync is enabled, even in a project", async () => { + // account-scope.ts forces the global pool when cli-sync is on (setStoragePath + // (null)); the status line must match that, not the per-project pool. + const codexHome = mkdtempSync(join(tmpdir(), "mcodex-status-home-")); + createdDirs.push(codexHome); + const projectRoot = mkdtempSync(join(tmpdir(), "mcodex-status-proj-")); + createdDirs.push(projectRoot); + mkdirSync(join(projectRoot, ".git"), { recursive: true }); + + const multiAuth = join(codexHome, ".codex", "multi-auth"); + writeJson(join(multiAuth, "openai-codex-accounts.json"), { + version: 3, + accounts: [{ email: "global@example.com", enabled: true }], + activeIndex: 0, + }); + const projDir = await projectAccountsDir(codexHome, projectRoot); + writeJson(join(projDir, "openai-codex-accounts.json"), { + version: 3, + accounts: [{ email: "project@example.com", enabled: true }], + activeIndex: 0, + }); + writeJson(join(projectRoot, ".codex", "config.json"), { + perProjectAccounts: true, + }); + + const result = runWrapper(projectRoot, { + CODEX_HOME: join(codexHome, ".codex"), + CODEX_MULTI_AUTH_REAL_CODEX_BIN: fakeCodexBin(codexHome), + CODEX_MULTI_AUTH_SYNC_CODEX_CLI: "1", + }); + + expect(result.stderr).toContain("global@example.com"); + expect(result.stderr).not.toContain("project@example.com"); + }); +});