diff --git a/src/main/credentials/api-key-file-unreadable-error.ts b/src/main/credentials/api-key-file-unreadable-error.ts new file mode 100644 index 00000000000..9c1af30dc65 --- /dev/null +++ b/src/main/credentials/api-key-file-unreadable-error.ts @@ -0,0 +1,2 @@ +/** A transient read failure; the saved key may be fine, so callers must not ask to re-enter it. */ +export class ApiKeyFileUnreadableError extends Error {} diff --git a/src/main/credentials/encrypted-api-key-file-store.ts b/src/main/credentials/encrypted-api-key-file-store.ts new file mode 100644 index 00000000000..8d6e9f68f7b --- /dev/null +++ b/src/main/credentials/encrypted-api-key-file-store.ts @@ -0,0 +1,177 @@ +import { safeStorage } from 'electron' +import { existsSync, readFileSync, rmSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { + hardenExistingSecureFile, + isUnreadableError, + writeSecureFile +} from '../../shared/secure-file' +import type { SecretAtRestProtection } from '../../shared/secret-at-rest-protection' +import { ApiKeyFileUnreadableError } from './api-key-file-unreadable-error' + +type EncryptedApiKeyFileStore = { + protection: () => SecretAtRestProtection | null + has: () => boolean + save: (key: string) => void + read: () => string | null + clear: () => void +} + +export function createEncryptedApiKeyFileStore({ + fileName, + envelopePrefix, + providerLabel, + logScope +}: { + fileName: string + envelopePrefix: string + providerLabel: string + logScope: string +}): EncryptedApiKeyFileStore { + let cachedApiKey: string | null = null + let warnedStatusHardenFailure = false + + type ApiKeyEnvelope = { + kind: 'encrypted' | 'plaintext' + payload: Buffer + } + + function getOrcaDir(): string { + return join(homedir(), '.orca') + } + + function getApiKeyPath(): string { + return join(getOrcaDir(), fileName) + } + + function encodeApiKeyEnvelope(kind: ApiKeyEnvelope['kind'], payload: Buffer): string { + return `${envelopePrefix}${kind}:${payload.toString('base64')}` + } + + function decodeApiKeyEnvelope(raw: Buffer): ApiKeyEnvelope { + const text = raw.toString('utf8') + if (!text.startsWith(envelopePrefix)) { + throw new Error(`${providerLabel} API key could not be decrypted`) + } + const rest = text.slice(envelopePrefix.length) + const separator = rest.indexOf(':') + if (separator === -1) { + throw new Error(`${providerLabel} API key could not be decrypted`) + } + const kind = rest.slice(0, separator) + if (kind !== 'encrypted' && kind !== 'plaintext') { + throw new Error(`${providerLabel} API key could not be decrypted`) + } + return { + kind, + payload: Buffer.from(rest.slice(separator + 1), 'base64') + } + } + + function readEnvelope(envelope: ApiKeyEnvelope): string { + if (envelope.kind === 'plaintext') { + return envelope.payload.toString('utf8') + } + if (!safeStorage.isEncryptionAvailable()) { + throw new Error(`${providerLabel} API key could not be decrypted`) + } + return safeStorage.decryptString(envelope.payload) + } + + function has(): boolean { + const keyPath = getApiKeyPath() + if (!existsSync(keyPath)) { + return false + } + try { + hardenExistingSecureFile(keyPath) + } catch (error) { + if (!warnedStatusHardenFailure) { + warnedStatusHardenFailure = true + console.warn( + `[${logScope}] Failed to harden ${providerLabel} API key file while checking status`, + error + ) + } + } + return true + } + + function protection(): SecretAtRestProtection | null { + const path = getApiKeyPath() + if (!existsSync(path)) { + return null + } + try { + return decodeApiKeyEnvelope(readFileSync(path)).kind === 'plaintext' ? 'plaintext' : 'sealed' + } catch { + return null + } + } + + function save(key: string): void { + const trimmed = key.trim() + if (!trimmed) { + throw new Error(`${providerLabel} API key is required`) + } + if (safeStorage.isEncryptionAvailable()) { + writeSecureFile( + getApiKeyPath(), + encodeApiKeyEnvelope('encrypted', safeStorage.encryptString(trimmed)), + { durable: true } + ) + cachedApiKey = trimmed + return + } + console.warn( + `[${logScope}] safeStorage encryption unavailable — storing ${providerLabel} API key in plaintext` + ) + writeSecureFile( + getApiKeyPath(), + encodeApiKeyEnvelope('plaintext', Buffer.from(trimmed, 'utf8')), + { durable: true } + ) + cachedApiKey = trimmed + } + + function read(): string | null { + if (cachedApiKey !== null) { + return cachedApiKey + } + const keyPath = getApiKeyPath() + if (!existsSync(keyPath)) { + return null + } + // Why: permission failures must not be reported as decryption failures. + try { + hardenExistingSecureFile(keyPath) + } catch (error) { + console.warn( + `[${logScope}] Failed to harden ${providerLabel} API key file while reading`, + error + ) + } + let raw: Buffer | null = null + try { + raw = readFileSync(keyPath) + const envelope = decodeApiKeyEnvelope(raw) + cachedApiKey = readEnvelope(envelope) + return cachedApiKey + } catch (error) { + if (raw === null && isUnreadableError(error)) { + console.warn(`[${logScope}] failed to read API key file`, error) + throw new ApiKeyFileUnreadableError(`${providerLabel} API key file could not be read`) + } + console.error(`[${logScope}] failed to decode/decrypt API key`, error) + throw new Error(`${providerLabel} API key could not be decrypted`) + } + } + + function clear(): void { + cachedApiKey = null + rmSync(getApiKeyPath(), { force: true }) + } + + return { has, save, read, clear, protection } +} diff --git a/src/main/ipc/credential-change-rate-limit-refresh.test.ts b/src/main/ipc/credential-change-rate-limit-refresh.test.ts new file mode 100644 index 00000000000..98b06918c70 --- /dev/null +++ b/src/main/ipc/credential-change-rate-limit-refresh.test.ts @@ -0,0 +1,40 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createEmptyRateLimitState } from '../../shared/rate-limit-state-factory' +import { refreshAfterCredentialChange } from './credential-change-rate-limit-refresh' + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('refreshAfterCredentialChange', () => { + it('does nothing without a rate-limit service', () => { + const invalidate = vi.fn() + refreshAfterCredentialChange(null, invalidate, '[test] refresh failed:') + expect(invalidate).not.toHaveBeenCalled() + }) + + it('invalidates before refreshing', () => { + const order: string[] = [] + const service = { + refresh: vi.fn(async () => { + order.push('refresh') + return createEmptyRateLimitState() + }) + } + refreshAfterCredentialChange(service, () => order.push('invalidate'), '[test] refresh failed:') + expect(order).toEqual(['invalidate', 'refresh']) + }) + + it('logs a failed background refresh with the caller message', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + const failure = new Error('network down') + const service = { + refresh: vi.fn(async () => { + throw failure + }) + } + + refreshAfterCredentialChange(service, () => {}, '[test] refresh failed:') + await vi.waitFor(() => expect(error).toHaveBeenCalledWith('[test] refresh failed:', failure)) + }) +}) diff --git a/src/main/ipc/credential-change-rate-limit-refresh.ts b/src/main/ipc/credential-change-rate-limit-refresh.ts new file mode 100644 index 00000000000..7e5bf3eaf3e --- /dev/null +++ b/src/main/ipc/credential-change-rate-limit-refresh.ts @@ -0,0 +1,19 @@ +import type { RateLimitService } from '../rate-limits/service' + +/** + * Drops a provider's stale usage, then refreshes in the background. + * Why fire-and-forget: callers return the persisted credential status immediately; a failed refresh only logs. + */ +export function refreshAfterCredentialChange>( + rateLimits: T | null, + invalidate: (rateLimits: T) => void, + failureLogMessage: string +): void { + if (!rateLimits) { + return + } + invalidate(rateLimits) + void rateLimits.refresh().catch((error: unknown) => { + console.error(failureLogMessage, error) + }) +} diff --git a/src/main/ipc/minimax-credentials.ts b/src/main/ipc/minimax-credentials.ts index cac2567db98..c488c1d8def 100644 --- a/src/main/ipc/minimax-credentials.ts +++ b/src/main/ipc/minimax-credentials.ts @@ -12,6 +12,7 @@ import { saveMiniMaxApiKey } from '../minimax/minimax-api-key-store' import { clearMiniMaxSessionCookieJar } from '../rate-limits/minimax/minimax-request-context' +import { refreshAfterCredentialChange } from './credential-change-rate-limit-refresh' import type { RateLimitService } from '../rate-limits/service' import type { SecretAtRestProtection } from '../../shared/secret-at-rest-protection' @@ -36,16 +37,15 @@ function getMiniMaxCredentialsStatus(): MiniMaxCredentialsStatus { } } -// Why: fire-and-forget — callers get the persisted credential status immediately; -// the rate-limit refresh runs in the background and only logs on failure. function refreshAfterMiniMaxCredentialChange( rateLimits: RateLimitService | null, action: 'save' | 'clear' ): void { - rateLimits?.invalidateMiniMaxCredentialState() - void rateLimits?.refresh().catch((error: unknown) => { - console.error(`[minimax] failed to trigger rate-limit refresh after ${action}:`, error) - }) + refreshAfterCredentialChange( + rateLimits, + (service) => service.invalidateMiniMaxCredentialState(), + `[minimax] failed to trigger rate-limit refresh after ${action}:` + ) } export function registerMiniMaxCredentialsHandlers(rateLimits: RateLimitService | null): void { diff --git a/src/main/ipc/opencode-go-credentials.test.ts b/src/main/ipc/opencode-go-credentials.test.ts new file mode 100644 index 00000000000..a90438127b7 --- /dev/null +++ b/src/main/ipc/opencode-go-credentials.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createEmptyRateLimitState } from '../../shared/rate-limit-state-factory' +import { registerOpenCodeGoCredentialsHandlers } from './opencode-go-credentials' + +const mocks = vi.hoisted(() => ({ + handlers: new Map unknown>(), + has: vi.fn(() => false), + save: vi.fn(), + clear: vi.fn() +})) +vi.mock('electron', () => ({ + ipcMain: { + handle: (channel: string, handler: (event: unknown, key?: unknown) => unknown) => { + mocks.handlers.set(channel, handler) + } + } +})) +vi.mock('../opencode/opencode-go-api-key-store', () => ({ + hasOpenCodeGoApiKey: mocks.has, + saveOpenCodeGoApiKey: mocks.save, + clearOpenCodeGoApiKey: mocks.clear +})) + +function invoke(action: string, value?: unknown): unknown { + const handler = mocks.handlers.get(`opencodeGoCredentials:${action}`) + if (!handler) { + throw new Error('Missing credential handler') + } + return handler({}, value) +} + +beforeEach(() => { + vi.resetAllMocks() + mocks.handlers.clear() +}) + +describe('OpenCode Go write-only credentials', () => { + it('returns only boolean status and provides no key reader', () => { + registerOpenCodeGoCredentialsHandlers(null) + mocks.has.mockReturnValue(true) + expect(invoke('getStatus')).toEqual({ apiKeyConfigured: true }) + expect([...mocks.handlers.keys()]).toEqual([ + 'opencodeGoCredentials:getStatus', + 'opencodeGoCredentials:saveApiKey', + 'opencodeGoCredentials:clearApiKey' + ]) + }) + + it.each(['saveApiKey', 'clearApiKey'])('invalidates before refreshing on %s', (action) => { + const invalidate = vi.fn() + const refresh = vi.fn(async () => { + expect(invalidate).toHaveBeenCalledOnce() + return createEmptyRateLimitState() + }) + registerOpenCodeGoCredentialsHandlers({ + invalidateOpenCodeGoCredentialState: invalidate, + refresh + }) + mocks.has.mockReturnValue(action === 'saveApiKey') + expect(invoke(action, 'fake-key')).toEqual({ apiKeyConfigured: action === 'saveApiKey' }) + expect(action === 'saveApiKey' ? mocks.save : mocks.clear).toHaveBeenCalledOnce() + expect(refresh).toHaveBeenCalledOnce() + // Why: only clearing the saved key may hide the chip; another key source can still exist after a save. + expect(invalidate).toHaveBeenCalledWith({ apiKeyCleared: action === 'clearApiKey' }) + }) + + it.each([null, undefined, 42, {}])('rejects a non-string key', (key) => { + registerOpenCodeGoCredentialsHandlers(null) + expect(() => invoke('saveApiKey', key)).toThrow('OpenCode Go API key must be a string') + expect(mocks.save).not.toHaveBeenCalled() + }) + + it('does not refresh after a failed save', () => { + const invalidate = vi.fn() + const refresh = vi.fn() + registerOpenCodeGoCredentialsHandlers({ + invalidateOpenCodeGoCredentialState: invalidate, + refresh + }) + mocks.save.mockImplementation(() => { + throw new Error('Could not save credential') + }) + expect(() => invoke('saveApiKey', 'fake-key')).toThrow('Could not save credential') + expect(invalidate).not.toHaveBeenCalled() + expect(refresh).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/opencode-go-credentials.ts b/src/main/ipc/opencode-go-credentials.ts new file mode 100644 index 00000000000..58788257714 --- /dev/null +++ b/src/main/ipc/opencode-go-credentials.ts @@ -0,0 +1,47 @@ +import { ipcMain } from 'electron' +import { + clearOpenCodeGoApiKey, + hasOpenCodeGoApiKey, + saveOpenCodeGoApiKey +} from '../opencode/opencode-go-api-key-store' +import type { RateLimitService } from '../rate-limits/service' +import { refreshAfterCredentialChange } from './credential-change-rate-limit-refresh' + +type CredentialRateLimits = Pick< + RateLimitService, + 'invalidateOpenCodeGoCredentialState' | 'refresh' +> + +function getOpenCodeGoCredentialsStatus(): { apiKeyConfigured: boolean } { + return { apiKeyConfigured: hasOpenCodeGoApiKey() } +} + +function refreshAfterOpenCodeGoCredentialChange( + rateLimits: CredentialRateLimits | null, + apiKeyCleared: boolean +): void { + refreshAfterCredentialChange( + rateLimits, + (service) => service.invalidateOpenCodeGoCredentialState({ apiKeyCleared }), + '[opencode-go] failed to refresh usage after a credential change:' + ) +} + +export function registerOpenCodeGoCredentialsHandlers( + rateLimits: CredentialRateLimits | null +): void { + ipcMain.handle('opencodeGoCredentials:getStatus', () => getOpenCodeGoCredentialsStatus()) + ipcMain.handle('opencodeGoCredentials:saveApiKey', (_event, key: unknown) => { + if (typeof key !== 'string') { + throw new Error('OpenCode Go API key must be a string') + } + saveOpenCodeGoApiKey(key) + refreshAfterOpenCodeGoCredentialChange(rateLimits, false) + return getOpenCodeGoCredentialsStatus() + }) + ipcMain.handle('opencodeGoCredentials:clearApiKey', () => { + clearOpenCodeGoApiKey() + refreshAfterOpenCodeGoCredentialChange(rateLimits, true) + return getOpenCodeGoCredentialsStatus() + }) +} diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers/register-core-handlers.test.ts index 99d459501aa..7620a07c8db 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.test.ts @@ -35,6 +35,7 @@ const { registerCodexAccountHandlersMock, registerAgentHookHandlersMock, registerClaudeAccountHandlersMock, + registerOpenCodeGoCredentialsHandlersMock, registerMiniMaxCredentialsHandlersMock, registerZcodePlanCredentialsHandlersMock, registerGrokAccountHandlersMock, @@ -103,6 +104,7 @@ const { registerCodexAccountHandlersMock: vi.fn(), registerAgentHookHandlersMock: vi.fn(), registerClaudeAccountHandlersMock: vi.fn(), + registerOpenCodeGoCredentialsHandlersMock: vi.fn(), registerMiniMaxCredentialsHandlersMock: vi.fn(), registerZcodePlanCredentialsHandlersMock: vi.fn(), registerGrokAccountHandlersMock: vi.fn(), @@ -334,6 +336,10 @@ vi.mock('../claude-accounts', () => ({ registerClaudeAccountHandlers: registerClaudeAccountHandlersMock })) +vi.mock('../opencode-go-credentials', () => ({ + registerOpenCodeGoCredentialsHandlers: registerOpenCodeGoCredentialsHandlersMock +})) + vi.mock('../minimax-credentials', () => ({ registerMiniMaxCredentialsHandlers: registerMiniMaxCredentialsHandlersMock })) @@ -446,6 +452,7 @@ describe('registerCoreHandlers', () => { registerCodexAccountHandlersMock.mockReset() registerAgentHookHandlersMock.mockReset() registerClaudeAccountHandlersMock.mockReset() + registerOpenCodeGoCredentialsHandlersMock.mockReset() registerMiniMaxCredentialsHandlersMock.mockReset() registerZcodePlanCredentialsHandlersMock.mockReset() registerClipboardHandlersMock.mockReset() @@ -546,6 +553,7 @@ describe('registerCoreHandlers', () => { ) expect(registerPetHandlersMock).toHaveBeenCalled() expect(registerClaudeAccountHandlersMock).toHaveBeenCalledWith(claudeAccounts) + expect(registerOpenCodeGoCredentialsHandlersMock).toHaveBeenCalledWith(rateLimits) expect(registerMiniMaxCredentialsHandlersMock).toHaveBeenCalledWith(rateLimits) expect(registerZcodePlanCredentialsHandlersMock).toHaveBeenCalledWith(rateLimits) expect(registerGrokAccountHandlersMock).toHaveBeenCalled() diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.ts b/src/main/ipc/register-core-handlers/register-core-handlers.ts index 618dab409d9..45a3ecb390f 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.ts @@ -61,6 +61,7 @@ import { registerAgentHookHandlers } from '../agent-hooks' import { registerCodexConfigSyncHandlers } from '../codex-config-sync' import { getPtyIdForPaneKey } from '../pty' import { registerClaudeAccountHandlers } from '../claude-accounts' +import { registerOpenCodeGoCredentialsHandlers } from '../opencode-go-credentials' import { registerMiniMaxCredentialsHandlers } from '../minimax-credentials' import { registerZcodePlanCredentialsHandlers } from '../zcode-plan-credentials' import { registerGrokAccountHandlers } from '../grok-accounts' @@ -151,6 +152,7 @@ export function registerCoreHandlers( registerAgentHookHandlers(runtime, { getPtyIdForPaneKey }) registerCodexConfigSyncHandlers(codexAccounts.runtimeHomeService) registerClaudeAccountHandlers(claudeAccounts) + registerOpenCodeGoCredentialsHandlers(rateLimits) registerMiniMaxCredentialsHandlers(rateLimits) registerZcodePlanCredentialsHandlers(rateLimits) registerGrokAccountHandlers() diff --git a/src/main/minimax/minimax-api-key-store.test.ts b/src/main/minimax/minimax-api-key-store.test.ts index 756ed9069b1..90ac8012fc5 100644 --- a/src/main/minimax/minimax-api-key-store.test.ts +++ b/src/main/minimax/minimax-api-key-store.test.ts @@ -36,6 +36,8 @@ vi.mock('node:path', () => ({ vi.mock('../../shared/secure-file', () => ({ hardenExistingSecureFile: hardenExistingSecureFileMock, + isUnreadableError: (error: unknown) => + error instanceof Error && 'code' in error && error.code === 'EBUSY', writeSecureFile: writeSecureFileMock })) @@ -102,7 +104,8 @@ describe('minimax-api-key-store', () => { expect(safeStorageMock.encryptString).toHaveBeenCalledWith('sk-test-1234567890') expect(writeSecureFileMock).toHaveBeenCalledWith( storePath, - envelope('encrypted', 'sk-test-1234567890') + envelope('encrypted', 'sk-test-1234567890'), + { durable: true } ) }) @@ -114,7 +117,8 @@ describe('minimax-api-key-store', () => { store.saveMiniMaxApiKey('sk-test-1234567890') expect(writeSecureFileMock).toHaveBeenCalledWith( storePath, - envelope('plaintext', 'sk-test-1234567890') + envelope('plaintext', 'sk-test-1234567890'), + { durable: true } ) expect(warn).toHaveBeenCalledWith(expect.stringContaining('safeStorage encryption unavailable')) warn.mockRestore() @@ -211,6 +215,23 @@ describe('minimax-api-key-store', () => { expect(() => store.readMiniMaxApiKey()).toThrow(/could not be decrypted/) }) + it('reports a transient read failure as unreadable, not undecryptable, and retries next read', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined) + existsSyncMock.mockReturnValue(true) + readFileSyncMock.mockImplementationOnce(() => { + throw Object.assign(new Error('resource busy'), { code: 'EBUSY' }) + }) + readFileSyncMock.mockReturnValueOnce(Buffer.from(envelope('encrypted', 'encrypted-payload'))) + safeStorageMock.decryptString.mockReturnValueOnce('sk-after-retry') + const store = await loadStore() + expect(() => store.readMiniMaxApiKey()).toThrow('MiniMax API key file could not be read') + expect(error).not.toHaveBeenCalled() + expect(store.readMiniMaxApiKey()).toBe('sk-after-retry') + warn.mockRestore() + error.mockRestore() + }) + it('clears the cached key and removes the file', async () => { existsSyncMock.mockReturnValueOnce(true) readFileSyncMock.mockReturnValueOnce(Buffer.from(envelope('encrypted', 'encrypted-payload'))) diff --git a/src/main/minimax/minimax-api-key-store.ts b/src/main/minimax/minimax-api-key-store.ts index c2fea05621f..ac18c8822b3 100644 --- a/src/main/minimax/minimax-api-key-store.ts +++ b/src/main/minimax/minimax-api-key-store.ts @@ -1,147 +1,14 @@ -import { safeStorage } from 'electron' -import { existsSync, readFileSync, rmSync } from 'node:fs' -import { homedir } from 'node:os' -import { join } from 'node:path' -import { hardenExistingSecureFile, writeSecureFile } from '../../shared/secure-file' -import type { SecretAtRestProtection } from '../../shared/secret-at-rest-protection' +import { createEncryptedApiKeyFileStore } from '../credentials/encrypted-api-key-file-store' -const MINIMAX_API_KEY_FILE = 'minimax-api-key.enc' -const API_KEY_ENVELOPE_PREFIX = 'orca-minimax-api-key:v1:' -let cachedMiniMaxApiKey: string | null = null -let warnedMiniMaxApiKeyStatusHardenFailure = false +const store = createEncryptedApiKeyFileStore({ + fileName: 'minimax-api-key.enc', + envelopePrefix: 'orca-minimax-api-key:v1:', + providerLabel: 'MiniMax', + logScope: 'minimax' +}) -type MiniMaxApiKeyEnvelope = { - kind: 'encrypted' | 'plaintext' - payload: Buffer -} - -function getOrcaDir(): string { - return join(homedir(), '.orca') -} - -function getMiniMaxApiKeyPath(): string { - return join(getOrcaDir(), MINIMAX_API_KEY_FILE) -} - -function encodeApiKeyEnvelope(kind: MiniMaxApiKeyEnvelope['kind'], payload: Buffer): string { - return `${API_KEY_ENVELOPE_PREFIX}${kind}:${payload.toString('base64')}` -} - -function decodeApiKeyEnvelope(raw: Buffer): MiniMaxApiKeyEnvelope { - const text = raw.toString('utf8') - if (!text.startsWith(API_KEY_ENVELOPE_PREFIX)) { - throw new Error('MiniMax API key could not be decrypted') - } - const rest = text.slice(API_KEY_ENVELOPE_PREFIX.length) - const separator = rest.indexOf(':') - if (separator === -1) { - throw new Error('MiniMax API key could not be decrypted') - } - const kind = rest.slice(0, separator) - if (kind !== 'encrypted' && kind !== 'plaintext') { - throw new Error('MiniMax API key could not be decrypted') - } - return { - kind, - payload: Buffer.from(rest.slice(separator + 1), 'base64') - } -} - -function readEnvelope(envelope: MiniMaxApiKeyEnvelope): string { - if (envelope.kind === 'plaintext') { - return envelope.payload.toString('utf8') - } - if (!safeStorage.isEncryptionAvailable()) { - throw new Error('MiniMax API key could not be decrypted') - } - return safeStorage.decryptString(envelope.payload) -} - -export function hasMiniMaxApiKey(): boolean { - const keyPath = getMiniMaxApiKeyPath() - if (!existsSync(keyPath)) { - return false - } - try { - hardenExistingSecureFile(keyPath) - } catch (error) { - if (!warnedMiniMaxApiKeyStatusHardenFailure) { - warnedMiniMaxApiKeyStatusHardenFailure = true - console.warn('[minimax] Failed to harden MiniMax API key file while checking status', error) - } - } - return true -} - -/** - * How the stored key is protected, or null when none is stored. - * - * Reads the envelope kind only — no decrypt, so this cannot trigger a keychain prompt - * and is safe to call from a status handler. - */ -export function getMiniMaxApiKeyProtection(): SecretAtRestProtection | null { - const keyPath = getMiniMaxApiKeyPath() - if (!existsSync(keyPath)) { - return null - } - try { - return decodeApiKeyEnvelope(readFileSync(keyPath)).kind === 'plaintext' ? 'plaintext' : 'sealed' - } catch { - // An undecodable envelope is a decrypt-time error to report, not a protection claim. - return null - } -} - -export function saveMiniMaxApiKey(key: string): void { - const trimmed = key.trim() - if (!trimmed) { - throw new Error('MiniMax API key is required') - } - if (safeStorage.isEncryptionAvailable()) { - writeSecureFile( - getMiniMaxApiKeyPath(), - encodeApiKeyEnvelope('encrypted', safeStorage.encryptString(trimmed)) - ) - cachedMiniMaxApiKey = trimmed - return - } - console.warn( - '[minimax] safeStorage encryption unavailable — storing MiniMax API key in plaintext' - ) - writeSecureFile( - getMiniMaxApiKeyPath(), - encodeApiKeyEnvelope('plaintext', Buffer.from(trimmed, 'utf8')) - ) - cachedMiniMaxApiKey = trimmed -} - -export function readMiniMaxApiKey(): string | null { - if (cachedMiniMaxApiKey !== null) { - return cachedMiniMaxApiKey - } - const keyPath = getMiniMaxApiKeyPath() - if (!existsSync(keyPath)) { - return null - } - // Why: keep hardening out of the decode/decrypt try below so a chmod/ACL - // failure isn't misreported as a decrypt failure (matches hasMiniMaxApiKey). - try { - hardenExistingSecureFile(keyPath) - } catch (error) { - console.warn('[minimax] Failed to harden MiniMax API key file while reading', error) - } - try { - const raw = readFileSync(keyPath) - const envelope = decodeApiKeyEnvelope(raw) - cachedMiniMaxApiKey = readEnvelope(envelope) - return cachedMiniMaxApiKey - } catch (error) { - console.error('[minimax] failed to decode/decrypt API key', error) - throw new Error('MiniMax API key could not be decrypted') - } -} - -export function clearMiniMaxApiKey(): void { - cachedMiniMaxApiKey = null - rmSync(getMiniMaxApiKeyPath(), { force: true }) -} +export const hasMiniMaxApiKey = store.has +export const getMiniMaxApiKeyProtection = store.protection +export const saveMiniMaxApiKey = store.save +export const readMiniMaxApiKey = store.read +export const clearMiniMaxApiKey = store.clear diff --git a/src/main/opencode-usage/opencode-database-discovery.ts b/src/main/opencode-usage/opencode-database-discovery.ts index 7f7fea72ecf..5f7ca25415d 100644 --- a/src/main/opencode-usage/opencode-database-discovery.ts +++ b/src/main/opencode-usage/opencode-database-discovery.ts @@ -10,8 +10,11 @@ type OpenCodeDatabaseOverride = { path: string | null } -function getOpenCodeDatabaseOverride(dataDirectory: string): OpenCodeDatabaseOverride { - const raw = process.env.OPENCODE_DB?.trim() +function getOpenCodeDatabaseOverride( + dataDirectory: string, + environment: NodeJS.ProcessEnv +): OpenCodeDatabaseOverride { + const raw = environment.OPENCODE_DB?.trim() if (!raw) { return { isConfigured: false, path: null } } @@ -32,31 +35,44 @@ export async function listOpenCodeDatabases( * "OpenCode not used" rather than "we could not look". */ 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 + onFsError?: (path: string, error: unknown) => void, + signal?: AbortSignal, + environment: NodeJS.ProcessEnv = process.env ): Promise { - const dataDirectory = resolveOpenCodeDataDirectory() - const databaseOverride = getOpenCodeDatabaseOverride(dataDirectory) + const dataDirectory = resolveOpenCodeDataDirectory(environment) + const databaseOverride = getOpenCodeDatabaseOverride(dataDirectory, environment) if (databaseOverride.isConfigured) { if (!databaseOverride.path) { return [] } try { - return (await wslGatedStat(databaseOverride.path, 'scan')).isFile() + return (await wslGatedStat(databaseOverride.path, 'scan', signal)).isFile() ? [databaseOverride.path] : [] } catch (error) { + signal?.throwIfAborted() reportFailure(databaseOverride.path, error, onRefusal, onFsError) return [] } } + return listOpenCodeDatabasesInDirectory(dataDirectory, onRefusal, signal, onFsError) +} + +export async function listOpenCodeDatabasesInDirectory( + dataDirectory: string, + onRefusal?: (path: string, error: WslTranscriptFsError) => void, + signal?: AbortSignal, + onFsError?: (path: string, error: unknown) => void +): Promise { try { - const entries = await wslGatedReaddir(dataDirectory, 'scan') + const entries = await wslGatedReaddir(dataDirectory, 'scan', signal) return entries .filter((entry) => entry.isFile() && /^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/.test(entry.name)) .map((entry) => join(dataDirectory, entry.name)) .sort() } catch (error) { + signal?.throwIfAborted() reportFailure(dataDirectory, error, onRefusal, onFsError) return [] } diff --git a/src/main/opencode/opencode-credential-backend.test.ts b/src/main/opencode/opencode-credential-backend.test.ts new file mode 100644 index 00000000000..3bf00336eb1 --- /dev/null +++ b/src/main/opencode/opencode-credential-backend.test.ts @@ -0,0 +1,182 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' +import { resolveCommandOnLocalPath } from '../ipc/command-path-resolver' +import { + detectOpenCodeCredentialBackend, + resetOpenCodeCredentialBackendProbes +} from './opencode-credential-backend' + +const files = vi.hoisted(() => ({ realpath: vi.fn(), stat: vi.fn() })) + +vi.mock('../../shared/child-process/run-process', () => ({ runProcess: vi.fn() })) +vi.mock('../ipc/command-path-resolver', () => ({ resolveCommandOnLocalPath: vi.fn() })) +vi.mock('node:fs/promises', () => files) + +describe('OpenCode credential execution backend', () => { + beforeEach(() => { + vi.resetAllMocks() + resetOpenCodeCredentialBackendProbes() + files.realpath.mockImplementation(async (path: string) => path) + files.stat.mockResolvedValue({ dev: 1, ino: 2, size: 3, mtimeMs: 4, ctimeMs: 5 }) + vi.mocked(resolveCommandOnLocalPath).mockResolvedValue('/task/bin/opencode') + vi.mocked(runProcess).mockResolvedValue({ + code: 0, + signal: null, + stdout: '1.18.30\n', + stderr: '', + timedOut: false + }) + }) + + it.each([ + ['1.18.30\n', 'v1'], + ['opencode v2.0.16\n', 'v2'], + ['2.0.16', 'v2'], + ['opencode v2.0.16-beta.1', 'v2'], + ['3.0.0', null], + ['wrapper 2.0.16', null], + ['', null] + ])('uses the reported backend for %j', async (stdout, backend) => { + vi.mocked(runProcess).mockResolvedValue({ + code: 0, + signal: null, + stdout, + stderr: '', + timedOut: false + }) + + expect(await detectOpenCodeCredentialBackend()).toBe(backend) + }) + + it('resolves and executes the binary in the caller environment with a bounded probe', async () => { + const environment = { PATH: '/task/bin', XDG_DATA_HOME: '/task/data' } + + await detectOpenCodeCredentialBackend(environment, '/task/workspace') + + expect(resolveCommandOnLocalPath).toHaveBeenCalledExactlyOnceWith('opencode', { + env: environment, + cwd: '/task/workspace' + }) + expect(runProcess).toHaveBeenCalledExactlyOnceWith({ + program: '/task/bin/opencode', + args: ['--version'], + env: environment, + cwd: '/task/workspace', + timeoutMs: 5_000, + maxOutputBytes: 1_024 + }) + }) + + it('probes opencode2 only when the default opencode command is absent', async () => { + vi.mocked(resolveCommandOnLocalPath) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce('/task/bin/opencode2') + vi.mocked(runProcess).mockResolvedValue({ + code: 0, + signal: null, + stdout: 'opencode v2.0.16', + stderr: '', + timedOut: false + }) + + expect(await detectOpenCodeCredentialBackend()).toBe('v2') + expect(runProcess).toHaveBeenCalledWith( + expect.objectContaining({ program: '/task/bin/opencode2' }) + ) + }) + + it.each([ + { code: 1, timedOut: false }, + { code: 0, timedOut: true }, + { code: 0, timedOut: false, outputTruncated: true } + ])('withholds authority when the installed probe fails: %j', async (failure) => { + vi.mocked(runProcess).mockResolvedValue({ + ...failure, + signal: null, + stdout: 'opencode v2.0.16', + stderr: '' + }) + + expect(await detectOpenCodeCredentialBackend()).toBeNull() + expect(resolveCommandOnLocalPath).toHaveBeenCalledTimes(1) + }) + + it('withholds authority after a spawn error without substituting another CLI', async () => { + vi.mocked(runProcess).mockRejectedValue(new Error('unavailable')) + + expect(await detectOpenCodeCredentialBackend()).toBeNull() + expect(resolveCommandOnLocalPath).toHaveBeenCalledTimes(1) + }) + + it('withholds authority when neither CLI is installed', async () => { + vi.mocked(resolveCommandOnLocalPath).mockResolvedValue(null) + + expect(await detectOpenCodeCredentialBackend()).toBeNull() + expect(runProcess).not.toHaveBeenCalled() + }) + + it('coalesces concurrent calls and reuses a successful binary identity', async () => { + expect( + await Promise.all([ + detectOpenCodeCredentialBackend(), + detectOpenCodeCredentialBackend(), + detectOpenCodeCredentialBackend() + ]) + ).toEqual(['v1', 'v1', 'v1']) + expect(await detectOpenCodeCredentialBackend()).toBe('v1') + expect(runProcess).toHaveBeenCalledTimes(1) + }) + + it('reprobes when the resolved binary is replaced', async () => { + expect(await detectOpenCodeCredentialBackend()).toBe('v1') + files.stat.mockResolvedValue({ dev: 1, ino: 6, size: 3, mtimeMs: 7, ctimeMs: 8 }) + vi.mocked(runProcess).mockResolvedValue({ + code: 0, + signal: null, + stdout: 'opencode v2.0.16', + stderr: '', + timedOut: false + }) + + expect(await detectOpenCodeCredentialBackend()).toBe('v2') + expect(runProcess).toHaveBeenCalledTimes(2) + }) + + it('does not reuse a probe across caller environments or working directories', async () => { + await detectOpenCodeCredentialBackend( + { PATH: '/task/bin', XDG_DATA_HOME: '/task/a' }, + '/task/a' + ) + await detectOpenCodeCredentialBackend( + { PATH: '/task/bin', XDG_DATA_HOME: '/task/b' }, + '/task/a' + ) + await detectOpenCodeCredentialBackend( + { PATH: '/task/bin', XDG_DATA_HOME: '/task/b' }, + '/task/b' + ) + + expect(runProcess).toHaveBeenCalledTimes(3) + }) + + it('retries an unknown backend after its short cache expires', async () => { + const now = vi.spyOn(Date, 'now').mockReturnValue(1_000) + vi.mocked(runProcess).mockRejectedValueOnce(new Error('temporarily unavailable')) + try { + expect(await detectOpenCodeCredentialBackend()).toBeNull() + expect(await detectOpenCodeCredentialBackend()).toBeNull() + now.mockReturnValue(6_001) + expect(await detectOpenCodeCredentialBackend()).toBe('v1') + expect(runProcess).toHaveBeenCalledTimes(2) + } finally { + now.mockRestore() + } + }) + + it('withholds authority when the selected binary identity cannot be read', async () => { + files.stat.mockRejectedValue(new Error('unreadable executable')) + + expect(await detectOpenCodeCredentialBackend()).toBeNull() + expect(runProcess).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/opencode/opencode-credential-backend.ts b/src/main/opencode/opencode-credential-backend.ts new file mode 100644 index 00000000000..69c860dbe48 --- /dev/null +++ b/src/main/opencode/opencode-credential-backend.ts @@ -0,0 +1,91 @@ +import { runProcess } from '../../shared/child-process/run-process' +import { resolveCommandOnLocalPath } from '../ipc/command-path-resolver' +import { createHash } from 'node:crypto' +import { realpath, stat } from 'node:fs/promises' + +export type OpenCodeCredentialBackend = 'v1' | 'v2' + +// Native runtime processes own separate execution-host caches. +const probes = new Map< + string, + { result: Promise; expiresAt: number } +>() +const MAX_PROBES = 32 + +export function resetOpenCodeCredentialBackendProbes(): void { + probes.clear() +} + +export async function detectOpenCodeCredentialBackend( + environment: NodeJS.ProcessEnv = process.env, + cwd = process.cwd() +): Promise { + // The execution host's default CLI owns this lookup; table presence proves neither backend. + const program = + (await resolveCommandOnLocalPath('opencode', { env: environment, cwd })) ?? + (await resolveCommandOnLocalPath('opencode2', { env: environment, cwd })) + if (!program) { + return null + } + try { + const binary = await realpath(program) + const identity = await stat(binary) + const environmentDigest = createHash('sha256') + .update(JSON.stringify(Object.entries(environment).sort(([a], [b]) => a.localeCompare(b)))) + .digest('hex') + const key = JSON.stringify([ + binary, + identity.dev, + identity.ino, + identity.size, + identity.mtimeMs, + identity.ctimeMs, + cwd, + environmentDigest + ]) + const cached = probes.get(key) + if (cached && cached.expiresAt > Date.now()) { + return cached.result + } + const result = probeBackend(binary, environment, cwd) + const entry = { result, expiresAt: Number.POSITIVE_INFINITY } + probes.set(key, entry) + if (probes.size > MAX_PROBES) { + const oldest = probes.keys().next().value + if (oldest !== undefined) { + probes.delete(oldest) + } + } + const backend = await result + entry.expiresAt = Date.now() + (backend ? 60_000 : 5_000) + return backend + } catch { + return null + } +} + +async function probeBackend( + program: string, + environment: NodeJS.ProcessEnv, + cwd: string +): Promise { + try { + const result = await runProcess({ + program, + args: ['--version'], + env: environment, + cwd, + timeoutMs: 5_000, + maxOutputBytes: 1_024 + }) + if (result.code !== 0 || result.timedOut || result.outputTruncated) { + return null + } + const version = /^(?:opencode\s+)?v?([12])\.\d+\.\d+(?:[-+][\w.-]+)?$/i.exec( + result.stdout.trim() + ) + return version?.[1] === '1' ? 'v1' : version?.[1] === '2' ? 'v2' : null + } catch { + return null + } +} diff --git a/src/main/opencode/opencode-go-api-key-store.test.ts b/src/main/opencode/opencode-go-api-key-store.test.ts new file mode 100644 index 00000000000..728665076f9 --- /dev/null +++ b/src/main/opencode/opencode-go-api-key-store.test.ts @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import type * as NodeFs from 'node:fs' +import type * as NodeOs from 'node:os' +import { join } from 'node:path' + +const home = vi.hoisted(() => { + const state: { directory: string; readError: Error | null } = { directory: '', readError: null } + return state +}) +vi.mock('node:os', async (importOriginal) => ({ + ...(await importOriginal()), + homedir: () => home.directory +})) +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + readFileSync: (...args: Parameters) => { + if (home.readError) { + throw home.readError + } + return actual.readFileSync(...args) + } + } +}) +vi.mock('electron', () => ({ + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (key: string) => Buffer.from(`encrypted:${key}`), + decryptString: (bytes: Buffer) => bytes.toString().slice('encrypted:'.length) + } +})) + +beforeEach(() => { + home.readError = null + home.directory = mkdtempSync(join(tmpdir(), 'orca-go-key-store-')) + vi.resetModules() +}) +afterEach(() => rmSync(home.directory, { recursive: true, force: true })) + +describe('OpenCode Go main-owned API key file', () => { + it('persists a versioned encrypted envelope, reads it after restart, and clears it', async () => { + const store = await import('./opencode-go-api-key-store') + expect(store.hasOpenCodeGoApiKey()).toBe(false) + store.saveOpenCodeGoApiKey(' fake-key ') + expect(store.hasOpenCodeGoApiKey()).toBe(true) + const path = join(home.directory, '.orca', 'opencode-go-api-key.enc') + expect(readFileSync(path, 'utf8')).toBe( + `orca-opencode-go-api-key:v1:encrypted:${Buffer.from('encrypted:fake-key').toString('base64')}` + ) + vi.resetModules() + const restarted = await import('./opencode-go-api-key-store') + expect(restarted.readOpenCodeGoApiKey()).toBe('fake-key') + restarted.clearOpenCodeGoApiKey() + expect(restarted.hasOpenCodeGoApiKey()).toBe(false) + expect(restarted.readOpenCodeGoApiKey()).toBeNull() + }) + + it('keeps the MiniMax cache and file independent', async () => { + const go = await import('./opencode-go-api-key-store') + const miniMax = await import('../minimax/minimax-api-key-store') + go.saveOpenCodeGoApiKey('fake-go') + miniMax.saveMiniMaxApiKey('fake-minimax') + expect(go.readOpenCodeGoApiKey()).toBe('fake-go') + expect(miniMax.readMiniMaxApiKey()).toBe('fake-minimax') + go.clearOpenCodeGoApiKey() + expect(miniMax.hasMiniMaxApiKey()).toBe(true) + expect(miniMax.readMiniMaxApiKey()).toBe('fake-minimax') + }) + + it('rejects empty keys and malformed envelopes without returning the key', async () => { + const store = await import('./opencode-go-api-key-store') + expect(() => store.saveOpenCodeGoApiKey(' ')).toThrow('required') + store.saveOpenCodeGoApiKey('fake-key') + writeFileSync(join(home.directory, '.orca', 'opencode-go-api-key.enc'), 'fake-invalid-envelope') + vi.resetModules() + const restarted = await import('./opencode-go-api-key-store') + expect(() => restarted.readOpenCodeGoApiKey()).toThrow( + 'OpenCode Go API key could not be decrypted' + ) + }) + + it('throws a distinct unreadable error for a transient read failure', async () => { + const store = await import('./opencode-go-api-key-store') + store.saveOpenCodeGoApiKey('fake-key') + vi.resetModules() + const restarted = await import('./opencode-go-api-key-store') + vi.spyOn(console, 'warn').mockImplementation(() => {}) + home.readError = Object.assign(new Error('resource busy'), { code: 'EBUSY' }) + + expect(() => restarted.readOpenCodeGoApiKey()).toThrow( + 'OpenCode Go API key file could not be read' + ) + home.readError = null + expect(restarted.readOpenCodeGoApiKey()).toBe('fake-key') + }) +}) diff --git a/src/main/opencode/opencode-go-api-key-store.ts b/src/main/opencode/opencode-go-api-key-store.ts new file mode 100644 index 00000000000..212f9166255 --- /dev/null +++ b/src/main/opencode/opencode-go-api-key-store.ts @@ -0,0 +1,13 @@ +import { createEncryptedApiKeyFileStore } from '../credentials/encrypted-api-key-file-store' + +const store = createEncryptedApiKeyFileStore({ + fileName: 'opencode-go-api-key.enc', + envelopePrefix: 'orca-opencode-go-api-key:v1:', + providerLabel: 'OpenCode Go', + logScope: 'opencode-go' +}) + +export const hasOpenCodeGoApiKey = store.has +export const saveOpenCodeGoApiKey = store.save +export const readOpenCodeGoApiKey = store.read +export const clearOpenCodeGoApiKey = store.clear diff --git a/src/main/persistence/applying-settings/settings-update.ts b/src/main/persistence/applying-settings/settings-update.ts index 97190a6f890..26703ab4396 100644 --- a/src/main/persistence/applying-settings/settings-update.ts +++ b/src/main/persistence/applying-settings/settings-update.ts @@ -60,9 +60,6 @@ export function updateSettings( if ('opencodeSessionCookie' in updates && !updates.opencodeSessionCookie) { operations.removeRetainedBlob(PROTECTED_SECRET_SLOT.opencodeSessionCookie) } - if ('opencodeGoApiKey' in updates && !updates.opencodeGoApiKey) { - operations.removeRetainedBlob(PROTECTED_SECRET_SLOT.opencodeGoApiKey) - } if ('httpProxyUrl' in updates && !updates.httpProxyUrl) { operations.removeRetainedBlob(PROTECTED_SECRET_SLOT.httpProxyUrl) } diff --git a/src/main/persistence/applying-settings/terminal-settings-migrations.ts b/src/main/persistence/applying-settings/terminal-settings-migrations.ts index 161afcd1de5..551516e70b3 100644 --- a/src/main/persistence/applying-settings/terminal-settings-migrations.ts +++ b/src/main/persistence/applying-settings/terminal-settings-migrations.ts @@ -60,6 +60,8 @@ type RetiredGlobalSettings = { terminalScrollbackBytes?: unknown enableGitHubAttribution?: unknown showAgentsSidebar?: unknown + // Why: #22551 kept this key in settings; it now lives in a main-owned store and must never ride along. + opencodeGoApiKey?: unknown } export function stripRetiredGlobalSettings( @@ -69,11 +71,13 @@ export function stripRetiredGlobalSettings( terminalScrollbackBytes: _legacyScrollbackBytes, enableGitHubAttribution: _legacyGitHubAttribution, showAgentsSidebar: _legacyShowAgentsSidebar, + opencodeGoApiKey: _legacyOpenCodeGoApiKey, ...rest } = (settings ?? {}) as Partial & RetiredGlobalSettings void _legacyScrollbackBytes void _legacyGitHubAttribution void _legacyShowAgentsSidebar + void _legacyOpenCodeGoApiKey return rest } diff --git a/src/main/persistence/leasing-ssh-ptys/secret-validation.ts b/src/main/persistence/leasing-ssh-ptys/secret-validation.ts index 8ead3d56fe3..33303b660c7 100644 --- a/src/main/persistence/leasing-ssh-ptys/secret-validation.ts +++ b/src/main/persistence/leasing-ssh-ptys/secret-validation.ts @@ -6,13 +6,6 @@ export function isLegacyOpenCodeSessionCookie(value: string): boolean { ) } -// OpenCode Go keys are opaque bearer tokens; the console issues `sk-` -// (legacy) and `oc_sk_` (new console) prefixes. -export function isLegacyOpenCodeGoApiKey(value: string): boolean { - const trimmed = value.trim() - return /^(?:oc_)?sk[-_][A-Za-z0-9._-]+$/.test(trimmed) -} - export function isLegacySshPtyOwnerLease(value: string): boolean { return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value) } diff --git a/src/main/persistence/loading-store/legacy-opencode-go-api-key-migration.test.ts b/src/main/persistence/loading-store/legacy-opencode-go-api-key-migration.test.ts new file mode 100644 index 00000000000..a5e94937b51 --- /dev/null +++ b/src/main/persistence/loading-store/legacy-opencode-go-api-key-migration.test.ts @@ -0,0 +1,200 @@ +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' + +const secretStore = vi.hoisted(() => ({ + isEncryptionAvailable: vi.fn(() => true), + encryptString: vi.fn((value: string) => Buffer.from(`enc:${value}`)), + decryptString: vi.fn((value: Buffer) => value.toString().slice(4)) +})) + +vi.mock('../../../shared/secret-store', () => ({ getSecretStore: () => secretStore })) + +const { ProtectedSecretPersistence } = await import('../../protected-secret-persistence') +const { + LEGACY_OPENCODE_GO_API_KEY_SLOT, + migrateLegacyOpenCodeGoApiKey, + retainLegacyOpenCodeGoApiKey +} = await import('./legacy-opencode-go-api-key-migration') + +const SEALED = Buffer.from('enc:fake-legacy-key').toString('base64') + +function memoryStore(initial: string | null = null): { + has: () => boolean + read: () => string | null + save: Mock<(key: string) => void> + value: () => string | null +} { + let saved = initial + return { + has: () => saved !== null, + read: () => saved, + save: vi.fn((key: string) => { + saved = key + }), + value: () => saved + } +} + +function parked(value: unknown = SEALED): InstanceType { + const secrets = new ProtectedSecretPersistence() + retainLegacyOpenCodeGoApiKey({ opencodeGoApiKey: value }, secrets) + return secrets +} + +beforeEach(() => { + vi.clearAllMocks() + secretStore.isEncryptionAvailable.mockReturnValue(true) + secretStore.decryptString.mockImplementation((value: Buffer) => value.toString().slice(4)) + vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('retainLegacyOpenCodeGoApiKey', () => { + it('drops the field from settings and parks the ciphertext without decrypting it', () => { + const settings: Record = { opencodeGoApiKey: SEALED, other: 1 } + const secrets = new ProtectedSecretPersistence() + + retainLegacyOpenCodeGoApiKey(settings, secrets) + + expect(settings).toEqual({ other: 1 }) + expect(secrets.sealedBlob(LEGACY_OPENCODE_GO_API_KEY_SLOT)).toBe(SEALED) + expect(secretStore.decryptString).not.toHaveBeenCalled() + }) + + it('seals a #22551 plaintext key before parking it, and migration still recovers it', () => { + const secrets = parked('sk-fake-plaintext-key') + + const blob = secrets.sealedBlob(LEGACY_OPENCODE_GO_API_KEY_SLOT) + expect(blob).toBe(Buffer.from('enc:sk-fake-plaintext-key').toString('base64')) + const store = memoryStore() + expect(migrateLegacyOpenCodeGoApiKey(secrets, store)).toBe(true) + expect(store.value()).toBe('sk-fake-plaintext-key') + }) + + it('parks a plaintext key verbatim while encryption is unavailable', () => { + secretStore.isEncryptionAvailable.mockReturnValue(false) + const secrets = parked('sk-fake-plaintext-key') + + expect(secrets.sealedBlob(LEGACY_OPENCODE_GO_API_KEY_SLOT)).toBe('sk-fake-plaintext-key') + expect(secretStore.encryptString).not.toHaveBeenCalled() + }) + + it('ignores settings without the field and drops malformed values', () => { + const secrets = new ProtectedSecretPersistence() + retainLegacyOpenCodeGoApiKey({ other: 1 }, secrets) + retainLegacyOpenCodeGoApiKey(undefined, secrets) + const malformed: Record = { opencodeGoApiKey: { nested: 'fake' } } + retainLegacyOpenCodeGoApiKey(malformed, secrets) + + expect(malformed).toEqual({}) + expect(secrets.sealedBlob(LEGACY_OPENCODE_GO_API_KEY_SLOT)).toBeNull() + }) +}) + +describe('migrateLegacyOpenCodeGoApiKey', () => { + it('saves the key into an empty store, then releases the legacy value', () => { + const secrets = parked() + const store = memoryStore() + + expect(migrateLegacyOpenCodeGoApiKey(secrets, store)).toBe(true) + expect(store.value()).toBe('fake-legacy-key') + expect(secrets.sealedBlob(LEGACY_OPENCODE_GO_API_KEY_SLOT)).toBeNull() + }) + + it('is idempotent: a second run has nothing left to move', () => { + const secrets = parked() + const store = memoryStore() + + migrateLegacyOpenCodeGoApiKey(secrets, store) + expect(migrateLegacyOpenCodeGoApiKey(secrets, store)).toBe(false) + expect(store.save).toHaveBeenCalledOnce() + }) + + it('never overwrites a readable key already saved in the store, and releases the legacy value', () => { + const secrets = parked() + const store = memoryStore('fake-current-key') + + expect(migrateLegacyOpenCodeGoApiKey(secrets, store)).toBe(true) + expect(store.save).not.toHaveBeenCalled() + expect(store.value()).toBe('fake-current-key') + expect(secretStore.decryptString).not.toHaveBeenCalled() + expect(console.warn).toHaveBeenCalledWith( + '[opencode-go] Kept the existing saved API key and dropped the one from settings.' + ) + }) + + it('keeps the legacy value when the existing store file cannot be read by this build', () => { + const secrets = parked() + const store = { + ...memoryStore('fake-other-build-key'), + read: () => { + throw new Error('OpenCode Go API key could not be decrypted') + } + } + + expect(migrateLegacyOpenCodeGoApiKey(secrets, store)).toBe(false) + expect(store.save).not.toHaveBeenCalled() + expect(secrets.sealedBlob(LEGACY_OPENCODE_GO_API_KEY_SLOT)).toBe(SEALED) + }) + + it('keeps the ciphertext while safeStorage is unavailable so a later startup retries', () => { + const secrets = parked() + const store = memoryStore() + secretStore.isEncryptionAvailable.mockReturnValue(false) + + expect(migrateLegacyOpenCodeGoApiKey(secrets, store)).toBe(false) + expect(secrets.sealedBlob(LEGACY_OPENCODE_GO_API_KEY_SLOT)).toBe(SEALED) + + secretStore.isEncryptionAvailable.mockReturnValue(true) + expect(migrateLegacyOpenCodeGoApiKey(secrets, store)).toBe(true) + expect(store.value()).toBe('fake-legacy-key') + }) + + it('keeps the ciphertext after a definitive decrypt failure', () => { + const secrets = parked() + const store = memoryStore() + secretStore.decryptString.mockImplementation(() => { + throw new Error('bad key') + }) + + expect(migrateLegacyOpenCodeGoApiKey(secrets, store)).toBe(false) + expect(store.save).not.toHaveBeenCalled() + expect(secrets.sealedBlob(LEGACY_OPENCODE_GO_API_KEY_SLOT)).toBe(SEALED) + }) + + it('moves a plaintext key #22551 accepted when it failed to decrypt', () => { + secretStore.isEncryptionAvailable.mockReturnValue(false) + const secrets = parked('sk-fake-plaintext-key') + secretStore.isEncryptionAvailable.mockReturnValue(true) + const store = memoryStore() + secretStore.decryptString.mockImplementation(() => { + throw new Error('not ciphertext') + }) + + expect(migrateLegacyOpenCodeGoApiKey(secrets, store)).toBe(true) + expect(store.value()).toBe('sk-fake-plaintext-key') + }) + + it('keeps the ciphertext when the store cannot save, without logging the key', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const secrets = parked() + const store = { + has: () => false, + read: () => null, + save: () => { + throw new Error('disk full') + } + } + + expect(migrateLegacyOpenCodeGoApiKey(secrets, store)).toBe(false) + expect(secrets.sealedBlob(LEGACY_OPENCODE_GO_API_KEY_SLOT)).toBe(SEALED) + expect(JSON.stringify(warn.mock.calls)).not.toContain('fake-legacy-key') + + const working = memoryStore() + expect(migrateLegacyOpenCodeGoApiKey(secrets, working)).toBe(true) + expect(working.value()).toBe('fake-legacy-key') + expect(secrets.sealedBlob(LEGACY_OPENCODE_GO_API_KEY_SLOT)).toBeNull() + }) +}) diff --git a/src/main/persistence/loading-store/legacy-opencode-go-api-key-migration.ts b/src/main/persistence/loading-store/legacy-opencode-go-api-key-migration.ts new file mode 100644 index 00000000000..2571f6af024 --- /dev/null +++ b/src/main/persistence/loading-store/legacy-opencode-go-api-key-migration.ts @@ -0,0 +1,100 @@ +import type { ProtectedSecretPersistence } from '../../protected-secret-persistence' + +// Why a private slot: #22551 sealed the key under this name; only this migration still reads it. +export const LEGACY_OPENCODE_GO_API_KEY_SLOT = 'settings.opencodeGoApiKey' + +type LegacyOpenCodeGoApiKeySecrets = Pick< + ProtectedSecretPersistence, + 'decryptWithStatus' | 'removeRetainedBlob' | 'retainSealed' | 'sealedBlob' +> + +export type OpenCodeGoApiKeyTarget = { + has: () => boolean + /** Throws when the saved key cannot be read or decrypted by this build. */ + read: () => string | null + save: (key: string) => void +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +// #22551 accepted a plaintext `sk-`/`oc_sk_` value that failed to decrypt; keep honoring it. +function isLegacyPlaintextOpenCodeGoApiKey(value: string): boolean { + return /^(?:oc_)?sk[-_][A-Za-z0-9._-]+$/.test(value.trim()) +} + +/** + * Moves the #22551 settings value out of settings and parks its ciphertext undecrypted, so every + * Store consumer (orcad included) writes it back until the desktop migration gives it a new home. + */ +export function retainLegacyOpenCodeGoApiKey( + settings: unknown, + secrets: Pick +): void { + if (!isRecord(settings) || !('opencodeGoApiKey' in settings)) { + return + } + const sealed = settings.opencodeGoApiKey + // Why: in-memory settings reach the renderer and remote settings.get; the key must not ride along. + delete settings.opencodeGoApiKey + if (typeof sealed !== 'string' || !sealed) { + return + } + if (!isLegacyPlaintextOpenCodeGoApiKey(sealed)) { + secrets.retainSealed(LEGACY_OPENCODE_GO_API_KEY_SLOT, sealed) + return + } + // Why: orcad-only profiles never migrate, so seal it now; without encryption #22551 kept it plaintext too. + const encrypted = secrets.encrypt(LEGACY_OPENCODE_GO_API_KEY_SLOT, sealed) + secrets.retainSealed( + LEGACY_OPENCODE_GO_API_KEY_SLOT, + encrypted.degraded ? sealed : encrypted.blob + ) +} + +/** + * Saves the parked key into the main-owned store; a key already saved there wins. + * @returns True once the legacy value is released and may be dropped from disk. + */ +export function migrateLegacyOpenCodeGoApiKey( + secrets: LegacyOpenCodeGoApiKeySecrets, + target: OpenCodeGoApiKeyTarget +): boolean { + const sealed = secrets.sealedBlob(LEGACY_OPENCODE_GO_API_KEY_SLOT) + if (!sealed) { + return false + } + try { + // Why: a file another app identity sealed is unreadable here; read throws and keeps the legacy key. + const existing = target.has() ? target.read() : null + if (existing === null) { + const decrypted = secrets.decryptWithStatus( + LEGACY_OPENCODE_GO_API_KEY_SLOT, + sealed, + isLegacyPlaintextOpenCodeGoApiKey + ) + // Why keep a definitive failure too: a restored keychain can still open the inert ciphertext, while dropping it is irreversible. + if (decrypted.status === 'unavailable' || !decrypted.plaintext) { + secrets.retainSealed(LEGACY_OPENCODE_GO_API_KEY_SLOT, sealed) + return false + } + const key = decrypted.plaintext.trim() + if (key) { + target.save(key) + } + } else { + // Why: the store is machine-wide, so another profile's key can win; leave a trace of the drop. + console.warn( + '[opencode-go] Kept the existing saved API key and dropped the one from settings.' + ) + } + } catch { + // Why: startup must not fail on a disk or keychain error; the next startup retries. + secrets.retainSealed(LEGACY_OPENCODE_GO_API_KEY_SLOT, sealed) + console.warn('[opencode-go] Could not migrate the saved API key out of settings.') + return false + } + secrets.removeRetainedBlob(LEGACY_OPENCODE_GO_API_KEY_SLOT) + return true +} diff --git a/src/main/persistence/loading-store/loaded-state-parsing.ts b/src/main/persistence/loading-store/loaded-state-parsing.ts index b93dc251500..cf699cd7724 100644 --- a/src/main/persistence/loading-store/loaded-state-parsing.ts +++ b/src/main/persistence/loading-store/loaded-state-parsing.ts @@ -14,11 +14,11 @@ import { sshPtyOwnerLeaseSecretSlot } from '../../protected-secret-persistence' import { - isLegacyOpenCodeGoApiKey, isLegacyOpenCodeSessionCookie, isLegacySshPtyOwnerLease } from '../leasing-ssh-ptys/secret-validation' import { readGithubCacheSnapshot } from './user-data-path' +import { retainLegacyOpenCodeGoApiKey } from './legacy-opencode-go-api-key-migration' import { gcStaleWorktreeMeta, normalizeWorktreeLinkedItemMetadata @@ -113,13 +113,7 @@ export class LoadedStateParsingOperations { isLegacyOpenCodeSessionCookie ) } - if (parsed.settings?.opencodeGoApiKey) { - parsed.settings.opencodeGoApiKey = this.runtime.protectedSecrets.decrypt( - PROTECTED_SECRET_SLOT.opencodeGoApiKey, - parsed.settings.opencodeGoApiKey, - isLegacyOpenCodeGoApiKey - ) - } + retainLegacyOpenCodeGoApiKey(parsed.settings, this.runtime.protectedSecrets) if (parsed.settings?.httpProxyUrl) { const decryptedProxy = this.runtime.protectedSecrets.decryptWithStatus( PROTECTED_SECRET_SLOT.httpProxyUrl, diff --git a/src/main/persistence/loading-store/profile-state-delayed-authority-fixture.ts b/src/main/persistence/loading-store/profile-state-delayed-authority-fixture.ts index 26709b33e3f..feaddd284c3 100644 --- a/src/main/persistence/loading-store/profile-state-delayed-authority-fixture.ts +++ b/src/main/persistence/loading-store/profile-state-delayed-authority-fixture.ts @@ -113,12 +113,21 @@ afterEach(async () => { vi.restoreAllMocks() }) -export async function fixture() { +export async function fixture(legacyOpenCodeGoApiKey?: string) { const directory = mkdtempSync(join(tmpdir(), 'orca-worker-coordination-')) const path = join(directory, 'profile-state.db') const inner = new ProfileStateSqliteAuthority(path, 'coordination-test') + const initial = buildProfileStateCutoverFixture(directory) inner.writeSerializedState( - Buffer.from(JSON.stringify(buildProfileStateCutoverFixture(directory))) + Buffer.from( + JSON.stringify({ + ...initial, + settings: { + ...initial.settings, + ...(legacyOpenCodeGoApiKey ? { opencodeGoApiKey: legacyOpenCodeGoApiKey } : {}) + } + }) + ) ) const authority = new DelayedAuthority(inner) const store = new Store({ diff --git a/src/main/persistence/loading-store/profile-state-worker-secret-retention.test.ts b/src/main/persistence/loading-store/profile-state-worker-secret-retention.test.ts index 4d72ce144c6..648319f59f0 100644 --- a/src/main/persistence/loading-store/profile-state-worker-secret-retention.test.ts +++ b/src/main/persistence/loading-store/profile-state-worker-secret-retention.test.ts @@ -27,7 +27,7 @@ beforeEach(() => { }) afterEach(() => setSecretStore(previousSecretStore)) -describe.each(['opencodeSessionCookie', 'opencodeGoApiKey'] as const)( +describe.each(['opencodeSessionCookie'] as const)( 'Store %s retention across worker acknowledgements', (setting) => { it('does not restore ciphertext cleared while its commit acknowledgement was pending', async () => { @@ -94,14 +94,14 @@ describe.each(['opencodeSessionCookie', 'opencodeGoApiKey'] as const)( describe('worker protected settings serialization', () => { it.each(['selective', 'complete'] as const)( - 'encrypts both protected credentials in a %s write to SQLite', + 'seals the cookie and retains the legacy Go key privately in a %s SQLite write', async (mode) => { - const { store, authority, readState } = await fixture() + const { store, authority, readState } = await fixture(ciphertext('legacy-go-key')) const selectiveWrite = vi.spyOn(authority, 'writeSerializedDomains') const completeWrite = vi.spyOn(authority, 'writeCompleteSerializedDomains') const secrets = { opencodeSessionCookie: 'cookie-only-plaintext', - opencodeGoApiKey: 'api-key-only-plaintext' + opencodeGoApiKey: 'retired-write-is-ignored' } store.updateSettings(secrets) if (mode === 'complete') { @@ -113,12 +113,34 @@ describe('worker protected settings serialization', () => { const persisted = readState() expect(persisted.settings).toMatchObject({ opencodeSessionCookie: ciphertext(secrets.opencodeSessionCookie), - opencodeGoApiKey: ciphertext(secrets.opencodeGoApiKey) + opencodeGoApiKey: ciphertext('legacy-go-key') }) for (const plaintext of Object.values(secrets)) { expect(JSON.stringify(persisted)).not.toContain(plaintext) } - expect(store.getSettings()).toMatchObject(secrets) + expect(store.getSettings().opencodeSessionCookie).toBe(secrets.opencodeSessionCookie) + expect(store.getSettings()).not.toHaveProperty('opencodeGoApiKey') } ) }) + +it('does not restore the migrated legacy Go ciphertext from a pending worker acknowledgement', async () => { + const { store, authority, readState } = await fixture(ciphertext('legacy-go-key')) + const gate = authority.pause() + store.updateSettings({ theme: 'dark' }) + const write = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await gate.started.promise + const save = vi.fn() + store.migrateLegacyOpenCodeGoApiKey({ has: () => false, read: () => null, save }) + encryptionAvailable = false + gate.finish.resolve() + await write + await store.flushPendingOrThrowAsync() + expect(save).toHaveBeenCalledExactlyOnceWith('legacy-go-key') + expect(readState().settings).not.toHaveProperty('opencodeGoApiKey') + expect(store.getSettings()).not.toHaveProperty('opencodeGoApiKey') + encryptionAvailable = true + store.updateSettings({ theme: 'light' }) + await store.flushPendingOrThrowAsync() + expect(readState().settings).not.toHaveProperty('opencodeGoApiKey') +}) diff --git a/src/main/persistence/loading-store/state-serialization-secret-handling.ts b/src/main/persistence/loading-store/state-serialization-secret-handling.ts index 40d40818343..c4346545ef4 100644 --- a/src/main/persistence/loading-store/state-serialization-secret-handling.ts +++ b/src/main/persistence/loading-store/state-serialization-secret-handling.ts @@ -12,6 +12,7 @@ import { type ProtectedSecretRetentionUpdate } from '../../protected-secret-persistence' import { stripRetiredGlobalSettings } from '../applying-settings/terminal-settings-migrations' +import { LEGACY_OPENCODE_GO_API_KEY_SLOT } from './legacy-opencode-go-api-key-migration' import { omitDefaultWorktreeMetaFieldsInMap } from '../../../shared/worktree/meta-persisted-defaults' import { projectWorktreeMetaByIdentityOntoLocators } from './worktree-meta-alias-projection' import { withoutRedundantPartitionGlobals } from '../../../shared/workspace-session-host-field-ownership' @@ -260,10 +261,9 @@ export class StateSerializationSecretHandlingOperations { PROTECTED_SECRET_SLOT.opencodeSessionCookie, this.runtime.state.settings.opencodeSessionCookie ), - opencodeGoApiKey: encrypt( - PROTECTED_SECRET_SLOT.opencodeGoApiKey, - this.runtime.state.settings.opencodeGoApiKey ?? '' - ), + ...(this.runtime.protectedSecrets.sealedBlob(LEGACY_OPENCODE_GO_API_KEY_SLOT) + ? { opencodeGoApiKey: encrypt(LEGACY_OPENCODE_GO_API_KEY_SLOT, '') } + : {}), httpProxyUrl: encrypt( PROTECTED_SECRET_SLOT.httpProxyUrl, this.runtime.state.settings.httpProxyUrl ?? '' diff --git a/src/main/persistence/loading-store/state-write-round-trip.test.ts b/src/main/persistence/loading-store/state-write-round-trip.test.ts index 1d543fb5b5f..3550c3b5312 100644 --- a/src/main/persistence/loading-store/state-write-round-trip.test.ts +++ b/src/main/persistence/loading-store/state-write-round-trip.test.ts @@ -1,7 +1,8 @@ import { closeTestStores, createSqliteTestStore, - readPersistedStateJson + readPersistedStateJson, + writePersistedStateJson } from '../../persistence-test-harness' /** * The write path now hands the file a Buffer it built in one pass instead of a string it rebuilt @@ -10,6 +11,7 @@ import { * against (a mis-sliced segment, a re-encoded payload, a dropped sentinel) is invisible until * something reads the bytes back. */ +import { getSecretStore } from '../../../shared/secret-store' import { mkdtempSync, realpathSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -77,6 +79,68 @@ function session(activeTabId: string): WorkspaceSessionState { } describe('persisted state survives a save/load round trip', () => { + it('keeps a #22551 settings-slot OpenCode Go key on disk until its new owner has it', () => { + const dataFile = join( + realpathSync(mkdtempSync(join(tmpdir(), 'orca-legacy-opencode-go-key-'))), + 'state.json' + ) + const first = openStore(dataFile) + first.updateSettings({ opencodeWorkspaceId: 'wrk_test' }) + first.flush() + const persisted = JSON.parse(readPersistedStateJson(dataFile)) + // Sealed exactly as #22551's protected-secret slot wrote it. + const sealed = getSecretStore().encryptString('fake-legacy-key').toString('base64') + persisted.settings.opencodeGoApiKey = sealed + writePersistedStateJson(dataFile, JSON.stringify(persisted)) + const onDiskKey = (): unknown => + JSON.parse(readPersistedStateJson(dataFile)).settings.opencodeGoApiKey + + // orcad-style consumer: loads and flushes the profile but never runs the migration. + const daemon = createSqliteTestStore(Store, { dataFile }) + stores.push(daemon) + expect(daemon.getSettings()).not.toHaveProperty('opencodeGoApiKey') + daemon.updateSettings({ opencodeWorkspaceId: 'wrk_daemon' }) + daemon.flush() + expect(onDiskKey()).toBe(sealed) + expect(readPersistedStateJson(dataFile)).not.toContain('fake-legacy-key') + + const loaded = openStore(dataFile) + expect(loaded.getSettings().opencodeWorkspaceId).toBe('wrk_daemon') + expect(loaded.getSettings()).not.toHaveProperty('opencodeGoApiKey') + vi.spyOn(console, 'warn').mockImplementation(() => {}) + loaded.migrateLegacyOpenCodeGoApiKey({ + has: () => false, + read: () => null, + save: () => { + throw new Error('disk full') + } + }) + loaded.flush() + expect(onDiskKey()).toBe(sealed) + + // Why: an older paired client can still send the retired field; it must not be stored. + const updates = { opencodeGoApiKey: 'fake-remote-key', opencodeWorkspaceId: 'wrk_next' } + expect(loaded.updateSettings(updates)).not.toHaveProperty('opencodeGoApiKey') + loaded.flush() + expect(onDiskKey()).toBe(sealed) + + const saved: string[] = [] + loaded.migrateLegacyOpenCodeGoApiKey({ + has: () => saved.length > 0, + read: () => saved[0] ?? null, + save: (key) => saved.push(key) + }) + loaded.migrateLegacyOpenCodeGoApiKey({ + has: () => saved.length > 0, + read: () => saved[0] ?? null, + save: (key) => saved.push(key) + }) + expect(saved).toEqual(['fake-legacy-key']) + loaded.flush() + expect(readPersistedStateJson(dataFile)).not.toContain('opencodeGoApiKey') + expect(openStore(dataFile).getSettings()).not.toHaveProperty('opencodeGoApiKey') + }) + it('reloads settings, secrets and both session partitions unchanged', () => { const dataFile = join( realpathSync(mkdtempSync(join(tmpdir(), 'orca-store-round-trip-'))), diff --git a/src/main/persistence/loading-store/store.ts b/src/main/persistence/loading-store/store.ts index d60f1a7e3e1..20528b93bac 100644 --- a/src/main/persistence/loading-store/store.ts +++ b/src/main/persistence/loading-store/store.ts @@ -17,6 +17,10 @@ import { } from './store-domain-composition' import type { PersistedState } from '../../../shared/persisted-state-types' import { scheduleSave } from './write-scheduling' +import { + migrateLegacyOpenCodeGoApiKey, + type OpenCodeGoApiKeyTarget +} from './legacy-opencode-go-api-key-migration' import { enqueuePrimaryStateOperation, writeToDiskAsync } from './primary-state-writes' import type { ProfileStateDatabaseQuarantine } from '../profile-state/profile-state-database-quarantine' import { writeVersionedProfileStateExport } from '../profile-state/legacy-json/profile-state-versioned-export' @@ -151,6 +155,13 @@ export class Store { } } + /** Moves the #22551 settings-slot OpenCode Go key into `target`; it stays on disk until that succeeds. */ + migrateLegacyOpenCodeGoApiKey(target: OpenCodeGoApiKeyTarget): void { + if (migrateLegacyOpenCodeGoApiKey(this.runtime.protectedSecrets, target)) { + scheduleSave(this.domains.scheduling) + } + } + getProfileStorageDirectory(): string { return dirname(this.runtime.dataFile) } diff --git a/src/main/protected-secret-persistence.ts b/src/main/protected-secret-persistence.ts index c72af096619..5a9c6ed3bc5 100644 --- a/src/main/protected-secret-persistence.ts +++ b/src/main/protected-secret-persistence.ts @@ -2,7 +2,6 @@ import { getSecretStore } from '../shared/secret-store' export const PROTECTED_SECRET_SLOT = { opencodeSessionCookie: 'settings.opencodeSessionCookie', - opencodeGoApiKey: 'settings.opencodeGoApiKey', httpProxyUrl: 'settings.httpProxyUrl', browserKagiSessionLink: 'ui.browserKagiSessionLink' } as const @@ -49,6 +48,17 @@ export class ProtectedSecretPersistence { this.pendingEncryption.delete(slot) } + /** Parks ciphertext without allowing an earlier pending write to replace it. */ + retainSealed(slot: string, blob: string): void { + this.removeRetainedBlob(slot) + this.retainedBlobs.set(slot, blob) + this.sealedSlots.add(slot) + } + + sealedBlob(slot: string): string | null { + return this.sealedSlots.has(slot) ? (this.retainedBlobs.get(slot) ?? null) : null + } + isSealed(slot: string, value: string): boolean { return this.sealedSlots.has(slot) && this.retainedBlobs.get(slot) === value } 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 d1842792eda..51786fef1ff 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 @@ -5,19 +5,33 @@ 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 { detectOpenCodeCredentialBackend } from '../opencode/opencode-credential-backend' +import { + listOpenCodeDatabases, + listOpenCodeDatabasesInDirectory +} from '../opencode-usage/opencode-database-discovery' import { getOpenCodeAuthFilePath, readOpenCodeAuthFileGoKey, resolveOpenCodeGoApiKey } from './opencode-go-api-key-source' +vi.mock('../opencode/opencode-credential-backend', () => ({ + detectOpenCodeCredentialBackend: vi.fn() +})) + // Placeholder values only — a real key must never reach a fixture. const SETTINGS_KEY = 'settings-placeholder-key' const ENVIRONMENT_KEY = 'environment-placeholder-key' const AUTH_FILE_KEY = 'auth-file-placeholder-key' const DATABASE_KEY = 'database-placeholder-key' -const ENVIRONMENT_KEYS = ['XDG_DATA_HOME', 'OPENCODE_API_KEY', 'OPENCODE_DB'] as const +const ENVIRONMENT_KEYS = [ + 'XDG_DATA_HOME', + 'OPENCODE_API_KEY', + 'OPENCODE_DB', + 'OPENCODE_AUTH_CONTENT' +] 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 })) @@ -61,10 +75,12 @@ describe('resolveOpenCodeGoApiKey', () => { } beforeEach(() => { + vi.mocked(detectOpenCodeCredentialBackend).mockReset().mockResolvedValue('v2') originalEnvironment = Object.fromEntries(ENVIRONMENT_KEYS.map((key) => [key, process.env[key]])) dataHome = mkdtempSync(join(tmpdir(), 'orca-opencode-go-key-')) process.env.XDG_DATA_HOME = dataHome delete process.env.OPENCODE_API_KEY + delete process.env.OPENCODE_AUTH_CONTENT // Keeps the credential-database tier from touching the developer's own store. process.env.OPENCODE_DB = ':memory:' }) @@ -82,6 +98,69 @@ describe('resolveOpenCodeGoApiKey', () => { rmSync(dataHome, { recursive: true, force: true }) }) + it.each([undefined, 'selected.db'] as const)( + 'uses the fourth-argument environment for discovery (%s)', + async (override) => { + const directory = join(dataHome, 'opencode') + mkdirSync(directory) + const path = join(directory, override ?? 'opencode.db') + writeFileSync(path, '') + const onFsError = vi.fn() + + await expect( + listOpenCodeDatabases(undefined, onFsError, undefined, { + XDG_DATA_HOME: dataHome, + OPENCODE_DB: override + }) + ).resolves.toEqual([path]) + expect(onFsError).not.toHaveBeenCalled() + expect(process.env.OPENCODE_DB).toBe(':memory:') + } + ) + + it('keeps filesystem error reporting in the second argument', async () => { + const error = Object.assign(new Error('discovery denied'), { code: 'EACCES' }) + readdirFailure.error = error + const onFsError = vi.fn() + + await expect( + listOpenCodeDatabases(undefined, onFsError, undefined, { XDG_DATA_HOME: dataHome }) + ).resolves.toEqual([]) + expect(onFsError).toHaveBeenCalledWith(join(dataHome, 'opencode'), error) + }) + + it.each([undefined, 'missing.db'] as const)( + 'preserves third-argument cancellation before reporting discovery errors (%s)', + async (override) => { + readdirFailure.error = new Error('discovery stopped') + const reason = new Error('caller cancelled') + const controller = new AbortController() + controller.abort(reason) + const onFsError = vi.fn() + + await expect( + listOpenCodeDatabases(undefined, onFsError, controller.signal, { + XDG_DATA_HOME: dataHome, + OPENCODE_DB: override + }) + ).rejects.toBe(reason) + expect(onFsError).not.toHaveBeenCalled() + } + ) + + it('preserves the native directory helper cancellation and error argument positions', async () => { + readdirFailure.error = new Error('directory read stopped') + const reason = new Error('caller cancelled') + const controller = new AbortController() + controller.abort(reason) + const onFsError = vi.fn() + + await expect( + listOpenCodeDatabasesInDirectory(dataHome, undefined, controller.signal, onFsError) + ).rejects.toBe(reason) + expect(onFsError).not.toHaveBeenCalled() + }) + it('reads auth.json from XDG_DATA_HOME, which OpenCode uses on every platform', () => { expect(getOpenCodeAuthFilePath({ XDG_DATA_HOME: '/data' })).toBe('/data/opencode/auth.json') // OpenCode's global-roots.ts falls back to os.homedir() + .local/share even on Windows. @@ -121,13 +200,13 @@ describe('resolveOpenCodeGoApiKey', () => { }) }) - it('falls back to the key OpenCode 1.x saved on /connect', async () => { + it('uses the key OpenCode 1.x saved on /connect', async () => { writeAuthFile({ anthropic: { type: 'oauth', refresh: 'r', access: 'a', expires: 1 }, 'opencode-go': { type: 'api', key: AUTH_FILE_KEY } }) - await expect(resolveOpenCodeGoApiKey({})).resolves.toEqual({ + await expect(resolveOpenCodeGoApiKey({ backend: 'v1' })).resolves.toEqual({ status: 'found', key: AUTH_FILE_KEY, tier: 'opencode-auth-file' @@ -231,6 +310,271 @@ describe('resolveOpenCodeGoApiKey', () => { }) }) + it('prefers the credential table over a stale auth.json, since OpenCode 2 stops writing the file', async () => { + process.env.OPENCODE_API_KEY = ENVIRONMENT_KEY + writeAuthFile({ 'opencode-go': { type: 'api', key: AUTH_FILE_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('uses v1 auth.json despite a conflicting populated credential table', async () => { + process.env.OPENCODE_API_KEY = ENVIRONMENT_KEY + writeAuthFile({ 'opencode-go': { type: 'api', key: AUTH_FILE_KEY } }) + const { path } = writeCredentialDatabase([ + { value: JSON.stringify({ type: 'key', key: DATABASE_KEY }), active: 1, created: 1 } + ]) + process.env.OPENCODE_DB = path + + await expect(resolveOpenCodeGoApiKey({ backend: 'v1' })).resolves.toEqual({ + status: 'found', + key: AUTH_FILE_KEY, + tier: 'opencode-auth-file' + }) + }) + + it('does not use a v2 table-only credential for v1 execution', 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({ backend: 'v1' })).resolves.toEqual({ + status: 'found', + key: ENVIRONMENT_KEY, + tier: 'environment' + }) + }) + + it('applies the installed v1 backend even when the table has a key', async () => { + vi.mocked(detectOpenCodeCredentialBackend).mockResolvedValue('v1') + writeAuthFile({ 'opencode-go': { type: 'api', key: AUTH_FILE_KEY } }) + process.env.OPENCODE_DB = writeCredentialDatabase([ + { value: JSON.stringify({ type: 'key', key: DATABASE_KEY }), active: 1, created: 1 } + ]).path + + await expect(resolveOpenCodeGoApiKey({})).resolves.toEqual({ + status: 'found', + key: AUTH_FILE_KEY, + tier: 'opencode-auth-file' + }) + }) + + it('uses v1 inline authentication ahead of a conflicting auth file and table', async () => { + writeAuthFile({ 'opencode-go': { type: 'api', key: AUTH_FILE_KEY } }) + process.env.OPENCODE_DB = writeCredentialDatabase([ + { value: JSON.stringify({ type: 'key', key: DATABASE_KEY }), active: 1, created: 1 } + ]).path + process.env.OPENCODE_AUTH_CONTENT = JSON.stringify({ + 'opencode-go': { type: 'api', key: 'inline-placeholder-key' } + }) + + await expect(resolveOpenCodeGoApiKey({ backend: 'v1' })).resolves.toEqual({ + status: 'found', + key: 'inline-placeholder-key', + tier: 'opencode-auth-content' + }) + }) + + it('does not fall back to the auth file when valid v1 inline auth omits Go', async () => { + writeAuthFile({ 'opencode-go': { type: 'api', key: AUTH_FILE_KEY } }) + process.env.OPENCODE_AUTH_CONTENT = '{}' + process.env.OPENCODE_API_KEY = ENVIRONMENT_KEY + + await expect(resolveOpenCodeGoApiKey({ backend: 'v1' })).resolves.toEqual({ + status: 'found', + key: ENVIRONMENT_KEY, + tier: 'environment' + }) + }) + + it('uses the v1 auth file after malformed inline authentication', async () => { + writeAuthFile({ 'opencode-go': { type: 'api', key: AUTH_FILE_KEY } }) + process.env.OPENCODE_AUTH_CONTENT = '{invalid' + + await expect(resolveOpenCodeGoApiKey({ backend: 'v1' })).resolves.toEqual({ + status: 'found', + key: AUTH_FILE_KEY, + tier: 'opencode-auth-file' + }) + }) + + it('withholds stored credentials when the execution backend cannot be determined', async () => { + vi.mocked(detectOpenCodeCredentialBackend).mockResolvedValue(null) + writeAuthFile({ 'opencode-go': { type: 'api', key: AUTH_FILE_KEY } }) + process.env.OPENCODE_DB = writeCredentialDatabase([ + { value: JSON.stringify({ type: 'key', key: DATABASE_KEY }), active: 1, created: 1 } + ]).path + + await expect(resolveOpenCodeGoApiKey({})).resolves.toEqual({ status: 'missing' }) + }) + + it('uses the provided environment key without reading stores when the backend is unknown', async () => { + vi.mocked(detectOpenCodeCredentialBackend).mockResolvedValue(null) + const environment: NodeJS.ProcessEnv = { OPENCODE_API_KEY: ` ${ENVIRONMENT_KEY} ` } + const readStoreContext = vi.fn(() => { + throw new Error('Unknown backend must not read version-specific stores') + }) + for (const name of ['OPENCODE_AUTH_CONTENT', 'XDG_DATA_HOME', 'OPENCODE_DB']) { + Object.defineProperty(environment, name, { get: readStoreContext }) + } + + await expect(resolveOpenCodeGoApiKey({ environment, cwd: dataHome })).resolves.toEqual({ + status: 'found', + key: ENVIRONMENT_KEY, + tier: 'environment' + }) + expect(vi.mocked(detectOpenCodeCredentialBackend).mock.calls[0]?.[0]).toBe(environment) + expect(vi.mocked(detectOpenCodeCredentialBackend).mock.calls[0]?.[1]).toBe(dataHome) + expect(readStoreContext).not.toHaveBeenCalled() + }) + + it.each([undefined, '', ' \t\n '])( + 'does not borrow the host key when an unknown selected backend has environment key %j', + async (key) => { + vi.mocked(detectOpenCodeCredentialBackend).mockResolvedValue(null) + process.env.OPENCODE_API_KEY = ENVIRONMENT_KEY + const environment: NodeJS.ProcessEnv = { OPENCODE_API_KEY: key } + + await expect(resolveOpenCodeGoApiKey({ environment })).resolves.toEqual({ status: 'missing' }) + expect(detectOpenCodeCredentialBackend).toHaveBeenCalledWith(environment, undefined) + } + ) + + it('keeps host and selected environment keys isolated when the backend is unknown', async () => { + vi.mocked(detectOpenCodeCredentialBackend).mockResolvedValue(null) + process.env.OPENCODE_API_KEY = ENVIRONMENT_KEY + const selected = { OPENCODE_API_KEY: 'selected-environment-placeholder' } + const inherited = { ...process.env } + + const first = await resolveOpenCodeGoApiKey({}) + const managed = await resolveOpenCodeGoApiKey({ environment: selected }) + const restored = await resolveOpenCodeGoApiKey({}) + + expect(first).toEqual({ status: 'found', key: ENVIRONMENT_KEY, tier: 'environment' }) + expect(managed).toEqual({ + status: 'found', + key: 'selected-environment-placeholder', + tier: 'environment' + }) + expect(restored).toEqual(first) + expect(process.env).toEqual(inherited) + expect(selected).toEqual({ OPENCODE_API_KEY: 'selected-environment-placeholder' }) + }) + + it.each(['v1', 'v2'] as const)( + 'uses caller backend authority for %s without probing', + async (backend) => { + vi.mocked(detectOpenCodeCredentialBackend).mockRejectedValue(new Error('Unexpected probe')) + process.env.OPENCODE_API_KEY = ENVIRONMENT_KEY + writeAuthFile({ 'opencode-go': { type: 'api', key: AUTH_FILE_KEY } }) + process.env.OPENCODE_DB = writeCredentialDatabase([ + { value: JSON.stringify({ type: 'key', key: DATABASE_KEY }), active: 1, created: 1 } + ]).path + + await expect(resolveOpenCodeGoApiKey({ backend })).resolves.toEqual( + backend === 'v1' + ? { status: 'found', key: AUTH_FILE_KEY, tier: 'opencode-auth-file' } + : { status: 'found', key: DATABASE_KEY, tier: 'opencode-credential-database' } + ) + expect(detectOpenCodeCredentialBackend).not.toHaveBeenCalled() + } + ) + + it('returns the manual override before reading execution context or probing a CLI', async () => { + const input = { settingsOverride: SETTINGS_KEY } + Object.defineProperty(input, 'environment', { + get: () => { + throw new Error('Selected profile metadata is unreadable') + } + }) + vi.mocked(detectOpenCodeCredentialBackend).mockRejectedValue(new Error('Unexpected probe')) + + await expect(resolveOpenCodeGoApiKey(input)).resolves.toEqual({ + status: 'found', + key: SETTINGS_KEY, + tier: 'settings' + }) + expect(detectOpenCodeCredentialBackend).not.toHaveBeenCalled() + }) + + it('propagates unreadable selected context instead of reading host credentials', async () => { + writeAuthFile({ 'opencode-go': { type: 'api', key: AUTH_FILE_KEY } }) + const input = {} + Object.defineProperty(input, 'environment', { + get: () => { + throw new Error('Selected profile metadata is unreadable') + } + }) + + await expect(resolveOpenCodeGoApiKey(input)).rejects.toThrow( + 'Selected profile metadata is unreadable' + ) + }) + + it.each(['v1', 'v2'] as const)( + 'keeps System and selected data roots isolated for %s', + async (backend) => { + writeAuthFile({ 'opencode-go': { type: 'api', key: AUTH_FILE_KEY } }) + const system = { XDG_DATA_HOME: dataHome, OPENCODE_DB: ':memory:' } + const selectedDataHome = join(dataHome, 'selected') + mkdirSync(join(selectedDataHome, 'opencode'), { recursive: true }) + writeFileSync( + join(selectedDataHome, 'opencode', 'auth.json'), + JSON.stringify({ + 'opencode-go': { type: 'api', key: 'selected-auth-placeholder' } + }) + ) + const { path } = writeCredentialDatabase([ + { + value: JSON.stringify({ type: 'key', key: 'selected-table-placeholder' }), + active: 1, + created: 1 + } + ]) + const selected = { XDG_DATA_HOME: selectedDataHome, OPENCODE_DB: path } + const inherited = { ...process.env } + + const first = await resolveOpenCodeGoApiKey({ environment: system, backend }) + const managed = await resolveOpenCodeGoApiKey({ environment: selected, backend }) + const restored = await resolveOpenCodeGoApiKey({ environment: system, backend }) + + expect(first).toEqual({ status: 'found', key: AUTH_FILE_KEY, tier: 'opencode-auth-file' }) + expect(managed).toEqual( + backend === 'v1' + ? { status: 'found', key: 'selected-auth-placeholder', tier: 'opencode-auth-file' } + : { + status: 'found', + key: 'selected-table-placeholder', + tier: 'opencode-credential-database' + } + ) + expect(restored).toEqual(first) + expect(process.env).toEqual(inherited) + } + ) + + it('keeps the settings override above the credential table', async () => { + const { path } = writeCredentialDatabase([ + { value: JSON.stringify({ type: 'key', key: DATABASE_KEY }), active: 1, created: 1 } + ]) + process.env.OPENCODE_DB = path + + await expect(resolveOpenCodeGoApiKey({ settingsOverride: SETTINGS_KEY })).resolves.toEqual({ + status: 'found', + key: SETTINGS_KEY, + tier: 'settings' + }) + }) + 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 f722e4cbeaa..52a713f0058 100644 --- a/src/main/rate-limits/opencode-go-api-key-source.ts +++ b/src/main/rate-limits/opencode-go-api-key-source.ts @@ -8,6 +8,10 @@ import { tableExists } from '../opencode-usage/schema-helpers' import { isWslUncPath } from '../../shared/wsl-paths' import { resolveOpenCodeDataDirectory } from '../opencode/opencode-data-directory' import Database from '../sqlite/sync-database' +import { + detectOpenCodeCredentialBackend, + type OpenCodeCredentialBackend +} from '../opencode/opencode-credential-backend' /** OpenCode's provider/integration id for the Go subscription. */ const OPENCODE_GO_INTEGRATION_ID = 'opencode-go' @@ -20,6 +24,7 @@ const MAX_AUTH_FILE_BYTES = 1_000_000 export type OpenCodeGoApiKeyTier = | 'settings' | 'environment' + | 'opencode-auth-content' | 'opencode-auth-file' | 'opencode-credential-database' @@ -61,6 +66,27 @@ function trimmedKey(value: unknown): string | null { return trimmed ? trimmed : null } +function readOpenCodeInlineGoKey(environment: NodeJS.ProcessEnv): { + configured: boolean + key: string | null +} { + const content = environment.OPENCODE_AUTH_CONTENT + if (content) { + try { + const parsed: unknown = JSON.parse(content) + return { + configured: true, + key: isRecord(parsed) + ? keyFromCredentialRecord(parsed[OPENCODE_GO_INTEGRATION_ID], 'api') + : null + } + } catch { + // V1 ignores invalid inline JSON and then reads auth.json. + } + } + return { configured: false, key: null } +} + /** * Read the `opencode-go` API key OpenCode 1.x writes on `/connect`. * @@ -87,8 +113,6 @@ export function readOpenCodeAuthFileGoKey( } return keyFromCredentialRecord(parsed[OPENCODE_GO_INTEGRATION_ID], 'api') } catch { - // Why: a malformed or unreadable auth file is "no key here", not a fetch - // failure — later tiers and the cookie path still deserve their turn. return null } } @@ -135,22 +159,28 @@ function selectCredentialKey(database: Database.Database): string | null { * OpenCode 2 imports `auth.json` into SQLite once (migration * `20260805200742_import_legacy_credentials`) and every later `/connect` writes * 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. + * V1 also creates this table and can populate it through integration routes; + * only a caller with V2 execution authority should use it for Go credentials. * @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. + * `unreadable` when discovery, opening, or querying failed without a key. */ -export async function readOpenCodeCredentialDatabaseGoKey(): Promise { +export async function readOpenCodeCredentialDatabaseGoKey( + environment: NodeJS.ProcessEnv = process.env +): Promise { let sawUnreadable = false let paths: string[] try { - 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 - } - }) + 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 + } + }, + undefined, + environment + ) paths = [...listed].sort(compareOpenCodeClaimPriority) } catch { return { status: 'missing' } @@ -184,38 +214,46 @@ export async function readOpenCodeCredentialDatabaseGoKey(): Promise { - const environment = input.environment ?? process.env const override = trimmedKey(input.settingsOverride) if (override) { return { status: 'found', key: override, tier: 'settings' } } - const fromAuthFile = readOpenCodeAuthFileGoKey(environment) + const environment = input.environment ?? process.env + const backend = input.backend ?? (await detectOpenCodeCredentialBackend(environment, input.cwd)) + let databaseUnreadable = false + if (backend === 'v2') { + const fromDatabase = await readOpenCodeCredentialDatabaseGoKey(environment) + if (fromDatabase.status === 'found') { + return { status: 'found', key: fromDatabase.key, tier: 'opencode-credential-database' } + } + databaseUnreadable = fromDatabase.status === 'unreadable' + } + const inline = backend === 'v1' ? readOpenCodeInlineGoKey(environment) : null + if (inline?.key) { + return { status: 'found', key: inline.key, tier: 'opencode-auth-content' } + } + const fromAuthFile = + backend && !inline?.configured ? readOpenCodeAuthFileGoKey(environment) : null if (fromAuthFile) { return { status: 'found', key: fromAuthFile, tier: 'opencode-auth-file' } } - const fromDatabase = await readOpenCodeCredentialDatabaseGoKey() - if (fromDatabase.status === 'found') { - return { status: 'found', key: fromDatabase.key, tier: 'opencode-credential-database' } - } const fromEnvironment = trimmedKey(environment[OPENCODE_API_KEY_ENV]) - if (fromEnvironment && fromDatabase.status === 'unreadable') { + if (fromEnvironment && databaseUnreadable) { return { status: 'credential-database-unreadable' } } if (fromEnvironment) { diff --git a/src/main/rate-limits/opencode-go-usage-source-selection.test.ts b/src/main/rate-limits/opencode-go-usage-source-selection.test.ts index d8fb15e145a..64b7ac622ca 100644 --- a/src/main/rate-limits/opencode-go-usage-source-selection.test.ts +++ b/src/main/rate-limits/opencode-go-usage-source-selection.test.ts @@ -76,6 +76,44 @@ describe('fetchOpenCodeGoUsage', () => { expect(resolveApiKeyMock).toHaveBeenCalledWith({ settingsOverride: API_KEY }) }) + it('passes trusted execution context and selected roots through to credential lookup', async () => { + resolveApiKeyMock.mockResolvedValue({ status: 'missing' }) + const environment = { XDG_DATA_HOME: '/task/selected', OPENCODE_DB: ':memory:' } + + await fetchOpenCodeGoUsage({ cookie: '', backend: 'v1', environment, cwd: '/task/workspace' }) + + expect(resolveApiKeyMock).toHaveBeenCalledExactlyOnceWith({ + settingsOverride: undefined, + backend: 'v1', + environment, + cwd: '/task/workspace' + }) + }) + + it('passes a manual override without evaluating selected execution context', async () => { + resolveApiKeyMock.mockResolvedValue({ status: 'found', key: API_KEY, tier: 'settings' }) + fetchWithApiKeyMock.mockResolvedValue({ kind: 'ok', windows: WINDOWS }) + const input = { cookie: '', settingsApiKey: API_KEY } + Object.defineProperty(input, 'environment', { + get: () => { + throw new Error('Selected profile metadata is unreadable') + } + }) + + expect((await fetchOpenCodeGoUsage(input)).status).toBe('ok') + expect(resolveApiKeyMock).toHaveBeenCalledExactlyOnceWith({ settingsOverride: API_KEY }) + }) + + it('propagates a rejected selected-account lookup without trying host or cookie credentials', async () => { + resolveApiKeyMock.mockRejectedValue(new Error('Selected profile metadata is unreadable')) + + await expect(fetchOpenCodeGoUsage({ cookie: COOKIE })).rejects.toThrow( + 'Selected profile metadata is unreadable' + ) + expect(fetchWithApiKeyMock).not.toHaveBeenCalled() + expect(fetchWithCookieMock).not.toHaveBeenCalled() + }) + it('names the missing subscription instead of a generic refresh failure', async () => { resolveApiKeyMock.mockResolvedValue({ status: 'found', 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 61378eddb14..c38fc1dc844 100644 --- a/src/main/rate-limits/opencode-go-usage-source-selection.ts +++ b/src/main/rate-limits/opencode-go-usage-source-selection.ts @@ -5,6 +5,7 @@ import { type OpenCodeGoApiKeyResolution } from './opencode-go-api-key-source' import type { OpenCodeGoUsageWindows } from './opencode-go-status-parsing' +import type { OpenCodeCredentialBackend } from '../opencode/opencode-credential-backend' import { fetchOpenCodeGoUsageWithApiKey, type OpenCodeGoUsageApiOutcome @@ -14,6 +15,9 @@ import { fetchOpenCodeGoRateLimits, normalizeCookieInput } from './opencode-go-u export type OpenCodeGoUsageSourceInput = { /** Explicit Orca override; the highest-precedence key tier. */ settingsApiKey?: string + environment?: NodeJS.ProcessEnv + backend?: OpenCodeCredentialBackend + cwd?: string cookie: string workspaceIdOverride?: string networkProxySettings?: NetworkProxySettings @@ -96,8 +100,16 @@ function apiFailureResult( export async function fetchOpenCodeGoUsage( input: OpenCodeGoUsageSourceInput ): Promise { + const context = input.settingsApiKey?.trim() + ? {} + : { + ...(input.environment ? { environment: input.environment } : {}), + ...(input.backend ? { backend: input.backend } : {}), + ...(input.cwd ? { cwd: input.cwd } : {}) + } const apiKeyResolution = await resolveOpenCodeGoApiKey({ - settingsOverride: input.settingsApiKey + settingsOverride: input.settingsApiKey, + ...context }) input.onApiKeyResolved?.(apiKeyResolution) const hasCookie = Boolean(normalizeCookieInput(input.cookie)) diff --git a/src/main/rate-limits/service-opencode-go-credentials.test.ts b/src/main/rate-limits/service-opencode-go-credentials.test.ts new file mode 100644 index 00000000000..5e98ce4a1d6 --- /dev/null +++ b/src/main/rate-limits/service-opencode-go-credentials.test.ts @@ -0,0 +1,195 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ProviderRateLimits } from '../../shared/rate-limit-types' +import { RateLimitService } from './service' +import { fetchClaudeRateLimits } from './claude-fetcher' +import { fetchCodexRateLimits } from './codex-fetcher' +import { fetchOpenCodeGoUsage } from './opencode-go-usage-source-selection' +import { ApiKeyFileUnreadableError } from '../credentials/api-key-file-unreadable-error' +import { + deferred, + flushMicrotasks, + okProvider, + resetRateLimitProviderMocks, + unavailableProvider +} from './rate-limit-service-test-harness' + +vi.mock('./claude-fetcher', () => ({ + fetchClaudeRateLimits: vi.fn(), + fetchManagedAccountUsage: vi.fn() +})) + +vi.mock('./codex-fetcher', () => ({ + consumeCodexRateLimitResetCredit: vi.fn(), + fetchCodexRateLimits: vi.fn() +})) + +vi.mock('./gemini-usage-fetcher', () => ({ + fetchGeminiRateLimits: vi.fn() +})) + +vi.mock('./kimi-fetcher', () => ({ + fetchKimiRateLimits: vi.fn() +})) + +vi.mock('./cursor-fetcher', () => ({ fetchCursorRateLimits: vi.fn() })) +vi.mock('./cursor-auth', () => ({ readCursorAuthSession: vi.fn() })) +vi.mock('./zcode-usage-fetcher', () => ({ fetchZcodeRateLimits: vi.fn() })) +vi.mock('./antigravity-usage-fetcher', () => ({ fetchAntigravityRateLimits: vi.fn() })) + +vi.mock('./opencode-go-usage-source-selection', () => ({ + fetchOpenCodeGoUsage: vi.fn() +})) + +vi.mock('./minimax/minimax-fetcher', () => ({ + fetchMiniMaxRateLimits: vi.fn() +})) + +vi.mock('./grok-fetcher', () => ({ + fetchGrokRateLimits: vi.fn() +})) + +vi.mock('./grok-auth', () => ({ + readGrokAuthSession: vi.fn(() => ({ status: 'missing' })) +})) + +vi.mock('../minimax/minimax-cookie-store', () => ({ + hasMiniMaxSessionCookie: vi.fn(() => false) +})) + +const DECRYPT_ERROR = + 'OpenCode Go API key could not be decrypted. Re-enter or clear the key in Settings.' + +function serviceWithCookie(apiKeyResolver: () => string | null): RateLimitService { + const service = new RateLimitService() + service.setOpenCodeGoConfigResolver( + () => ({ sessionCookie: 'auth=fake-cookie', workspaceIdOverride: '' }), + apiKeyResolver + ) + return service +} + +function undecryptableKey(): string | null { + throw new Error('OpenCode Go API key could not be decrypted') +} + +describe('OpenCode Go credential state', () => { + beforeEach(() => { + resetRateLimitProviderMocks() + vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 0)) + vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 0)) + }) + + it('treats an undecryptable saved key as absent so the cookie still produces usage', async () => { + const service = serviceWithCookie(undecryptableKey) + vi.mocked(fetchOpenCodeGoUsage).mockResolvedValueOnce(okProvider('opencode-go', 40)) + + await service.refresh() + + expect(fetchOpenCodeGoUsage).toHaveBeenCalledWith( + expect.objectContaining({ settingsApiKey: '', cookie: 'auth=fake-cookie' }) + ) + const state = service.getState() + expect(state.opencodeGo?.status).toBe('ok') + expect(state.opencodeGo?.error).toBeNull() + }) + + it('shows the decrypt error only when no other source produced usage', async () => { + const service = serviceWithCookie(undecryptableKey) + vi.mocked(fetchOpenCodeGoUsage).mockImplementationOnce(async (input) => { + input.onApiKeyResolved?.({ status: 'missing' }) + return unavailableProvider('opencode-go', 'No OpenCode Go API key or session cookie') + }) + + await service.refresh() + + const state = service.getState() + expect(state.opencodeGo?.status).toBe('error') + expect(state.opencodeGo?.error).toBe(DECRYPT_ERROR) + // Why: the bar must stay visible to surface how to fix the saved key. + expect(state.opencodeGoApiKeyConfigured).toBe(true) + }) + + it('skips a transiently unreadable saved key without blaming it, keeping the bar visible', async () => { + const service = serviceWithCookie(() => { + throw new ApiKeyFileUnreadableError('OpenCode Go API key file could not be read') + }) + vi.mocked(fetchOpenCodeGoUsage).mockImplementationOnce(async (input) => { + input.onApiKeyResolved?.({ status: 'missing' }) + return unavailableProvider('opencode-go', 'No OpenCode Go API key or session cookie') + }) + + await service.refresh() + + expect(fetchOpenCodeGoUsage).toHaveBeenCalledWith( + expect.objectContaining({ settingsApiKey: '', cookie: 'auth=fake-cookie' }) + ) + const state = service.getState() + expect(state.opencodeGo?.status).toBe('unavailable') + expect(state.opencodeGo?.error).not.toBe(DECRYPT_ERROR) + expect(state.opencodeGoApiKeyConfigured).toBe(true) + }) + + it('keeps a real cookie error instead of the decrypt error', async () => { + const service = serviceWithCookie(undecryptableKey) + vi.mocked(fetchOpenCodeGoUsage).mockResolvedValueOnce({ + ...unavailableProvider('opencode-go', 'OpenCode session cookie expired'), + status: 'error' + }) + + await service.refresh() + + const state = service.getState() + expect(state.opencodeGo?.status).toBe('error') + expect(state.opencodeGo?.error).toBe('OpenCode session cookie expired') + }) + + it('keeps the chip visible across a cookie change while a key source exists', async () => { + const service = serviceWithCookie(() => null) + vi.mocked(fetchOpenCodeGoUsage).mockImplementation(async (input) => { + input.onApiKeyResolved?.({ status: 'found', key: 'fake-env-key', tier: 'environment' }) + return okProvider('opencode-go', 10) + }) + await service.refresh() + expect(service.getState().opencodeGoApiKeyConfigured).toBe(true) + + service.invalidateOpenCodeGoCredentialState() + + expect(service.getState().opencodeGo?.status).toBe('fetching') + expect(service.getState().opencodeGoApiKeyConfigured).toBe(true) + + service.invalidateOpenCodeGoCredentialState({ apiKeyCleared: true }) + expect(service.getState().opencodeGoApiKeyConfigured).toBe(false) + }) + + it('passes the saved key to the fetch as the settings override', async () => { + const service = serviceWithCookie(() => 'fake-saved-key') + + await service.refresh() + + expect(fetchOpenCodeGoUsage).toHaveBeenCalledWith( + expect.objectContaining({ settingsApiKey: 'fake-saved-key' }) + ) + }) + + it('drops an in-flight result fetched with a credential that was since replaced', async () => { + const service = serviceWithCookie(() => 'fake-old-key') + const pending = deferred() + let resolvedWithOldKey: (() => void) | undefined + vi.mocked(fetchOpenCodeGoUsage).mockImplementationOnce((input) => { + resolvedWithOldKey = () => + input.onApiKeyResolved?.({ status: 'found', key: 'fake-old-key', tier: 'settings' }) + return pending.promise + }) + + const refresh = service.refresh() + await flushMicrotasks() + service.invalidateOpenCodeGoCredentialState() + resolvedWithOldKey?.() + pending.resolve(okProvider('opencode-go', 90)) + await refresh + + const state = service.getState() + expect(state.opencodeGo?.session?.usedPercent).not.toBe(90) + expect(state.opencodeGoApiKeyConfigured).toBe(false) + }) +}) diff --git a/src/main/rate-limits/service-refresh-orchestration.test.ts b/src/main/rate-limits/service-refresh-orchestration.test.ts index e0a1d9c9b04..2cef94ba2b3 100644 --- a/src/main/rate-limits/service-refresh-orchestration.test.ts +++ b/src/main/rate-limits/service-refresh-orchestration.test.ts @@ -431,8 +431,7 @@ describe('RateLimitService', () => { const service = new RateLimitService() service.setOpenCodeGoConfigResolver(() => ({ sessionCookie: 'session=abc123', - workspaceIdOverride: '', - apiKey: '' + workspaceIdOverride: '' })) const networkProxySettings = { httpProxyUrl: 'http://proxy.example:8080', @@ -562,8 +561,7 @@ describe('RateLimitService', () => { const service = new RateLimitService() service.setOpenCodeGoConfigResolver(() => ({ sessionCookie: '', - workspaceIdOverride: '', - apiKey: '' + workspaceIdOverride: '' })) vi.mocked(fetchClaudeRateLimits).mockRejectedValueOnce(new Error('claude down')) @@ -587,8 +585,7 @@ describe('RateLimitService', () => { let cookie = 'session=valid' service.setOpenCodeGoConfigResolver(() => ({ sessionCookie: cookie, - workspaceIdOverride: '', - apiKey: '' + workspaceIdOverride: '' })) // 1. Success fetch @@ -624,8 +621,7 @@ describe('RateLimitService', () => { let workspaceId = 'wrk_A' service.setOpenCodeGoConfigResolver(() => ({ sessionCookie: 'session=valid', - workspaceIdOverride: workspaceId, - apiKey: '' + workspaceIdOverride: workspaceId })) // 1. Success fetch for Workspace A diff --git a/src/main/rate-limits/service/service-account-refresh.ts b/src/main/rate-limits/service/service-account-refresh.ts index e6090e28228..6aeacd99583 100644 --- a/src/main/rate-limits/service/service-account-refresh.ts +++ b/src/main/rate-limits/service/service-account-refresh.ts @@ -29,6 +29,19 @@ export abstract class RateLimitServiceAccountRefresh extends RateLimitServiceIna return this.getState() } + invalidateOpenCodeGoCredentialState(options: { apiKeyCleared?: boolean } = {}): void { + this.opencodeFetchGeneration += 1 + // Why: a key from env, OpenCode's DB or auth.json survives a cookie change; only clearing the saved key hides the chip. + if (options.apiKeyCleared) { + this.openCodeGoApiKeyConfigured = false + } + // Why: a credential change must discard the snapshot and any result still in flight. + this.updateState({ + ...this.state, + opencodeGo: this.withFetchingStatus(null, 'opencode-go') + }) + } + invalidateMiniMaxCredentialState(): void { this.minimaxFetchGeneration += 1 // Why: saving/forgetting the cookie can race an in-flight fetch; clear the visible snapshot before any old-cookie result returns. diff --git a/src/main/rate-limits/service/service-configuration.ts b/src/main/rate-limits/service/service-configuration.ts index b2acd9882ed..fd9dcf1375c 100644 --- a/src/main/rate-limits/service/service-configuration.ts +++ b/src/main/rate-limits/service/service-configuration.ts @@ -43,8 +43,12 @@ export abstract class RateLimitServiceConfiguration extends RateLimitServiceAcco this.claudeFetchTarget = normalizeClaudeAccountSelectionTarget(target) } - setOpenCodeGoConfigResolver(resolver: () => OpenCodeGoRateLimitConfig): void { + setOpenCodeGoConfigResolver( + resolver: () => OpenCodeGoRateLimitConfig, + apiKeyResolver?: () => string | null + ): void { this.openCodeGoConfigResolver = resolver + this.openCodeGoApiKeyResolver = apiKeyResolver ?? null } setMiniMaxConfigResolver(resolver: () => MiniMaxRateLimitConfig): void { diff --git a/src/main/rate-limits/service/service-fetch-targets.ts b/src/main/rate-limits/service/service-fetch-targets.ts index 93dc9e20553..3da1389d483 100644 --- a/src/main/rate-limits/service/service-fetch-targets.ts +++ b/src/main/rate-limits/service/service-fetch-targets.ts @@ -7,6 +7,7 @@ import { type CodexAccountSelectionTarget, type MiniMaxResolvedConfig, type ZcodePlanResolvedConfig, + type OpenCodeGoResolvedConfig, type NormalizedCodexAccountSelectionTarget, type NormalizedClaudeAccountSelectionTarget, type ProviderRateLimits, @@ -14,6 +15,7 @@ import { toErrorMessage } from './service-types' import type { CodexRateLimitResetOutcome } from '../../../shared/rate-limit-types' +import { ApiKeyFileUnreadableError } from '../../credentials/api-key-file-unreadable-error' const CODEX_RESET_REFRESH_RETRIES = 3 const CODEX_RESET_REFRESH_DELAY_MS = 250 @@ -188,6 +190,34 @@ export abstract class RateLimitServiceFetchTargets extends RateLimitServiceResul return process.platform !== 'win32' } + protected resolveOpenCodeGoConfig(): OpenCodeGoResolvedConfig { + const config = this.openCodeGoConfigResolver?.() ?? { + sessionCookie: '', + workspaceIdOverride: '' + } + try { + return { + ...config, + apiKey: this.openCodeGoApiKeyResolver?.() ?? '', + apiKeyError: null, + apiKeyReadSkipped: false + } + } catch (error) { + // Why: a transient read failure says nothing about the key, so skip it this cycle without blaming it. + if (error instanceof ApiKeyFileUnreadableError) { + return { ...config, apiKey: '', apiKeyError: null, apiKeyReadSkipped: true } + } + // Why: an unreadable saved key is treated as absent so the cookie and OpenCode's own key still run. + return { + ...config, + apiKey: '', + apiKeyError: + 'OpenCode Go API key could not be decrypted. Re-enter or clear the key in Settings.', + apiKeyReadSkipped: false + } + } + } + protected resolveMiniMaxConfig(): MiniMaxResolvedConfig { try { return { diff --git a/src/main/rate-limits/service/service-full-cycle-preparation.ts b/src/main/rate-limits/service/service-full-cycle-preparation.ts index 842a2d02244..f847cbf1509 100644 --- a/src/main/rate-limits/service/service-full-cycle-preparation.ts +++ b/src/main/rate-limits/service/service-full-cycle-preparation.ts @@ -84,10 +84,12 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ ? null : this.getCodexProvenance(codexTarget, codexHomePath) const codexGeneration = this.codexFetchGeneration - const openCodeGoConfig = this.openCodeGoConfigResolver?.() - const cookie = openCodeGoConfig?.sessionCookie ?? '' - const workspaceIdOverride = openCodeGoConfig?.workspaceIdOverride ?? '' - const openCodeGoApiKey = openCodeGoConfig?.apiKey ?? '' + const openCodeGoConfig = this.resolveOpenCodeGoConfig() + const cookie = openCodeGoConfig.sessionCookie + const workspaceIdOverride = openCodeGoConfig.workspaceIdOverride + const openCodeGoApiKey = openCodeGoConfig.apiKey + const openCodeGoApiKeyError = openCodeGoConfig.apiKeyError + const openCodeGoApiKeyReadSkipped = openCodeGoConfig.apiKeyReadSkipped const miniMaxConfigResult = this.resolveMiniMaxConfig() const miniMaxCookie = miniMaxConfigResult.config.sessionCookie const miniMaxGroupId = miniMaxConfigResult.config.groupId @@ -104,7 +106,7 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ const apiKeyFingerprint = openCodeGoApiKey ? createHash('sha256').update(openCodeGoApiKey).digest('hex') : '' - const currentConfigHash = `${cookie}|${workspaceIdOverride}|${apiKeyFingerprint}` + const currentConfigHash = `${cookie}|${workspaceIdOverride}|${apiKeyFingerprint}|${openCodeGoApiKeyError ?? ''}` const opencodeConfigChanged = currentConfigHash !== this.lastOpencodeConfigHash if (opencodeConfigChanged) { this.lastOpencodeConfigHash = currentConfigHash @@ -236,7 +238,15 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ // Why here: the key can also come from the environment or OpenCode's // own store, so presence is only known once the fetch resolves it. onApiKeyResolved: (resolution) => { - this.openCodeGoApiKeyConfigured = resolution.status === 'found' + // Why: a credential change mid-fetch bumps the generation; its stale presence must not win. + if (opencodeGeneration !== this.opencodeFetchGeneration) { + return + } + // An undecryptable or briefly unreadable saved key still counts, so the bar stays up. + this.openCodeGoApiKeyConfigured = + resolution.status === 'found' || + openCodeGoApiKeyError !== null || + openCodeGoApiKeyReadSkipped }, cookie, workspaceIdOverride: workspaceIdOverride || undefined, @@ -258,6 +268,18 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ if (signal.aborted) { return null } + // Why: the decrypt error only replaces a result with no usage and no diagnosis of its own; a real cookie error stays visible. + if ( + openCodeGoApiKeyError && + opencodeGoResult.status === 'fulfilled' && + opencodeGoResult.value.status === 'unavailable' + ) { + opencodeGoResult.value = { + ...opencodeGoResult.value, + error: openCodeGoApiKeyError, + status: 'error' + } + } return { claudeTarget, claudeGeneration, diff --git a/src/main/rate-limits/service/service-state.ts b/src/main/rate-limits/service/service-state.ts index 78f59b281bf..0ec13020a6e 100644 --- a/src/main/rate-limits/service/service-state.ts +++ b/src/main/rate-limits/service/service-state.ts @@ -103,6 +103,7 @@ export abstract class RateLimitServiceState { wslDistro: null } protected openCodeGoConfigResolver: (() => OpenCodeGoRateLimitConfig) | null = null + protected openCodeGoApiKeyResolver: (() => string | null) | null = null protected miniMaxConfigResolver: (() => MiniMaxRateLimitConfig) | null = null protected zcodePlanConfigResolver: (() => ZcodePlanRateLimitConfig) | null = null protected geminiCliOAuthEnabledResolver: GeminiCliOAuthEnabledResolver | null = null diff --git a/src/main/rate-limits/service/service-types.ts b/src/main/rate-limits/service/service-types.ts index c999277f61f..5bae83b1449 100644 --- a/src/main/rate-limits/service/service-types.ts +++ b/src/main/rate-limits/service/service-types.ts @@ -45,8 +45,15 @@ export type ClaudeAuthPreparationResolver = ( export type OpenCodeGoRateLimitConfig = { sessionCookie: string workspaceIdOverride: string - /** Explicit Orca override; empty means fall back to env and OpenCode's own store. */ +} + +export type OpenCodeGoResolvedConfig = OpenCodeGoRateLimitConfig & { + /** Explicit Orca override; empty means fall back to OpenCode's own store and env. */ apiKey: string + /** Set when the saved override exists but cannot be decrypted. */ + apiKeyError: string | null + /** Set when the saved override exists but a transient read failure skipped it this cycle. */ + apiKeyReadSkipped: boolean } export type MiniMaxRateLimitConfig = { diff --git a/src/main/startup/main-process-account-services.ts b/src/main/startup/main-process-account-services.ts index a974c7d9edc..63f32663f73 100644 --- a/src/main/startup/main-process-account-services.ts +++ b/src/main/startup/main-process-account-services.ts @@ -16,6 +16,11 @@ import { getKimiRuntimeTarget, resolveKimiHome } from '../kimi/kimi-runtime-home import { readMiniMaxSessionCookie } from '../minimax/minimax-cookie-store' import { readMiniMaxApiKey } from '../minimax/minimax-api-key-store' import { readZcodePlanApiKey } from '../zcode/zcode-plan-api-key-store' +import { + hasOpenCodeGoApiKey, + readOpenCodeGoApiKey, + saveOpenCodeGoApiKey +} from '../opencode/opencode-go-api-key-store' import { createAccountRuntimeTargetSettingsSync } from '../rate-limits/account-runtime-target-sync' import { normalizeCodexRuntimeSelection } from '../codex-accounts/runtime-selection' import { normalizeClaudeRuntimeSelection } from '../claude-accounts/runtime-selection' @@ -88,6 +93,15 @@ export function initializeMainProcessAccountServices(): void { void syncAccountRuntimeTargets(updates, settings).catch((error) => console.warn('[rate-limits] Failed to apply account runtime target:', error) ) + if ('opencodeSessionCookie' in updates || 'opencodeWorkspaceId' in updates) { + state.rateLimits?.invalidateOpenCodeGoCredentialState() + void state.rateLimits?.refresh().catch((error: unknown) => { + console.warn( + '[rate-limits] Failed to refresh OpenCode Go usage after a settings change:', + error + ) + }) + } // Why: these three pick the MiniMax host and quota bucket, so a stale snapshot from the // previous endpoint would otherwise sit in the status bar until the next poll. if ( @@ -122,14 +136,18 @@ export function initializeMainProcessAccountServices(): void { agentHookServer.setClaudeStatusLineListener((event) => { state.rateLimits!.ingestLiveClaudeRateLimits(event) }) + store.migrateLegacyOpenCodeGoApiKey({ + has: hasOpenCodeGoApiKey, + read: readOpenCodeGoApiKey, + save: saveOpenCodeGoApiKey + }) state.rateLimits.setOpenCodeGoConfigResolver(() => { const settings = store.getSettings() return { sessionCookie: settings.opencodeSessionCookie, - workspaceIdOverride: settings.opencodeWorkspaceId, - apiKey: settings.opencodeGoApiKey + workspaceIdOverride: settings.opencodeWorkspaceId } - }) + }, readOpenCodeGoApiKey) state.rateLimits.setMiniMaxConfigResolver(() => { const settings = store.getSettings() const apiKey = readMiniMaxApiKey() ?? '' diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 6c395100f90..cf5060fab89 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -142,6 +142,11 @@ export type PreloadApi = { runtime: RuntimeApi['runtime'] runtimeEnvironments: RuntimeApi['runtimeEnvironments'] rateLimits: RateLimitsApi + opencodeGoCredentials: { + getStatus: () => Promise<{ apiKeyConfigured: boolean }> + saveApiKey: (key: string) => Promise<{ apiKeyConfigured: boolean }> + clearApiKey: () => Promise<{ apiKeyConfigured: boolean }> + } minimaxCredentials: MinimaxCredentialsApi zcodePlanCredentials: ZcodePlanCredentialsApi grokAccounts: GrokAccountsApi diff --git a/src/preload/api/opencode-go-credentials-bridge.ts b/src/preload/api/opencode-go-credentials-bridge.ts new file mode 100644 index 00000000000..35e1a668bc1 --- /dev/null +++ b/src/preload/api/opencode-go-credentials-bridge.ts @@ -0,0 +1,11 @@ +import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' + +export const opencodeGoCredentialsApi = { + getStatus: (): Promise<{ apiKeyConfigured: boolean }> => + ipcRenderer.invoke('opencodeGoCredentials:getStatus'), + saveApiKey: (key: string): Promise<{ apiKeyConfigured: boolean }> => + ipcRenderer.invoke('opencodeGoCredentials:saveApiKey', key), + clearApiKey: (): Promise<{ apiKeyConfigured: boolean }> => + ipcRenderer.invoke('opencodeGoCredentials:clearApiKey') +} satisfies PreloadApi['opencodeGoCredentials'] diff --git a/src/preload/index.ts b/src/preload/index.ts index a68a3965070..5f64a1e2e35 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -75,6 +75,7 @@ import { nativeChatApi } from './api/native-chat-bridge' import { runtimeApi } from './api/runtime-bridge' import { runtimeEnvironmentsApi } from './api/runtime-environments-bridge' import { rateLimitsApi } from './api/rate-limits-bridge' +import { opencodeGoCredentialsApi } from './api/opencode-go-credentials-bridge' import { minimaxCredentialsApi } from './api/minimax-credentials-bridge' import { zcodePlanCredentialsApi } from './api/zcode-plan-credentials-bridge' import { grokAccountsApi } from './api/grok-accounts-bridge' @@ -175,6 +176,7 @@ const api = { runtime: runtimeApi, runtimeEnvironments: runtimeEnvironmentsApi, rateLimits: rateLimitsApi, + opencodeGoCredentials: opencodeGoCredentialsApi, minimaxCredentials: minimaxCredentialsApi, zcodePlanCredentials: zcodePlanCredentialsApi, grokAccounts: grokAccountsApi, diff --git a/src/preload/opencode-go-credentials.test.ts b/src/preload/opencode-go-credentials.test.ts new file mode 100644 index 00000000000..e7c5f6d4bea --- /dev/null +++ b/src/preload/opencode-go-credentials.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from 'vitest' +import { opencodeGoCredentialsApi } from './api/opencode-go-credentials-bridge' + +const invoke = vi.hoisted(() => vi.fn(async () => ({ apiKeyConfigured: true }))) +vi.mock('electron', () => ({ ipcRenderer: { invoke } })) + +describe('OpenCode Go credential bridge', () => { + it('exposes only status and write operations', async () => { + expect(await opencodeGoCredentialsApi.getStatus()).toEqual({ apiKeyConfigured: true }) + await opencodeGoCredentialsApi.saveApiKey('fake-key') + await opencodeGoCredentialsApi.clearApiKey() + expect(invoke.mock.calls).toEqual([ + ['opencodeGoCredentials:getStatus'], + ['opencodeGoCredentials:saveApiKey', 'fake-key'], + ['opencodeGoCredentials:clearApiKey'] + ]) + expect(Object.keys(opencodeGoCredentialsApi)).toEqual([ + 'getStatus', + 'saveApiKey', + 'clearApiKey' + ]) + }) +}) diff --git a/src/renderer/src/components/settings/AccountsPane.section-lifetime.test.tsx b/src/renderer/src/components/settings/AccountsPane.section-lifetime.test.tsx index 89161b687ef..4f225e89ba9 100644 --- a/src/renderer/src/components/settings/AccountsPane.section-lifetime.test.tsx +++ b/src/renderer/src/components/settings/AccountsPane.section-lifetime.test.tsx @@ -83,6 +83,11 @@ beforeEach(() => { fake.cursorUsage = { updatedAt: 0 } Object.assign(window, { api: { + opencodeGoCredentials: { + getStatus: vi.fn(async () => ({ apiKeyConfigured: false })), + saveApiKey: vi.fn(async () => ({ apiKeyConfigured: true })), + clearApiKey: vi.fn(async () => ({ apiKeyConfigured: false })) + }, minimaxCredentials: { getStatus: vi.fn(async () => ({ cookieConfigured: false, apiKeyConfigured: false })) }, diff --git a/src/renderer/src/components/settings/accounts-pane-opencode-credentials.test.ts b/src/renderer/src/components/settings/accounts-pane-opencode-credentials.test.ts new file mode 100644 index 00000000000..63d5665defd --- /dev/null +++ b/src/renderer/src/components/settings/accounts-pane-opencode-credentials.test.ts @@ -0,0 +1,47 @@ +// @vitest-environment happy-dom +import { createElement } from 'react' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { OpenCodeGoCredentials } from './accounts-pane-opencode-credentials' + +vi.mock('../../store', () => ({ + useAppStore: (selector: (state: { settingsSearchQuery: string }) => unknown) => + selector({ settingsSearchQuery: '' }) +})) + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) + +describe('OpenCode Go credentials setting', () => { + it('shows saved status without reading a key, saves a draft, and clears it', async () => { + const getStatus = vi.fn(async () => ({ apiKeyConfigured: true })) + const saveApiKey = vi.fn(async () => ({ apiKeyConfigured: true })) + const clearApiKey = vi.fn(async () => ({ apiKeyConfigured: false })) + Object.defineProperty(window, 'api', { + configurable: true, + value: { + opencodeGoCredentials: { getStatus, saveApiKey, clearApiKey } + } + }) + const onSaved = vi.fn() + render(createElement(OpenCodeGoCredentials, { onSaved })) + await screen.findByText('Saved') + const input = screen.getByLabelText('OpenCode Go API key') + expect(input).toBeInstanceOf(HTMLInputElement) + if (!(input instanceof HTMLInputElement)) { + throw new Error('Missing credential input') + } + expect(input.value).toBe('') + fireEvent.change(input, { target: { value: 'fake-new-key' } }) + fireEvent.click(screen.getByRole('button', { name: 'Replace' })) + await waitFor(() => expect(saveApiKey).toHaveBeenCalledWith('fake-new-key')) + await waitFor(() => expect(input.value).toBe('')) + fireEvent.click(screen.getByRole('button', { name: 'Clear' })) + await screen.findByText('Not saved') + expect(clearApiKey).toHaveBeenCalledOnce() + expect(onSaved).toHaveBeenCalledTimes(2) + expect(screen.queryByRole('button', { name: 'Clear' })).toBeNull() + }) +}) diff --git a/src/renderer/src/components/settings/accounts-pane-opencode-credentials.tsx b/src/renderer/src/components/settings/accounts-pane-opencode-credentials.tsx new file mode 100644 index 00000000000..efddf3c02f3 --- /dev/null +++ b/src/renderer/src/components/settings/accounts-pane-opencode-credentials.tsx @@ -0,0 +1,113 @@ +import { useEffect, useState } from 'react' +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import { Badge } from '../ui/badge' +import { Button } from '../ui/button' +import { Input } from '../ui/input' +import { Label } from '../ui/label' +import { SearchableSetting } from './SearchableSetting' + +export function OpenCodeGoCredentials({ onSaved }: { onSaved: () => void }): React.JSX.Element { + const [draft, setDraft] = useState('') + const [configured, setConfigured] = useState(false) + const [busy, setBusy] = useState(false) + useEffect(() => { + let active = true + void window.api.opencodeGoCredentials.getStatus().then( + (status) => { + if (active) { + setConfigured(status.apiKeyConfigured) + } + }, + () => console.error('Failed to load OpenCode Go credential status') + ) + return () => { + active = false + } + }, []) + + const updateCredential = async (clear: boolean): Promise => { + setBusy(true) + try { + const status = clear + ? await window.api.opencodeGoCredentials.clearApiKey() + : await window.api.opencodeGoCredentials.saveApiKey(draft.trim()) + setConfigured(status.apiKeyConfigured) + setDraft('') + onSaved() + } catch { + toast.error(translate('sessionHistory.settings.saveError', 'Could not save. Try again.')) + } finally { + setBusy(false) + } + } + + return ( + +
+ + + {configured + ? translate('auto.components.settings.AccountsPane.73ea15f24b', 'Saved') + : translate('auto.components.settings.AccountsPane.23afe8f226', 'Not saved')} + +
+
+ setDraft(event.target.value)} + placeholder={translate( + 'auto.components.settings.AccountsPane.opencodeGo.apiKey.placeholder', + 'Leave blank to use the key saved by /connect or OPENCODE_API_KEY' + )} + spellCheck={false} + className="flex-1" + /> + + {configured && ( + + )} +
+

+ {translate( + 'auto.components.settings.AccountsPane.opencodeGo.apiKey.help', + 'Used for OpenCode Go usage in the status bar. The session cookie below is only needed for legacy console (OpenCode Black) accounts.' + )} +

+
+ ) +} diff --git a/src/renderer/src/components/settings/accounts-pane-provider-setting-sections.tsx b/src/renderer/src/components/settings/accounts-pane-provider-setting-sections.tsx index 4111bfe1062..388a9945e84 100644 --- a/src/renderer/src/components/settings/accounts-pane-provider-setting-sections.tsx +++ b/src/renderer/src/components/settings/accounts-pane-provider-setting-sections.tsx @@ -5,6 +5,7 @@ import { Switch } from '../ui/switch' import { GeminiIcon, OpenCodeGoIcon } from '../status-bar/icons' import { SearchableSetting } from './SearchableSetting' import type { AccountsPaneSectionModel } from './accounts-pane-types' +import { OpenCodeGoCredentials } from './accounts-pane-opencode-credentials' import { DebouncedSettingsTextInput } from './DebouncedSettingsTextInput' export function renderGeminiAccountsSection(model: AccountsPaneSectionModel): React.JSX.Element { @@ -95,58 +96,7 @@ export function renderOpenCodeAccountsSection(model: AccountsPaneSectionModel):

- - -
- recordOpenCodeSettingEdit('apiKey')} - commit={(opencodeGoApiKey) => updateSettings({ opencodeGoApiKey })} - placeholder={translate( - 'auto.components.settings.AccountsPane.opencodeGo.apiKey.placeholder', - 'Leave blank to use the key saved by /connect or OPENCODE_API_KEY' - )} - spellCheck={false} - className="flex-1 text-xs" - /> - {settings.opencodeGoApiKey && ( - - )} -
-

