mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
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:
@@ -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'
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user