From 7a6d10064e25285da1cbe8f9a39d7fa2401c265c Mon Sep 17 00:00:00 2001 From: Wooseong Kim <2222333+innocarpe@users.noreply.github.com> Date: Mon, 21 Sep 2026 12:12:08 +0900 Subject: [PATCH] fix: read OpenCode Go usage from the console API (#21462) * fix: read OpenCode Go usage from the console API The workspace HTML page now 302s to console login. Fetch /console/api/go/status with x-org-id, map JSON meters into the existing windows, and keep __Host-console_session on the closed cookie allowlist. Fixes #21420 * fix: tell users to paste the OpenCode console session cookie The Go status API is authed by __Host-console_session. Settings still told people to paste auth only, which 401s. Ask for the full Cookie header; auth remains enough for workspace discovery. --- .../rate-limits/opencode-go-page-scraper.ts | 172 ----------- .../opencode-go-status-parsing.test.ts | 118 ++++++++ .../rate-limits/opencode-go-status-parsing.ts | 86 ++++++ .../opencode-go-usage-fetcher.test.ts | 268 +++++++++++------- .../rate-limits/opencode-go-usage-fetcher.ts | 62 ++-- .../components/settings/AccountsPane.test.tsx | 9 + ...ccounts-pane-provider-setting-sections.tsx | 29 +- .../settings/accounts-search.test.ts | 22 +- .../components/settings/accounts-search.ts | 5 +- src/renderer/src/i18n/locales/en.json | 6 + 10 files changed, 447 insertions(+), 330 deletions(-) delete mode 100644 src/main/rate-limits/opencode-go-page-scraper.ts create mode 100644 src/main/rate-limits/opencode-go-status-parsing.test.ts create mode 100644 src/main/rate-limits/opencode-go-status-parsing.ts diff --git a/src/main/rate-limits/opencode-go-page-scraper.ts b/src/main/rate-limits/opencode-go-page-scraper.ts deleted file mode 100644 index 0f092da6db3..00000000000 --- a/src/main/rate-limits/opencode-go-page-scraper.ts +++ /dev/null @@ -1,172 +0,0 @@ -// Why: the opencode.ai page is rendered with React Server Components. The -// embedded JS uses a wire format where object references look like: -// key:$R[28]={field:value,...} -// rather than plain `key:{field:value,...}`. A single key (e.g. monthlyUsage) -// can appear multiple times — once with real data and once as `null` inside a -// different component's props. We must find the occurrence that is an object -// with both usagePercent and resetInSec, not the null one. - -/** - * Finds the brace-balanced object block assigned to `key` anywhere in `text`. - * Skips React Flight assignment tokens (e.g. `$R[N]=`) between the colon and - * the opening brace. Returns the first block that contains `usagePercent` AND - * `resetInSec` as direct numeric properties (not nested), so that placeholder - * `null` occurrences and billing-context duplicates are ignored. - */ -function extractUsageBlock(text: string, key: string): string | null { - // Match every occurrence of `key:` (with optional $R[N]= assignment) - // Why: React Flight wire format embeds object references between the colon - // and the literal brace, so we skip over any `$R[N]=` tokens to reach `{`. - const keyRegex = new RegExp(`\\b${key}\\b\\s*:`, 'g') - let keyMatch: RegExpExecArray | null - - while ((keyMatch = keyRegex.exec(text)) !== null) { - // Scan forward from after the colon to find the opening `{`, - // allowing for the `$R[N]=` token or plain whitespace in between. - // We only scan a short window so we don't accidentally land on the - // next occurrence of the key. - const searchStart = keyMatch.index + keyMatch[0].length - const searchWindow = text.slice(searchStart, searchStart + 30) - const braceOffset = searchWindow.indexOf('{') - if (braceOffset === -1) { - // This occurrence has no object (e.g. `monthlyUsage:null`) — skip. - continue - } - - const openBrace = searchStart + braceOffset - // Extract the balanced block - // Why: this brace-depth parser does not skip string literals. React Flight's - // current format does not emit raw { } inside strings, but this is a scraper - // against HTML we don't control — treat as fragile. - let depth = 0 - let block: string | null = null - for (let i = openBrace; i < text.length; i++) { - if (text[i] === '{') { - depth++ - } else if (text[i] === '}') { - depth-- - if (depth === 0) { - block = text.slice(openBrace, i + 1) - break - } - } - } - - if (!block) { - continue - } - - // Verify this block has both required numeric fields as direct properties - // (depth 1 within the block). This rejects billing/plan objects that share - // the key name but lack usage data. - if ( - hasDirectNumericField(block, 'usagePercent') && - hasDirectNumericField(block, 'resetInSec') - ) { - return block - } - } - - return null -} - -/** - * Returns true if `fieldName` exists as a direct (depth-1) numeric property - * of the object string `objText`. - */ -function hasDirectNumericField(objText: string, fieldName: string): boolean { - return extractTopLevelNumber(objText, fieldName) !== null -} - -/** - * Extracts a numeric field at depth 1 of `objText` — ignores the same field - * inside nested sub-objects. - * Why: without depth tracking, a regex matches the first occurrence regardless - * of nesting, returning wrong values when a sub-object contains the same name. - */ -function extractTopLevelNumber(objText: string, fieldName: string): number | null { - const fieldRegex = new RegExp(`\\b${fieldName}\\b\\s*:\\s*(-?[0-9]+(?:\\.[0-9]+)?)`) - // Why: this brace-depth parser does not skip string literals. React Flight's - // current format does not emit raw { } inside strings, but this is a scraper - // against HTML we don't control — treat as fragile. - let depth = 0 - - for (let i = 0; i < objText.length; i++) { - const ch = objText[i] - if (ch === '{') { - depth++ - continue - } - if (ch === '}') { - depth-- - continue - } - - // Only match at depth 1 (direct property of the root object). - if (depth === 1) { - const slice = objText.slice(i, i + fieldName.length + 30) - const m = fieldRegex.exec(slice) - if (m && m.index === 0) { - const n = Number.parseFloat(m[1]) - return Number.isFinite(n) ? n : null - } - } - } - return null -} - -type ParsedSubscription = { - rollingUsagePercent: number - weeklyUsagePercent: number - monthlyUsagePercent: number | null - rollingResetInSec: number - weeklyResetInSec: number - monthlyResetInSec: number | null -} - -export function parseSubscriptionFromPageText(text: string): ParsedSubscription | null { - // Why: OpenCode usage is scraped from HTML-embedded JS (React Flight wire - // format). Defensive size check prevents runaway parsing on unexpected payloads. - if (!text || text.length > 10_000_000) { - return null - } - - // Find the first occurrence of each usage key that has both usagePercent and - // resetInSec as direct numeric fields. This skips null occurrences and - // billing-context duplicates that use the same key name without usage data. - const rollingBlock = extractUsageBlock(text, 'rollingUsage') - const weeklyBlock = extractUsageBlock(text, 'weeklyUsage') - const monthlyBlock = extractUsageBlock(text, 'monthlyUsage') - - const rollingPercent = - rollingBlock !== null ? extractTopLevelNumber(rollingBlock, 'usagePercent') : null - const rollingReset = - rollingBlock !== null ? extractTopLevelNumber(rollingBlock, 'resetInSec') : null - const weeklyPercent = - weeklyBlock !== null ? extractTopLevelNumber(weeklyBlock, 'usagePercent') : null - const weeklyReset = weeklyBlock !== null ? extractTopLevelNumber(weeklyBlock, 'resetInSec') : null - - if ( - rollingPercent === null || - rollingReset === null || - weeklyPercent === null || - weeklyReset === null - ) { - return null - } - - const monthlyPercent = - monthlyBlock !== null ? extractTopLevelNumber(monthlyBlock, 'usagePercent') : null - const monthlyReset = - monthlyBlock !== null ? extractTopLevelNumber(monthlyBlock, 'resetInSec') : null - - return { - rollingUsagePercent: Math.min(100, Math.max(0, rollingPercent)), - weeklyUsagePercent: Math.min(100, Math.max(0, weeklyPercent)), - monthlyUsagePercent: - monthlyPercent !== null ? Math.min(100, Math.max(0, monthlyPercent)) : null, - rollingResetInSec: rollingReset, - weeklyResetInSec: weeklyReset, - monthlyResetInSec: monthlyReset - } -} diff --git a/src/main/rate-limits/opencode-go-status-parsing.test.ts b/src/main/rate-limits/opencode-go-status-parsing.test.ts new file mode 100644 index 00000000000..f8e0eeb29e7 --- /dev/null +++ b/src/main/rate-limits/opencode-go-status-parsing.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest' +import { parseOpenCodeGoStatusPayload } from './opencode-go-status-parsing' + +const ISSUE_PAYLOAD = { + access: { + meters: { + fiveHour: { + resetsAt: '2026-09-18T12:42:04.962Z', + limitMicroCents: '1200000000', + usedMicroCents: '121745383' + }, + week: { + resetsAt: '2026-09-21T00:00:00.000Z', + limitMicroCents: '3000000000', + usedMicroCents: '121745383' + }, + month: { + limitMicroCents: '6000000000', + usedMicroCents: '121745383' + } + } + } +} + +describe('parseOpenCodeGoStatusPayload', () => { + it('maps fiveHour/week/month meters into session/weekly/monthly windows', () => { + const parsed = parseOpenCodeGoStatusPayload(JSON.stringify(ISSUE_PAYLOAD)) + + expect(parsed).not.toBeNull() + expect(parsed?.session).toEqual({ + usedPercent: (121745383 / 1_200_000_000) * 100, + windowMinutes: 300, + resetsAt: Date.parse('2026-09-18T12:42:04.962Z'), + resetDescription: null + }) + expect(parsed?.weekly).toEqual({ + usedPercent: (121745383 / 3_000_000_000) * 100, + windowMinutes: 10_080, + resetsAt: Date.parse('2026-09-21T00:00:00.000Z'), + resetDescription: null + }) + expect(parsed?.monthly).toEqual({ + usedPercent: (121745383 / 6_000_000_000) * 100, + windowMinutes: 43_200, + resetsAt: null, + resetDescription: null + }) + }) + + it('accepts numeric microCents', () => { + const parsed = parseOpenCodeGoStatusPayload( + JSON.stringify({ + access: { + meters: { + fiveHour: { + resetsAt: '2026-09-18T12:42:04.962Z', + limitMicroCents: 100, + usedMicroCents: 25 + }, + week: { + resetsAt: '2026-09-21T00:00:00.000Z', + limitMicroCents: 200, + usedMicroCents: 50 + } + } + } + }) + ) + + expect(parsed?.session?.usedPercent).toBe(25) + expect(parsed?.weekly?.usedPercent).toBe(25) + expect(parsed?.monthly).toBeNull() + }) + + it('caps usedPercent at 100 and floors at 0', () => { + const parsed = parseOpenCodeGoStatusPayload( + JSON.stringify({ + access: { + meters: { + fiveHour: { + resetsAt: '2026-09-18T12:42:04.962Z', + limitMicroCents: '100', + usedMicroCents: '150' + }, + week: { + resetsAt: '2026-09-21T00:00:00.000Z', + limitMicroCents: '100', + usedMicroCents: '-5' + } + } + } + }) + ) + + expect(parsed?.session?.usedPercent).toBe(100) + expect(parsed?.weekly?.usedPercent).toBe(0) + }) + + it('returns null for HTML and other non-JSON bodies', () => { + expect(parseOpenCodeGoStatusPayload('rollingUsage:{usagePercent:30}')).toBeNull() + expect(parseOpenCodeGoStatusPayload('')).toBeNull() + expect(parseOpenCodeGoStatusPayload('{not json')).toBeNull() + }) + + it('returns null when fiveHour or week meters are missing', () => { + expect( + parseOpenCodeGoStatusPayload( + JSON.stringify({ + access: { + meters: { + week: { limitMicroCents: '100', usedMicroCents: '10' } + } + } + }) + ) + ).toBeNull() + }) +}) diff --git a/src/main/rate-limits/opencode-go-status-parsing.ts b/src/main/rate-limits/opencode-go-status-parsing.ts new file mode 100644 index 00000000000..9137dd911a0 --- /dev/null +++ b/src/main/rate-limits/opencode-go-status-parsing.ts @@ -0,0 +1,86 @@ +import type { RateLimitWindow } from '../../shared/rate-limit-types' + +const SESSION_WINDOW_MINUTES = 300 +const WEEKLY_WINDOW_MINUTES = 10_080 +const MONTHLY_WINDOW_MINUTES = 43_200 +const MAX_STATUS_PAYLOAD_CHARS = 1_000_000 + +export type OpenCodeGoUsageWindows = { + session: RateLimitWindow + weekly: RateLimitWindow + monthly: RateLimitWindow | null +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function parseMicroCents(value: unknown): number | null { + if (typeof value === 'number') { + return Number.isFinite(value) ? value : null + } + if (typeof value !== 'string') { + return null + } + const trimmed = value.trim() + if (!trimmed) { + return null + } + const parsed = Number(trimmed) + return Number.isFinite(parsed) ? parsed : null +} + +function parseResetsAt(value: unknown): number | null { + if (typeof value !== 'string' || value.trim() === '') { + return null + } + const resetsAt = Date.parse(value) + return Number.isFinite(resetsAt) ? resetsAt : null +} + +function meterToWindow(meter: unknown, windowMinutes: number): RateLimitWindow | null { + if (!isRecord(meter)) { + return null + } + const used = parseMicroCents(meter.usedMicroCents) + const limit = parseMicroCents(meter.limitMicroCents) + if (used === null || limit === null || limit <= 0) { + return null + } + return { + usedPercent: Math.min(100, Math.max(0, (used / limit) * 100)), + windowMinutes, + resetsAt: parseResetsAt(meter.resetsAt), + resetDescription: null + } +} + +export function parseOpenCodeGoStatusPayload(text: string): OpenCodeGoUsageWindows | null { + if (!text || text.length > MAX_STATUS_PAYLOAD_CHARS) { + return null + } + + let payload: unknown + try { + payload = JSON.parse(text) + } catch { + return null + } + + if (!isRecord(payload) || !isRecord(payload.access) || !isRecord(payload.access.meters)) { + return null + } + + const meters = payload.access.meters + const session = meterToWindow(meters.fiveHour, SESSION_WINDOW_MINUTES) + const weekly = meterToWindow(meters.week, WEEKLY_WINDOW_MINUTES) + if (!session || !weekly) { + return null + } + + return { + session, + weekly, + monthly: meterToWindow(meters.month, MONTHLY_WINDOW_MINUTES) + } +} diff --git a/src/main/rate-limits/opencode-go-usage-fetcher.test.ts b/src/main/rate-limits/opencode-go-usage-fetcher.test.ts index 24db88111a4..704a15b9b73 100644 --- a/src/main/rate-limits/opencode-go-usage-fetcher.test.ts +++ b/src/main/rate-limits/opencode-go-usage-fetcher.test.ts @@ -12,7 +12,10 @@ vi.mock('electron', () => ({ })) import { fetchOpenCodeGoRateLimits, normalizeCookieInput } from './opencode-go-usage-fetcher' + const WORKSPACES_SERVER_ID = 'def39973159c7f0483d8793a822b8dbb10d067e12c65455fcb4608459ba0234f' +const CONSOLE_STATUS_URL = 'https://opencode.ai/console/api/go/status' +const LEGACY_WORKSPACE_GO_URL = /https:\/\/opencode\.ai\/workspace\/[^/]+\/go/ function makeResponse(body: string, status = 200): Response { return { @@ -22,25 +25,61 @@ function makeResponse(body: string, status = 200): Response { } as Response } -// Real React Flight wire format from opencode.ai — keys like `monthlyUsage` -// appear multiple times: once with actual data (as `$R[N]={...}`) and once as -// `null` inside a billing-context object. The parser must pick the data one. -const USAGE_PAGE_WITH_MONTHLY = ` - -` +function makeJsonResponse(body: unknown, status = 200): Response { + return makeResponse(JSON.stringify(body), status) +} -const USAGE_PAGE_NO_MONTHLY = ` +const STATUS_WITH_MONTHLY = { + access: { + meters: { + fiveHour: { + resetsAt: '2026-04-24T14:00:00.000Z', + limitMicroCents: '1000', + usedMicroCents: '300' + }, + week: { + resetsAt: '2026-05-01T12:00:00.000Z', + limitMicroCents: '1000', + usedMicroCents: '510' + }, + month: { + resetsAt: '2026-05-24T12:00:00.000Z', + limitMicroCents: '1000', + usedMicroCents: '890' + } + } + } +} + +const STATUS_NO_MONTHLY = { + access: { + meters: { + fiveHour: { + resetsAt: '2026-04-24T13:00:00.000Z', + limitMicroCents: '100', + usedMicroCents: '10' + }, + week: { + resetsAt: '2026-04-25T12:00:00.000Z', + limitMicroCents: '100', + usedMicroCents: '20' + } + } + } +} + +const LEGACY_USAGE_PAGE = ` ` const WORKSPACES_RESPONSE = 'id: "wrk_TESTWORKSPACEID123"' +function requestedUrls(): string[] { + return netFetchMock.mock.calls.map(([url]) => String(url)) +} + describe('fetchOpenCodeGoRateLimits', () => { beforeEach(() => { vi.useFakeTimers() @@ -79,7 +118,7 @@ describe('fetchOpenCodeGoRateLimits', () => { expect(netFetchMock).not.toHaveBeenCalled() }) - it('returns error when cookie has no auth or __Host-auth name', async () => { + it('returns error when cookie has no known auth name', async () => { const result = await fetchOpenCodeGoRateLimits('session=abc123; other=xyz') expect(result.status).toBe('error') @@ -105,8 +144,17 @@ describe('fetchOpenCodeGoRateLimits', () => { expect(normalizeCookieInput('__Host-auth=token')).toBe('__Host-auth=token') }) + it('leaves __Host-console_session=... unchanged', () => { + expect(normalizeCookieInput('__Host-console_session=consoleTok')).toBe( + '__Host-console_session=consoleTok' + ) + }) + it('leaves multi-pair cookie headers unchanged', () => { expect(normalizeCookieInput('auth=tok; other=val')).toBe('auth=tok; other=val') + expect(normalizeCookieInput('auth=tok; __Host-console_session=consoleTok')).toBe( + 'auth=tok; __Host-console_session=consoleTok' + ) }) it('trims surrounding whitespace before wrapping', () => { @@ -123,7 +171,7 @@ describe('fetchOpenCodeGoRateLimits', () => { it('accepts a bare token (auto-wraps to auth=)', async () => { netFetchMock .mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)) - .mockResolvedValueOnce(makeResponse(USAGE_PAGE_WITH_MONTHLY)) + .mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY)) const result = await fetchOpenCodeGoRateLimits('Fe26.2**baretoken') @@ -136,7 +184,7 @@ describe('fetchOpenCodeGoRateLimits', () => { it('uses GET /_server?id= with correct headers for workspaces', async () => { netFetchMock .mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)) - .mockResolvedValueOnce(makeResponse(USAGE_PAGE_WITH_MONTHLY)) + .mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY)) await fetchOpenCodeGoRateLimits('auth=mytoken') @@ -156,7 +204,7 @@ describe('fetchOpenCodeGoRateLimits', () => { it('uses an isolated session cookie jar and clears it after fetching', async () => { netFetchMock .mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)) - .mockResolvedValueOnce(makeResponse(USAGE_PAGE_WITH_MONTHLY)) + .mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY)) await fetchOpenCodeGoRateLimits('auth=mytoken') @@ -210,9 +258,9 @@ describe('fetchOpenCodeGoRateLimits', () => { it('applies configured proxy settings once to the isolated session', async () => { netFetchMock .mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)) - .mockResolvedValueOnce(makeResponse(USAGE_PAGE_WITH_MONTHLY)) + .mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY)) .mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)) - .mockResolvedValueOnce(makeResponse(USAGE_PAGE_WITH_MONTHLY)) + .mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY)) const proxySettings = { httpProxyUrl: 'http://proxy.example:8080', @@ -244,55 +292,61 @@ describe('fetchOpenCodeGoRateLimits', () => { expect(netFetchMock).not.toHaveBeenCalled() }) - it('fetches usage from /workspace//go after resolving workspace ID', async () => { + it('fetches usage from /console/api/go/status with x-org-id and never scrapes /workspace//go', async () => { netFetchMock .mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)) - .mockResolvedValueOnce(makeResponse(USAGE_PAGE_WITH_MONTHLY)) + .mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY)) await fetchOpenCodeGoRateLimits('auth=mytoken') + expect(requestedUrls().some((url) => LEGACY_WORKSPACE_GO_URL.test(url))).toBe(false) expect(netFetchMock).toHaveBeenNthCalledWith( 2, - 'https://opencode.ai/workspace/wrk_TESTWORKSPACEID123/go', - expect.objectContaining({ method: 'GET' }) + CONSOLE_STATUS_URL, + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + 'x-org-id': 'wrk_TESTWORKSPACEID123', + Accept: 'application/json' + }) + }) ) + expect(netFetchMock.mock.calls[1][1].headers).not.toHaveProperty('Cookie') }) - it('returns ok with session, weekly, and monthly windows', async () => { + it('returns ok with session, weekly, and monthly windows from JSON meters', async () => { netFetchMock .mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)) - .mockResolvedValueOnce(makeResponse(USAGE_PAGE_WITH_MONTHLY)) + .mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY)) - const now = Date.now() const result = await fetchOpenCodeGoRateLimits('auth=mytoken') expect(result.status).toBe('ok') expect(result.error).toBeNull() - expect(result.session).toEqual({ usedPercent: 30, windowMinutes: 300, - resetsAt: now + 7200 * 1000, + resetsAt: Date.parse('2026-04-24T14:00:00.000Z'), resetDescription: null }) expect(result.weekly).toEqual({ usedPercent: 51, - windowMinutes: 10080, - resetsAt: now + 259200 * 1000, + windowMinutes: 10_080, + resetsAt: Date.parse('2026-05-01T12:00:00.000Z'), resetDescription: null }) expect(result.monthly).toEqual({ usedPercent: 89, - windowMinutes: 43200, - resetsAt: now + 1296000 * 1000, + windowMinutes: 43_200, + resetsAt: Date.parse('2026-05-24T12:00:00.000Z'), resetDescription: null }) }) - it('returns ok with null monthly when monthlyUsage is absent', async () => { + it('returns ok with null monthly when the month meter is absent', async () => { netFetchMock .mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)) - .mockResolvedValueOnce(makeResponse(USAGE_PAGE_NO_MONTHLY)) + .mockResolvedValueOnce(makeJsonResponse(STATUS_NO_MONTHLY)) const result = await fetchOpenCodeGoRateLimits('auth=mytoken') @@ -303,13 +357,24 @@ describe('fetchOpenCodeGoRateLimits', () => { }) it('caps usedPercent at 100 and floors at 0', async () => { - const page = ` - rollingUsage: { usagePercent: 150, resetInSec: 3600 } - weeklyUsage: { usagePercent: -5, resetInSec: 86400 } - ` - netFetchMock - .mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)) - .mockResolvedValueOnce(makeResponse(page)) + netFetchMock.mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)).mockResolvedValueOnce( + makeJsonResponse({ + access: { + meters: { + fiveHour: { + resetsAt: '2026-04-24T13:00:00.000Z', + limitMicroCents: '100', + usedMicroCents: '150' + }, + week: { + resetsAt: '2026-04-25T12:00:00.000Z', + limitMicroCents: '100', + usedMicroCents: '-5' + } + } + } + }) + ) const result = await fetchOpenCodeGoRateLimits('auth=token') @@ -318,77 +383,73 @@ describe('fetchOpenCodeGoRateLimits', () => { expect(result.weekly?.usedPercent).toBe(0) }) - it('parses React Flight wire format with $R[N]= assignment tokens', async () => { - // Real format from opencode.ai — keys have $R[N]= between the colon and brace. - const page = ` - rollingUsage:$R[21]={status:"ok",resetInSec:1337,usagePercent:42}, - weeklyUsage:$R[22]={status:"ok",resetInSec:86400,usagePercent:68} - ` + it('does not treat the old HTML usage page as success', async () => { netFetchMock .mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)) - .mockResolvedValueOnce(makeResponse(page)) + .mockResolvedValueOnce(makeResponse(LEGACY_USAGE_PAGE)) - const result = await fetchOpenCodeGoRateLimits('auth=token') + const result = await fetchOpenCodeGoRateLimits('auth=mytoken') - expect(result.status).toBe('ok') - expect(result.session?.usedPercent).toBe(42) - expect(result.weekly?.usedPercent).toBe(68) - }) - - it('skips null occurrences and finds the real data block for monthlyUsage', async () => { - // Regression: on refresh, monthlyUsage:null appeared BEFORE the real - // monthlyUsage:$R[N]={usagePercent:89,...} in a different component's props. - // Parser must skip the null and find the data block. - const page = ` - rollingUsage:$R[21]={status:"ok",resetInSec:18000,usagePercent:0}, - weeklyUsage:$R[22]={status:"ok",resetInSec:57781,usagePercent:51}, - monthlyUsage:null,timeMonthlyUsageUpdated:null, - monthlyUsage:$R[28]={status:"ok",resetInSec:1214779,usagePercent:89} - ` - netFetchMock - .mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)) - .mockResolvedValueOnce(makeResponse(page)) - - const result = await fetchOpenCodeGoRateLimits('auth=token') - - expect(result.status).toBe('ok') - expect(result.monthly?.usedPercent).toBe(89) - expect(result.monthly?.resetsAt).toBe(Date.now() + 1214779 * 1000) - }) - - it('returns null monthly when all monthlyUsage occurrences are null', async () => { - const page = ` - rollingUsage:$R[21]={status:"ok",resetInSec:3600,usagePercent:10}, - weeklyUsage:$R[22]={status:"ok",resetInSec:86400,usagePercent:20}, - monthlyUsage:null,timeMonthlyUsageUpdated:null - ` - netFetchMock - .mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)) - .mockResolvedValueOnce(makeResponse(page)) - - const result = await fetchOpenCodeGoRateLimits('auth=token') - - expect(result.status).toBe('ok') - expect(result.monthly).toBeNull() + expect(result.status).toBe('error') + expect(result.error).toBe('Could not parse usage data') + expect(result.session).toBeNull() }) it('skips workspace lookup when workspaceIdOverride is provided', async () => { - netFetchMock.mockResolvedValueOnce(makeResponse(USAGE_PAGE_WITH_MONTHLY)) + netFetchMock.mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY)) const result = await fetchOpenCodeGoRateLimits('auth=mytoken', 'wrk_OVERRIDE123') expect(netFetchMock).toHaveBeenCalledTimes(1) + expect(requestedUrls().some((url) => LEGACY_WORKSPACE_GO_URL.test(url))).toBe(false) expect(netFetchMock).toHaveBeenCalledWith( - 'https://opencode.ai/workspace/wrk_OVERRIDE123/go', - expect.anything() + CONSOLE_STATUS_URL, + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ 'x-org-id': 'wrk_OVERRIDE123' }) + }) ) expect(result.status).toBe('ok') }) + it('keeps __Host-console_session and drops unrelated cookie names', async () => { + netFetchMock.mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY)) + + await fetchOpenCodeGoRateLimits( + 'session=secret; __Host-console_session=consoleTok; tracking=xyz; auth=realtoken', + 'wrk_OVERRIDE123' + ) + + expect(cookiesSetMock).toHaveBeenCalledTimes(2) + expect(cookiesSetMock).toHaveBeenCalledWith( + expect.objectContaining({ name: '__Host-console_session', value: 'consoleTok' }) + ) + expect(cookiesSetMock).toHaveBeenCalledWith( + expect.objectContaining({ name: 'auth', value: 'realtoken' }) + ) + expect(cookiesSetMock).not.toHaveBeenCalledWith(expect.objectContaining({ name: 'session' })) + expect(cookiesSetMock).not.toHaveBeenCalledWith(expect.objectContaining({ name: 'tracking' })) + }) + + it('accepts a console session cookie without wrapping it as auth=', async () => { + netFetchMock.mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY)) + + const result = await fetchOpenCodeGoRateLimits( + '__Host-console_session=consoleTok', + 'wrk_OVERRIDE123' + ) + + expect(result.status).toBe('ok') + expect(cookiesSetMock).toHaveBeenCalledTimes(1) + expect(cookiesSetMock).toHaveBeenCalledWith( + expect.objectContaining({ name: '__Host-console_session', value: 'consoleTok' }) + ) + }) + it('filters cookie to auth name only', async () => { netFetchMock .mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)) - .mockResolvedValueOnce(makeResponse(USAGE_PAGE_WITH_MONTHLY)) + .mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY)) await fetchOpenCodeGoRateLimits('session=secret; auth=realtoken; tracking=xyz') @@ -426,7 +487,7 @@ describe('fetchOpenCodeGoRateLimits', () => { expect(result.error).toMatch(/No workspace ID found/) }) - it('returns error on non-ok usage page response', async () => { + it('returns error on non-ok usage response', async () => { netFetchMock .mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)) .mockResolvedValueOnce(makeResponse('Not Found', 404)) @@ -434,18 +495,31 @@ describe('fetchOpenCodeGoRateLimits', () => { const result = await fetchOpenCodeGoRateLimits('auth=mytoken') expect(result.status).toBe('error') - expect(result.error).toBe('Usage page fetch failed (404)') + expect(result.error).toBe('Usage fetch failed (404)') }) - it('returns error when usage data cannot be parsed from page', async () => { + it('tells the user to include __Host-console_session when usage fetch returns 401', async () => { netFetchMock .mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)) - .mockResolvedValueOnce(makeResponse('no usage data here')) + .mockResolvedValueOnce(makeResponse('Unauthorized', 401)) const result = await fetchOpenCodeGoRateLimits('auth=mytoken') expect(result.status).toBe('error') - expect(result.error).toBe('Could not parse usage data from page') + expect(result.error).toBe( + 'Usage fetch failed (401) — paste the full Cookie header including __Host-console_session (auth alone is not enough)' + ) + }) + + it('returns error when usage data cannot be parsed', async () => { + netFetchMock + .mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)) + .mockResolvedValueOnce(makeResponse('{"access":{}}')) + + const result = await fetchOpenCodeGoRateLimits('auth=mytoken') + + expect(result.status).toBe('error') + expect(result.error).toBe('Could not parse usage data') }) it('never logs the cookie in error messages', async () => { diff --git a/src/main/rate-limits/opencode-go-usage-fetcher.ts b/src/main/rate-limits/opencode-go-usage-fetcher.ts index f2ea68b7588..1d9699bec88 100644 --- a/src/main/rate-limits/opencode-go-usage-fetcher.ts +++ b/src/main/rate-limits/opencode-go-usage-fetcher.ts @@ -1,24 +1,25 @@ import type { Session } from 'electron' import { randomUUID } from 'node:crypto' import type { NetworkProxySettings } from '../../shared/network-proxy' -import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' +import type { ProviderRateLimits } from '../../shared/rate-limit-types' import { clearOpenCodeSessionCookies, createOpenCodeRequestSession, OPENCODE_BASE_URL } from './opencode-go-request-session' -import { parseSubscriptionFromPageText } from './opencode-go-page-scraper' +import { parseOpenCodeGoStatusPayload } from './opencode-go-status-parsing' const OPENCODE_SERVER_URL = 'https://opencode.ai/_server' +const OPENCODE_GO_STATUS_URL = `${OPENCODE_BASE_URL}/console/api/go/status` const API_TIMEOUT_MS = 15_000 // Server-function hash for the workspaces endpoint — stable identifier used by // the opencode.ai SST/TanStack router server-fn protocol. const WORKSPACES_SERVER_ID = 'def39973159c7f0483d8793a822b8dbb10d067e12c65455fcb4608459ba0234f' -// Only these cookie names carry session auth on opencode.ai. Sending unrelated -// cookies pollutes the header and can expose sensitive data from other sites. -const AUTH_COOKIE_NAMES = new Set(['auth', '__Host-auth']) +// Closed allowlist: only known opencode.ai auth cookies. Console Go usage is +// authed by __Host-console_session; /_server workspace discovery still uses auth. +const AUTH_COOKIE_NAMES = new Set(['auth', '__Host-auth', '__Host-console_session']) // Why: users may paste just the token value (e.g. "Fe26.2**...") instead of // the full cookie header ("auth=Fe26.2**..."). Auto-wrapping avoids a confusing @@ -29,7 +30,7 @@ export function normalizeCookieInput(raw: string): string { return trimmed } // Already a valid cookie header: has multiple pairs or starts with known name. - if (trimmed.includes(';') || /^(?:auth|__Host-auth)=/i.test(trimmed)) { + if (trimmed.includes(';') || /^(?:auth|__Host-auth|__Host-console_session)=/i.test(trimmed)) { return trimmed } // Only wrap if it looks like an Iron Session seal (starts with Fe26.2**) @@ -73,19 +74,6 @@ function parseWorkspaceIds(text: string): string[] { return ids } -function makeWindow( - usedPercent: number, - resetInSec: number, - windowMinutes: number -): RateLimitWindow { - return { - usedPercent, - windowMinutes, - resetsAt: Date.now() + resetInSec * 1000, - resetDescription: null - } -} - export async function fetchOpenCodeGoRateLimits( cookie: string, workspaceIdOverride?: string, @@ -229,47 +217,43 @@ async function fetchOpenCodeGoRateLimitsWithSession( } } - // Step 2: Robust workspace resolution. Try each candidate ID until one returns 200 OK - // and valid usage data. Each candidate gets its own timeout so a slow or - // hung candidate cannot starve the rest. + // Why: /workspace//go now 302s to console login. Usage is JSON at + // /console/api/go/status, scoped by x-org-id and authed by the console session. let lastError = '' for (const candidateId of ids) { try { - const usagePageUrl = `${OPENCODE_BASE_URL}/workspace/${candidateId}/go` - const pageRes = await openCodeSession.fetch(usagePageUrl, { + const statusRes = await openCodeSession.fetch(OPENCODE_GO_STATUS_URL, { method: 'GET', headers: { - Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + Accept: 'application/json', Origin: OPENCODE_BASE_URL, - Referer: OPENCODE_BASE_URL + Referer: `${OPENCODE_BASE_URL}/console/${candidateId}/go`, + 'x-org-id': candidateId }, signal: AbortSignal.timeout(API_TIMEOUT_MS) }) - if (!pageRes.ok) { - lastError = `Usage page fetch failed (${pageRes.status})` + if (!statusRes.ok) { + lastError = + statusRes.status === 401 + ? 'Usage fetch failed (401) — paste the full Cookie header including __Host-console_session (auth alone is not enough)' + : `Usage fetch failed (${statusRes.status})` continue } - const pageText = await pageRes.text() - const parsed = parseSubscriptionFromPageText(pageText) + const parsed = parseOpenCodeGoStatusPayload(await statusRes.text()) if (parsed) { - const monthly = - parsed.monthlyUsagePercent !== null && parsed.monthlyResetInSec !== null - ? makeWindow(parsed.monthlyUsagePercent, parsed.monthlyResetInSec, 43200) // 30d - : null - return { provider: 'opencode-go', - session: makeWindow(parsed.rollingUsagePercent, parsed.rollingResetInSec, 300), - weekly: makeWindow(parsed.weeklyUsagePercent, parsed.weeklyResetInSec, 10080), - monthly, + session: parsed.session, + weekly: parsed.weekly, + monthly: parsed.monthly, updatedAt: Date.now(), error: null, status: 'ok' } } - lastError = 'Could not parse usage data from page' + lastError = 'Could not parse usage data' } catch (err) { const message = err instanceof Error ? err.message : 'Unknown error' lastError = message diff --git a/src/renderer/src/components/settings/AccountsPane.test.tsx b/src/renderer/src/components/settings/AccountsPane.test.tsx index bf7656856d5..b1be5e1a305 100644 --- a/src/renderer/src/components/settings/AccountsPane.test.tsx +++ b/src/renderer/src/components/settings/AccountsPane.test.tsx @@ -167,4 +167,13 @@ describe('AccountsPane', () => { markup.slice(markup.lastIndexOf(' { + const markup = renderPane(getDefaultSettings('/tmp')) + + expect(markup).toContain('__Host-console_session') + expect(markup).toContain('auth=…; __Host-console_session=…') + expect(markup).toContain('auth cookie still covers workspace discovery') + expect(markup).not.toContain('Fe26.2**… token or auth=Fe26.2**… header') + }) }) diff --git a/src/renderer/src/components/settings/accounts-pane-provider-setting-sections.tsx b/src/renderer/src/components/settings/accounts-pane-provider-setting-sections.tsx index 6bf03df51f4..5d3a8953eca 100644 --- a/src/renderer/src/components/settings/accounts-pane-provider-setting-sections.tsx +++ b/src/renderer/src/components/settings/accounts-pane-provider-setting-sections.tsx @@ -101,10 +101,10 @@ export function renderOpenCodeAccountsSection(model: AccountsPaneSectionModel): 'OpenCode Go Session Cookie' )} description={translate( - 'auto.components.settings.AccountsPane.b2b1aa936d', - 'Paste your opencode.ai session cookie for rate limit fetching.' + 'auto.components.settings.AccountsPane.0335bd31d5', + 'Paste the full opencode.ai Cookie header, including __Host-console_session, for rate limit fetching.' )} - keywords={['opencode', 'cookie', 'session', 'rate limit', 'status bar']} + keywords={['opencode', 'cookie', 'session', 'console', 'rate limit', 'status bar']} className="space-y-2" >