From 2413517f317149d667a2289d5fae344e9a697786 Mon Sep 17 00:00:00 2001 From: Nowaker Date: Tue, 29 Sep 2026 20:55:19 -0500 Subject: [PATCH 1/3] fix(limits): say which cached accounts the plugin can no longer read When the pool poller fails to read an account it keeps that account's last good reading, so the pool line is not judged on a subset. An account whose refresh token has died therefore keeps its last figures indefinitely, and `limits`, which reports the poller's snapshot, showed it as a healthy idle seat (`0% used`, `Read: 2d 20h ago`) that counted toward the pool total. The poller now records on a carried-over entry since when its reads have failed (`readFailedAt`) and why the latest one did (`readError`, masked, one line). A header reading merged in from the request path proves the account works again and clears both. `limits` still makes no request for such an account. It reads the failure from state the plugin already holds - the snapshot entry, or an `auth-failure` cooldown the request path left on the account - shows the last known figures under an `Error:` line that says why and since when, leaves the account out of the pool total, and exits 1. A refresh failure's message is the token endpoint's JSON body, cut to a bounded length, which printed as a lone `{`. `summarizeCodexErrorMessage` (lib/codex-usage.ts) reads the human message out of it, whole or truncated, so the error fits on one line. AI-Tool: opencode 1.18.32 AI-Model: anthropic/claude-opus-5-5 AI-Variant: high AI-Platform: linux AI-Harness: Vibeterm e07a557 --- docs/tools-and-cli.md | 2 +- lib/codex-usage.ts | 28 ++++++ lib/tui-quota-cache.ts | 13 ++- lib/tui-quota-overview.ts | 46 ++++++--- scripts/install-oc-codex-multi-auth-core.js | 50 ++++++++-- test/standalone-cli.test.ts | 102 +++++++++++++++++++- test/tui-quota-overview.test.ts | 17 ++++ 7 files changed, 232 insertions(+), 26 deletions(-) diff --git a/docs/tools-and-cli.md b/docs/tools-and-cli.md index 16c83274..c95faad4 100644 --- a/docs/tools-and-cli.md +++ b/docs/tools-and-cli.md @@ -194,7 +194,7 @@ Readings: the plugin's last readings, taken 2026-09-27 13:17:22 (14m ago); --ref Pool: 93% used of 81x across 11 accounts ``` -- **Snapshot-backed, not live.** The plugin polls `/wham/usage` for the pool status line and keeps the last readings in `oc-codex-multi-auth-tui-quota-overview.json` under the OpenCode state dir (`$OPENCODE_STATE_DIR`, else `$XDG_STATE_HOME/opencode` or `~/.local/state/opencode`). `limits` reports those readings; accounts with no snapshot entry (or a rotated token fingerprint) are read live. `--refresh` reads the whole pool live, and a full live read becomes the plugin's new snapshot. Nothing is written for `--tag` subsets, `--config-path` stores, or when the snapshot no longer describes the pool. +- **Snapshot-backed, not live.** The plugin polls `/wham/usage` for the pool status line and keeps the last readings in `oc-codex-multi-auth-tui-quota-overview.json` under the OpenCode state dir (`$OPENCODE_STATE_DIR`, else `$XDG_STATE_HOME/opencode` or `~/.local/state/opencode`). `limits` reports those readings; accounts with no snapshot entry (or a rotated token fingerprint) are read live. An account the plugin can no longer read is not read live either: when the poller keeps a failing account's last good reading it records since when and why the reads fail, and the request path marks an account whose credentials were refused (`auth-failure` cooldown). `limits` shows such an account's last known figures under an `Error:` line carrying that reason (for example a refresh token that needs a new `opencode auth login`), leaves it out of the pool total, and exits 1. A plain `limits` never refreshes a token. `--refresh` reads the whole pool live, and a full live read becomes the plugin's new snapshot. Nothing is written for `--tag` subsets, `--config-path` stores, or when the snapshot no longer describes the pool. - **Workspace names.** Business seats show their ChatGPT workspace name (owner-titled) via one `/wham/accounts/check` per login, cached in `oc-codex-multi-auth-workspace-names.json` beside the quota snapshot; lookup gives up after ~5s and a failure only drops the line. - **Sorting.** `--sort usage|reset` judges each account by its governing window — the one with least headroom, and on ties the later reset. Accounts with no readable value sort last. Persist a default via `"limitsSort": { "by": "reset", "direction": "asc" }` in `~/.opencode/openai-codex-auth-config.json`. - **Pool total.** `81x` is the sum of per-plan seat weights (see [plan allotments](plan-allotments.md)); the percentage is the weighted mean over exactly that sum, not a plain average. Plans with no published ratio weigh one baseline seat and print no `Nx` badge. Accounts with unreadable usage are excluded from both figures; `pool` is `null` in `--json` when nothing was readable. Both `used` and `left` percentages are emitted so `quotaDisplay` wording never changes the data. diff --git a/lib/codex-usage.ts b/lib/codex-usage.ts index 1e9a6690..a5f1cfaa 100644 --- a/lib/codex-usage.ts +++ b/lib/codex-usage.ts @@ -754,6 +754,34 @@ export function parseCodexUsagePayload( }; } +/** + * One readable line out of a failed request's error text. The OAuth refresh + * failure carries the endpoint's JSON body - pretty-printed, and cut to a + * bounded length before it reaches here, so often not parseable - which renders + * as a lone `{` on a report line. The human message inside it is what says + * what happened, so it is read out of the body, whole or truncated. Text that + * holds no such message is only collapsed onto one line. + */ +export function summarizeCodexErrorMessage(text: string, maxChars = 200): string { + const start = text.indexOf("{"); + let summary: string | undefined; + if (start !== -1) { + const body = text.slice(start); + const match = + /"(?:message|error_description)"\s*:\s*"((?:[^"\\]|\\.)*)("?)/.exec(body) ?? + /"error"\s*:\s*"((?:[^"\\]|\\.)*)("?)/.exec(body); + const message = match?.[1]?.replace(/\\(.)/g, "$1").trim(); + if (message) { + const complete = match?.[2] === '"'; + const prefix = text.slice(0, start).trim().replace(/:$/, ""); + const readable = complete ? message : `${message.replace(/\.*$/, "")}…`; + summary = prefix ? `${prefix}: ${readable}` : readable; + } + } + const line = (summary ?? text).replace(/\s+/g, " ").trim(); + return line.length > maxChars ? `${line.slice(0, maxChars - 1)}…` : line; +} + /** * Build a safe error message from a failed Codex backend response. * diff --git a/lib/tui-quota-cache.ts b/lib/tui-quota-cache.ts index 2e7a5588..f45f8e91 100644 --- a/lib/tui-quota-cache.ts +++ b/lib/tui-quota-cache.ts @@ -385,6 +385,15 @@ export type TuiQuotaOverviewAccount = { * build wrote, where the snapshot's time is all there is. */ fetchedAt?: number; + /** + * Set while the poller cannot read this account and keeps its previous + * reading instead: when the reads started failing, and why the latest one + * did. The reading then describes the account as it was at `fetchedAt`, not + * as it is - an account whose refresh token has died keeps failing, and its + * last reading would otherwise pass for a healthy, idle seat. + */ + readFailedAt?: number; + readError?: string; }; /** @@ -420,7 +429,9 @@ function isTuiQuotaOverviewAccount( (typeof value.resetCreditsApplicable === "number" && Number.isInteger(value.resetCreditsApplicable) && value.resetCreditsApplicable >= 0)) && Array.isArray(value.limits) && value.limits.every(isTuiQuotaLimit) && - isOptionalFiniteNumber(value.fetchedAt) + isOptionalFiniteNumber(value.fetchedAt) && + isOptionalFiniteNumber(value.readFailedAt) && + (value.readError === undefined || typeof value.readError === "string") ); } diff --git a/lib/tui-quota-overview.ts b/lib/tui-quota-overview.ts index 6d3d6aad..a5a73d08 100644 --- a/lib/tui-quota-overview.ts +++ b/lib/tui-quota-overview.ts @@ -23,10 +23,11 @@ import { hasUsageWindow, parseCodexUsagePayload, resolveCodexUsageAccountId, + summarizeCodexErrorMessage, type CodexUsageSummary, type LimitWindow, } from "./codex-usage.js"; -import { logDebug } from "./logger.js"; +import { logDebug, maskString } from "./logger.js"; import type { QuotaOverviewAccount } from "./quota-overview.js"; import { loadAccounts, type AccountStorageV3 } from "./storage.js"; import { @@ -98,10 +99,14 @@ export function toOverviewAccount(params: { }; } +type OverviewFetchResult = + | { reading: TuiQuotaOverviewAccount } + | { error: string }; + async function fetchOverviewAccount( storage: AccountStorageV3, index: number, -): Promise { +): Promise { const account = storage.accounts[index]; if (!account) return undefined; try { @@ -110,7 +115,7 @@ async function fetchOverviewAccount( account, accessToken: credentials.accessToken, }); - if (!accountId) return undefined; + if (!accountId) return { error: "could not resolve account id (re-login may be required)" }; const usage = parseCodexUsagePayload( await fetchCodexUsage({ accountId, @@ -119,18 +124,23 @@ async function fetchOverviewAccount( normalizeAccountErrors: true, }), ); - return toOverviewAccount({ - fingerprint: createUsageAccountFingerprint(account), - index: index + 1, - usage, - email: account.email, - label: account.accountLabel, - }); + return { + reading: toOverviewAccount({ + fingerprint: createUsageAccountFingerprint(account), + index: index + 1, + usage, + email: account.email, + label: account.accountLabel, + }), + }; } catch (error) { - logDebug( - `Failed to fetch pool quota for one account: ${(error as Error).message}`, + // The refresh endpoint's error body can echo token material, so the + // reason is masked before it is written to a shared cache file. + const message = maskString( + summarizeCodexErrorMessage(error instanceof Error ? error.message : String(error)), ); - return undefined; + logDebug(`Failed to fetch pool quota for one account: ${message}`); + return { error: message }; } } @@ -157,8 +167,8 @@ export async function fetchTuiQuotaOverview(params: { chunk.map((index) => fetchOverviewAccount(storage, index)), ); for (const [position, result] of results.entries()) { - if (result) { - accounts.push({ ...result, fetchedAt: now }); + if (result && "reading" in result) { + accounts.push({ ...result.reading, fetchedAt: now }); continue; } // A failed fetch must not drop the account out of the snapshot: the @@ -185,6 +195,8 @@ export async function fetchTuiQuotaOverview(params: { accounts.push({ ...previous, fetchedAt: previous.fetchedAt ?? cached?.fetchedAt, + readFailedAt: previous.readFailedAt ?? now, + readError: result?.error ?? previous.readError, }); carriedOver = true; } @@ -244,12 +256,16 @@ export function mergeOverviewWithLatestAccount( return account; } merged = true; + // A response just came back on this account, so a failed poll of it + // no longer describes it. return { ...account, planType: latest.planType ?? account.planType, email: account.email ?? (latest.accountEmail?.trim() || undefined), limits: latest.limits, fetchedAt: latest.fetchedAt, + readFailedAt: undefined, + readError: undefined, }; }); return merged ? { ...snapshot, accounts } : snapshot; diff --git a/scripts/install-oc-codex-multi-auth-core.js b/scripts/install-oc-codex-multi-auth-core.js index e2b0e178..8241bf52 100644 --- a/scripts/install-oc-codex-multi-auth-core.js +++ b/scripts/install-oc-codex-multi-auth-core.js @@ -1928,6 +1928,7 @@ async function runLimitsCommandInner(parsed, options = {}) { accountId, accessToken, organizationId: account.organizationId, + normalizeAccountErrors: true, }), quotaDisplay, ); @@ -2008,11 +2009,18 @@ async function runLimitsCommandInner(parsed, options = {}) { const reading = cachedAccount ? toCachedLimitsReading(cachedAccount, usageMod, quotaDisplay) : await readLive(account, index, entry); - poolMembers.push({ - planType: reading.planType, - primary: reading.windows[0] ?? {}, - secondary: reading.windows[1] ?? {}, - }); + const failure = cachedAccount && readPluginQuotaFailure(cachedAccount.account, account); + if (failure) { + // Its figures are last known, not capacity it can spend now. + entry.readFailure = failure; + failedCount += 1; + } else { + poolMembers.push({ + planType: reading.planType, + primary: reading.windows[0] ?? {}, + secondary: reading.windows[1] ?? {}, + }); + } sortKeys.set(entry, readLimitsSortKeys(usageMod, reading.windows)); entry.source = reading.source; entry.readAt = reading.readAt; @@ -2028,7 +2036,9 @@ async function runLimitsCommandInner(parsed, options = {}) { // response, so the message is redacted through the logger's token // patterns before it reaches stdout, JSON output, or CI logs. // Truncation alone does not protect bearer/JWT/refresh-token material. - entry.error = loggerMod.maskString(formatErrorForLog(error)).slice(0, 160); + entry.error = loggerMod.maskString( + usageMod.summarizeCodexErrorMessage?.(formatErrorForLog(error)) ?? formatErrorForLog(error).slice(0, 160), + ); failedCount += 1; } results.push(entry); @@ -2171,6 +2181,26 @@ function findPluginQuotaReading(readings, account, usageMod) { return { account: found, readAt: found.fetchedAt ?? readings.snapshot.fetchedAt }; } +/** + * What the plugin last knew had gone wrong with an account, from state it + * already holds - nothing here asks upstream. The poller keeps a failing + * account's last good reading and records why the reads fail; the request + * path marks an account whose credentials were refused. Either way the cached + * figures describe the account as it was, and it cannot serve requests now. + */ +function readPluginQuotaFailure(entry, account) { + if (Number.isFinite(entry.readFailedAt)) { + return { + since: entry.readFailedAt, + message: typeof entry.readError === "string" && entry.readError ? entry.readError : "the plugin could not read it", + }; + } + if (account.cooldownReason === "auth-failure") { + return { since: undefined, message: "the plugin's last request with it was refused (auth failure)" }; + } + return undefined; +} + /** * A window nobody has drawn from reports "now plus the window" as its reset. * The plugin now drops that reset, but a snapshot an older build wrote still @@ -2325,7 +2355,7 @@ async function attachWorkspaceNames({ results, entryAccounts, entryWorkspaceIds, let changed = false; for (const entry of results) { const workspaceId = entryWorkspaceIds.get(entry); - if (!workspaceId || known.has(workspaceId) || entry.error) continue; + if (!workspaceId || known.has(workspaceId) || entry.error || entry.readFailure) continue; try { const names = await lookup(entryAccounts.get(entry), entry.source === "live"); if (!names) continue; @@ -2460,6 +2490,12 @@ function buildLimitsAccountRows(account, render, now, readings) { rows.push(["Error", account.error]); return rows; } + if (account.readFailure) { + const since = Number.isFinite(account.readFailure.since) + ? ` (failing since ${formatLimitsReadTime(account.readFailure.since, render, now)})` + : ""; + rows.push(["Error", `${account.readFailure.message}${since}; last known figures below, left out of the pool total`]); + } for (const limit of account.limits ?? []) { rows.push([limit.name, formatLimitsPercent(limit, render)]); const renewal = formatLimitsRenewal(limit, render, now); diff --git a/test/standalone-cli.test.ts b/test/standalone-cli.test.ts index a630e9a0..7b837a84 100644 --- a/test/standalone-cli.test.ts +++ b/test/standalone-cli.test.ts @@ -1420,7 +1420,54 @@ describe("standalone oc-codex-multi-auth CLI commands", () => { expect(printed).not.toMatch(/- \[0\][^\n]*\n(?: [^\n]*\n)* Read:/); }); - it("limits: never refreshes a token just to name an account it reports from cache", async () => { + it.each([ + [ + "the plugin's last poll of it failed", + {}, + { readFailedAt: Date.now() - 86_400_000, readError: "Your refresh token has already been used" }, + /Error:\s+Your refresh token has already been used \(failing since \d{4}-[^)]*\(1d ago\)\); last known figures below/, + ], + [ + "the request path last had it refused", + { cooldownReason: "auth-failure", coolingDownUntil: Date.now() + 60_000 }, + {}, + /Error:\s+the plugin's last request with it was refused \(auth failure\); last known figures below/, + ], + ])("limits: reports a cached account the plugin can no longer read once %s, without asking upstream", async (_case, accountOver, entryOver, line) => { + vi.resetModules(); + tempHome = await createTempHome(); + vi.stubEnv("HOME", tempHome); + vi.stubEnv("USERPROFILE", tempHome); + const pool = [ + freshAccount({ refreshToken: "rt-a", accountId: "acct_a", ...accountOver }), + freshAccount({ refreshToken: "rt-b", accountId: "acct_b" }), + ]; + await writeAccounts(tempHome, pool); + await writePluginSnapshot(tempHome, Date.now() - 3 * 86_400_000, [ + { account: pool[0], planType: "plus", limits: [cachedWeekly(0, Date.now() + 86_400_000)], ...entryOver }, + { account: pool[1], planType: "plus", limits: [cachedWeekly(50, Date.now() + 86_400_000)] }, + ]); + const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response(JSON.stringify({ accounts: [] }))); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const { runInstaller } = await import("../scripts/install-oc-codex-multi-auth-core.js"); + const env = { ...process.env, HOME: tempHome, USERPROFILE: tempHome, NO_COLOR: "1" }; + + const result = await runInstaller(["limits", "--json"], { env }); + const output = JSON.parse(String(logSpy.mock.calls.at(-1)?.[0])); + await runInstaller(["limits"], { env }); + const printed = logSpy.mock.calls.map((call) => String(call[0])).join("\n"); + + // Only the healthy account is named; nothing refreshes a token or reads usage. + expect(fetchSpy.mock.calls.map(([url]) => String(url)).filter((url) => !url.includes("/wham/accounts/check"))).toEqual([]); + expect(result.exitCode).toBe(1); + expect(output.accounts[0]).toMatchObject({ source: "cache", readFailure: expect.any(Object) }); + expect(output.accounts[0].limits[0].leftPercent).toBe(100); + // Its last known 100% headroom is not capacity the pool can spend. + expect(output.pool).toMatchObject({ leftPercent: 50, countedAccounts: 1 }); + expect(printed).toMatch(line); + }); + + it("limits: never refreshes an expired token when the plugin holds a reading", async () => { vi.resetModules(); tempHome = await createTempHome(); vi.stubEnv("HOME", tempHome); @@ -1430,7 +1477,7 @@ describe("standalone oc-codex-multi-auth CLI commands", () => { await writePluginSnapshot(tempHome, Date.now() - 60_000, [ { account: pool[0], planType: "plus", limits: [cachedWeekly(40, Date.now() + 86_400_000)] }, ]); - const fetchSpy = mockUsageSequence([], [{ id: "acct_a", structure: "workspace", name: "dh" }]); + const fetchSpy = vi.spyOn(globalThis, "fetch"); vi.spyOn(console, "log").mockImplementation(() => {}); const { runInstaller } = await import("../scripts/install-oc-codex-multi-auth-core.js"); @@ -1442,6 +1489,57 @@ describe("standalone oc-codex-multi-auth CLI commands", () => { expect(fetchSpy).not.toHaveBeenCalled(); }); + it("limits: shows the message inside a refresh failure's JSON body on one line", async () => { + vi.resetModules(); + tempHome = await createTempHome(); + vi.stubEnv("HOME", tempHome); + vi.stubEnv("USERPROFILE", tempHome); + await writeAccounts(tempHome, [freshAccount({ expiresAt: Date.now() - 60_000 })]); + const body = JSON.stringify( + { error: { message: "Your refresh token has already been used to generate a new access token.", code: "refresh_token_reused" } }, + null, + 2, + ); + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(body, { status: 401 })); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const { runInstaller } = await import("../scripts/install-oc-codex-multi-auth-core.js"); + + await runInstaller(["limits", "--json"], { + env: { ...process.env, HOME: tempHome, USERPROFILE: tempHome }, + }); + + const output = JSON.parse(String(logSpy.mock.calls.at(-1)?.[0])); + expect(output.accounts[0].error).toContain("Your refresh token has already been used to generate a new access token."); + expect(output.accounts[0].error).not.toContain("\n"); + expect(output.accounts[0].error).not.toMatch(/^\{/); + }); + + it("limits: names a cached account only from a stored token that is still valid", async () => { + vi.resetModules(); + tempHome = await createTempHome(); + vi.stubEnv("HOME", tempHome); + vi.stubEnv("USERPROFILE", tempHome); + const pool = [freshAccount({ refreshToken: "rt-a", accountId: "acct_a" })]; + await writeAccounts(tempHome, pool); + await writePluginSnapshot(tempHome, Date.now() - 60_000, [ + { account: pool[0], planType: "plus", limits: [cachedWeekly(40, Date.now() + 86_400_000)] }, + ]); + const fetchSpy = mockUsageSequence([], [{ id: "acct_a", structure: "workspace", name: "dh" }]); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const { runInstaller } = await import("../scripts/install-oc-codex-multi-auth-core.js"); + + const result = await runInstaller(["limits", "--json"], { + env: { ...process.env, HOME: tempHome, USERPROFILE: tempHome }, + }); + + const output = JSON.parse(String(logSpy.mock.calls.at(-1)?.[0])); + expect(result.exitCode).toBe(0); + expect(output.accounts[0]).toMatchObject({ source: "cache", workspaceName: "dh" }); + expect(fetchSpy.mock.calls.map(([url]) => String(url))).toEqual([ + expect.stringContaining("/wham/accounts/check"), + ]); + }); + it("limits: --refresh reads live and hands the reading back to the plugin", async () => { vi.resetModules(); tempHome = await createTempHome(); diff --git a/test/tui-quota-overview.test.ts b/test/tui-quota-overview.test.ts index 3c129bed..e97f405f 100644 --- a/test/tui-quota-overview.test.ts +++ b/test/tui-quota-overview.test.ts @@ -262,6 +262,9 @@ describe("overview cache round trip", () => { expect(result && isFreshTuiQuotaSnapshot(result, later)).toBe(false); // Each account keeps its own time, so the fresh one is not aged with it. expect(result?.accounts.map((account) => account.fetchedAt)).toEqual([later, NOW]); + // The kept reading says the poll failed, so no reader passes it off as current. + expect(result?.accounts.map((account) => account.readFailedAt)).toEqual([undefined, later]); + expect(result?.accounts[1]?.readError).toBe("transient"); }); }); @@ -360,6 +363,20 @@ describe("mergeOverviewWithLatestAccount", () => { const merged = mergeOverviewWithLatestAccount(snapshot(), latest); expect(merged.accounts[1]!.limits[0]!.leftPercent).toBe(12); expect(merged.accounts[1]!.fetchedAt).toBe(NOW + 60_000); + }); + + it("clears a failed poll once a response comes back on the account", () => { + const base = snapshot(); + const failed = { + ...base, + accounts: base.accounts.map((account, position) => + position === 1 ? { ...account, readFailedAt: NOW, readError: "boom" } : account, + ), + }; + const merged = mergeOverviewWithLatestAccount(failed, latest); + expect(merged.accounts[1]!.readFailedAt).toBeUndefined(); + expect(merged.accounts[1]!.readError).toBeUndefined(); + expect(merged.accounts[1]!.limits[0]!.leftPercent).toBe(12); expect(merged.accounts[0]!.limits[0]!.leftPercent).toBe(0); }); From 7daeda947d8da99ccce17e1dca2a9b40e29d13c5 Mon Sep 17 00:00:00 2001 From: Nowaker Date: Tue, 29 Sep 2026 21:56:49 -0500 Subject: [PATCH 2/3] fix(limits): mark only dead credentials, and only until they recover Review follow-ups on the failure marks the pool poller leaves on a kept reading. - A timeout, network error, 429 or 5xx marked the account as unusable, so `limits` dropped a seat whose credentials still worked from the pool total and exited 1. `ensureCodexUsageAccessToken` (lib/codex-usage.ts) now throws a `CodexAuthError` carrying the refresh failure reason, with the transient rule `refreshAndUpdateToken` already applies, and `isCodexCredentialFailure` tells a refused refresh, an invalidated access token or a deactivated workspace apart from the rest. Only those mark the reading; a transient failure keeps whatever an earlier poll concluded. - `readFailedAt` is now the latest such failure and `readFailedSince` the first, and a header reading clears the mark only when it is newer than the latest failure. A response recorded before a failed poll no longer erases that failure. - An `auth-failure` cooldown counted after it expired. It now counts while it runs, or after it while the stored access token has also expired, which means no refresh has succeeded since. - `summarizeCodexErrorMessage` decodes the message as a JSON string, so `\n`, `\t` and `\uXXXX` read correctly, including in a body cut off mid-escape. - The docs no longer promise that `limits` never refreshes a token: an account with no snapshot entry is read live and can be refreshed. AI-Tool: opencode 1.18.32 AI-Model: anthropic/claude-opus-5-5 AI-Variant: high AI-Platform: linux AI-Harness: Vibeterm e07a557 --- docs/tools-and-cli.md | 2 +- lib/codex-usage.ts | 54 ++++++++++++++++- lib/tui-quota-cache.ts | 14 +++-- lib/tui-quota-overview.ts | 32 +++++++--- scripts/install-oc-codex-multi-auth-core.js | 19 +++--- test/codex-usage.test.ts | 18 ++++++ test/standalone-cli.test.ts | 55 +++++++++++++++-- test/tui-quota-overview.test.ts | 65 ++++++++++++++++++++- 8 files changed, 225 insertions(+), 34 deletions(-) diff --git a/docs/tools-and-cli.md b/docs/tools-and-cli.md index c95faad4..886b6663 100644 --- a/docs/tools-and-cli.md +++ b/docs/tools-and-cli.md @@ -194,7 +194,7 @@ Readings: the plugin's last readings, taken 2026-09-27 13:17:22 (14m ago); --ref Pool: 93% used of 81x across 11 accounts ``` -- **Snapshot-backed, not live.** The plugin polls `/wham/usage` for the pool status line and keeps the last readings in `oc-codex-multi-auth-tui-quota-overview.json` under the OpenCode state dir (`$OPENCODE_STATE_DIR`, else `$XDG_STATE_HOME/opencode` or `~/.local/state/opencode`). `limits` reports those readings; accounts with no snapshot entry (or a rotated token fingerprint) are read live. An account the plugin can no longer read is not read live either: when the poller keeps a failing account's last good reading it records since when and why the reads fail, and the request path marks an account whose credentials were refused (`auth-failure` cooldown). `limits` shows such an account's last known figures under an `Error:` line carrying that reason (for example a refresh token that needs a new `opencode auth login`), leaves it out of the pool total, and exits 1. A plain `limits` never refreshes a token. `--refresh` reads the whole pool live, and a full live read becomes the plugin's new snapshot. Nothing is written for `--tag` subsets, `--config-path` stores, or when the snapshot no longer describes the pool. +- **Snapshot-backed, not live.** The plugin polls `/wham/usage` for the pool status line and keeps the last readings in `oc-codex-multi-auth-tui-quota-overview.json` under the OpenCode state dir (`$OPENCODE_STATE_DIR`, else `$XDG_STATE_HOME/opencode` or `~/.local/state/opencode`). `limits` reports those readings; accounts with no snapshot entry (or a rotated token fingerprint) are read live. An account the plugin can no longer read is not read live either: when the poller keeps an account's last good reading because its credentials are dead (a refused refresh, an invalidated token, a deactivated workspace) it records since when and why, and the request path marks an account whose credentials were refused (`auth-failure` cooldown, counted while it runs or while the stored access token stays expired). `limits` shows such an account's last known figures under an `Error:` line carrying that reason (for example a refresh token that needs a new `opencode auth login`), leaves it out of the pool total, and exits 1. A transient failure (timeout, network error, 429, 5xx) does not mark an account; its older `Read:` time says the figures are old. `limits` never refreshes a token for an account with a snapshot entry; an account read live because it has none can have its token refreshed. `--refresh` reads the whole pool live, and a full live read becomes the plugin's new snapshot. Nothing is written for `--tag` subsets, `--config-path` stores, or when the snapshot no longer describes the pool. - **Workspace names.** Business seats show their ChatGPT workspace name (owner-titled) via one `/wham/accounts/check` per login, cached in `oc-codex-multi-auth-workspace-names.json` beside the quota snapshot; lookup gives up after ~5s and a failure only drops the line. - **Sorting.** `--sort usage|reset` judges each account by its governing window — the one with least headroom, and on ties the later reset. Accounts with no readable value sort last. Persist a default via `"limitsSort": { "by": "reset", "direction": "asc" }` in `~/.opencode/openai-codex-auth-config.json`. - **Pool total.** `81x` is the sum of per-plan seat weights (see [plan allotments](plan-allotments.md)); the percentage is the weighted mean over exactly that sum, not a plain average. Plans with no published ratio weigh one baseline seat and print no `Nx` badge. Accounts with unreadable usage are excluded from both figures; `pool` is `null` in `--json` when nothing was readable. Both `used` and `left` percentages are emitted so `quotaDisplay` wording never changes the data. diff --git a/lib/codex-usage.ts b/lib/codex-usage.ts index a5f1cfaa..b61020f5 100644 --- a/lib/codex-usage.ts +++ b/lib/codex-usage.ts @@ -8,7 +8,10 @@ import { CODEX_BASE_URL, PLUGIN_NAME } from "./constants.js"; import { createDeactivatedWorkspaceError, createUsageRequestTimeoutError, + isDeactivatedWorkspaceErrorMessage, + isInvalidatedAuthTokenMessage, } from "./error-sentinels.js"; +import { CodexAuthError } from "./errors.js"; import { logWarn } from "./logger.js"; import { DEFAULT_QUOTA_DISPLAY_MODE, @@ -754,6 +757,36 @@ export function parseCodexUsagePayload( }; } +/** + * Whether a failed usage read says the account's credentials are dead, as + * opposed to a timeout, a network error, a rate limit or an upstream outage. + * Only the first means the account cannot serve requests until someone logs + * in again: a refresh the token endpoint refused, an access token the backend + * reports invalidated, or a deactivated workspace. + */ +export function isCodexCredentialFailure(error: unknown): boolean { + if (error instanceof CodexAuthError && error.refreshFailureReason !== undefined) { + return !error.retryable; + } + const message = error instanceof Error ? error.message : undefined; + return isInvalidatedAuthTokenMessage(message) || isDeactivatedWorkspaceErrorMessage(message); +} + +/** + * Decode the inside of a JSON string literal that may have been cut off + * mid-escape, as a bounded error body is. + */ +function decodeJsonStringFragment(raw: string): string { + for (const candidate of [raw, raw.replace(/\\(?:u[0-9a-fA-F]{0,3})?$/, "")]) { + try { + return JSON.parse(`"${candidate}"`) as string; + } catch { + // Try the fragment with a dangling escape removed. + } + } + return raw; +} + /** * One readable line out of a failed request's error text. The OAuth refresh * failure carries the endpoint's JSON body - pretty-printed, and cut to a @@ -770,7 +803,7 @@ export function summarizeCodexErrorMessage(text: string, maxChars = 200): string const match = /"(?:message|error_description)"\s*:\s*"((?:[^"\\]|\\.)*)("?)/.exec(body) ?? /"error"\s*:\s*"((?:[^"\\]|\\.)*)("?)/.exec(body); - const message = match?.[1]?.replace(/\\(.)/g, "$1").trim(); + const message = match?.[1] === undefined ? undefined : decodeJsonStringFragment(match[1]).trim(); if (message) { const complete = match?.[2] === '"'; const prefix = text.slice(0, start).trim().replace(/:$/, ""); @@ -1001,11 +1034,26 @@ export async function ensureCodexUsageAccessToken(params: { const previousRefreshToken = params.account.refreshToken; if (!previousRefreshToken) { - throw new Error("Cannot refresh: account has no refresh token"); + throw new CodexAuthError("Cannot refresh: account has no refresh token", { + refreshFailureReason: "missing_refresh", + }); } const refreshResult = await coordinatePersistedRefresh(params.account); if (refreshResult.type !== "success") { - throw new Error(refreshResult.message ?? refreshResult.reason); + // Same transient rule as the request path's `refreshAndUpdateToken`, + // so a caller can tell a dead refresh token from a flaky network. + const statusCode = + typeof refreshResult.statusCode === "number" ? refreshResult.statusCode : undefined; + throw new CodexAuthError(refreshResult.message ?? refreshResult.reason ?? "token refresh failed", { + retryable: + refreshResult.reason === "network_error" || + refreshResult.reason === "invalid_response" || + (refreshResult.reason === "http_error" && + statusCode !== undefined && + (statusCode >= 500 || statusCode === 408 || statusCode === 429)), + refreshFailureReason: refreshResult.reason, + statusCode, + }); } let refreshedCount = 0; for (const storedAccount of params.storage.accounts) { diff --git a/lib/tui-quota-cache.ts b/lib/tui-quota-cache.ts index f45f8e91..66792867 100644 --- a/lib/tui-quota-cache.ts +++ b/lib/tui-quota-cache.ts @@ -386,13 +386,16 @@ export type TuiQuotaOverviewAccount = { */ fetchedAt?: number; /** - * Set while the poller cannot read this account and keeps its previous - * reading instead: when the reads started failing, and why the latest one - * did. The reading then describes the account as it was at `fetchedAt`, not - * as it is - an account whose refresh token has died keeps failing, and its - * last reading would otherwise pass for a healthy, idle seat. + * Set while the poller cannot read this account because its credentials + * are dead, and keeps its previous reading instead: when the latest such + * read failed, when they started failing, and why. The reading then + * describes the account as it was at `fetchedAt`, not as it is - a dead + * refresh token keeps failing, and its last reading would otherwise pass + * for a healthy, idle seat. A transient failure (timeout, network, 429, + * 5xx) sets none of these. */ readFailedAt?: number; + readFailedSince?: number; readError?: string; }; @@ -431,6 +434,7 @@ function isTuiQuotaOverviewAccount( value.limits.every(isTuiQuotaLimit) && isOptionalFiniteNumber(value.fetchedAt) && isOptionalFiniteNumber(value.readFailedAt) && + isOptionalFiniteNumber(value.readFailedSince) && (value.readError === undefined || typeof value.readError === "string") ); } diff --git a/lib/tui-quota-overview.ts b/lib/tui-quota-overview.ts index a5a73d08..c39a0108 100644 --- a/lib/tui-quota-overview.ts +++ b/lib/tui-quota-overview.ts @@ -20,6 +20,7 @@ import { ensureCodexUsageAccessToken, fetchCodexUsage, getUsageLeftPercent, + isCodexCredentialFailure, hasUsageWindow, parseCodexUsagePayload, resolveCodexUsageAccountId, @@ -101,7 +102,7 @@ export function toOverviewAccount(params: { type OverviewFetchResult = | { reading: TuiQuotaOverviewAccount } - | { error: string }; + | { error: string; credential: boolean }; async function fetchOverviewAccount( storage: AccountStorageV3, @@ -115,7 +116,9 @@ async function fetchOverviewAccount( account, accessToken: credentials.accessToken, }); - if (!accountId) return { error: "could not resolve account id (re-login may be required)" }; + if (!accountId) { + return { error: "could not resolve account id (re-login may be required)", credential: true }; + } const usage = parseCodexUsagePayload( await fetchCodexUsage({ accountId, @@ -140,7 +143,7 @@ async function fetchOverviewAccount( summarizeCodexErrorMessage(error instanceof Error ? error.message : String(error)), ); logDebug(`Failed to fetch pool quota for one account: ${message}`); - return { error: message }; + return { error: message, credential: isCodexCredentialFailure(error) }; } } @@ -192,11 +195,19 @@ export async function fetchTuiQuotaOverview(params: { createUsageAccountFingerprint(account), ); if (previous) { + // Only dead credentials mark the reading as describing an account + // that cannot serve requests. A transient failure keeps whatever + // an earlier poll concluded; the older `fetchedAt` already dates it. accounts.push({ ...previous, fetchedAt: previous.fetchedAt ?? cached?.fetchedAt, - readFailedAt: previous.readFailedAt ?? now, - readError: result?.error ?? previous.readError, + ...(result && "credential" in result && result.credential + ? { + readFailedAt: now, + readFailedSince: previous.readFailedSince ?? previous.readFailedAt ?? now, + readError: result.error, + } + : {}), }); carriedOver = true; } @@ -256,16 +267,19 @@ export function mergeOverviewWithLatestAccount( return account; } merged = true; - // A response just came back on this account, so a failed poll of it - // no longer describes it. + // A response that came back after the latest failed poll proves the + // account works again. One from before it proves nothing about it. + const recovered = + account.readFailedAt === undefined || latest.fetchedAt > account.readFailedAt; return { ...account, planType: latest.planType ?? account.planType, email: account.email ?? (latest.accountEmail?.trim() || undefined), limits: latest.limits, fetchedAt: latest.fetchedAt, - readFailedAt: undefined, - readError: undefined, + ...(recovered + ? { readFailedAt: undefined, readFailedSince: undefined, readError: undefined } + : {}), }; }); return merged ? { ...snapshot, accounts } : snapshot; diff --git a/scripts/install-oc-codex-multi-auth-core.js b/scripts/install-oc-codex-multi-auth-core.js index 8241bf52..8ec03f8c 100644 --- a/scripts/install-oc-codex-multi-auth-core.js +++ b/scripts/install-oc-codex-multi-auth-core.js @@ -2009,7 +2009,7 @@ async function runLimitsCommandInner(parsed, options = {}) { const reading = cachedAccount ? toCachedLimitsReading(cachedAccount, usageMod, quotaDisplay) : await readLive(account, index, entry); - const failure = cachedAccount && readPluginQuotaFailure(cachedAccount.account, account); + const failure = cachedAccount && readPluginQuotaFailure(cachedAccount.account, account, now); if (failure) { // Its figures are last known, not capacity it can spend now. entry.readFailure = failure; @@ -2184,18 +2184,23 @@ function findPluginQuotaReading(readings, account, usageMod) { /** * What the plugin last knew had gone wrong with an account, from state it * already holds - nothing here asks upstream. The poller keeps a failing - * account's last good reading and records why the reads fail; the request - * path marks an account whose credentials were refused. Either way the cached - * figures describe the account as it was, and it cannot serve requests now. + * account's last good reading and records when dead credentials made the reads + * fail; the request path marks an account whose credentials were refused. That + * mark counts while its cooldown runs, and after it only if the stored access + * token has also expired - no refresh has succeeded since, or it would have + * moved `expiresAt`. Either way the cached figures describe the account as it + * was, and it cannot serve requests now. */ -function readPluginQuotaFailure(entry, account) { +function readPluginQuotaFailure(entry, account, now) { if (Number.isFinite(entry.readFailedAt)) { return { - since: entry.readFailedAt, + since: Number.isFinite(entry.readFailedSince) ? entry.readFailedSince : entry.readFailedAt, message: typeof entry.readError === "string" && entry.readError ? entry.readError : "the plugin could not read it", }; } - if (account.cooldownReason === "auth-failure") { + const coolingDown = Number.isFinite(account.coolingDownUntil) && account.coolingDownUntil > now; + const tokenExpired = Number.isFinite(account.expiresAt) && account.expiresAt <= now; + if (account.cooldownReason === "auth-failure" && (coolingDown || tokenExpired)) { return { since: undefined, message: "the plugin's last request with it was refused (auth failure)" }; } return undefined; diff --git a/test/codex-usage.test.ts b/test/codex-usage.test.ts index 6c1fc2d3..cfcc1dcb 100644 --- a/test/codex-usage.test.ts +++ b/test/codex-usage.test.ts @@ -21,6 +21,7 @@ import { persistUsageQuotaRecovery, isUsageQuotaRecovered, resolveCodexUsageActiveAccount, + summarizeCodexErrorMessage, summarizeUsagePool, type UsagePayload, type UsagePoolMember, @@ -29,6 +30,23 @@ import { loadAccounts, saveAccounts, type AccountStorageV3 } from "../lib/storag import { setStoragePathDirect } from "../lib/storage/state.js"; import { formatQuotaDetailsText, type CompactQuotaStatus } from "../lib/tui-status.js"; +describe("summarizeCodexErrorMessage", () => { + it("decodes JSON escapes in the message it reads out of a body", () => { + const body = JSON.stringify({ error: { message: "Token \"x\" used\nagain \u00e9" } }, null, 2); + expect(summarizeCodexErrorMessage(body)).toBe('Token "x" used again é'); + }); + + it("reads a message out of a body cut off mid-escape", () => { + expect(summarizeCodexErrorMessage('{\n "error": {\n "message": "Your refresh token has already been used \\u00')).toBe( + "Your refresh token has already been used…", + ); + }); + + it("collapses text with no message onto one line", () => { + expect(summarizeCodexErrorMessage("HTTP 500:\n upstream\tboom")).toBe("HTTP 500: upstream boom"); + }); +}); + describe("usage renewal", () => { it.each([ [(6 * 1440 + 21 * 60 + 5) * 60_000, "6d 21h"], diff --git a/test/standalone-cli.test.ts b/test/standalone-cli.test.ts index 7b837a84..52b7611a 100644 --- a/test/standalone-cli.test.ts +++ b/test/standalone-cli.test.ts @@ -1420,20 +1420,37 @@ describe("standalone oc-codex-multi-auth CLI commands", () => { expect(printed).not.toMatch(/- \[0\][^\n]*\n(?: [^\n]*\n)* Read:/); }); + const refusedLine = /Error:\s+the plugin's last request with it was refused \(auth failure\); last known figures below/; + it.each([ [ "the plugin's last poll of it failed", - {}, - { readFailedAt: Date.now() - 86_400_000, readError: "Your refresh token has already been used" }, + () => ({}), + (now: number) => ({ + readFailedAt: now - 3_600_000, + readFailedSince: now - 86_400_000, + readError: "Your refresh token has already been used", + }), /Error:\s+Your refresh token has already been used \(failing since \d{4}-[^)]*\(1d ago\)\); last known figures below/, ], [ "the request path last had it refused", - { cooldownReason: "auth-failure", coolingDownUntil: Date.now() + 60_000 }, - {}, - /Error:\s+the plugin's last request with it was refused \(auth failure\); last known figures below/, + (now: number) => ({ cooldownReason: "auth-failure", coolingDownUntil: now + 60_000 }), + () => ({}), + refusedLine, ], - ])("limits: reports a cached account the plugin can no longer read once %s, without asking upstream", async (_case, accountOver, entryOver, line) => { + [ + "its refusal's cooldown ran out and no refresh has succeeded since", + (now: number) => ({ cooldownReason: "auth-failure", coolingDownUntil: now - 60_000, expiresAt: now - 120_000 }), + () => ({}), + refusedLine, + ], + ])("limits: reports a cached account the plugin can no longer read once %s, without asking upstream", async (_case, accountOverAt, entryOverAt, line) => { + // Every relative time is taken here, not when the table was built, so a + // slow run cannot turn `1d ago` into `1d 3m ago`. + const now = Date.now(); + const accountOver = accountOverAt(now); + const entryOver = entryOverAt(now); vi.resetModules(); tempHome = await createTempHome(); vi.stubEnv("HOME", tempHome); @@ -1467,6 +1484,32 @@ describe("standalone oc-codex-multi-auth CLI commands", () => { expect(printed).toMatch(line); }); + it("limits: counts an account again once its refusal's cooldown ran out and its token is valid", async () => { + vi.resetModules(); + tempHome = await createTempHome(); + vi.stubEnv("HOME", tempHome); + vi.stubEnv("USERPROFILE", tempHome); + const pool = [ + freshAccount({ refreshToken: "rt-a", accountId: "acct_a", cooldownReason: "auth-failure", coolingDownUntil: Date.now() - 60_000 }), + ]; + await writeAccounts(tempHome, pool); + await writePluginSnapshot(tempHome, Date.now() - 60_000, [ + { account: pool[0], planType: "plus", limits: [cachedWeekly(40, Date.now() + 86_400_000)] }, + ]); + vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response(JSON.stringify({ accounts: [] }))); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const { runInstaller } = await import("../scripts/install-oc-codex-multi-auth-core.js"); + + const result = await runInstaller(["limits", "--json"], { + env: { ...process.env, HOME: tempHome, USERPROFILE: tempHome }, + }); + + const output = JSON.parse(String(logSpy.mock.calls.at(-1)?.[0])); + expect(result.exitCode).toBe(0); + expect(output.accounts[0].readFailure).toBeUndefined(); + expect(output.pool).toMatchObject({ countedAccounts: 1 }); + }); + it("limits: never refreshes an expired token when the plugin holds a reading", async () => { vi.resetModules(); tempHome = await createTempHome(); diff --git a/test/tui-quota-overview.test.ts b/test/tui-quota-overview.test.ts index e97f405f..553655ad 100644 --- a/test/tui-quota-overview.test.ts +++ b/test/tui-quota-overview.test.ts @@ -17,6 +17,7 @@ import { ensureCodexUsageAccessToken, fetchCodexUsage, } from "../lib/codex-usage.js"; +import { CodexAuthError } from "../lib/errors.js"; import { isPoolFullySpent } from "../lib/quota-overview.js"; import { getTuiQuotaOverviewCachePath, @@ -262,9 +263,51 @@ describe("overview cache round trip", () => { expect(result && isFreshTuiQuotaSnapshot(result, later)).toBe(false); // Each account keeps its own time, so the fresh one is not aged with it. expect(result?.accounts.map((account) => account.fetchedAt)).toEqual([later, NOW]); - // The kept reading says the poll failed, so no reader passes it off as current. - expect(result?.accounts.map((account) => account.readFailedAt)).toEqual([undefined, later]); - expect(result?.accounts[1]?.readError).toBe("transient"); + // A transient failure says nothing about the credentials, so it marks nothing. + expect(result?.accounts[1]?.readFailedAt).toBeUndefined(); + expect(result?.accounts[1]?.readError).toBeUndefined(); + }); + + it("marks a kept reading when dead credentials failed the read, and keeps the mark through a transient one", async () => { + const path = join(dir, TUI_QUOTA_OVERVIEW_CACHE_FILE); + const account = { refreshToken: "refresh-dead", accountId: "account-dead", enabled: true }; + const reading = { + fingerprint: createUsageAccountFingerprint(account as never), + index: 1, + planType: "plus", + limits: [{ label: "weekly", leftPercent: 100, usedPercent: 0, windowMinutes: 10080 }], + }; + await writeTuiQuotaOverviewSnapshot(snapshot({ accounts: [reading] }), path); + const loadStorage = async () => ({ version: 3, accounts: [account], activeIndex: 0 }) as never; + const hour = 60 * 60 * 1000; + + vi.mocked(ensureCodexUsageAccessToken).mockRejectedValueOnce( + new CodexAuthError("Your refresh token has already been used", { + refreshFailureReason: "http_error", + statusCode: 401, + }), + ); + const first = await fetchTuiQuotaOverview({ cachePath: path, now: NOW + hour, loadStorage }); + expect(first?.accounts[0]).toMatchObject({ + readFailedAt: NOW + hour, + readFailedSince: NOW + hour, + readError: "Your refresh token has already been used", + }); + + vi.mocked(ensureCodexUsageAccessToken).mockRejectedValueOnce( + new CodexAuthError("Your refresh token has already been used", { + refreshFailureReason: "http_error", + statusCode: 401, + }), + ); + const second = await fetchTuiQuotaOverview({ cachePath: path, now: NOW + 2 * hour, loadStorage }); + expect(second?.accounts[0]).toMatchObject({ readFailedAt: NOW + 2 * hour, readFailedSince: NOW + hour }); + + vi.mocked(ensureCodexUsageAccessToken).mockRejectedValueOnce( + new CodexAuthError("network down", { retryable: true, refreshFailureReason: "network_error" }), + ); + const third = await fetchTuiQuotaOverview({ cachePath: path, now: NOW + 3 * hour, loadStorage }); + expect(third?.accounts[0]).toMatchObject({ readFailedAt: NOW + 2 * hour, readFailedSince: NOW + hour }); }); }); @@ -380,6 +423,22 @@ describe("mergeOverviewWithLatestAccount", () => { expect(merged.accounts[0]!.limits[0]!.leftPercent).toBe(0); }); + it("keeps a failed poll that came after the response", () => { + // T0 kept reading < T1 header reading < T2 failed poll. + const base = snapshot(); + const failed = { + ...base, + accounts: base.accounts.map((account, position) => + position === 1 + ? { ...account, fetchedAt: NOW, readFailedAt: NOW + 120_000, readFailedSince: NOW + 120_000, readError: "boom" } + : account, + ), + }; + const merged = mergeOverviewWithLatestAccount(failed, latest); + expect(merged.accounts[1]!.limits[0]!.leftPercent).toBe(12); + expect(merged.accounts[1]).toMatchObject({ readFailedAt: NOW + 120_000, readError: "boom" }); + }); + it("ignores a reading older than that account's own reading", () => { const base = snapshot(); const fresher = { From a89a90110bcdb76acc7ea936c248d7d78fbfcd5a Mon Sep 17 00:00:00 2001 From: Nowaker Date: Tue, 29 Sep 2026 22:23:55 -0500 Subject: [PATCH 3/3] fix(usage): drop control characters from a decoded error message `summarizeCodexErrorMessage` (lib/codex-usage.ts) decodes the message it reads out of an OAuth error body as a JSON string, so an escaped `\u001b` became a live ESC. Whitespace folding and token masking leave it in place, and `limits` printed it to the terminal, where an ANSI sequence from the endpoint could recolour or hide the report. C0, DEL and C1 characters are now replaced with a space before the line is folded. AI-Tool: opencode 1.18.32 AI-Model: anthropic/claude-opus-5-5 AI-Variant: high AI-Platform: linux AI-Harness: Vibeterm e07a557 --- lib/codex-usage.ts | 7 ++++++- test/codex-usage.test.ts | 6 ++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/lib/codex-usage.ts b/lib/codex-usage.ts index b61020f5..05a3ac51 100644 --- a/lib/codex-usage.ts +++ b/lib/codex-usage.ts @@ -811,7 +811,12 @@ export function summarizeCodexErrorMessage(text: string, maxChars = 200): string summary = prefix ? `${prefix}: ${readable}` : readable; } } - const line = (summary ?? text).replace(/\s+/g, " ").trim(); + // Decoding turns an escaped `\u001b` into a live ESC, so control characters + // are dropped before the line can reach a terminal. + const line = (summary ?? text) + .replace(/[\u0000-\u001f\u007f-\u009f]/g, " ") + .replace(/\s+/g, " ") + .trim(); return line.length > maxChars ? `${line.slice(0, maxChars - 1)}…` : line; } diff --git a/test/codex-usage.test.ts b/test/codex-usage.test.ts index cfcc1dcb..6dac79fb 100644 --- a/test/codex-usage.test.ts +++ b/test/codex-usage.test.ts @@ -42,6 +42,12 @@ describe("summarizeCodexErrorMessage", () => { ); }); + it("drops control characters an escaped message decodes into", () => { + const body = JSON.stringify({ error: { message: "token \u001b[31mreused\u001b[0m\u0007 now" } }); + expect(body).toContain("\\u001b"); + expect(summarizeCodexErrorMessage(body)).toBe("token [31mreused [0m now"); + }); + it("collapses text with no message onto one line", () => { expect(summarizeCodexErrorMessage("HTTP 500:\n upstream\tboom")).toBe("HTTP 500: upstream boom"); });