From a1da632eb9ea358c15f756e7541fe4f25f0eebbe Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:00:27 -0400 Subject: [PATCH] fix(opencode-go): don't fall back to OPENCODE_API_KEY when the credential database is unreadable (#24605) * fix(opencode-go): stop before OPENCODE_API_KEY when the credential database is unreadable An unreadable OpenCode credential database read as 'no key', so the Go key resolver fell through to OPENCODE_API_KEY, which OpenCode shares with its Zen provider and can show usage for the wrong key. The database read now reports unreadable separately; with an env key set the resolver stops, and the usage fetch uses a configured cookie or shows a readable error. * fix(opencode-go): treat a denied credential-database listing as unreadable, not missing --- .../opencode-database-discovery.ts | 15 ++- .../opencode-go-api-key-source.test.ts | 94 ++++++++++++++++++- .../rate-limits/opencode-go-api-key-source.ts | 54 ++++++++--- ...opencode-go-usage-source-selection.test.ts | 22 +++++ .../opencode-go-usage-source-selection.ts | 9 +- 5 files changed, 175 insertions(+), 19 deletions(-) diff --git a/src/main/opencode-usage/opencode-database-discovery.ts b/src/main/opencode-usage/opencode-database-discovery.ts index fb406dc7a79..7f7fea72ecf 100644 --- a/src/main/opencode-usage/opencode-database-discovery.ts +++ b/src/main/opencode-usage/opencode-database-discovery.ts @@ -30,7 +30,9 @@ function getOpenCodeDatabaseOverride(dataDirectory: string): OpenCodeDatabaseOve export async function listOpenCodeDatabases( /** Lets a caller report the refusal; an empty list otherwise reads as * "OpenCode not used" rather than "we could not look". */ - onRefusal?: (path: string, error: WslTranscriptFsError) => void + onRefusal?: (path: string, error: WslTranscriptFsError) => void, + /** Every other stat/readdir failure, including ENOENT; also read as an empty list. */ + onFsError?: (path: string, error: unknown) => void ): Promise { const dataDirectory = resolveOpenCodeDataDirectory() const databaseOverride = getOpenCodeDatabaseOverride(dataDirectory) @@ -43,7 +45,7 @@ export async function listOpenCodeDatabases( ? [databaseOverride.path] : [] } catch (error) { - reportRefusal(databaseOverride.path, error, onRefusal) + reportFailure(databaseOverride.path, error, onRefusal, onFsError) return [] } } @@ -55,18 +57,21 @@ export async function listOpenCodeDatabases( .map((entry) => join(dataDirectory, entry.name)) .sort() } catch (error) { - reportRefusal(dataDirectory, error, onRefusal) + reportFailure(dataDirectory, error, onRefusal, onFsError) return [] } } -function reportRefusal( +function reportFailure( path: string, error: unknown, - onRefusal?: (path: string, error: WslTranscriptFsError) => void + onRefusal?: (path: string, error: WslTranscriptFsError) => void, + onFsError?: (path: string, error: unknown) => void ): void { if (error instanceof WslTranscriptFsError) { onRefusal?.(path, error) + } else { + onFsError?.(path, error) } } diff --git a/src/main/rate-limits/opencode-go-api-key-source.test.ts b/src/main/rate-limits/opencode-go-api-key-source.test.ts index aadbf42a2ab..d1842792eda 100644 --- a/src/main/rate-limits/opencode-go-api-key-source.test.ts +++ b/src/main/rate-limits/opencode-go-api-key-source.test.ts @@ -1,7 +1,9 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as WslTranscriptFsAccess from '../native-chat/wsl-transcript-fs-access' +import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-error' import Database from '../sqlite/sync-database' import { getOpenCodeAuthFilePath, @@ -17,6 +19,17 @@ const DATABASE_KEY = 'database-placeholder-key' const ENVIRONMENT_KEYS = ['XDG_DATA_HOME', 'OPENCODE_API_KEY', 'OPENCODE_DB'] as const +// Lets a test fail the data-directory listing with an error the host filesystem cannot portably produce. +const readdirFailure = vi.hoisted((): { error: unknown } => ({ error: null })) +vi.mock('../native-chat/wsl-transcript-fs-access', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + wslGatedReaddir: (...args: Parameters) => + readdirFailure.error ? Promise.reject(readdirFailure.error) : actual.wslGatedReaddir(...args) + } +}) + describe('resolveOpenCodeGoApiKey', () => { let dataHome: string let originalEnvironment: Partial> @@ -57,6 +70,7 @@ describe('resolveOpenCodeGoApiKey', () => { }) afterEach(() => { + readdirFailure.error = null for (const key of ENVIRONMENT_KEYS) { const value = originalEnvironment[key] if (value === undefined) { @@ -139,6 +153,84 @@ describe('resolveOpenCodeGoApiKey', () => { }) }) + it('does not fall back to OPENCODE_API_KEY when the credential database is unreadable', async () => { + process.env.OPENCODE_API_KEY = ENVIRONMENT_KEY + const path = join(dataHome, 'opencode-unreadable.db') + writeFileSync(path, 'not a sqlite database') + process.env.OPENCODE_DB = path + + await expect(resolveOpenCodeGoApiKey({})).resolves.toEqual({ + status: 'credential-database-unreadable' + }) + }) + + it('does not fall back to OPENCODE_API_KEY when the data directory cannot be listed', async () => { + process.env.OPENCODE_API_KEY = ENVIRONMENT_KEY + delete process.env.OPENCODE_DB + readdirFailure.error = Object.assign(new Error('permission denied'), { code: 'EACCES' }) + + await expect(resolveOpenCodeGoApiKey({})).resolves.toEqual({ + status: 'credential-database-unreadable' + }) + }) + + it('still falls back to OPENCODE_API_KEY when the data directory does not exist', async () => { + process.env.OPENCODE_API_KEY = ENVIRONMENT_KEY + delete process.env.OPENCODE_DB + + await expect(resolveOpenCodeGoApiKey({})).resolves.toEqual({ + status: 'found', + key: ENVIRONMENT_KEY, + tier: 'environment' + }) + }) + + it('still falls back to OPENCODE_API_KEY when the WSL gate refuses the listing', async () => { + process.env.OPENCODE_API_KEY = ENVIRONMENT_KEY + delete process.env.OPENCODE_DB + readdirFailure.error = new WslTranscriptFsError('timeout', 'slow') + + await expect(resolveOpenCodeGoApiKey({})).resolves.toEqual({ + status: 'found', + key: ENVIRONMENT_KEY, + tier: 'environment' + }) + }) + + it('reports missing for an unreadable database when no env key could be misused', async () => { + const path = join(dataHome, 'opencode-unreadable.db') + writeFileSync(path, 'not a sqlite database') + process.env.OPENCODE_DB = path + + await expect(resolveOpenCodeGoApiKey({})).resolves.toEqual({ status: 'missing' }) + }) + + it('still falls back to OPENCODE_API_KEY when a readable database holds no Go key', async () => { + process.env.OPENCODE_API_KEY = ENVIRONMENT_KEY + const { path } = writeCredentialDatabase([]) + process.env.OPENCODE_DB = path + + await expect(resolveOpenCodeGoApiKey({})).resolves.toEqual({ + status: 'found', + key: ENVIRONMENT_KEY, + tier: 'environment' + }) + }) + + it('prefers a database key over OPENCODE_API_KEY', async () => { + process.env.OPENCODE_API_KEY = ENVIRONMENT_KEY + const { path } = writeCredentialDatabase([ + { value: JSON.stringify({ type: 'key', key: DATABASE_KEY }), active: 1, created: 1 } + ]) + process.env.OPENCODE_DB = path + + await expect(resolveOpenCodeGoApiKey({})).resolves.toEqual({ + status: 'found', + key: DATABASE_KEY, + tier: 'opencode-credential-database' + }) + }) + it('reports missing when no tier holds a key', async () => { writeAuthFile({ 'opencode-go': { type: 'oauth', refresh: 'r', access: 'a', expires: 1 } }) diff --git a/src/main/rate-limits/opencode-go-api-key-source.ts b/src/main/rate-limits/opencode-go-api-key-source.ts index 545bd058d8e..f722e4cbeaa 100644 --- a/src/main/rate-limits/opencode-go-api-key-source.ts +++ b/src/main/rate-limits/opencode-go-api-key-source.ts @@ -26,6 +26,13 @@ export type OpenCodeGoApiKeyTier = export type OpenCodeGoApiKeyResolution = | { status: 'found'; key: string; tier: OpenCodeGoApiKeyTier } | { status: 'missing' } + /** A credential database failed to read while OPENCODE_API_KEY was set. */ + | { status: 'credential-database-unreadable' } + +export type OpenCodeCredentialDatabaseGoKeyRead = + | { status: 'found'; key: string } + | { status: 'missing' } + | { status: 'unreadable' } export function getOpenCodeAuthFilePath( environment: NodeJS.ProcessEnv = process.env, @@ -86,6 +93,14 @@ export function readOpenCodeAuthFileGoKey( } } +function isMissingPathError(error: unknown): boolean { + return ( + error instanceof Error && + 'code' in error && + (error.code === 'ENOENT' || error.code === 'ENOTDIR') + ) +} + function selectCredentialKey(database: Database.Database): string | null { if (!tableExists(database, 'credential')) { return null @@ -122,14 +137,23 @@ function selectCredentialKey(database: Database.Database): string | null { * only there, so a fresh OpenCode 2 install has no `auth.json` entry at all. * The table itself is not a version marker — 1.18.x creates it too (verified * empty on a real 1.18.16 install), so probe it regardless of version. - * @returns The key, or null when no database, table, or row carries one. + * @returns The key; `missing` when no database, table, or row carries one; + * `unreadable` when none had a key but discovery failed for a reason other than + * absence, or at least one database failed to open or query. */ -export async function readOpenCodeCredentialDatabaseGoKey(): Promise { +export async function readOpenCodeCredentialDatabaseGoKey(): Promise { + let sawUnreadable = false let paths: string[] try { - paths = [...(await listOpenCodeDatabases())].sort(compareOpenCodeClaimPriority) + const listed = await listOpenCodeDatabases(undefined, (path, error) => { + // A UNC location is never opened here (below), so failing to list it is no evidence either. + if (!isWslUncPath(path) && !isMissingPathError(error)) { + sawUnreadable = true + } + }) + paths = [...listed].sort(compareOpenCodeClaimPriority) } catch { - return null + return { status: 'missing' } } for (const path of paths) { // A synchronous open against a 9p/UNC share can hang the main process, and @@ -143,17 +167,18 @@ export async function readOpenCodeCredentialDatabaseGoKey(): Promise { expect(result.status).toBe('ok') }) + it('reports an unreadable credential database instead of using an env key', async () => { + resolveApiKeyMock.mockResolvedValue({ status: 'credential-database-unreadable' }) + + const result = await fetchOpenCodeGoUsage({ cookie: ' ' }) + + expect(fetchWithApiKeyMock).not.toHaveBeenCalled() + expect(fetchWithCookieMock).not.toHaveBeenCalled() + expect(result.status).toBe('error') + expect(result.usageMetadata?.failureKind).toBe('usage-unavailable') + expect(result.error).toContain("Could not read OpenCode's credential database") + }) + + it('uses the configured cookie when the credential database is unreadable', async () => { + resolveApiKeyMock.mockResolvedValue({ status: 'credential-database-unreadable' }) + fetchWithCookieMock.mockResolvedValue(cookieResult('ok')) + + const result = await fetchOpenCodeGoUsage({ cookie: COOKIE }) + + expect(fetchWithApiKeyMock).not.toHaveBeenCalled() + expect(result.status).toBe('ok') + }) + it('stays unavailable when neither a key nor a cookie is configured', async () => { resolveApiKeyMock.mockResolvedValue({ status: 'missing' }) diff --git a/src/main/rate-limits/opencode-go-usage-source-selection.ts b/src/main/rate-limits/opencode-go-usage-source-selection.ts index 9d5399a585e..61378eddb14 100644 --- a/src/main/rate-limits/opencode-go-usage-source-selection.ts +++ b/src/main/rate-limits/opencode-go-usage-source-selection.ts @@ -101,7 +101,14 @@ export async function fetchOpenCodeGoUsage( }) input.onApiKeyResolved?.(apiKeyResolution) const hasCookie = Boolean(normalizeCookieInput(input.cookie)) - if (apiKeyResolution.status === 'missing') { + if (apiKeyResolution.status === 'credential-database-unreadable' && !hasCookie) { + return emptyResult( + "Could not read OpenCode's credential database, so OPENCODE_API_KEY was not used; it may be a Zen key. Add your OpenCode Go key in Settings, or retry.", + 'error', + { failureKind: 'usage-unavailable' } + ) + } + if (apiKeyResolution.status !== 'found') { return hasCookie ? fetchOpenCodeGoRateLimits( input.cookie,