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 e6057c52571..acde11260e9 100644 --- a/src/main/rate-limits/opencode-go-usage-fetcher.test.ts +++ b/src/main/rate-limits/opencode-go-usage-fetcher.test.ts @@ -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' @@ -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 () => { @@ -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=, 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= with correct headers for workspaces', async () => { @@ -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//go after resolving workspace ID', async () => { @@ -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 () => { diff --git a/src/main/rate-limits/opencode-go-usage-fetcher.ts b/src/main/rate-limits/opencode-go-usage-fetcher.ts index bc0aade4c3b..a778857a27e 100644 --- a/src/main/rate-limits/opencode-go-usage-fetcher.ts +++ b/src/main/rate-limits/opencode-go-usage-fetcher.ts @@ -1,4 +1,4 @@ -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' @@ -6,6 +6,7 @@ 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. @@ -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 { + await openCodeSession.clearStorageData({ origin: OPENCODE_BASE_URL, storages: ['cookies'] }) +} + +async function createOpenCodeRequestSession( + authCookies: { name: string; value: string }[] +): Promise { + 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[] { @@ -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, @@ -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 { // Step 1: resolve workspace IDs to try. let ids: string[] = [] const override = workspaceIdOverride?.trim() @@ -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', @@ -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