mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 08:02:21 +00:00
fix(opencode): keep Go credentials private and resolve backend keys (#24615)
Preserve the complete credential storage, migration, IPC, Settings and rate-limit refresh change alongside standalone GLM plans, current-main database diagnostics and the reviewed unknown-backend environment correction. Keep native discovery cancellation third and selected environment fourth. Original-topic-commit:7903f1cddbOriginal-topic-commit:588117b5cfOriginal-topic-commit:dedd4f8c86Original-topic-commit:6645dae104Original-topic-commit: a25b80c02c1af7830b0e6a65e72d965b3ad98276 Restacked-from: a25b80c02c1af7830b0e6a65e72d965b3ad98276 Restacked-onto:b032867021Co-authored-by: kespineira <kespineira@users.noreply.github.com> Co-authored-by: kevimux <kevimux@users.noreply.github.com> Reported-by: pullfrog Reviewed-full-source: 849fe093073f4c1606bd65d79a0c725d955d0d1f Native-helper-source:80dbe23237Reviewed-full-current-source: 36acb57d44adb3d378c0289c8c15f7da0fda214c
This commit is contained in:
co-authored by
kespineira
kevimux
parent
e8402b4619
commit
53f9ea7839
@@ -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 {}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
@@ -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<T extends Pick<RateLimitService, 'refresh'>>(
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string, (event: unknown, key?: unknown) => 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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -35,6 +35,7 @@ const {
|
||||
registerCodexAccountHandlersMock,
|
||||
registerAgentHookHandlersMock,
|
||||
registerClaudeAccountHandlersMock,
|
||||
registerOpenCodeGoCredentialsHandlersMock,
|
||||
registerMiniMaxCredentialsHandlersMock,
|
||||
registerZcodePlanCredentialsHandlersMock,
|
||||
registerGrokAccountHandlersMock,
|
||||
@@ -103,6 +104,7 @@ const {
|
||||
registerCodexAccountHandlersMock: vi.fn(),
|
||||
registerAgentHookHandlersMock: vi.fn(),
|
||||
registerClaudeAccountHandlersMock: vi.fn(),
|
||||
registerOpenCodeGoCredentialsHandlersMock: vi.fn(),
|
||||
registerMiniMaxCredentialsHandlersMock: vi.fn(),
|
||||
registerZcodePlanCredentialsHandlersMock: vi.fn(),
|
||||
registerGrokAccountHandlersMock: vi.fn(),
|
||||
@@ -334,6 +336,10 @@ vi.mock('../claude-accounts', () => ({
|
||||
registerClaudeAccountHandlers: registerClaudeAccountHandlersMock
|
||||
}))
|
||||
|
||||
vi.mock('../opencode-go-credentials', () => ({
|
||||
registerOpenCodeGoCredentialsHandlers: registerOpenCodeGoCredentialsHandlersMock
|
||||
}))
|
||||
|
||||
vi.mock('../minimax-credentials', () => ({
|
||||
registerMiniMaxCredentialsHandlers: registerMiniMaxCredentialsHandlersMock
|
||||
}))
|
||||
@@ -446,6 +452,7 @@ describe('registerCoreHandlers', () => {
|
||||
registerCodexAccountHandlersMock.mockReset()
|
||||
registerAgentHookHandlersMock.mockReset()
|
||||
registerClaudeAccountHandlersMock.mockReset()
|
||||
registerOpenCodeGoCredentialsHandlersMock.mockReset()
|
||||
registerMiniMaxCredentialsHandlersMock.mockReset()
|
||||
registerZcodePlanCredentialsHandlersMock.mockReset()
|
||||
registerClipboardHandlersMock.mockReset()
|
||||
@@ -546,6 +553,7 @@ describe('registerCoreHandlers', () => {
|
||||
)
|
||||
expect(registerPetHandlersMock).toHaveBeenCalled()
|
||||
expect(registerClaudeAccountHandlersMock).toHaveBeenCalledWith(claudeAccounts)
|
||||
expect(registerOpenCodeGoCredentialsHandlersMock).toHaveBeenCalledWith(rateLimits)
|
||||
expect(registerMiniMaxCredentialsHandlersMock).toHaveBeenCalledWith(rateLimits)
|
||||
expect(registerZcodePlanCredentialsHandlersMock).toHaveBeenCalledWith(rateLimits)
|
||||
expect(registerGrokAccountHandlersMock).toHaveBeenCalled()
|
||||
|
||||
@@ -61,6 +61,7 @@ import { registerAgentHookHandlers } from '../agent-hooks'
|
||||
import { registerCodexConfigSyncHandlers } from '../codex-config-sync'
|
||||
import { getPtyIdForPaneKey } from '../pty'
|
||||
import { registerClaudeAccountHandlers } from '../claude-accounts'
|
||||
import { registerOpenCodeGoCredentialsHandlers } from '../opencode-go-credentials'
|
||||
import { registerMiniMaxCredentialsHandlers } from '../minimax-credentials'
|
||||
import { registerZcodePlanCredentialsHandlers } from '../zcode-plan-credentials'
|
||||
import { registerGrokAccountHandlers } from '../grok-accounts'
|
||||
@@ -151,6 +152,7 @@ export function registerCoreHandlers(
|
||||
registerAgentHookHandlers(runtime, { getPtyIdForPaneKey })
|
||||
registerCodexConfigSyncHandlers(codexAccounts.runtimeHomeService)
|
||||
registerClaudeAccountHandlers(claudeAccounts)
|
||||
registerOpenCodeGoCredentialsHandlers(rateLimits)
|
||||
registerMiniMaxCredentialsHandlers(rateLimits)
|
||||
registerZcodePlanCredentialsHandlers(rateLimits)
|
||||
registerGrokAccountHandlers()
|
||||
|
||||
@@ -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')))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,8 +10,11 @@ type OpenCodeDatabaseOverride = {
|
||||
path: string | null
|
||||
}
|
||||
|
||||
function getOpenCodeDatabaseOverride(dataDirectory: string): OpenCodeDatabaseOverride {
|
||||
const raw = process.env.OPENCODE_DB?.trim()
|
||||
function getOpenCodeDatabaseOverride(
|
||||
dataDirectory: string,
|
||||
environment: NodeJS.ProcessEnv
|
||||
): OpenCodeDatabaseOverride {
|
||||
const raw = environment.OPENCODE_DB?.trim()
|
||||
if (!raw) {
|
||||
return { isConfigured: false, path: null }
|
||||
}
|
||||
@@ -32,31 +35,44 @@ export async function listOpenCodeDatabases(
|
||||
* "OpenCode not used" rather than "we could not look". */
|
||||
onRefusal?: (path: string, error: WslTranscriptFsError) => void,
|
||||
/** Every other stat/readdir failure, including ENOENT; also read as an empty list. */
|
||||
onFsError?: (path: string, error: unknown) => void
|
||||
onFsError?: (path: string, error: unknown) => void,
|
||||
signal?: AbortSignal,
|
||||
environment: NodeJS.ProcessEnv = process.env
|
||||
): Promise<string[]> {
|
||||
const dataDirectory = resolveOpenCodeDataDirectory()
|
||||
const databaseOverride = getOpenCodeDatabaseOverride(dataDirectory)
|
||||
const dataDirectory = resolveOpenCodeDataDirectory(environment)
|
||||
const databaseOverride = getOpenCodeDatabaseOverride(dataDirectory, environment)
|
||||
if (databaseOverride.isConfigured) {
|
||||
if (!databaseOverride.path) {
|
||||
return []
|
||||
}
|
||||
try {
|
||||
return (await wslGatedStat(databaseOverride.path, 'scan')).isFile()
|
||||
return (await wslGatedStat(databaseOverride.path, 'scan', signal)).isFile()
|
||||
? [databaseOverride.path]
|
||||
: []
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted()
|
||||
reportFailure(databaseOverride.path, error, onRefusal, onFsError)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
return listOpenCodeDatabasesInDirectory(dataDirectory, onRefusal, signal, onFsError)
|
||||
}
|
||||
|
||||
export async function listOpenCodeDatabasesInDirectory(
|
||||
dataDirectory: string,
|
||||
onRefusal?: (path: string, error: WslTranscriptFsError) => void,
|
||||
signal?: AbortSignal,
|
||||
onFsError?: (path: string, error: unknown) => void
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
const entries = await wslGatedReaddir(dataDirectory, 'scan')
|
||||
const entries = await wslGatedReaddir(dataDirectory, 'scan', signal)
|
||||
return entries
|
||||
.filter((entry) => entry.isFile() && /^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/.test(entry.name))
|
||||
.map((entry) => join(dataDirectory, entry.name))
|
||||
.sort()
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted()
|
||||
reportFailure(dataDirectory, error, onRefusal, onFsError)
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { runProcess } from '../../shared/child-process/run-process'
|
||||
import { resolveCommandOnLocalPath } from '../ipc/command-path-resolver'
|
||||
import {
|
||||
detectOpenCodeCredentialBackend,
|
||||
resetOpenCodeCredentialBackendProbes
|
||||
} from './opencode-credential-backend'
|
||||
|
||||
const files = vi.hoisted(() => ({ realpath: vi.fn(), stat: vi.fn() }))
|
||||
|
||||
vi.mock('../../shared/child-process/run-process', () => ({ runProcess: vi.fn() }))
|
||||
vi.mock('../ipc/command-path-resolver', () => ({ resolveCommandOnLocalPath: vi.fn() }))
|
||||
vi.mock('node:fs/promises', () => files)
|
||||
|
||||
describe('OpenCode credential execution backend', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
resetOpenCodeCredentialBackendProbes()
|
||||
files.realpath.mockImplementation(async (path: string) => path)
|
||||
files.stat.mockResolvedValue({ dev: 1, ino: 2, size: 3, mtimeMs: 4, ctimeMs: 5 })
|
||||
vi.mocked(resolveCommandOnLocalPath).mockResolvedValue('/task/bin/opencode')
|
||||
vi.mocked(runProcess).mockResolvedValue({
|
||||
code: 0,
|
||||
signal: null,
|
||||
stdout: '1.18.30\n',
|
||||
stderr: '',
|
||||
timedOut: false
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['1.18.30\n', 'v1'],
|
||||
['opencode v2.0.16\n', 'v2'],
|
||||
['2.0.16', 'v2'],
|
||||
['opencode v2.0.16-beta.1', 'v2'],
|
||||
['3.0.0', null],
|
||||
['wrapper 2.0.16', null],
|
||||
['', null]
|
||||
])('uses the reported backend for %j', async (stdout, backend) => {
|
||||
vi.mocked(runProcess).mockResolvedValue({
|
||||
code: 0,
|
||||
signal: null,
|
||||
stdout,
|
||||
stderr: '',
|
||||
timedOut: false
|
||||
})
|
||||
|
||||
expect(await detectOpenCodeCredentialBackend()).toBe(backend)
|
||||
})
|
||||
|
||||
it('resolves and executes the binary in the caller environment with a bounded probe', async () => {
|
||||
const environment = { PATH: '/task/bin', XDG_DATA_HOME: '/task/data' }
|
||||
|
||||
await detectOpenCodeCredentialBackend(environment, '/task/workspace')
|
||||
|
||||
expect(resolveCommandOnLocalPath).toHaveBeenCalledExactlyOnceWith('opencode', {
|
||||
env: environment,
|
||||
cwd: '/task/workspace'
|
||||
})
|
||||
expect(runProcess).toHaveBeenCalledExactlyOnceWith({
|
||||
program: '/task/bin/opencode',
|
||||
args: ['--version'],
|
||||
env: environment,
|
||||
cwd: '/task/workspace',
|
||||
timeoutMs: 5_000,
|
||||
maxOutputBytes: 1_024
|
||||
})
|
||||
})
|
||||
|
||||
it('probes opencode2 only when the default opencode command is absent', async () => {
|
||||
vi.mocked(resolveCommandOnLocalPath)
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce('/task/bin/opencode2')
|
||||
vi.mocked(runProcess).mockResolvedValue({
|
||||
code: 0,
|
||||
signal: null,
|
||||
stdout: 'opencode v2.0.16',
|
||||
stderr: '',
|
||||
timedOut: false
|
||||
})
|
||||
|
||||
expect(await detectOpenCodeCredentialBackend()).toBe('v2')
|
||||
expect(runProcess).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ program: '/task/bin/opencode2' })
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ code: 1, timedOut: false },
|
||||
{ code: 0, timedOut: true },
|
||||
{ code: 0, timedOut: false, outputTruncated: true }
|
||||
])('withholds authority when the installed probe fails: %j', async (failure) => {
|
||||
vi.mocked(runProcess).mockResolvedValue({
|
||||
...failure,
|
||||
signal: null,
|
||||
stdout: 'opencode v2.0.16',
|
||||
stderr: ''
|
||||
})
|
||||
|
||||
expect(await detectOpenCodeCredentialBackend()).toBeNull()
|
||||
expect(resolveCommandOnLocalPath).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('withholds authority after a spawn error without substituting another CLI', async () => {
|
||||
vi.mocked(runProcess).mockRejectedValue(new Error('unavailable'))
|
||||
|
||||
expect(await detectOpenCodeCredentialBackend()).toBeNull()
|
||||
expect(resolveCommandOnLocalPath).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('withholds authority when neither CLI is installed', async () => {
|
||||
vi.mocked(resolveCommandOnLocalPath).mockResolvedValue(null)
|
||||
|
||||
expect(await detectOpenCodeCredentialBackend()).toBeNull()
|
||||
expect(runProcess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('coalesces concurrent calls and reuses a successful binary identity', async () => {
|
||||
expect(
|
||||
await Promise.all([
|
||||
detectOpenCodeCredentialBackend(),
|
||||
detectOpenCodeCredentialBackend(),
|
||||
detectOpenCodeCredentialBackend()
|
||||
])
|
||||
).toEqual(['v1', 'v1', 'v1'])
|
||||
expect(await detectOpenCodeCredentialBackend()).toBe('v1')
|
||||
expect(runProcess).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reprobes when the resolved binary is replaced', async () => {
|
||||
expect(await detectOpenCodeCredentialBackend()).toBe('v1')
|
||||
files.stat.mockResolvedValue({ dev: 1, ino: 6, size: 3, mtimeMs: 7, ctimeMs: 8 })
|
||||
vi.mocked(runProcess).mockResolvedValue({
|
||||
code: 0,
|
||||
signal: null,
|
||||
stdout: 'opencode v2.0.16',
|
||||
stderr: '',
|
||||
timedOut: false
|
||||
})
|
||||
|
||||
expect(await detectOpenCodeCredentialBackend()).toBe('v2')
|
||||
expect(runProcess).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not reuse a probe across caller environments or working directories', async () => {
|
||||
await detectOpenCodeCredentialBackend(
|
||||
{ PATH: '/task/bin', XDG_DATA_HOME: '/task/a' },
|
||||
'/task/a'
|
||||
)
|
||||
await detectOpenCodeCredentialBackend(
|
||||
{ PATH: '/task/bin', XDG_DATA_HOME: '/task/b' },
|
||||
'/task/a'
|
||||
)
|
||||
await detectOpenCodeCredentialBackend(
|
||||
{ PATH: '/task/bin', XDG_DATA_HOME: '/task/b' },
|
||||
'/task/b'
|
||||
)
|
||||
|
||||
expect(runProcess).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('retries an unknown backend after its short cache expires', async () => {
|
||||
const now = vi.spyOn(Date, 'now').mockReturnValue(1_000)
|
||||
vi.mocked(runProcess).mockRejectedValueOnce(new Error('temporarily unavailable'))
|
||||
try {
|
||||
expect(await detectOpenCodeCredentialBackend()).toBeNull()
|
||||
expect(await detectOpenCodeCredentialBackend()).toBeNull()
|
||||
now.mockReturnValue(6_001)
|
||||
expect(await detectOpenCodeCredentialBackend()).toBe('v1')
|
||||
expect(runProcess).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
now.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('withholds authority when the selected binary identity cannot be read', async () => {
|
||||
files.stat.mockRejectedValue(new Error('unreadable executable'))
|
||||
|
||||
expect(await detectOpenCodeCredentialBackend()).toBeNull()
|
||||
expect(runProcess).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import { runProcess } from '../../shared/child-process/run-process'
|
||||
import { resolveCommandOnLocalPath } from '../ipc/command-path-resolver'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { realpath, stat } from 'node:fs/promises'
|
||||
|
||||
export type OpenCodeCredentialBackend = 'v1' | 'v2'
|
||||
|
||||
// Native runtime processes own separate execution-host caches.
|
||||
const probes = new Map<
|
||||
string,
|
||||
{ result: Promise<OpenCodeCredentialBackend | null>; expiresAt: number }
|
||||
>()
|
||||
const MAX_PROBES = 32
|
||||
|
||||
export function resetOpenCodeCredentialBackendProbes(): void {
|
||||
probes.clear()
|
||||
}
|
||||
|
||||
export async function detectOpenCodeCredentialBackend(
|
||||
environment: NodeJS.ProcessEnv = process.env,
|
||||
cwd = process.cwd()
|
||||
): Promise<OpenCodeCredentialBackend | null> {
|
||||
// The execution host's default CLI owns this lookup; table presence proves neither backend.
|
||||
const program =
|
||||
(await resolveCommandOnLocalPath('opencode', { env: environment, cwd })) ??
|
||||
(await resolveCommandOnLocalPath('opencode2', { env: environment, cwd }))
|
||||
if (!program) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const binary = await realpath(program)
|
||||
const identity = await stat(binary)
|
||||
const environmentDigest = createHash('sha256')
|
||||
.update(JSON.stringify(Object.entries(environment).sort(([a], [b]) => a.localeCompare(b))))
|
||||
.digest('hex')
|
||||
const key = JSON.stringify([
|
||||
binary,
|
||||
identity.dev,
|
||||
identity.ino,
|
||||
identity.size,
|
||||
identity.mtimeMs,
|
||||
identity.ctimeMs,
|
||||
cwd,
|
||||
environmentDigest
|
||||
])
|
||||
const cached = probes.get(key)
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.result
|
||||
}
|
||||
const result = probeBackend(binary, environment, cwd)
|
||||
const entry = { result, expiresAt: Number.POSITIVE_INFINITY }
|
||||
probes.set(key, entry)
|
||||
if (probes.size > MAX_PROBES) {
|
||||
const oldest = probes.keys().next().value
|
||||
if (oldest !== undefined) {
|
||||
probes.delete(oldest)
|
||||
}
|
||||
}
|
||||
const backend = await result
|
||||
entry.expiresAt = Date.now() + (backend ? 60_000 : 5_000)
|
||||
return backend
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function probeBackend(
|
||||
program: string,
|
||||
environment: NodeJS.ProcessEnv,
|
||||
cwd: string
|
||||
): Promise<OpenCodeCredentialBackend | null> {
|
||||
try {
|
||||
const result = await runProcess({
|
||||
program,
|
||||
args: ['--version'],
|
||||
env: environment,
|
||||
cwd,
|
||||
timeoutMs: 5_000,
|
||||
maxOutputBytes: 1_024
|
||||
})
|
||||
if (result.code !== 0 || result.timedOut || result.outputTruncated) {
|
||||
return null
|
||||
}
|
||||
const version = /^(?:opencode\s+)?v?([12])\.\d+\.\d+(?:[-+][\w.-]+)?$/i.exec(
|
||||
result.stdout.trim()
|
||||
)
|
||||
return version?.[1] === '1' ? 'v1' : version?.[1] === '2' ? 'v2' : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -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<typeof NodeOs>()),
|
||||
homedir: () => home.directory
|
||||
}))
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof NodeFs>()
|
||||
return {
|
||||
...actual,
|
||||
readFileSync: (...args: Parameters<typeof actual.readFileSync>) => {
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<GlobalSettings> & RetiredGlobalSettings
|
||||
void _legacyScrollbackBytes
|
||||
void _legacyGitHubAttribution
|
||||
void _legacyShowAgentsSidebar
|
||||
void _legacyOpenCodeGoApiKey
|
||||
return rest
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<typeof ProtectedSecretPersistence> {
|
||||
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<string, unknown> = { 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<string, unknown> = { 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()
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown> {
|
||||
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<ProtectedSecretPersistence, 'encrypt' | 'retainSealed'>
|
||||
): 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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -113,12 +113,21 @@ afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
export async function fixture() {
|
||||
export async function fixture(legacyOpenCodeGoApiKey?: string) {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'orca-worker-coordination-'))
|
||||
const path = join(directory, 'profile-state.db')
|
||||
const inner = new ProfileStateSqliteAuthority(path, 'coordination-test')
|
||||
const initial = buildProfileStateCutoverFixture(directory)
|
||||
inner.writeSerializedState(
|
||||
Buffer.from(JSON.stringify(buildProfileStateCutoverFixture(directory)))
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
...initial,
|
||||
settings: {
|
||||
...initial.settings,
|
||||
...(legacyOpenCodeGoApiKey ? { opencodeGoApiKey: legacyOpenCodeGoApiKey } : {})
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
const authority = new DelayedAuthority(inner)
|
||||
const store = new Store({
|
||||
|
||||
@@ -27,7 +27,7 @@ beforeEach(() => {
|
||||
})
|
||||
afterEach(() => setSecretStore(previousSecretStore))
|
||||
|
||||
describe.each(['opencodeSessionCookie', 'opencodeGoApiKey'] as const)(
|
||||
describe.each(['opencodeSessionCookie'] as const)(
|
||||
'Store %s retention across worker acknowledgements',
|
||||
(setting) => {
|
||||
it('does not restore ciphertext cleared while its commit acknowledgement was pending', async () => {
|
||||
@@ -94,14 +94,14 @@ describe.each(['opencodeSessionCookie', 'opencodeGoApiKey'] as const)(
|
||||
|
||||
describe('worker protected settings serialization', () => {
|
||||
it.each(['selective', 'complete'] as const)(
|
||||
'encrypts both protected credentials in a %s write to SQLite',
|
||||
'seals the cookie and retains the legacy Go key privately in a %s SQLite write',
|
||||
async (mode) => {
|
||||
const { store, authority, readState } = await fixture()
|
||||
const { store, authority, readState } = await fixture(ciphertext('legacy-go-key'))
|
||||
const selectiveWrite = vi.spyOn(authority, 'writeSerializedDomains')
|
||||
const completeWrite = vi.spyOn(authority, 'writeCompleteSerializedDomains')
|
||||
const secrets = {
|
||||
opencodeSessionCookie: 'cookie-only-plaintext',
|
||||
opencodeGoApiKey: 'api-key-only-plaintext'
|
||||
opencodeGoApiKey: 'retired-write-is-ignored'
|
||||
}
|
||||
store.updateSettings(secrets)
|
||||
if (mode === 'complete') {
|
||||
@@ -113,12 +113,34 @@ describe('worker protected settings serialization', () => {
|
||||
const persisted = readState()
|
||||
expect(persisted.settings).toMatchObject({
|
||||
opencodeSessionCookie: ciphertext(secrets.opencodeSessionCookie),
|
||||
opencodeGoApiKey: ciphertext(secrets.opencodeGoApiKey)
|
||||
opencodeGoApiKey: ciphertext('legacy-go-key')
|
||||
})
|
||||
for (const plaintext of Object.values(secrets)) {
|
||||
expect(JSON.stringify(persisted)).not.toContain(plaintext)
|
||||
}
|
||||
expect(store.getSettings()).toMatchObject(secrets)
|
||||
expect(store.getSettings().opencodeSessionCookie).toBe(secrets.opencodeSessionCookie)
|
||||
expect(store.getSettings()).not.toHaveProperty('opencodeGoApiKey')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('does not restore the migrated legacy Go ciphertext from a pending worker acknowledgement', async () => {
|
||||
const { store, authority, readState } = await fixture(ciphertext('legacy-go-key'))
|
||||
const gate = authority.pause()
|
||||
store.updateSettings({ theme: 'dark' })
|
||||
const write = store.flushPendingOrThrowAsync({ drainToStableGeneration: false })
|
||||
await gate.started.promise
|
||||
const save = vi.fn()
|
||||
store.migrateLegacyOpenCodeGoApiKey({ has: () => false, read: () => null, save })
|
||||
encryptionAvailable = false
|
||||
gate.finish.resolve()
|
||||
await write
|
||||
await store.flushPendingOrThrowAsync()
|
||||
expect(save).toHaveBeenCalledExactlyOnceWith('legacy-go-key')
|
||||
expect(readState().settings).not.toHaveProperty('opencodeGoApiKey')
|
||||
expect(store.getSettings()).not.toHaveProperty('opencodeGoApiKey')
|
||||
encryptionAvailable = true
|
||||
store.updateSettings({ theme: 'light' })
|
||||
await store.flushPendingOrThrowAsync()
|
||||
expect(readState().settings).not.toHaveProperty('opencodeGoApiKey')
|
||||
})
|
||||
|
||||
@@ -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 ?? ''
|
||||
|
||||
@@ -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-'))),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -5,19 +5,33 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as WslTranscriptFsAccess from '../native-chat/wsl-transcript-fs-access'
|
||||
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-error'
|
||||
import Database from '../sqlite/sync-database'
|
||||
import { detectOpenCodeCredentialBackend } from '../opencode/opencode-credential-backend'
|
||||
import {
|
||||
listOpenCodeDatabases,
|
||||
listOpenCodeDatabasesInDirectory
|
||||
} from '../opencode-usage/opencode-database-discovery'
|
||||
import {
|
||||
getOpenCodeAuthFilePath,
|
||||
readOpenCodeAuthFileGoKey,
|
||||
resolveOpenCodeGoApiKey
|
||||
} from './opencode-go-api-key-source'
|
||||
|
||||
vi.mock('../opencode/opencode-credential-backend', () => ({
|
||||
detectOpenCodeCredentialBackend: vi.fn()
|
||||
}))
|
||||
|
||||
// Placeholder values only — a real key must never reach a fixture.
|
||||
const SETTINGS_KEY = 'settings-placeholder-key'
|
||||
const ENVIRONMENT_KEY = 'environment-placeholder-key'
|
||||
const AUTH_FILE_KEY = 'auth-file-placeholder-key'
|
||||
const DATABASE_KEY = 'database-placeholder-key'
|
||||
|
||||
const ENVIRONMENT_KEYS = ['XDG_DATA_HOME', 'OPENCODE_API_KEY', 'OPENCODE_DB'] as const
|
||||
const ENVIRONMENT_KEYS = [
|
||||
'XDG_DATA_HOME',
|
||||
'OPENCODE_API_KEY',
|
||||
'OPENCODE_DB',
|
||||
'OPENCODE_AUTH_CONTENT'
|
||||
] as const
|
||||
|
||||
// Lets a test fail the data-directory listing with an error the host filesystem cannot portably produce.
|
||||
const readdirFailure = vi.hoisted((): { error: unknown } => ({ error: null }))
|
||||
@@ -61,10 +75,12 @@ describe('resolveOpenCodeGoApiKey', () => {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(detectOpenCodeCredentialBackend).mockReset().mockResolvedValue('v2')
|
||||
originalEnvironment = Object.fromEntries(ENVIRONMENT_KEYS.map((key) => [key, process.env[key]]))
|
||||
dataHome = mkdtempSync(join(tmpdir(), 'orca-opencode-go-key-'))
|
||||
process.env.XDG_DATA_HOME = dataHome
|
||||
delete process.env.OPENCODE_API_KEY
|
||||
delete process.env.OPENCODE_AUTH_CONTENT
|
||||
// Keeps the credential-database tier from touching the developer's own store.
|
||||
process.env.OPENCODE_DB = ':memory:'
|
||||
})
|
||||
@@ -82,6 +98,69 @@ describe('resolveOpenCodeGoApiKey', () => {
|
||||
rmSync(dataHome, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it.each([undefined, 'selected.db'] as const)(
|
||||
'uses the fourth-argument environment for discovery (%s)',
|
||||
async (override) => {
|
||||
const directory = join(dataHome, 'opencode')
|
||||
mkdirSync(directory)
|
||||
const path = join(directory, override ?? 'opencode.db')
|
||||
writeFileSync(path, '')
|
||||
const onFsError = vi.fn()
|
||||
|
||||
await expect(
|
||||
listOpenCodeDatabases(undefined, onFsError, undefined, {
|
||||
XDG_DATA_HOME: dataHome,
|
||||
OPENCODE_DB: override
|
||||
})
|
||||
).resolves.toEqual([path])
|
||||
expect(onFsError).not.toHaveBeenCalled()
|
||||
expect(process.env.OPENCODE_DB).toBe(':memory:')
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps filesystem error reporting in the second argument', async () => {
|
||||
const error = Object.assign(new Error('discovery denied'), { code: 'EACCES' })
|
||||
readdirFailure.error = error
|
||||
const onFsError = vi.fn()
|
||||
|
||||
await expect(
|
||||
listOpenCodeDatabases(undefined, onFsError, undefined, { XDG_DATA_HOME: dataHome })
|
||||
).resolves.toEqual([])
|
||||
expect(onFsError).toHaveBeenCalledWith(join(dataHome, 'opencode'), error)
|
||||
})
|
||||
|
||||
it.each([undefined, 'missing.db'] as const)(
|
||||
'preserves third-argument cancellation before reporting discovery errors (%s)',
|
||||
async (override) => {
|
||||
readdirFailure.error = new Error('discovery stopped')
|
||||
const reason = new Error('caller cancelled')
|
||||
const controller = new AbortController()
|
||||
controller.abort(reason)
|
||||
const onFsError = vi.fn()
|
||||
|
||||
await expect(
|
||||
listOpenCodeDatabases(undefined, onFsError, controller.signal, {
|
||||
XDG_DATA_HOME: dataHome,
|
||||
OPENCODE_DB: override
|
||||
})
|
||||
).rejects.toBe(reason)
|
||||
expect(onFsError).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves the native directory helper cancellation and error argument positions', async () => {
|
||||
readdirFailure.error = new Error('directory read stopped')
|
||||
const reason = new Error('caller cancelled')
|
||||
const controller = new AbortController()
|
||||
controller.abort(reason)
|
||||
const onFsError = vi.fn()
|
||||
|
||||
await expect(
|
||||
listOpenCodeDatabasesInDirectory(dataHome, undefined, controller.signal, onFsError)
|
||||
).rejects.toBe(reason)
|
||||
expect(onFsError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reads auth.json from XDG_DATA_HOME, which OpenCode uses on every platform', () => {
|
||||
expect(getOpenCodeAuthFilePath({ XDG_DATA_HOME: '/data' })).toBe('/data/opencode/auth.json')
|
||||
// OpenCode's global-roots.ts falls back to os.homedir() + .local/share even on Windows.
|
||||
@@ -121,13 +200,13 @@ describe('resolveOpenCodeGoApiKey', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the key OpenCode 1.x saved on /connect', async () => {
|
||||
it('uses the key OpenCode 1.x saved on /connect', async () => {
|
||||
writeAuthFile({
|
||||
anthropic: { type: 'oauth', refresh: 'r', access: 'a', expires: 1 },
|
||||
'opencode-go': { type: 'api', key: AUTH_FILE_KEY }
|
||||
})
|
||||
|
||||
await expect(resolveOpenCodeGoApiKey({})).resolves.toEqual({
|
||||
await expect(resolveOpenCodeGoApiKey({ backend: 'v1' })).resolves.toEqual({
|
||||
status: 'found',
|
||||
key: AUTH_FILE_KEY,
|
||||
tier: 'opencode-auth-file'
|
||||
@@ -231,6 +310,271 @@ describe('resolveOpenCodeGoApiKey', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('prefers the credential table over a stale auth.json, since OpenCode 2 stops writing the file', async () => {
|
||||
process.env.OPENCODE_API_KEY = ENVIRONMENT_KEY
|
||||
writeAuthFile({ 'opencode-go': { type: 'api', key: AUTH_FILE_KEY } })
|
||||
const { path } = writeCredentialDatabase([
|
||||
{ value: JSON.stringify({ type: 'key', key: DATABASE_KEY }), active: 1, created: 1 }
|
||||
])
|
||||
process.env.OPENCODE_DB = path
|
||||
|
||||
await expect(resolveOpenCodeGoApiKey({})).resolves.toEqual({
|
||||
status: 'found',
|
||||
key: DATABASE_KEY,
|
||||
tier: 'opencode-credential-database'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses v1 auth.json despite a conflicting populated credential table', async () => {
|
||||
process.env.OPENCODE_API_KEY = ENVIRONMENT_KEY
|
||||
writeAuthFile({ 'opencode-go': { type: 'api', key: AUTH_FILE_KEY } })
|
||||
const { path } = writeCredentialDatabase([
|
||||
{ value: JSON.stringify({ type: 'key', key: DATABASE_KEY }), active: 1, created: 1 }
|
||||
])
|
||||
process.env.OPENCODE_DB = path
|
||||
|
||||
await expect(resolveOpenCodeGoApiKey({ backend: 'v1' })).resolves.toEqual({
|
||||
status: 'found',
|
||||
key: AUTH_FILE_KEY,
|
||||
tier: 'opencode-auth-file'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not use a v2 table-only credential for v1 execution', async () => {
|
||||
process.env.OPENCODE_API_KEY = ENVIRONMENT_KEY
|
||||
const { path } = writeCredentialDatabase([
|
||||
{ value: JSON.stringify({ type: 'key', key: DATABASE_KEY }), active: 1, created: 1 }
|
||||
])
|
||||
process.env.OPENCODE_DB = path
|
||||
|
||||
await expect(resolveOpenCodeGoApiKey({ backend: 'v1' })).resolves.toEqual({
|
||||
status: 'found',
|
||||
key: ENVIRONMENT_KEY,
|
||||
tier: 'environment'
|
||||
})
|
||||
})
|
||||
|
||||
it('applies the installed v1 backend even when the table has a key', async () => {
|
||||
vi.mocked(detectOpenCodeCredentialBackend).mockResolvedValue('v1')
|
||||
writeAuthFile({ 'opencode-go': { type: 'api', key: AUTH_FILE_KEY } })
|
||||
process.env.OPENCODE_DB = writeCredentialDatabase([
|
||||
{ value: JSON.stringify({ type: 'key', key: DATABASE_KEY }), active: 1, created: 1 }
|
||||
]).path
|
||||
|
||||
await expect(resolveOpenCodeGoApiKey({})).resolves.toEqual({
|
||||
status: 'found',
|
||||
key: AUTH_FILE_KEY,
|
||||
tier: 'opencode-auth-file'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses v1 inline authentication ahead of a conflicting auth file and table', async () => {
|
||||
writeAuthFile({ 'opencode-go': { type: 'api', key: AUTH_FILE_KEY } })
|
||||
process.env.OPENCODE_DB = writeCredentialDatabase([
|
||||
{ value: JSON.stringify({ type: 'key', key: DATABASE_KEY }), active: 1, created: 1 }
|
||||
]).path
|
||||
process.env.OPENCODE_AUTH_CONTENT = JSON.stringify({
|
||||
'opencode-go': { type: 'api', key: 'inline-placeholder-key' }
|
||||
})
|
||||
|
||||
await expect(resolveOpenCodeGoApiKey({ backend: 'v1' })).resolves.toEqual({
|
||||
status: 'found',
|
||||
key: 'inline-placeholder-key',
|
||||
tier: 'opencode-auth-content'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not fall back to the auth file when valid v1 inline auth omits Go', async () => {
|
||||
writeAuthFile({ 'opencode-go': { type: 'api', key: AUTH_FILE_KEY } })
|
||||
process.env.OPENCODE_AUTH_CONTENT = '{}'
|
||||
process.env.OPENCODE_API_KEY = ENVIRONMENT_KEY
|
||||
|
||||
await expect(resolveOpenCodeGoApiKey({ backend: 'v1' })).resolves.toEqual({
|
||||
status: 'found',
|
||||
key: ENVIRONMENT_KEY,
|
||||
tier: 'environment'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the v1 auth file after malformed inline authentication', async () => {
|
||||
writeAuthFile({ 'opencode-go': { type: 'api', key: AUTH_FILE_KEY } })
|
||||
process.env.OPENCODE_AUTH_CONTENT = '{invalid'
|
||||
|
||||
await expect(resolveOpenCodeGoApiKey({ backend: 'v1' })).resolves.toEqual({
|
||||
status: 'found',
|
||||
key: AUTH_FILE_KEY,
|
||||
tier: 'opencode-auth-file'
|
||||
})
|
||||
})
|
||||
|
||||
it('withholds stored credentials when the execution backend cannot be determined', async () => {
|
||||
vi.mocked(detectOpenCodeCredentialBackend).mockResolvedValue(null)
|
||||
writeAuthFile({ 'opencode-go': { type: 'api', key: AUTH_FILE_KEY } })
|
||||
process.env.OPENCODE_DB = writeCredentialDatabase([
|
||||
{ value: JSON.stringify({ type: 'key', key: DATABASE_KEY }), active: 1, created: 1 }
|
||||
]).path
|
||||
|
||||
await expect(resolveOpenCodeGoApiKey({})).resolves.toEqual({ status: 'missing' })
|
||||
})
|
||||
|
||||
it('uses the provided environment key without reading stores when the backend is unknown', async () => {
|
||||
vi.mocked(detectOpenCodeCredentialBackend).mockResolvedValue(null)
|
||||
const environment: NodeJS.ProcessEnv = { OPENCODE_API_KEY: ` ${ENVIRONMENT_KEY} ` }
|
||||
const readStoreContext = vi.fn(() => {
|
||||
throw new Error('Unknown backend must not read version-specific stores')
|
||||
})
|
||||
for (const name of ['OPENCODE_AUTH_CONTENT', 'XDG_DATA_HOME', 'OPENCODE_DB']) {
|
||||
Object.defineProperty(environment, name, { get: readStoreContext })
|
||||
}
|
||||
|
||||
await expect(resolveOpenCodeGoApiKey({ environment, cwd: dataHome })).resolves.toEqual({
|
||||
status: 'found',
|
||||
key: ENVIRONMENT_KEY,
|
||||
tier: 'environment'
|
||||
})
|
||||
expect(vi.mocked(detectOpenCodeCredentialBackend).mock.calls[0]?.[0]).toBe(environment)
|
||||
expect(vi.mocked(detectOpenCodeCredentialBackend).mock.calls[0]?.[1]).toBe(dataHome)
|
||||
expect(readStoreContext).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([undefined, '', ' \t\n '])(
|
||||
'does not borrow the host key when an unknown selected backend has environment key %j',
|
||||
async (key) => {
|
||||
vi.mocked(detectOpenCodeCredentialBackend).mockResolvedValue(null)
|
||||
process.env.OPENCODE_API_KEY = ENVIRONMENT_KEY
|
||||
const environment: NodeJS.ProcessEnv = { OPENCODE_API_KEY: key }
|
||||
|
||||
await expect(resolveOpenCodeGoApiKey({ environment })).resolves.toEqual({ status: 'missing' })
|
||||
expect(detectOpenCodeCredentialBackend).toHaveBeenCalledWith(environment, undefined)
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps host and selected environment keys isolated when the backend is unknown', async () => {
|
||||
vi.mocked(detectOpenCodeCredentialBackend).mockResolvedValue(null)
|
||||
process.env.OPENCODE_API_KEY = ENVIRONMENT_KEY
|
||||
const selected = { OPENCODE_API_KEY: 'selected-environment-placeholder' }
|
||||
const inherited = { ...process.env }
|
||||
|
||||
const first = await resolveOpenCodeGoApiKey({})
|
||||
const managed = await resolveOpenCodeGoApiKey({ environment: selected })
|
||||
const restored = await resolveOpenCodeGoApiKey({})
|
||||
|
||||
expect(first).toEqual({ status: 'found', key: ENVIRONMENT_KEY, tier: 'environment' })
|
||||
expect(managed).toEqual({
|
||||
status: 'found',
|
||||
key: 'selected-environment-placeholder',
|
||||
tier: 'environment'
|
||||
})
|
||||
expect(restored).toEqual(first)
|
||||
expect(process.env).toEqual(inherited)
|
||||
expect(selected).toEqual({ OPENCODE_API_KEY: 'selected-environment-placeholder' })
|
||||
})
|
||||
|
||||
it.each(['v1', 'v2'] as const)(
|
||||
'uses caller backend authority for %s without probing',
|
||||
async (backend) => {
|
||||
vi.mocked(detectOpenCodeCredentialBackend).mockRejectedValue(new Error('Unexpected probe'))
|
||||
process.env.OPENCODE_API_KEY = ENVIRONMENT_KEY
|
||||
writeAuthFile({ 'opencode-go': { type: 'api', key: AUTH_FILE_KEY } })
|
||||
process.env.OPENCODE_DB = writeCredentialDatabase([
|
||||
{ value: JSON.stringify({ type: 'key', key: DATABASE_KEY }), active: 1, created: 1 }
|
||||
]).path
|
||||
|
||||
await expect(resolveOpenCodeGoApiKey({ backend })).resolves.toEqual(
|
||||
backend === 'v1'
|
||||
? { status: 'found', key: AUTH_FILE_KEY, tier: 'opencode-auth-file' }
|
||||
: { status: 'found', key: DATABASE_KEY, tier: 'opencode-credential-database' }
|
||||
)
|
||||
expect(detectOpenCodeCredentialBackend).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('returns the manual override before reading execution context or probing a CLI', async () => {
|
||||
const input = { settingsOverride: SETTINGS_KEY }
|
||||
Object.defineProperty(input, 'environment', {
|
||||
get: () => {
|
||||
throw new Error('Selected profile metadata is unreadable')
|
||||
}
|
||||
})
|
||||
vi.mocked(detectOpenCodeCredentialBackend).mockRejectedValue(new Error('Unexpected probe'))
|
||||
|
||||
await expect(resolveOpenCodeGoApiKey(input)).resolves.toEqual({
|
||||
status: 'found',
|
||||
key: SETTINGS_KEY,
|
||||
tier: 'settings'
|
||||
})
|
||||
expect(detectOpenCodeCredentialBackend).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('propagates unreadable selected context instead of reading host credentials', async () => {
|
||||
writeAuthFile({ 'opencode-go': { type: 'api', key: AUTH_FILE_KEY } })
|
||||
const input = {}
|
||||
Object.defineProperty(input, 'environment', {
|
||||
get: () => {
|
||||
throw new Error('Selected profile metadata is unreadable')
|
||||
}
|
||||
})
|
||||
|
||||
await expect(resolveOpenCodeGoApiKey(input)).rejects.toThrow(
|
||||
'Selected profile metadata is unreadable'
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['v1', 'v2'] as const)(
|
||||
'keeps System and selected data roots isolated for %s',
|
||||
async (backend) => {
|
||||
writeAuthFile({ 'opencode-go': { type: 'api', key: AUTH_FILE_KEY } })
|
||||
const system = { XDG_DATA_HOME: dataHome, OPENCODE_DB: ':memory:' }
|
||||
const selectedDataHome = join(dataHome, 'selected')
|
||||
mkdirSync(join(selectedDataHome, 'opencode'), { recursive: true })
|
||||
writeFileSync(
|
||||
join(selectedDataHome, 'opencode', 'auth.json'),
|
||||
JSON.stringify({
|
||||
'opencode-go': { type: 'api', key: 'selected-auth-placeholder' }
|
||||
})
|
||||
)
|
||||
const { path } = writeCredentialDatabase([
|
||||
{
|
||||
value: JSON.stringify({ type: 'key', key: 'selected-table-placeholder' }),
|
||||
active: 1,
|
||||
created: 1
|
||||
}
|
||||
])
|
||||
const selected = { XDG_DATA_HOME: selectedDataHome, OPENCODE_DB: path }
|
||||
const inherited = { ...process.env }
|
||||
|
||||
const first = await resolveOpenCodeGoApiKey({ environment: system, backend })
|
||||
const managed = await resolveOpenCodeGoApiKey({ environment: selected, backend })
|
||||
const restored = await resolveOpenCodeGoApiKey({ environment: system, backend })
|
||||
|
||||
expect(first).toEqual({ status: 'found', key: AUTH_FILE_KEY, tier: 'opencode-auth-file' })
|
||||
expect(managed).toEqual(
|
||||
backend === 'v1'
|
||||
? { status: 'found', key: 'selected-auth-placeholder', tier: 'opencode-auth-file' }
|
||||
: {
|
||||
status: 'found',
|
||||
key: 'selected-table-placeholder',
|
||||
tier: 'opencode-credential-database'
|
||||
}
|
||||
)
|
||||
expect(restored).toEqual(first)
|
||||
expect(process.env).toEqual(inherited)
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps the settings override above the credential table', async () => {
|
||||
const { path } = writeCredentialDatabase([
|
||||
{ value: JSON.stringify({ type: 'key', key: DATABASE_KEY }), active: 1, created: 1 }
|
||||
])
|
||||
process.env.OPENCODE_DB = path
|
||||
|
||||
await expect(resolveOpenCodeGoApiKey({ settingsOverride: SETTINGS_KEY })).resolves.toEqual({
|
||||
status: 'found',
|
||||
key: SETTINGS_KEY,
|
||||
tier: 'settings'
|
||||
})
|
||||
})
|
||||
|
||||
it('reports missing when no tier holds a key', async () => {
|
||||
writeAuthFile({ 'opencode-go': { type: 'oauth', refresh: 'r', access: 'a', expires: 1 } })
|
||||
|
||||
|
||||
@@ -8,6 +8,10 @@ import { tableExists } from '../opencode-usage/schema-helpers'
|
||||
import { isWslUncPath } from '../../shared/wsl-paths'
|
||||
import { resolveOpenCodeDataDirectory } from '../opencode/opencode-data-directory'
|
||||
import Database from '../sqlite/sync-database'
|
||||
import {
|
||||
detectOpenCodeCredentialBackend,
|
||||
type OpenCodeCredentialBackend
|
||||
} from '../opencode/opencode-credential-backend'
|
||||
|
||||
/** OpenCode's provider/integration id for the Go subscription. */
|
||||
const OPENCODE_GO_INTEGRATION_ID = 'opencode-go'
|
||||
@@ -20,6 +24,7 @@ const MAX_AUTH_FILE_BYTES = 1_000_000
|
||||
export type OpenCodeGoApiKeyTier =
|
||||
| 'settings'
|
||||
| 'environment'
|
||||
| 'opencode-auth-content'
|
||||
| 'opencode-auth-file'
|
||||
| 'opencode-credential-database'
|
||||
|
||||
@@ -61,6 +66,27 @@ function trimmedKey(value: unknown): string | null {
|
||||
return trimmed ? trimmed : null
|
||||
}
|
||||
|
||||
function readOpenCodeInlineGoKey(environment: NodeJS.ProcessEnv): {
|
||||
configured: boolean
|
||||
key: string | null
|
||||
} {
|
||||
const content = environment.OPENCODE_AUTH_CONTENT
|
||||
if (content) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(content)
|
||||
return {
|
||||
configured: true,
|
||||
key: isRecord(parsed)
|
||||
? keyFromCredentialRecord(parsed[OPENCODE_GO_INTEGRATION_ID], 'api')
|
||||
: null
|
||||
}
|
||||
} catch {
|
||||
// V1 ignores invalid inline JSON and then reads auth.json.
|
||||
}
|
||||
}
|
||||
return { configured: false, key: null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the `opencode-go` API key OpenCode 1.x writes on `/connect`.
|
||||
*
|
||||
@@ -87,8 +113,6 @@ export function readOpenCodeAuthFileGoKey(
|
||||
}
|
||||
return keyFromCredentialRecord(parsed[OPENCODE_GO_INTEGRATION_ID], 'api')
|
||||
} catch {
|
||||
// Why: a malformed or unreadable auth file is "no key here", not a fetch
|
||||
// failure — later tiers and the cookie path still deserve their turn.
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -135,22 +159,28 @@ function selectCredentialKey(database: Database.Database): string | null {
|
||||
* OpenCode 2 imports `auth.json` into SQLite once (migration
|
||||
* `20260805200742_import_legacy_credentials`) and every later `/connect` writes
|
||||
* only there, so a fresh OpenCode 2 install has no `auth.json` entry at all.
|
||||
* The table itself is not a version marker — 1.18.x creates it too (verified
|
||||
* empty on a real 1.18.16 install), so probe it regardless of version.
|
||||
* V1 also creates this table and can populate it through integration routes;
|
||||
* only a caller with V2 execution authority should use it for Go credentials.
|
||||
* @returns The key; `missing` when no database, table, or row carries one;
|
||||
* `unreadable` when none had a key but discovery failed for a reason other than
|
||||
* absence, or at least one database failed to open or query.
|
||||
* `unreadable` when discovery, opening, or querying failed without a key.
|
||||
*/
|
||||
export async function readOpenCodeCredentialDatabaseGoKey(): Promise<OpenCodeCredentialDatabaseGoKeyRead> {
|
||||
export async function readOpenCodeCredentialDatabaseGoKey(
|
||||
environment: NodeJS.ProcessEnv = process.env
|
||||
): Promise<OpenCodeCredentialDatabaseGoKeyRead> {
|
||||
let sawUnreadable = false
|
||||
let paths: string[]
|
||||
try {
|
||||
const listed = await listOpenCodeDatabases(undefined, (path, error) => {
|
||||
// A UNC location is never opened here (below), so failing to list it is no evidence either.
|
||||
if (!isWslUncPath(path) && !isMissingPathError(error)) {
|
||||
sawUnreadable = true
|
||||
}
|
||||
})
|
||||
const listed = await listOpenCodeDatabases(
|
||||
undefined,
|
||||
(path, error) => {
|
||||
// A UNC location is never opened here (below), so failing to list it is no evidence either.
|
||||
if (!isWslUncPath(path) && !isMissingPathError(error)) {
|
||||
sawUnreadable = true
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
environment
|
||||
)
|
||||
paths = [...listed].sort(compareOpenCodeClaimPriority)
|
||||
} catch {
|
||||
return { status: 'missing' }
|
||||
@@ -184,38 +214,46 @@ export async function readOpenCodeCredentialDatabaseGoKey(): Promise<OpenCodeCre
|
||||
/**
|
||||
* Resolve the OpenCode Go API key in the documented precedence order.
|
||||
*
|
||||
* Settings override, then whatever OpenCode itself stored on `/connect` —
|
||||
* `auth.json`, then the `credential` table — then `OPENCODE_API_KEY`. Both
|
||||
* stores are probed on every version: 1.18.x creates the `credential` table too,
|
||||
* so its presence is not a 2.x marker, and a 2.x install that never ran the
|
||||
* legacy import has no `auth.json` at all.
|
||||
* The stored key outranks the env var because OpenCode applies it after env,
|
||||
* and the env var is shared with the Zen provider — so an unreadable database
|
||||
* stops before the env var rather than risk reporting the wrong key's usage.
|
||||
* @param input.settingsOverride - The key a user pasted into Orca's settings.
|
||||
* Settings wins before backend discovery. V1 uses inline auth or auth.json;
|
||||
* V2 gives its credential table precedence over the legacy file. Stored keys
|
||||
* outrank the shared OPENCODE_API_KEY environment variable in both backends.
|
||||
* An unknown backend can still use the shared environment key, but no store.
|
||||
* An unreadable V2 database withholds the env key, which may belong to Zen.
|
||||
* @param input.settingsOverride - The key a user saved in Orca's settings.
|
||||
* @param input.environment - Process environment to read; injectable for tests.
|
||||
* @returns The first key found and the tier it came from, `missing`, or
|
||||
* `credential-database-unreadable`.
|
||||
* @returns The key and its tier, `missing`, or `credential-database-unreadable`.
|
||||
*/
|
||||
export async function resolveOpenCodeGoApiKey(input: {
|
||||
settingsOverride?: string
|
||||
environment?: NodeJS.ProcessEnv
|
||||
backend?: OpenCodeCredentialBackend
|
||||
cwd?: string
|
||||
}): Promise<OpenCodeGoApiKeyResolution> {
|
||||
const environment = input.environment ?? process.env
|
||||
const override = trimmedKey(input.settingsOverride)
|
||||
if (override) {
|
||||
return { status: 'found', key: override, tier: 'settings' }
|
||||
}
|
||||
const fromAuthFile = readOpenCodeAuthFileGoKey(environment)
|
||||
const environment = input.environment ?? process.env
|
||||
const backend = input.backend ?? (await detectOpenCodeCredentialBackend(environment, input.cwd))
|
||||
let databaseUnreadable = false
|
||||
if (backend === 'v2') {
|
||||
const fromDatabase = await readOpenCodeCredentialDatabaseGoKey(environment)
|
||||
if (fromDatabase.status === 'found') {
|
||||
return { status: 'found', key: fromDatabase.key, tier: 'opencode-credential-database' }
|
||||
}
|
||||
databaseUnreadable = fromDatabase.status === 'unreadable'
|
||||
}
|
||||
const inline = backend === 'v1' ? readOpenCodeInlineGoKey(environment) : null
|
||||
if (inline?.key) {
|
||||
return { status: 'found', key: inline.key, tier: 'opencode-auth-content' }
|
||||
}
|
||||
const fromAuthFile =
|
||||
backend && !inline?.configured ? readOpenCodeAuthFileGoKey(environment) : null
|
||||
if (fromAuthFile) {
|
||||
return { status: 'found', key: fromAuthFile, tier: 'opencode-auth-file' }
|
||||
}
|
||||
const fromDatabase = await readOpenCodeCredentialDatabaseGoKey()
|
||||
if (fromDatabase.status === 'found') {
|
||||
return { status: 'found', key: fromDatabase.key, tier: 'opencode-credential-database' }
|
||||
}
|
||||
const fromEnvironment = trimmedKey(environment[OPENCODE_API_KEY_ENV])
|
||||
if (fromEnvironment && fromDatabase.status === 'unreadable') {
|
||||
if (fromEnvironment && databaseUnreadable) {
|
||||
return { status: 'credential-database-unreadable' }
|
||||
}
|
||||
if (fromEnvironment) {
|
||||
|
||||
@@ -76,6 +76,44 @@ describe('fetchOpenCodeGoUsage', () => {
|
||||
expect(resolveApiKeyMock).toHaveBeenCalledWith({ settingsOverride: API_KEY })
|
||||
})
|
||||
|
||||
it('passes trusted execution context and selected roots through to credential lookup', async () => {
|
||||
resolveApiKeyMock.mockResolvedValue({ status: 'missing' })
|
||||
const environment = { XDG_DATA_HOME: '/task/selected', OPENCODE_DB: ':memory:' }
|
||||
|
||||
await fetchOpenCodeGoUsage({ cookie: '', backend: 'v1', environment, cwd: '/task/workspace' })
|
||||
|
||||
expect(resolveApiKeyMock).toHaveBeenCalledExactlyOnceWith({
|
||||
settingsOverride: undefined,
|
||||
backend: 'v1',
|
||||
environment,
|
||||
cwd: '/task/workspace'
|
||||
})
|
||||
})
|
||||
|
||||
it('passes a manual override without evaluating selected execution context', async () => {
|
||||
resolveApiKeyMock.mockResolvedValue({ status: 'found', key: API_KEY, tier: 'settings' })
|
||||
fetchWithApiKeyMock.mockResolvedValue({ kind: 'ok', windows: WINDOWS })
|
||||
const input = { cookie: '', settingsApiKey: API_KEY }
|
||||
Object.defineProperty(input, 'environment', {
|
||||
get: () => {
|
||||
throw new Error('Selected profile metadata is unreadable')
|
||||
}
|
||||
})
|
||||
|
||||
expect((await fetchOpenCodeGoUsage(input)).status).toBe('ok')
|
||||
expect(resolveApiKeyMock).toHaveBeenCalledExactlyOnceWith({ settingsOverride: API_KEY })
|
||||
})
|
||||
|
||||
it('propagates a rejected selected-account lookup without trying host or cookie credentials', async () => {
|
||||
resolveApiKeyMock.mockRejectedValue(new Error('Selected profile metadata is unreadable'))
|
||||
|
||||
await expect(fetchOpenCodeGoUsage({ cookie: COOKIE })).rejects.toThrow(
|
||||
'Selected profile metadata is unreadable'
|
||||
)
|
||||
expect(fetchWithApiKeyMock).not.toHaveBeenCalled()
|
||||
expect(fetchWithCookieMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('names the missing subscription instead of a generic refresh failure', async () => {
|
||||
resolveApiKeyMock.mockResolvedValue({
|
||||
status: 'found',
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type OpenCodeGoApiKeyResolution
|
||||
} from './opencode-go-api-key-source'
|
||||
import type { OpenCodeGoUsageWindows } from './opencode-go-status-parsing'
|
||||
import type { OpenCodeCredentialBackend } from '../opencode/opencode-credential-backend'
|
||||
import {
|
||||
fetchOpenCodeGoUsageWithApiKey,
|
||||
type OpenCodeGoUsageApiOutcome
|
||||
@@ -14,6 +15,9 @@ import { fetchOpenCodeGoRateLimits, normalizeCookieInput } from './opencode-go-u
|
||||
export type OpenCodeGoUsageSourceInput = {
|
||||
/** Explicit Orca override; the highest-precedence key tier. */
|
||||
settingsApiKey?: string
|
||||
environment?: NodeJS.ProcessEnv
|
||||
backend?: OpenCodeCredentialBackend
|
||||
cwd?: string
|
||||
cookie: string
|
||||
workspaceIdOverride?: string
|
||||
networkProxySettings?: NetworkProxySettings
|
||||
@@ -96,8 +100,16 @@ function apiFailureResult(
|
||||
export async function fetchOpenCodeGoUsage(
|
||||
input: OpenCodeGoUsageSourceInput
|
||||
): Promise<ProviderRateLimits> {
|
||||
const context = input.settingsApiKey?.trim()
|
||||
? {}
|
||||
: {
|
||||
...(input.environment ? { environment: input.environment } : {}),
|
||||
...(input.backend ? { backend: input.backend } : {}),
|
||||
...(input.cwd ? { cwd: input.cwd } : {})
|
||||
}
|
||||
const apiKeyResolution = await resolveOpenCodeGoApiKey({
|
||||
settingsOverride: input.settingsApiKey
|
||||
settingsOverride: input.settingsApiKey,
|
||||
...context
|
||||
})
|
||||
input.onApiKeyResolved?.(apiKeyResolution)
|
||||
const hasCookie = Boolean(normalizeCookieInput(input.cookie))
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ProviderRateLimits } from '../../shared/rate-limit-types'
|
||||
import { RateLimitService } from './service'
|
||||
import { fetchClaudeRateLimits } from './claude-fetcher'
|
||||
import { fetchCodexRateLimits } from './codex-fetcher'
|
||||
import { fetchOpenCodeGoUsage } from './opencode-go-usage-source-selection'
|
||||
import { ApiKeyFileUnreadableError } from '../credentials/api-key-file-unreadable-error'
|
||||
import {
|
||||
deferred,
|
||||
flushMicrotasks,
|
||||
okProvider,
|
||||
resetRateLimitProviderMocks,
|
||||
unavailableProvider
|
||||
} from './rate-limit-service-test-harness'
|
||||
|
||||
vi.mock('./claude-fetcher', () => ({
|
||||
fetchClaudeRateLimits: vi.fn(),
|
||||
fetchManagedAccountUsage: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./codex-fetcher', () => ({
|
||||
consumeCodexRateLimitResetCredit: vi.fn(),
|
||||
fetchCodexRateLimits: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./gemini-usage-fetcher', () => ({
|
||||
fetchGeminiRateLimits: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./kimi-fetcher', () => ({
|
||||
fetchKimiRateLimits: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./cursor-fetcher', () => ({ fetchCursorRateLimits: vi.fn() }))
|
||||
vi.mock('./cursor-auth', () => ({ readCursorAuthSession: vi.fn() }))
|
||||
vi.mock('./zcode-usage-fetcher', () => ({ fetchZcodeRateLimits: vi.fn() }))
|
||||
vi.mock('./antigravity-usage-fetcher', () => ({ fetchAntigravityRateLimits: vi.fn() }))
|
||||
|
||||
vi.mock('./opencode-go-usage-source-selection', () => ({
|
||||
fetchOpenCodeGoUsage: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./minimax/minimax-fetcher', () => ({
|
||||
fetchMiniMaxRateLimits: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./grok-fetcher', () => ({
|
||||
fetchGrokRateLimits: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./grok-auth', () => ({
|
||||
readGrokAuthSession: vi.fn(() => ({ status: 'missing' }))
|
||||
}))
|
||||
|
||||
vi.mock('../minimax/minimax-cookie-store', () => ({
|
||||
hasMiniMaxSessionCookie: vi.fn(() => false)
|
||||
}))
|
||||
|
||||
const DECRYPT_ERROR =
|
||||
'OpenCode Go API key could not be decrypted. Re-enter or clear the key in Settings.'
|
||||
|
||||
function serviceWithCookie(apiKeyResolver: () => string | null): RateLimitService {
|
||||
const service = new RateLimitService()
|
||||
service.setOpenCodeGoConfigResolver(
|
||||
() => ({ sessionCookie: 'auth=fake-cookie', workspaceIdOverride: '' }),
|
||||
apiKeyResolver
|
||||
)
|
||||
return service
|
||||
}
|
||||
|
||||
function undecryptableKey(): string | null {
|
||||
throw new Error('OpenCode Go API key could not be decrypted')
|
||||
}
|
||||
|
||||
describe('OpenCode Go credential state', () => {
|
||||
beforeEach(() => {
|
||||
resetRateLimitProviderMocks()
|
||||
vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 0))
|
||||
vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 0))
|
||||
})
|
||||
|
||||
it('treats an undecryptable saved key as absent so the cookie still produces usage', async () => {
|
||||
const service = serviceWithCookie(undecryptableKey)
|
||||
vi.mocked(fetchOpenCodeGoUsage).mockResolvedValueOnce(okProvider('opencode-go', 40))
|
||||
|
||||
await service.refresh()
|
||||
|
||||
expect(fetchOpenCodeGoUsage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ settingsApiKey: '', cookie: 'auth=fake-cookie' })
|
||||
)
|
||||
const state = service.getState()
|
||||
expect(state.opencodeGo?.status).toBe('ok')
|
||||
expect(state.opencodeGo?.error).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the decrypt error only when no other source produced usage', async () => {
|
||||
const service = serviceWithCookie(undecryptableKey)
|
||||
vi.mocked(fetchOpenCodeGoUsage).mockImplementationOnce(async (input) => {
|
||||
input.onApiKeyResolved?.({ status: 'missing' })
|
||||
return unavailableProvider('opencode-go', 'No OpenCode Go API key or session cookie')
|
||||
})
|
||||
|
||||
await service.refresh()
|
||||
|
||||
const state = service.getState()
|
||||
expect(state.opencodeGo?.status).toBe('error')
|
||||
expect(state.opencodeGo?.error).toBe(DECRYPT_ERROR)
|
||||
// Why: the bar must stay visible to surface how to fix the saved key.
|
||||
expect(state.opencodeGoApiKeyConfigured).toBe(true)
|
||||
})
|
||||
|
||||
it('skips a transiently unreadable saved key without blaming it, keeping the bar visible', async () => {
|
||||
const service = serviceWithCookie(() => {
|
||||
throw new ApiKeyFileUnreadableError('OpenCode Go API key file could not be read')
|
||||
})
|
||||
vi.mocked(fetchOpenCodeGoUsage).mockImplementationOnce(async (input) => {
|
||||
input.onApiKeyResolved?.({ status: 'missing' })
|
||||
return unavailableProvider('opencode-go', 'No OpenCode Go API key or session cookie')
|
||||
})
|
||||
|
||||
await service.refresh()
|
||||
|
||||
expect(fetchOpenCodeGoUsage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ settingsApiKey: '', cookie: 'auth=fake-cookie' })
|
||||
)
|
||||
const state = service.getState()
|
||||
expect(state.opencodeGo?.status).toBe('unavailable')
|
||||
expect(state.opencodeGo?.error).not.toBe(DECRYPT_ERROR)
|
||||
expect(state.opencodeGoApiKeyConfigured).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps a real cookie error instead of the decrypt error', async () => {
|
||||
const service = serviceWithCookie(undecryptableKey)
|
||||
vi.mocked(fetchOpenCodeGoUsage).mockResolvedValueOnce({
|
||||
...unavailableProvider('opencode-go', 'OpenCode session cookie expired'),
|
||||
status: 'error'
|
||||
})
|
||||
|
||||
await service.refresh()
|
||||
|
||||
const state = service.getState()
|
||||
expect(state.opencodeGo?.status).toBe('error')
|
||||
expect(state.opencodeGo?.error).toBe('OpenCode session cookie expired')
|
||||
})
|
||||
|
||||
it('keeps the chip visible across a cookie change while a key source exists', async () => {
|
||||
const service = serviceWithCookie(() => null)
|
||||
vi.mocked(fetchOpenCodeGoUsage).mockImplementation(async (input) => {
|
||||
input.onApiKeyResolved?.({ status: 'found', key: 'fake-env-key', tier: 'environment' })
|
||||
return okProvider('opencode-go', 10)
|
||||
})
|
||||
await service.refresh()
|
||||
expect(service.getState().opencodeGoApiKeyConfigured).toBe(true)
|
||||
|
||||
service.invalidateOpenCodeGoCredentialState()
|
||||
|
||||
expect(service.getState().opencodeGo?.status).toBe('fetching')
|
||||
expect(service.getState().opencodeGoApiKeyConfigured).toBe(true)
|
||||
|
||||
service.invalidateOpenCodeGoCredentialState({ apiKeyCleared: true })
|
||||
expect(service.getState().opencodeGoApiKeyConfigured).toBe(false)
|
||||
})
|
||||
|
||||
it('passes the saved key to the fetch as the settings override', async () => {
|
||||
const service = serviceWithCookie(() => 'fake-saved-key')
|
||||
|
||||
await service.refresh()
|
||||
|
||||
expect(fetchOpenCodeGoUsage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ settingsApiKey: 'fake-saved-key' })
|
||||
)
|
||||
})
|
||||
|
||||
it('drops an in-flight result fetched with a credential that was since replaced', async () => {
|
||||
const service = serviceWithCookie(() => 'fake-old-key')
|
||||
const pending = deferred<ProviderRateLimits>()
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -43,8 +43,12 @@ export abstract class RateLimitServiceConfiguration extends RateLimitServiceAcco
|
||||
this.claudeFetchTarget = normalizeClaudeAccountSelectionTarget(target)
|
||||
}
|
||||
|
||||
setOpenCodeGoConfigResolver(resolver: () => OpenCodeGoRateLimitConfig): void {
|
||||
setOpenCodeGoConfigResolver(
|
||||
resolver: () => OpenCodeGoRateLimitConfig,
|
||||
apiKeyResolver?: () => string | null
|
||||
): void {
|
||||
this.openCodeGoConfigResolver = resolver
|
||||
this.openCodeGoApiKeyResolver = apiKeyResolver ?? null
|
||||
}
|
||||
|
||||
setMiniMaxConfigResolver(resolver: () => MiniMaxRateLimitConfig): void {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type CodexAccountSelectionTarget,
|
||||
type MiniMaxResolvedConfig,
|
||||
type ZcodePlanResolvedConfig,
|
||||
type OpenCodeGoResolvedConfig,
|
||||
type NormalizedCodexAccountSelectionTarget,
|
||||
type NormalizedClaudeAccountSelectionTarget,
|
||||
type ProviderRateLimits,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
toErrorMessage
|
||||
} from './service-types'
|
||||
import type { CodexRateLimitResetOutcome } from '../../../shared/rate-limit-types'
|
||||
import { ApiKeyFileUnreadableError } from '../../credentials/api-key-file-unreadable-error'
|
||||
|
||||
const CODEX_RESET_REFRESH_RETRIES = 3
|
||||
const CODEX_RESET_REFRESH_DELAY_MS = 250
|
||||
@@ -188,6 +190,34 @@ export abstract class RateLimitServiceFetchTargets extends RateLimitServiceResul
|
||||
return process.platform !== 'win32'
|
||||
}
|
||||
|
||||
protected resolveOpenCodeGoConfig(): OpenCodeGoResolvedConfig {
|
||||
const config = this.openCodeGoConfigResolver?.() ?? {
|
||||
sessionCookie: '',
|
||||
workspaceIdOverride: ''
|
||||
}
|
||||
try {
|
||||
return {
|
||||
...config,
|
||||
apiKey: this.openCodeGoApiKeyResolver?.() ?? '',
|
||||
apiKeyError: null,
|
||||
apiKeyReadSkipped: false
|
||||
}
|
||||
} catch (error) {
|
||||
// Why: a transient read failure says nothing about the key, so skip it this cycle without blaming it.
|
||||
if (error instanceof ApiKeyFileUnreadableError) {
|
||||
return { ...config, apiKey: '', apiKeyError: null, apiKeyReadSkipped: true }
|
||||
}
|
||||
// Why: an unreadable saved key is treated as absent so the cookie and OpenCode's own key still run.
|
||||
return {
|
||||
...config,
|
||||
apiKey: '',
|
||||
apiKeyError:
|
||||
'OpenCode Go API key could not be decrypted. Re-enter or clear the key in Settings.',
|
||||
apiKeyReadSkipped: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected resolveMiniMaxConfig(): MiniMaxResolvedConfig {
|
||||
try {
|
||||
return {
|
||||
|
||||
@@ -84,10 +84,12 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ
|
||||
? null
|
||||
: this.getCodexProvenance(codexTarget, codexHomePath)
|
||||
const codexGeneration = this.codexFetchGeneration
|
||||
const openCodeGoConfig = this.openCodeGoConfigResolver?.()
|
||||
const cookie = openCodeGoConfig?.sessionCookie ?? ''
|
||||
const workspaceIdOverride = openCodeGoConfig?.workspaceIdOverride ?? ''
|
||||
const openCodeGoApiKey = openCodeGoConfig?.apiKey ?? ''
|
||||
const openCodeGoConfig = this.resolveOpenCodeGoConfig()
|
||||
const cookie = openCodeGoConfig.sessionCookie
|
||||
const workspaceIdOverride = openCodeGoConfig.workspaceIdOverride
|
||||
const openCodeGoApiKey = openCodeGoConfig.apiKey
|
||||
const openCodeGoApiKeyError = openCodeGoConfig.apiKeyError
|
||||
const openCodeGoApiKeyReadSkipped = openCodeGoConfig.apiKeyReadSkipped
|
||||
const miniMaxConfigResult = this.resolveMiniMaxConfig()
|
||||
const miniMaxCookie = miniMaxConfigResult.config.sessionCookie
|
||||
const miniMaxGroupId = miniMaxConfigResult.config.groupId
|
||||
@@ -104,7 +106,7 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ
|
||||
const apiKeyFingerprint = openCodeGoApiKey
|
||||
? createHash('sha256').update(openCodeGoApiKey).digest('hex')
|
||||
: ''
|
||||
const currentConfigHash = `${cookie}|${workspaceIdOverride}|${apiKeyFingerprint}`
|
||||
const currentConfigHash = `${cookie}|${workspaceIdOverride}|${apiKeyFingerprint}|${openCodeGoApiKeyError ?? ''}`
|
||||
const opencodeConfigChanged = currentConfigHash !== this.lastOpencodeConfigHash
|
||||
if (opencodeConfigChanged) {
|
||||
this.lastOpencodeConfigHash = currentConfigHash
|
||||
@@ -236,7 +238,15 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ
|
||||
// Why here: the key can also come from the environment or OpenCode's
|
||||
// own store, so presence is only known once the fetch resolves it.
|
||||
onApiKeyResolved: (resolution) => {
|
||||
this.openCodeGoApiKeyConfigured = resolution.status === 'found'
|
||||
// Why: a credential change mid-fetch bumps the generation; its stale presence must not win.
|
||||
if (opencodeGeneration !== this.opencodeFetchGeneration) {
|
||||
return
|
||||
}
|
||||
// An undecryptable or briefly unreadable saved key still counts, so the bar stays up.
|
||||
this.openCodeGoApiKeyConfigured =
|
||||
resolution.status === 'found' ||
|
||||
openCodeGoApiKeyError !== null ||
|
||||
openCodeGoApiKeyReadSkipped
|
||||
},
|
||||
cookie,
|
||||
workspaceIdOverride: workspaceIdOverride || undefined,
|
||||
@@ -258,6 +268,18 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ
|
||||
if (signal.aborted) {
|
||||
return null
|
||||
}
|
||||
// Why: the decrypt error only replaces a result with no usage and no diagnosis of its own; a real cookie error stays visible.
|
||||
if (
|
||||
openCodeGoApiKeyError &&
|
||||
opencodeGoResult.status === 'fulfilled' &&
|
||||
opencodeGoResult.value.status === 'unavailable'
|
||||
) {
|
||||
opencodeGoResult.value = {
|
||||
...opencodeGoResult.value,
|
||||
error: openCodeGoApiKeyError,
|
||||
status: 'error'
|
||||
}
|
||||
}
|
||||
return {
|
||||
claudeTarget,
|
||||
claudeGeneration,
|
||||
|
||||
@@ -103,6 +103,7 @@ export abstract class RateLimitServiceState {
|
||||
wslDistro: null
|
||||
}
|
||||
protected openCodeGoConfigResolver: (() => OpenCodeGoRateLimitConfig) | null = null
|
||||
protected openCodeGoApiKeyResolver: (() => string | null) | null = null
|
||||
protected miniMaxConfigResolver: (() => MiniMaxRateLimitConfig) | null = null
|
||||
protected zcodePlanConfigResolver: (() => ZcodePlanRateLimitConfig) | null = null
|
||||
protected geminiCliOAuthEnabledResolver: GeminiCliOAuthEnabledResolver | null = null
|
||||
|
||||
@@ -45,8 +45,15 @@ export type ClaudeAuthPreparationResolver = (
|
||||
export type OpenCodeGoRateLimitConfig = {
|
||||
sessionCookie: string
|
||||
workspaceIdOverride: string
|
||||
/** Explicit Orca override; empty means fall back to env and OpenCode's own store. */
|
||||
}
|
||||
|
||||
export type OpenCodeGoResolvedConfig = OpenCodeGoRateLimitConfig & {
|
||||
/** Explicit Orca override; empty means fall back to OpenCode's own store and env. */
|
||||
apiKey: string
|
||||
/** Set when the saved override exists but cannot be decrypted. */
|
||||
apiKeyError: string | null
|
||||
/** Set when the saved override exists but a transient read failure skipped it this cycle. */
|
||||
apiKeyReadSkipped: boolean
|
||||
}
|
||||
|
||||
export type MiniMaxRateLimitConfig = {
|
||||
|
||||
@@ -16,6 +16,11 @@ import { getKimiRuntimeTarget, resolveKimiHome } from '../kimi/kimi-runtime-home
|
||||
import { readMiniMaxSessionCookie } from '../minimax/minimax-cookie-store'
|
||||
import { readMiniMaxApiKey } from '../minimax/minimax-api-key-store'
|
||||
import { readZcodePlanApiKey } from '../zcode/zcode-plan-api-key-store'
|
||||
import {
|
||||
hasOpenCodeGoApiKey,
|
||||
readOpenCodeGoApiKey,
|
||||
saveOpenCodeGoApiKey
|
||||
} from '../opencode/opencode-go-api-key-store'
|
||||
import { createAccountRuntimeTargetSettingsSync } from '../rate-limits/account-runtime-target-sync'
|
||||
import { normalizeCodexRuntimeSelection } from '../codex-accounts/runtime-selection'
|
||||
import { normalizeClaudeRuntimeSelection } from '../claude-accounts/runtime-selection'
|
||||
@@ -88,6 +93,15 @@ export function initializeMainProcessAccountServices(): void {
|
||||
void syncAccountRuntimeTargets(updates, settings).catch((error) =>
|
||||
console.warn('[rate-limits] Failed to apply account runtime target:', error)
|
||||
)
|
||||
if ('opencodeSessionCookie' in updates || 'opencodeWorkspaceId' in updates) {
|
||||
state.rateLimits?.invalidateOpenCodeGoCredentialState()
|
||||
void state.rateLimits?.refresh().catch((error: unknown) => {
|
||||
console.warn(
|
||||
'[rate-limits] Failed to refresh OpenCode Go usage after a settings change:',
|
||||
error
|
||||
)
|
||||
})
|
||||
}
|
||||
// Why: these three pick the MiniMax host and quota bucket, so a stale snapshot from the
|
||||
// previous endpoint would otherwise sit in the status bar until the next poll.
|
||||
if (
|
||||
@@ -122,14 +136,18 @@ export function initializeMainProcessAccountServices(): void {
|
||||
agentHookServer.setClaudeStatusLineListener((event) => {
|
||||
state.rateLimits!.ingestLiveClaudeRateLimits(event)
|
||||
})
|
||||
store.migrateLegacyOpenCodeGoApiKey({
|
||||
has: hasOpenCodeGoApiKey,
|
||||
read: readOpenCodeGoApiKey,
|
||||
save: saveOpenCodeGoApiKey
|
||||
})
|
||||
state.rateLimits.setOpenCodeGoConfigResolver(() => {
|
||||
const settings = store.getSettings()
|
||||
return {
|
||||
sessionCookie: settings.opencodeSessionCookie,
|
||||
workspaceIdOverride: settings.opencodeWorkspaceId,
|
||||
apiKey: settings.opencodeGoApiKey
|
||||
workspaceIdOverride: settings.opencodeWorkspaceId
|
||||
}
|
||||
})
|
||||
}, readOpenCodeGoApiKey)
|
||||
state.rateLimits.setMiniMaxConfigResolver(() => {
|
||||
const settings = store.getSettings()
|
||||
const apiKey = readMiniMaxApiKey() ?? ''
|
||||
|
||||
@@ -142,6 +142,11 @@ export type PreloadApi = {
|
||||
runtime: RuntimeApi['runtime']
|
||||
runtimeEnvironments: RuntimeApi['runtimeEnvironments']
|
||||
rateLimits: RateLimitsApi
|
||||
opencodeGoCredentials: {
|
||||
getStatus: () => Promise<{ apiKeyConfigured: boolean }>
|
||||
saveApiKey: (key: string) => Promise<{ apiKeyConfigured: boolean }>
|
||||
clearApiKey: () => Promise<{ apiKeyConfigured: boolean }>
|
||||
}
|
||||
minimaxCredentials: MinimaxCredentialsApi
|
||||
zcodePlanCredentials: ZcodePlanCredentialsApi
|
||||
grokAccounts: GrokAccountsApi
|
||||
|
||||
@@ -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']
|
||||
@@ -75,6 +75,7 @@ import { nativeChatApi } from './api/native-chat-bridge'
|
||||
import { runtimeApi } from './api/runtime-bridge'
|
||||
import { runtimeEnvironmentsApi } from './api/runtime-environments-bridge'
|
||||
import { rateLimitsApi } from './api/rate-limits-bridge'
|
||||
import { opencodeGoCredentialsApi } from './api/opencode-go-credentials-bridge'
|
||||
import { minimaxCredentialsApi } from './api/minimax-credentials-bridge'
|
||||
import { zcodePlanCredentialsApi } from './api/zcode-plan-credentials-bridge'
|
||||
import { grokAccountsApi } from './api/grok-accounts-bridge'
|
||||
@@ -175,6 +176,7 @@ const api = {
|
||||
runtime: runtimeApi,
|
||||
runtimeEnvironments: runtimeEnvironmentsApi,
|
||||
rateLimits: rateLimitsApi,
|
||||
opencodeGoCredentials: opencodeGoCredentialsApi,
|
||||
minimaxCredentials: minimaxCredentialsApi,
|
||||
zcodePlanCredentials: zcodePlanCredentialsApi,
|
||||
grokAccounts: grokAccountsApi,
|
||||
|
||||
@@ -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'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -83,6 +83,11 @@ beforeEach(() => {
|
||||
fake.cursorUsage = { updatedAt: 0 }
|
||||
Object.assign(window, {
|
||||
api: {
|
||||
opencodeGoCredentials: {
|
||||
getStatus: vi.fn(async () => ({ apiKeyConfigured: false })),
|
||||
saveApiKey: vi.fn(async () => ({ apiKeyConfigured: true })),
|
||||
clearApiKey: vi.fn(async () => ({ apiKeyConfigured: false }))
|
||||
},
|
||||
minimaxCredentials: {
|
||||
getStatus: vi.fn(async () => ({ cookieConfigured: false, apiKeyConfigured: false }))
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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<void> => {
|
||||
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 (
|
||||
<SearchableSetting
|
||||
title={translate(
|
||||
'auto.components.settings.AccountsPane.opencodeGo.apiKey.title',
|
||||
'OpenCode Go API Key'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.settings.AccountsPane.opencodeGo.apiKey.description',
|
||||
'Optional override. Orca otherwise uses the key OpenCode saved when you ran /connect, then OPENCODE_API_KEY.'
|
||||
)}
|
||||
keywords={['opencode', 'go', 'api', 'key', 'connect', 'rate limit', 'status bar']}
|
||||
className="space-y-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="opencode-go-api-key">
|
||||
{translate(
|
||||
'auto.components.settings.AccountsPane.opencodeGo.apiKey.label',
|
||||
'OpenCode Go API key'
|
||||
)}
|
||||
</Label>
|
||||
<Badge variant={configured ? 'secondary' : 'outline'}>
|
||||
{configured
|
||||
? translate('auto.components.settings.AccountsPane.73ea15f24b', 'Saved')
|
||||
: translate('auto.components.settings.AccountsPane.23afe8f226', 'Not saved')}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="opencode-go-api-key"
|
||||
type="password"
|
||||
value={draft}
|
||||
disabled={busy}
|
||||
onChange={(event) => 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"
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
disabled={busy || !draft.trim()}
|
||||
onClick={() => void updateCredential(false)}
|
||||
>
|
||||
{configured
|
||||
? translate('auto.components.settings.AccountsPane.f38b9cc4bd', 'Replace')
|
||||
: translate('auto.components.settings.AccountsPane.590a3130f9', 'Save')}
|
||||
</Button>
|
||||
{configured && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
disabled={busy}
|
||||
onClick={() => void updateCredential(true)}
|
||||
>
|
||||
{translate('auto.components.settings.AccountsPane.b398b834c9', 'Clear')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{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.'
|
||||
)}
|
||||
</p>
|
||||
</SearchableSetting>
|
||||
)
|
||||
}
|
||||
@@ -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):
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SearchableSetting
|
||||
title={translate(
|
||||
'auto.components.settings.AccountsPane.opencodeGo.apiKey.title',
|
||||
'OpenCode Go API Key'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.settings.AccountsPane.opencodeGo.apiKey.description',
|
||||
'Optional override. Orca otherwise uses the key OpenCode saved when you ran /connect, then OPENCODE_API_KEY.'
|
||||
)}
|
||||
keywords={['opencode', 'go', 'api', 'key', 'connect', 'rate limit', 'status bar']}
|
||||
className="space-y-2"
|
||||
>
|
||||
<Label>
|
||||
{translate(
|
||||
'auto.components.settings.AccountsPane.opencodeGo.apiKey.label',
|
||||
'OpenCode Go API key'
|
||||
)}
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<DebouncedSettingsTextInput
|
||||
type="password"
|
||||
value={settings.opencodeGoApiKey}
|
||||
onEdit={() => 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 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
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')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{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.'
|
||||
)}
|
||||
</p>
|
||||
</SearchableSetting>
|
||||
<OpenCodeGoCredentials onSaved={() => recordOpenCodeSettingEdit('apiKey')} />
|
||||
|
||||
<SearchableSetting
|
||||
title={translate(
|
||||
|
||||
@@ -65,6 +65,16 @@ export function createCursorAccountsApi(): NonNullable<Partial<PreloadApi>['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<Partial<PreloadApi>['grokAccounts']> {
|
||||
const unsigned = {
|
||||
signedIn: false,
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ describe('web preload API composition', () => {
|
||||
'preflight',
|
||||
'notifications',
|
||||
'rateLimits',
|
||||
'opencodeGoCredentials',
|
||||
'minimaxCredentials',
|
||||
'zcodePlanCredentials',
|
||||
'grokAccounts',
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
createCursorAccountsApi,
|
||||
createGrokAccountsApi,
|
||||
createZcodePlanCredentialsApi,
|
||||
createOpenCodeGoCredentialsApi,
|
||||
createMiniMaxCredentialsApi
|
||||
} from './preload-api/web-agent-accounts-api'
|
||||
import { createWebAgentStatusApi } from './preload-api/web-agent-status-api'
|
||||
@@ -107,6 +108,7 @@ function createWebPreloadApi(): Partial<PreloadApi> {
|
||||
preflight: createPreflightApi(),
|
||||
notifications: createNotificationsApi(),
|
||||
rateLimits: createRateLimitsApi(),
|
||||
opencodeGoCredentials: createOpenCodeGoCredentialsApi(),
|
||||
minimaxCredentials: createMiniMaxCredentialsApi(),
|
||||
zcodePlanCredentials: createZcodePlanCredentialsApi(),
|
||||
grokAccounts: createGrokAccountsApi(),
|
||||
|
||||
@@ -211,7 +211,6 @@ export function buildDefaultSettings(args: {
|
||||
defaultLinearTeamSelection: null,
|
||||
opencodeSessionCookie: '',
|
||||
opencodeWorkspaceId: '',
|
||||
opencodeGoApiKey: '',
|
||||
minimaxGroupId: '',
|
||||
minimaxUsageModels: 'general',
|
||||
minimaxEndpoint: 'overseas',
|
||||
|
||||
@@ -394,8 +394,6 @@ export type GlobalSettings = {
|
||||
opencodeSessionCookie: string
|
||||
/** Optional OpenCode Go workspace ID override; when set, skips the workspaces lookup and fetches usage directly. */
|
||||
opencodeWorkspaceId: string
|
||||
/** Optional OpenCode Go API key override. Takes precedence over OpenCode's own stored key and OPENCODE_API_KEY. Stored encrypted. */
|
||||
opencodeGoApiKey: string
|
||||
/** Optional MiniMax group id. When empty, the usage fetcher extracts minimax_group_id_v2 from the cookie. */
|
||||
minimaxGroupId: string
|
||||
/** Comma-separated MiniMax model names to show in the status bar usage window. */
|
||||
|
||||
Reference in New Issue
Block a user