- {translate( - 'auto.components.settings.AccountsPane.opencodeGo.apiKey.help', - 'Used for OpenCode Go usage in the status bar. The session cookie below is only needed for legacy console (OpenCode Black) accounts.' - )} -

-
+ recordOpenCodeSettingEdit('apiKey')} /> ['curs } } +export function createOpenCodeGoCredentialsApi(): PreloadApi['opencodeGoCredentials'] { + const notConfigured = { apiKeyConfigured: false } + return { + getStatus: () => Promise.resolve(notConfigured), + saveApiKey: () => + Promise.reject(new Error('OpenCode Go key storage is only available in the desktop app.')), + clearApiKey: () => Promise.resolve(notConfigured) + } +} + export function createGrokAccountsApi(): NonNullable['grokAccounts']> { const unsigned = { signedIn: false, diff --git a/src/renderer/src/web/web-preload-api-agent-providers.test.ts b/src/renderer/src/web/web-preload-api-agent-providers.test.ts index 8809047fba2..0eea8ecc3c8 100644 --- a/src/renderer/src/web/web-preload-api-agent-providers.test.ts +++ b/src/renderer/src/web/web-preload-api-agent-providers.test.ts @@ -155,6 +155,17 @@ describe('web MiniMax preload API', () => { vi.unstubAllGlobals() }) + it('keeps OpenCode Go credential operations local to the desktop', async () => { + const { api } = await installApi('Linux') + await expect(api.opencodeGoCredentials.getStatus()).resolves.toEqual({ + apiKeyConfigured: false + }) + await expect(api.opencodeGoCredentials.saveApiKey('fake-key')).rejects.toThrow(/desktop app/i) + await expect(api.opencodeGoCredentials.clearApiKey()).resolves.toEqual({ + apiKeyConfigured: false + }) + }) + it('exposes desktop-only MiniMax credential reads as unconfigured and rejects saves', async () => { const { api } = await installApi('Linux') diff --git a/src/renderer/src/web/web-preload-api-composition.test.ts b/src/renderer/src/web/web-preload-api-composition.test.ts index 9ec74fb2d7e..2e67ca4391c 100644 --- a/src/renderer/src/web/web-preload-api-composition.test.ts +++ b/src/renderer/src/web/web-preload-api-composition.test.ts @@ -53,6 +53,7 @@ describe('web preload API composition', () => { 'preflight', 'notifications', 'rateLimits', + 'opencodeGoCredentials', 'minimaxCredentials', 'zcodePlanCredentials', 'grokAccounts', diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 7d221c0e0dc..635e6dd924e 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -7,6 +7,7 @@ import { createCursorAccountsApi, createGrokAccountsApi, createZcodePlanCredentialsApi, + createOpenCodeGoCredentialsApi, createMiniMaxCredentialsApi } from './preload-api/web-agent-accounts-api' import { createWebAgentStatusApi } from './preload-api/web-agent-status-api' @@ -107,6 +108,7 @@ function createWebPreloadApi(): Partial { preflight: createPreflightApi(), notifications: createNotificationsApi(), rateLimits: createRateLimitsApi(), + opencodeGoCredentials: createOpenCodeGoCredentialsApi(), minimaxCredentials: createMiniMaxCredentialsApi(), zcodePlanCredentials: createZcodePlanCredentialsApi(), grokAccounts: createGrokAccountsApi(), diff --git a/src/shared/default-global-settings.ts b/src/shared/default-global-settings.ts index cb085cfd728..80aa334b21c 100644 --- a/src/shared/default-global-settings.ts +++ b/src/shared/default-global-settings.ts @@ -211,7 +211,6 @@ export function buildDefaultSettings(args: { defaultLinearTeamSelection: null, opencodeSessionCookie: '', opencodeWorkspaceId: '', - opencodeGoApiKey: '', minimaxGroupId: '', minimaxUsageModels: 'general', minimaxEndpoint: 'overseas', diff --git a/src/shared/global-settings-types.ts b/src/shared/global-settings-types.ts index 995b97ebbf4..c078070d298 100644 --- a/src/shared/global-settings-types.ts +++ b/src/shared/global-settings-types.ts @@ -394,8 +394,6 @@ export type GlobalSettings = { opencodeSessionCookie: string /** Optional OpenCode Go workspace ID override; when set, skips the workspaces lookup and fetches usage directly. */ opencodeWorkspaceId: string - /** Optional OpenCode Go API key override. Takes precedence over OpenCode's own stored key and OPENCODE_API_KEY. Stored encrypted. */ - opencodeGoApiKey: string /** Optional MiniMax group id. When empty, the usage fetcher extracts minimax_group_id_v2 from the cookie. */ minimaxGroupId: string /** Comma-separated MiniMax model names to show in the status bar usage window. */