From 7903f1cddbc08fe1179f8bd964fff3dcb0e5d020 Mon Sep 17 00:00:00 2001
From: Neil
Date: Fri, 2 Oct 2026 01:01:34 -0700
Subject: [PATCH] fix(opencode): keep saved Go credentials in main-owned
storage
Co-authored-by: kespineira
Co-authored-by: kevimux
---
.../api-key-file-unreadable-error.ts | 2 +
.../encrypted-api-key-file-store.ts | 177 ++++++++++++++++
...edential-change-rate-limit-refresh.test.ts | 40 ++++
.../credential-change-rate-limit-refresh.ts | 19 ++
src/main/ipc/minimax-credentials.ts | 12 +-
src/main/ipc/opencode-go-credentials.test.ts | 87 ++++++++
src/main/ipc/opencode-go-credentials.ts | 47 ++++
.../register-core-handlers.test.ts | 8 +
.../register-core-handlers.ts | 2 +
.../minimax/minimax-api-key-store.test.ts | 25 ++-
src/main/minimax/minimax-api-key-store.ts | 157 ++------------
.../opencode-go-api-key-store.test.ts | 99 +++++++++
.../opencode/opencode-go-api-key-store.ts | 13 ++
.../applying-settings/settings-update.ts | 3 -
.../terminal-settings-migrations.ts | 4 +
.../leasing-ssh-ptys/secret-validation.ts | 7 -
...gacy-opencode-go-api-key-migration.test.ts | 200 ++++++++++++++++++
.../legacy-opencode-go-api-key-migration.ts | 100 +++++++++
.../loading-store/loaded-state-parsing.ts | 10 +-
.../state-serialization-secret-handling.ts | 8 +-
.../state-write-round-trip.test.ts | 66 +++++-
src/main/persistence/loading-store/store.ts | 11 +
src/main/protected-secret-persistence.ts | 12 +-
.../opencode-go-api-key-source.test.ts | 30 ++-
.../rate-limits/opencode-go-api-key-source.ts | 18 +-
.../service-opencode-go-credentials.test.ts | 195 +++++++++++++++++
.../service-refresh-orchestration.test.ts | 12 +-
.../service/service-account-refresh.ts | 13 ++
.../service/service-configuration.ts | 6 +-
.../service/service-fetch-targets.ts | 30 +++
.../service/service-full-cycle-preparation.ts | 34 ++-
src/main/rate-limits/service/service-state.ts | 1 +
src/main/rate-limits/service/service-types.ts | 9 +-
.../startup/main-process-account-services.ts | 24 ++-
src/preload/api-types.ts | 5 +
.../api/opencode-go-credentials-bridge.ts | 11 +
src/preload/index.ts | 2 +
src/preload/opencode-go-credentials.test.ts | 23 ++
...accounts-pane-opencode-credentials.test.ts | 47 ++++
.../accounts-pane-opencode-credentials.tsx | 113 ++++++++++
...ccounts-pane-provider-setting-sections.tsx | 54 +----
.../web/preload-api/web-agent-accounts-api.ts | 10 +
.../web-preload-api-agent-providers.test.ts | 11 +
.../web/web-preload-api-composition.test.ts | 1 +
src/renderer/src/web/web-preload-api.ts | 2 +
src/shared/default-global-settings.ts | 1 -
src/shared/global-settings-types.ts | 2 -
47 files changed, 1502 insertions(+), 261 deletions(-)
create mode 100644 src/main/credentials/api-key-file-unreadable-error.ts
create mode 100644 src/main/credentials/encrypted-api-key-file-store.ts
create mode 100644 src/main/ipc/credential-change-rate-limit-refresh.test.ts
create mode 100644 src/main/ipc/credential-change-rate-limit-refresh.ts
create mode 100644 src/main/ipc/opencode-go-credentials.test.ts
create mode 100644 src/main/ipc/opencode-go-credentials.ts
create mode 100644 src/main/opencode/opencode-go-api-key-store.test.ts
create mode 100644 src/main/opencode/opencode-go-api-key-store.ts
create mode 100644 src/main/persistence/loading-store/legacy-opencode-go-api-key-migration.test.ts
create mode 100644 src/main/persistence/loading-store/legacy-opencode-go-api-key-migration.ts
create mode 100644 src/main/rate-limits/service-opencode-go-credentials.test.ts
create mode 100644 src/preload/api/opencode-go-credentials-bridge.ts
create mode 100644 src/preload/opencode-go-credentials.test.ts
create mode 100644 src/renderer/src/components/settings/accounts-pane-opencode-credentials.test.ts
create mode 100644 src/renderer/src/components/settings/accounts-pane-opencode-credentials.tsx
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 4e67441ea34..6c938a8239f 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,
registerGrokAccountHandlersMock,
registerCursorAccountHandlersMock,
@@ -102,6 +103,7 @@ const {
registerCodexAccountHandlersMock: vi.fn(),
registerAgentHookHandlersMock: vi.fn(),
registerClaudeAccountHandlersMock: vi.fn(),
+ registerOpenCodeGoCredentialsHandlersMock: vi.fn(),
registerMiniMaxCredentialsHandlersMock: vi.fn(),
registerGrokAccountHandlersMock: vi.fn(),
registerCursorAccountHandlersMock: vi.fn(),
@@ -332,6 +334,10 @@ vi.mock('../claude-accounts', () => ({
registerClaudeAccountHandlers: registerClaudeAccountHandlersMock
}))
+vi.mock('../opencode-go-credentials', () => ({
+ registerOpenCodeGoCredentialsHandlers: registerOpenCodeGoCredentialsHandlersMock
+}))
+
vi.mock('../minimax-credentials', () => ({
registerMiniMaxCredentialsHandlers: registerMiniMaxCredentialsHandlersMock
}))
@@ -440,6 +446,7 @@ describe('registerCoreHandlers', () => {
registerCodexAccountHandlersMock.mockReset()
registerAgentHookHandlersMock.mockReset()
registerClaudeAccountHandlersMock.mockReset()
+ registerOpenCodeGoCredentialsHandlersMock.mockReset()
registerMiniMaxCredentialsHandlersMock.mockReset()
registerClipboardHandlersMock.mockReset()
setTrustedClipboardRendererWebContentsIdMock.mockReset()
@@ -539,6 +546,7 @@ describe('registerCoreHandlers', () => {
)
expect(registerPetHandlersMock).toHaveBeenCalled()
expect(registerClaudeAccountHandlersMock).toHaveBeenCalledWith(claudeAccounts)
+ expect(registerOpenCodeGoCredentialsHandlersMock).toHaveBeenCalledWith(rateLimits)
expect(registerMiniMaxCredentialsHandlersMock).toHaveBeenCalledWith(rateLimits)
expect(registerGrokAccountHandlersMock).toHaveBeenCalled()
expect(registerCursorAccountHandlersMock).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 d3a647cafd7..5105b8992e6 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 { registerGrokAccountHandlers } from '../grok-accounts'
import { registerCursorAccountHandlers } from '../cursor-accounts'
@@ -150,6 +151,7 @@ export function registerCoreHandlers(
registerAgentHookHandlers(runtime, { getPtyIdForPaneKey })
registerCodexConfigSyncHandlers(codexAccounts.runtimeHomeService)
registerClaudeAccountHandlers(claudeAccounts)
+ registerOpenCodeGoCredentialsHandlers(rateLimits)
registerMiniMaxCredentialsHandlers(rateLimits)
registerGrokAccountHandlers()
registerCursorAccountHandlers()
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/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/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 aadbf42a2ab..b463cea5b9f 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
@@ -107,7 +107,7 @@ describe('resolveOpenCodeGoApiKey', () => {
})
})
- it('falls back to the key OpenCode 1.x saved on /connect', async () => {
+ it('falls back to the key OpenCode 1.x saved on /connect when the table is empty', async () => {
writeAuthFile({
anthropic: { type: 'oauth', refresh: 'r', access: 'a', expires: 1 },
'opencode-go': { type: 'api', key: AUTH_FILE_KEY }
@@ -139,6 +139,34 @@ 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('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 545bd058d8e..eecfeec1ea7 100644
--- a/src/main/rate-limits/opencode-go-api-key-source.ts
+++ b/src/main/rate-limits/opencode-go-api-key-source.ts
@@ -160,13 +160,13 @@ export async function readOpenCodeCredentialDatabaseGoKey(): Promise ({
+ 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 ed8578e3694..22d95d974d4 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 2a788b1cf93..f0322ea476f 100644
--- a/src/main/rate-limits/service/service-configuration.ts
+++ b/src/main/rate-limits/service/service-configuration.ts
@@ -40,8 +40,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 a24322a5471..798c0a7917b 100644
--- a/src/main/rate-limits/service/service-fetch-targets.ts
+++ b/src/main/rate-limits/service/service-fetch-targets.ts
@@ -6,6 +6,7 @@ import {
type ClaudeRuntimeAuthPreparation,
type CodexAccountSelectionTarget,
type MiniMaxResolvedConfig,
+ type OpenCodeGoResolvedConfig,
type NormalizedCodexAccountSelectionTarget,
type NormalizedClaudeAccountSelectionTarget,
type ProviderRateLimits,
@@ -13,6 +14,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
@@ -187,6 +189,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 5395ace5fbf..bd6b6d69e6f 100644
--- a/src/main/rate-limits/service/service-full-cycle-preparation.ts
+++ b/src/main/rate-limits/service/service-full-cycle-preparation.ts
@@ -80,10 +80,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
@@ -100,7 +102,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
@@ -201,7 +203,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,
@@ -223,6 +233,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 7af82c6c313..1ebdb37b7b7 100644
--- a/src/main/rate-limits/service/service-state.ts
+++ b/src/main/rate-limits/service/service-state.ts
@@ -99,6 +99,7 @@ export abstract class RateLimitServiceState {
wslDistro: null
}
protected openCodeGoConfigResolver: (() => OpenCodeGoRateLimitConfig) | null = null
+ protected openCodeGoApiKeyResolver: (() => string | null) | null = null
protected miniMaxConfigResolver: (() => MiniMaxRateLimitConfig) | null = null
protected geminiCliOAuthEnabledResolver: GeminiCliOAuthEnabledResolver | null = null
protected inactiveClaudeAccountsResolver: (() => InactiveClaudeAccountInfo[]) | null = null
diff --git a/src/main/rate-limits/service/service-types.ts b/src/main/rate-limits/service/service-types.ts
index a3998c13abe..5a8210c62af 100644
--- a/src/main/rate-limits/service/service-types.ts
+++ b/src/main/rate-limits/service/service-types.ts
@@ -44,8 +44,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 6abf73d6610..f5fc9986673 100644
--- a/src/main/startup/main-process-account-services.ts
+++ b/src/main/startup/main-process-account-services.ts
@@ -15,6 +15,11 @@ import { getInitialClaudeRateLimitTarget } from '../rate-limits/claude-rate-limi
import { getKimiRuntimeTarget, resolveKimiHome } from '../kimi/kimi-runtime-home'
import { readMiniMaxSessionCookie } from '../minimax/minimax-cookie-store'
import { readMiniMaxApiKey } from '../minimax/minimax-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'
@@ -87,6 +92,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 (
@@ -110,14 +124,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 220b90c18a6..5597fed9022 100644
--- a/src/preload/api-types.ts
+++ b/src/preload/api-types.ts
@@ -141,6 +141,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
grokAccounts: GrokAccountsApi
cursorAccounts: CursorAccountsApi
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 42cf236c14d..cb762bbfc77 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 { grokAccountsApi } from './api/grok-accounts-bridge'
import { cursorAccountsApi } from './api/cursor-accounts-bridge'
@@ -174,6 +175,7 @@ const api = {
runtime: runtimeApi,
runtimeEnvironments: runtimeEnvironmentsApi,
rateLimits: rateLimitsApi,
+ opencodeGoCredentials: opencodeGoCredentialsApi,
minimaxCredentials: minimaxCredentialsApi,
grokAccounts: grokAccountsApi,
cursorAccounts: cursorAccountsApi,
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/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 (
+
+
+
+ {translate(
+ 'auto.components.settings.AccountsPane.opencodeGo.apiKey.label',
+ 'OpenCode Go API key'
+ )}
+
+
+ {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"
+ />
+ void updateCredential(false)}
+ >
+ {configured
+ ? translate('auto.components.settings.AccountsPane.f38b9cc4bd', 'Replace')
+ : translate('auto.components.settings.AccountsPane.590a3130f9', 'Save')}
+
+ {configured && (
+ void updateCredential(true)}
+ >
+ {translate('auto.components.settings.AccountsPane.b398b834c9', 'Clear')}
+
+ )}
+
+
+ {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 61428c5cd3b..0ebc60ff471 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):
-
-
- {translate(
- 'auto.components.settings.AccountsPane.opencodeGo.apiKey.label',
- 'OpenCode Go API key'
- )}
-
-
- 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 && (
- {
- recordFeatureInteraction('usage-tracking')
- updateSettings({ opencodeGoApiKey: '' })
- }}
- className="h-7 shrink-0 text-xs text-muted-foreground hover:text-foreground"
- >
- {translate('auto.components.settings.AccountsPane.b398b834c9', 'Clear')}
-
- )}
-
-
- {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 fc119f88258..5647401810f 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',
'grokAccounts',
'cursorAccounts',
diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts
index 17f77ded803..8edf8efae4a 100644
--- a/src/renderer/src/web/web-preload-api.ts
+++ b/src/renderer/src/web/web-preload-api.ts
@@ -6,6 +6,7 @@ import {
createCodexAccountsApi,
createCursorAccountsApi,
createGrokAccountsApi,
+ createOpenCodeGoCredentialsApi,
createMiniMaxCredentialsApi
} from './preload-api/web-agent-accounts-api'
import { createWebAgentStatusApi } from './preload-api/web-agent-status-api'
@@ -106,6 +107,7 @@ function createWebPreloadApi(): Partial {
preflight: createPreflightApi(),
notifications: createNotificationsApi(),
rateLimits: createRateLimitsApi(),
+ opencodeGoCredentials: createOpenCodeGoCredentialsApi(),
minimaxCredentials: createMiniMaxCredentialsApi(),
grokAccounts: createGrokAccountsApi(),
cursorAccounts: createCursorAccountsApi(),
diff --git a/src/shared/default-global-settings.ts b/src/shared/default-global-settings.ts
index 3c6a9d2aa59..2007f3aa916 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 c1a9b7d2df1..79bbc734774 100644
--- a/src/shared/global-settings-types.ts
+++ b/src/shared/global-settings-types.ts
@@ -392,8 +392,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. */