Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 45 additions & 6 deletions src/main/rate-limits/opencode-go-usage-fetcher.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'

const netFetchMock = vi.hoisted(() => vi.fn())
const cookiesSetMock = vi.hoisted(() => vi.fn())
const clearStorageDataMock = vi.hoisted(() => vi.fn())
const fromPartitionMock = vi.hoisted(() => vi.fn())

vi.mock('electron', () => ({
net: { fetch: netFetchMock }
session: { fromPartition: fromPartitionMock }
}))

import { fetchOpenCodeGoRateLimits, normalizeCookieInput } from './opencode-go-usage-fetcher'
Expand Down Expand Up @@ -42,6 +45,13 @@ describe('fetchOpenCodeGoRateLimits', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-04-24T12:00:00.000Z'))
netFetchMock.mockReset()
cookiesSetMock.mockReset().mockResolvedValue(undefined)
clearStorageDataMock.mockReset().mockResolvedValue(undefined)
fromPartitionMock.mockReset().mockReturnValue({
fetch: netFetchMock,
cookies: { set: cookiesSetMock },
clearStorageData: clearStorageDataMock
})
})

it('returns unavailable when cookie is empty', async () => {
Expand Down Expand Up @@ -112,8 +122,9 @@ describe('fetchOpenCodeGoRateLimits', () => {
const result = await fetchOpenCodeGoRateLimits('Fe26.2**baretoken')

expect(result.status).toBe('ok')
// Cookie sent to the server must be auth=<token>, not the bare value.
expect(netFetchMock.mock.calls[0][1].headers.Cookie).toBe('auth=Fe26.2**baretoken')
expect(cookiesSetMock).toHaveBeenCalledWith(
expect.objectContaining({ name: 'auth', value: 'Fe26.2**baretoken' })
)
})

it('uses GET /_server?id=<hash> with correct headers for workspaces', async () => {
Expand All @@ -129,11 +140,37 @@ describe('fetchOpenCodeGoRateLimits', () => {
expect.objectContaining({
method: 'GET',
headers: expect.objectContaining({
Cookie: 'auth=mytoken',
'X-Server-Id': WORKSPACES_SERVER_ID
})
})
)
expect(netFetchMock.mock.calls[0][1].headers).not.toHaveProperty('Cookie')
})

it('uses an isolated session cookie jar and clears it after fetching', async () => {
netFetchMock
.mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE))
.mockResolvedValueOnce(makeResponse(USAGE_PAGE_WITH_MONTHLY))

await fetchOpenCodeGoRateLimits('auth=mytoken')

expect(fromPartitionMock).toHaveBeenCalledWith('orca-opencode-go-rate-limit-fetch')
expect(clearStorageDataMock).toHaveBeenCalledTimes(2)
expect(clearStorageDataMock).toHaveBeenLastCalledWith({
origin: 'https://opencode.ai',
storages: ['cookies']
})
})

it('clears partially installed cookies when cookie setup fails', async () => {
cookiesSetMock.mockRejectedValueOnce(new Error('cookie rejected'))

const result = await fetchOpenCodeGoRateLimits('auth=mytoken')

expect(result.status).toBe('error')
expect(result.error).toBe('cookie rejected')
expect(clearStorageDataMock).toHaveBeenCalledTimes(2)
expect(netFetchMock).not.toHaveBeenCalled()
})

it('fetches usage from /workspace/<id>/go after resolving workspace ID', async () => {
Expand Down Expand Up @@ -284,8 +321,10 @@ describe('fetchOpenCodeGoRateLimits', () => {

await fetchOpenCodeGoRateLimits('session=secret; auth=realtoken; tracking=xyz')

const firstCall = netFetchMock.mock.calls[0]
expect(firstCall[1].headers.Cookie).toBe('auth=realtoken')
expect(cookiesSetMock).toHaveBeenCalledTimes(1)
expect(cookiesSetMock).toHaveBeenCalledWith(
expect.objectContaining({ name: 'auth', value: 'realtoken' })
)
})

it('returns error on 404 from workspaces fetch', async () => {
Expand Down
87 changes: 75 additions & 12 deletions src/main/rate-limits/opencode-go-usage-fetcher.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { net } from 'electron'
import { session, type Session } from 'electron'
import { randomUUID } from 'node:crypto'
import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types'
import { parseSubscriptionFromPageText } from './opencode-go-page-scraper'

const OPENCODE_BASE_URL = 'https://opencode.ai'
const OPENCODE_SERVER_URL = 'https://opencode.ai/_server'
const API_TIMEOUT_MS = 15_000
const OPENCODE_SESSION_PARTITION = 'orca-opencode-go-rate-limit-fetch'

// Server-function hash for the workspaces endpoint — stable identifier used by
// the opencode.ai SST/TanStack router server-fn protocol.
Expand Down Expand Up @@ -36,18 +37,48 @@ export function normalizeCookieInput(raw: string): string {
return trimmed
}

function filterAuthCookie(raw: string): string {
function parseAuthCookies(raw: string): { name: string; value: string }[] {
return raw
.split(';')
.map((p) => p.trim())
.filter((pair) => {
.map((pair) => {
const eq = pair.indexOf('=')
if (eq < 0) {
return false
return null
}
return AUTH_COOKIE_NAMES.has(pair.slice(0, eq).trim())
const name = pair.slice(0, eq).trim()
const value = pair.slice(eq + 1).trim()
return AUTH_COOKIE_NAMES.has(name) && value ? { name, value } : null
})
.join('; ')
.filter((pair): pair is { name: string; value: string } => pair !== null)
}

async function clearOpenCodeCookies(openCodeSession: Session): Promise<void> {
await openCodeSession.clearStorageData({ origin: OPENCODE_BASE_URL, storages: ['cookies'] })
}

async function createOpenCodeRequestSession(
authCookies: { name: string; value: string }[]
): Promise<Session> {
const openCodeSession = session.fromPartition(OPENCODE_SESSION_PARTITION)
await clearOpenCodeCookies(openCodeSession)
try {
await Promise.all(
authCookies.map(({ name, value }) =>
openCodeSession.cookies.set({
url: OPENCODE_BASE_URL,
name,
value,
secure: true,
path: '/'
})
)
)
return openCodeSession
} catch (error) {
await clearOpenCodeCookies(openCodeSession).catch(() => undefined)
throw error
}
}

function parseWorkspaceIds(text: string): string[] {
Expand Down Expand Up @@ -99,8 +130,8 @@ export async function fetchOpenCodeGoRateLimits(
}

// Filter to only auth cookies — avoids sending unrelated session data.
const cookieHeader = filterAuthCookie(normalizedCookie)
if (!cookieHeader) {
const authCookies = parseAuthCookies(normalizedCookie)
if (authCookies.length === 0) {
return {
provider: 'opencode-go',
session: null,
Expand All @@ -112,6 +143,40 @@ export async function fetchOpenCodeGoRateLimits(
}
}

// Why: Chromium can reject a manually supplied Cookie header on Windows.
// An isolated session jar lets its network stack attach auth normally.
let openCodeSession: Session
try {
openCodeSession = await createOpenCodeRequestSession(authCookies)
} catch (error) {
return makeOpenCodeError(error)
}

try {
return await fetchOpenCodeGoRateLimitsWithSession(openCodeSession, workspaceIdOverride)
} finally {
await clearOpenCodeCookies(openCodeSession).catch((error: unknown) => {
console.warn('[opencode-go] failed to clear session cookie jar after fetch', error)
})
}
}

function makeOpenCodeError(error: unknown): ProviderRateLimits {
return {
provider: 'opencode-go',
session: null,
weekly: null,
monthly: null,
updatedAt: Date.now(),
error: error instanceof Error ? error.message : 'Unknown error',
status: 'error'
}
}

async function fetchOpenCodeGoRateLimitsWithSession(
openCodeSession: Session,
workspaceIdOverride?: string
): Promise<ProviderRateLimits> {
// Step 1: resolve workspace IDs to try.
let ids: string[] = []
const override = workspaceIdOverride?.trim()
Expand All @@ -135,10 +200,9 @@ export async function fetchOpenCodeGoRateLimits(
// and X-Server-Id / X-Server-Instance headers for routing.
const instanceId = `server-fn:${randomUUID()}`
const workspacesUrl = `${OPENCODE_SERVER_URL}?id=${WORKSPACES_SERVER_ID}`
const workspacesRes = await net.fetch(workspacesUrl, {
const workspacesRes = await openCodeSession.fetch(workspacesUrl, {
method: 'GET',
headers: {
Cookie: cookieHeader,
'X-Server-Id': WORKSPACES_SERVER_ID,
'X-Server-Instance': instanceId,
Accept: 'text/javascript, application/json;q=0.9, */*;q=0.8',
Expand Down Expand Up @@ -195,10 +259,9 @@ export async function fetchOpenCodeGoRateLimits(
for (const candidateId of ids) {
try {
const usagePageUrl = `${OPENCODE_BASE_URL}/workspace/${candidateId}/go`
const pageRes = await net.fetch(usagePageUrl, {
const pageRes = await openCodeSession.fetch(usagePageUrl, {
method: 'GET',
headers: {
Cookie: cookieHeader,
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
Origin: OPENCODE_BASE_URL,
Referer: OPENCODE_BASE_URL
Expand Down
Loading