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.
This commit is contained in:
Wooseong Kim
2026-09-20 20:12:08 -07:00
committed by GitHub
parent 646fa3645f
commit 7a6d10064e
10 changed files with 447 additions and 330 deletions
@@ -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
}
}
@@ -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('<html>rollingUsage:{usagePercent:30}</html>')).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()
})
})
@@ -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<string, unknown> {
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)
}
}
@@ -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 = `
<html><body><script>
$RC=function(a,b){/*...*/};
$R[20]={rollingUsage:$R[21]={status:"ok",resetInSec:7200,usagePercent:30},weeklyUsage:$R[22]={status:"ok",resetInSec:259200,usagePercent:51},monthlyUsage:$R[23]={status:"ok",resetInSec:1296000,usagePercent:89}};
$R[14]={customerID:"cus_ABC",reloadTrigger:5,monthlyLimit:null,monthlyUsage:null,timeMonthlyUsageUpdated:null};
</script></body></html>
`
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 = `
<html><body><script>
$R[20]={rollingUsage:$R[21]={status:"ok",resetInSec:3600,usagePercent:10},weeklyUsage:$R[22]={status:"ok",resetInSec:86400,usagePercent:20}};
$R[20]={rollingUsage:$R[21]={status:"ok",resetInSec:7200,usagePercent:30},weeklyUsage:$R[22]={status:"ok",resetInSec:259200,usagePercent:51},monthlyUsage:$R[23]={status:"ok",resetInSec:1296000,usagePercent:89}};
</script></body></html>
`
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=<token>)', 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=<hash> 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/<id>/go after resolving workspace ID', async () => {
it('fetches usage from /console/api/go/status with x-org-id and never scrapes /workspace/<id>/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('<html>no usage data here</html>'))
.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 () => {
@@ -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/<id>/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
@@ -167,4 +167,13 @@ describe('AccountsPane', () => {
markup.slice(markup.lastIndexOf('<button', addAccountIndex), addAccountIndex)
).not.toContain('disabled=""')
})
it('tells users to paste the OpenCode console session cookie, not auth alone', () => {
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')
})
})
@@ -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"
>
<Label>
@@ -120,8 +120,8 @@ export function renderOpenCodeAccountsSection(model: AccountsPaneSectionModel):
onEdit={() => recordOpenCodeSettingEdit('cookie')}
commit={(opencodeSessionCookie) => updateSettings({ opencodeSessionCookie })}
placeholder={translate(
'auto.components.settings.AccountsPane.a7e38affcd',
'Fe26.2**… token or auth=Fe26.2**… header'
'auto.components.settings.AccountsPane.37b4b4a3f7',
'auth=…; __Host-console_session=…'
)}
spellCheck={false}
className="flex-1 text-xs"
@@ -142,22 +142,13 @@ export function renderOpenCodeAccountsSection(model: AccountsPaneSectionModel):
</div>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.AccountsPane.0023cc336e',
'Paste either the raw token value (e.g.'
'auto.components.settings.AccountsPane.62ab430f94',
"Paste the full Cookie header from your browser's DevTools → Network → any opencode.ai request, including __Host-console_session (e.g."
)}{' '}
<code className="text-xs">
{translate('auto.components.settings.AccountsPane.922b51e02d', 'Fe26.2**…')}
</code>
<code className="text-xs">auth=; __Host-console_session=</code>
{translate(
'auto.components.settings.AccountsPane.338820326a',
') or the full cookie header (e.g.'
)}{' '}
<code className="text-xs">
{translate('auto.components.settings.AccountsPane.8951c5309f', 'auth=Fe26.2**…')}
</code>
{translate(
'auto.components.settings.AccountsPane.7ce0e1907c',
"). Find it in your browser's DevTools → Network → any opencode.ai request → Cookie header. OpenCode Go auth is web-based and shared across Windows and WSL terminals."
'auto.components.settings.AccountsPane.d5267cce63',
'). The auth cookie still covers workspace discovery; auth alone is not enough for usage. OpenCode Go auth is web-based and shared across Windows and WSL terminals.'
)}
</p>
</SearchableSetting>
@@ -15,7 +15,11 @@ vi.mock('./settings-search-keywords', () => ({
translateSearchKeyword: (_key: string, fallback: string) => [fallback]
}))
import { getAccountsMiniMaxSearchEntries, getAccountsPaneSearchEntries } from './accounts-search'
import {
getAccountsMiniMaxSearchEntries,
getAccountsOpencodeSearchEntries,
getAccountsPaneSearchEntries
} from './accounts-search'
describe('getAccountsMiniMaxSearchEntries', () => {
it('returns a single entry that targets the MiniMax session cookie flow', () => {
@@ -42,3 +46,19 @@ describe('getAccountsMiniMaxSearchEntries', () => {
expect(titles).toContain('MiniMax Usage')
})
})
describe('getAccountsOpencodeSearchEntries', () => {
it('tells search to paste the full Cookie header including the console session', () => {
const cookieEntry = getAccountsOpencodeSearchEntries().find(
(entry) => entry.title === 'OpenCode Go Session Cookie'
)
expect(cookieEntry).toBeDefined()
expect(cookieEntry?.description).toContain('__Host-console_session')
expect(cookieEntry?.description).toContain('Cookie header')
expect(cookieEntry?.description).not.toMatch(/Fe26\.2\*\*/)
expect(cookieEntry?.keywords).toEqual(
expect.arrayContaining(['opencode', 'cookie', 'session', 'console', 'rate limit'])
)
})
})
@@ -134,13 +134,14 @@ export const getAccountsOpencodeSearchEntries = createLocalizedCatalog(() => [
'OpenCode Go Session Cookie'
),
description: translate(
'auto.components.settings.accounts.search.d1d2ae383c',
'Paste your opencode.ai session cookie for rate limit fetching.'
'auto.components.settings.accounts.search.25591bf95b',
'Paste the full opencode.ai Cookie header, including __Host-console_session, for rate limit fetching.'
),
keywords: [
...translateSearchKeyword('auto.components.settings.accounts.search.8dcbef1856', 'opencode'),
...translateSearchKeyword('auto.components.settings.accounts.search.61f7d1fcbe', 'cookie'),
...translateSearchKeyword('auto.components.settings.accounts.search.9c4e40cf6b', 'session'),
...translateSearchKeyword('auto.components.settings.accounts.search.37020a02c2', 'console'),
...translateSearchKeyword(
'auto.components.settings.accounts.search.e949b08ffb',
'rate limit'
+6
View File
@@ -6452,7 +6452,11 @@
"922b51e02d": "Fe26.2**…",
"0023cc336e": "Paste either the raw token value (e.g.",
"a7e38affcd": "Fe26.2**… token or auth=Fe26.2**… header",
"d5267cce63": "). The auth cookie still covers workspace discovery; auth alone is not enough for usage. OpenCode Go auth is web-based and shared across Windows and WSL terminals.",
"62ab430f94": "Paste the full Cookie header from your browser's DevTools → Network → any opencode.ai request, including __Host-console_session (e.g.",
"37b4b4a3f7": "auth=…; __Host-console_session=…",
"67e3c33670": "OpenCode Go session cookie",
"0335bd31d5": "Paste the full opencode.ai Cookie header, including __Host-console_session, for rate limit fetching.",
"b2b1aa936d": "Paste your opencode.ai session cookie for rate limit fetching.",
"36223200ac": "OpenCode Go Session Cookie",
"ea631977b5": "Configure OpenCode Go provider settings.",
@@ -8927,6 +8931,8 @@
"4ee2029e9c": "OpenCode Go Workspace ID",
"9c4e40cf6b": "session",
"61f7d1fcbe": "cookie",
"37020a02c2": "console",
"25591bf95b": "Paste the full opencode.ai Cookie header, including __Host-console_session, for rate limit fetching.",
"d1d2ae383c": "Paste your opencode.ai session cookie for rate limit fetching.",
"6ed1401020": "OpenCode Go Session Cookie",
"b7c2cee442": "experimental",