mirror of
https://github.com/stablyai/orca.git
synced 2026-10-08 00:02:38 +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,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
|
||||
Reference in New Issue
Block a user