mirror of
https://github.com/stablyai/orca.git
synced 2026-10-08 00:02:38 +00:00
Read OpenCode Go usage from the selected account (#24770)
* Add host-owned OpenCode and Devin account profiles * Manage OpenCode and Devin profiles in account Settings * Expose registered account roots to host transcript readers * Clarify managed profile provider flags * Retain isolated Electron home in browser sidecars * Restore inherited account environment and preserve cleanup retries * Check relay environment values before merging * fix(opencode): keep saved Go credentials in main-owned storage Co-authored-by: kespineira <kespineira@users.noreply.github.com> Co-authored-by: kevimux <kevimux@users.noreply.github.com> * test(opencode): retain legacy key ownership across worker writes * Consolidate managed account type imports * test(accounts): provide OpenCode credential API in lifetime fixture * fix(accounts): use existing localized provider names Align the new Japanese account copy with the existing catalog repair policy. * Keep managed account baselines private to the execution host * Align account enrollment help with accepted providers and flags * Show the active System account in managed profile lists * Document the validated Linux managed account scope * fix(opencode): resolve Go keys by execution backend * Use host-private account restoration for OpenCode Go usage * Fix managed account removal, credential audits, and runtime bundling * Quarantine removed accounts and audit captured credential snapshots * fix(accounts): canonicalize account removal to rm Use account rm as the canonical removal command and keep account remove as an alias. Preserve the existing accounts.removeData RPC and the full original 63-path account component. Original-Account-Source:cf71ae4cb6Frozen-Account-Base:08ee7ba9efFrozen-Integrated-Main:53f9ea7839Private validation source only; no ref or publication. * fix(accounts): recover interrupted profile removal on startup Scan private removal backups before quarantined cleanup, reuse the existing state schema to validate the exact UUID, and preserve registered or unmarked profiles. Mark account rm as destructive using the existing typo recovery policy. Retain the full original account component and public author ancestry. Account-PR: 24636 Original-Account-Source:cf71ae4cb6Reviewed-Public-Parent:6abaf736e3Frozen-Integrated-Main:53f9ea7839Private source only; no ref or publication. * fix(accounts): protect registered UUID case variants during removal recovery Compare validated account UUID identities without changing stored IDs or filesystem paths. Protect registered originals and quarantines, including explicit retry variants, and accept equivalent UUID spelling in a valid removal backup. Retain the complete account component and published contributor ancestry. Private source only; no index, ref, or public mutation. * Allow cold node-pty setup on Windows ARM CI Keep a ten-minute bound only for node-pty rebuilt on Windows ARM CI hosts; all other node-gyp calls retain five minutes. Cold setup in two completed ARM jobs left compilation less than a minute. --------- Co-authored-by: kespineira <kespineira@users.noreply.github.com> Co-authored-by: kevimux <kevimux@users.noreply.github.com>
This commit is contained in:
co-authored by
kespineira
kevimux
parent
c2dba12072
commit
786a040b96
@@ -8,6 +8,7 @@ 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 { getManagedDataAccountService } from '../managed-data-accounts/service'
|
||||
import {
|
||||
detectOpenCodeCredentialBackend,
|
||||
type OpenCodeCredentialBackend
|
||||
@@ -233,7 +234,12 @@ export async function resolveOpenCodeGoApiKey(input: {
|
||||
if (override) {
|
||||
return { status: 'found', key: override, tier: 'settings' }
|
||||
}
|
||||
const environment = input.environment ?? process.env
|
||||
const environment = input.environment ?? { ...process.env }
|
||||
if (!input.environment) {
|
||||
const accounts = getManagedDataAccountService()
|
||||
accounts.restoreOriginalEnvironment(environment)
|
||||
Object.assign(environment, accounts.launchEnvironment('opencode'))
|
||||
}
|
||||
const backend = input.backend ?? (await detectOpenCodeCredentialBackend(environment, input.cwd))
|
||||
let databaseUnreadable = false
|
||||
if (backend === 'v2') {
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getAppEnvironment, setAppEnvironment } from '../../shared/app-environment'
|
||||
import { getManagedDataAccountService } from '../managed-data-accounts/service'
|
||||
import { detectOpenCodeCredentialBackend } from '../opencode/opencode-credential-backend'
|
||||
import Database from '../sqlite/sync-database'
|
||||
import { resolveOpenCodeGoApiKey } from './opencode-go-api-key-source'
|
||||
|
||||
vi.mock('../opencode/opencode-credential-backend', () => ({
|
||||
detectOpenCodeCredentialBackend: vi.fn()
|
||||
}))
|
||||
|
||||
let root: string
|
||||
let systemDataHome: string
|
||||
let metadataPath: string
|
||||
|
||||
function writeCredentials(dataHome: string, name: string): void {
|
||||
const directory = join(dataHome, 'opencode')
|
||||
mkdirSync(directory, { recursive: true })
|
||||
writeFileSync(
|
||||
join(directory, 'auth.json'),
|
||||
JSON.stringify({ 'opencode-go': { type: 'api', key: `${name}-auth-placeholder` } })
|
||||
)
|
||||
const database = new Database(join(directory, 'opencode.db'))
|
||||
try {
|
||||
database.exec(
|
||||
'CREATE TABLE session (id TEXT); ' +
|
||||
'CREATE TABLE credential (integration_id TEXT, value TEXT, active INTEGER, time_created INTEGER)'
|
||||
)
|
||||
database
|
||||
.prepare('INSERT INTO credential VALUES (?, ?, 1, 1)')
|
||||
.run('opencode-go', JSON.stringify({ type: 'key', key: `${name}-table-placeholder` }))
|
||||
} finally {
|
||||
database.close()
|
||||
}
|
||||
}
|
||||
|
||||
async function enrollSelectedAccount(): Promise<string> {
|
||||
const accounts = getManagedDataAccountService()
|
||||
const source = join(root, 'source')
|
||||
writeCredentials(source, 'selected')
|
||||
const state = await accounts.add('opencode', source, 'Selected')
|
||||
const selected = accounts.launchEnvironment('opencode')
|
||||
writeFileSync(
|
||||
join(selected.XDG_DATA_HOME, 'opencode', 'auth.json'),
|
||||
readFileSync(join(source, 'opencode', 'auth.json'))
|
||||
)
|
||||
await accounts.select('opencode', null)
|
||||
return state.accounts[0].id
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'orca-go-managed-caller-'))
|
||||
systemDataHome = join(root, 'system-data')
|
||||
const original = getAppEnvironment()
|
||||
setAppEnvironment({
|
||||
...original,
|
||||
getPath: (name) => (name === 'userData' ? root : original.getPath(name))
|
||||
})
|
||||
metadataPath = join(root, 'managed-data-accounts', 'opencode', 'accounts.json')
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (key.startsWith('ORCA_DATA_ACCOUNT_')) {
|
||||
vi.stubEnv(key, undefined)
|
||||
}
|
||||
}
|
||||
vi.stubEnv('HOME', join(root, 'home'))
|
||||
vi.stubEnv('XDG_DATA_HOME', systemDataHome)
|
||||
vi.stubEnv('XDG_STATE_HOME', join(root, 'system-state'))
|
||||
vi.stubEnv('XDG_CONFIG_HOME', join(root, 'config'))
|
||||
vi.stubEnv('XDG_CACHE_HOME', join(root, 'cache'))
|
||||
vi.stubEnv('OPENCODE_DB', 'opencode.db')
|
||||
vi.stubEnv('OPENCODE_AUTH_CONTENT', undefined)
|
||||
vi.stubEnv('OPENCODE_API_KEY', undefined)
|
||||
vi.mocked(detectOpenCodeCredentialBackend).mockReset()
|
||||
writeCredentials(systemDataHome, 'system')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
getManagedDataAccountService().clearInlineAuthBaselines()
|
||||
vi.unstubAllEnvs()
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('OpenCode Go execution-host managed account lookup', () => {
|
||||
it.each(['v1', 'v2'] as const)(
|
||||
'uses System → selected → System credentials on %s',
|
||||
async (backend) => {
|
||||
vi.mocked(detectOpenCodeCredentialBackend).mockResolvedValue(backend)
|
||||
const id = await enrollSelectedAccount()
|
||||
const accounts = getManagedDataAccountService()
|
||||
const inherited = { ...process.env }
|
||||
const tier = backend === 'v1' ? 'opencode-auth-file' : 'opencode-credential-database'
|
||||
const suffix = backend === 'v1' ? 'auth' : 'table'
|
||||
|
||||
await expect(resolveOpenCodeGoApiKey({})).resolves.toEqual({
|
||||
status: 'found',
|
||||
key: `system-${suffix}-placeholder`,
|
||||
tier
|
||||
})
|
||||
await accounts.select('opencode', id)
|
||||
await expect(resolveOpenCodeGoApiKey({})).resolves.toEqual({
|
||||
status: 'found',
|
||||
key: `selected-${suffix}-placeholder`,
|
||||
tier
|
||||
})
|
||||
expect(detectOpenCodeCredentialBackend).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining(accounts.launchEnvironment('opencode')),
|
||||
undefined
|
||||
)
|
||||
await accounts.select('opencode', null)
|
||||
await expect(resolveOpenCodeGoApiKey({})).resolves.toEqual({
|
||||
status: 'found',
|
||||
key: `system-${suffix}-placeholder`,
|
||||
tier
|
||||
})
|
||||
expect(process.env).toEqual(inherited)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['v1', 'v2'] as const)(
|
||||
'restores an opaque inline System baseline through the host service on %s',
|
||||
async (backend) => {
|
||||
vi.mocked(detectOpenCodeCredentialBackend).mockResolvedValue(backend)
|
||||
const id = await enrollSelectedAccount()
|
||||
const accounts = getManagedDataAccountService()
|
||||
await accounts.select('opencode', id)
|
||||
const selected = accounts.launchEnvironment('opencode')
|
||||
const inline = JSON.stringify({
|
||||
'opencode-go': { type: 'api', key: 'system-inline-placeholder' }
|
||||
})
|
||||
const baseline: Record<string, string> = {
|
||||
XDG_DATA_HOME: systemDataHome,
|
||||
XDG_STATE_HOME: join(root, 'system-state'),
|
||||
OPENCODE_DB: 'opencode.db',
|
||||
OPENCODE_AUTH_CONTENT: inline
|
||||
}
|
||||
accounts.captureOriginalEnvironment(baseline, selected)
|
||||
const inheritedProfile: Record<string, string> = {
|
||||
...baseline,
|
||||
...selected,
|
||||
ORCA_DATA_ACCOUNT_PROVIDER: 'opencode',
|
||||
ORCA_DATA_ACCOUNT_DATA_HOME: selected.XDG_DATA_HOME,
|
||||
ORCA_DATA_ACCOUNT_STATE_HOME: selected.XDG_STATE_HOME
|
||||
}
|
||||
expect(inheritedProfile.ORCA_DATA_ACCOUNT_ORIGINAL_ENV).not.toContain(
|
||||
'system-inline-placeholder'
|
||||
)
|
||||
expect(JSON.parse(baseline.ORCA_DATA_ACCOUNT_ORIGINAL_ENV)).toMatchObject({
|
||||
OPENCODE_AUTH_CONTENT: null,
|
||||
inlineAuthReference: expect.any(String)
|
||||
})
|
||||
for (const [key, value] of Object.entries(inheritedProfile)) {
|
||||
vi.stubEnv(key, value)
|
||||
}
|
||||
const inherited = { ...process.env }
|
||||
|
||||
await accounts.select('opencode', null)
|
||||
const expected =
|
||||
backend === 'v1'
|
||||
? { status: 'found', key: 'system-inline-placeholder', tier: 'opencode-auth-content' }
|
||||
: {
|
||||
status: 'found',
|
||||
key: 'system-table-placeholder',
|
||||
tier: 'opencode-credential-database'
|
||||
}
|
||||
await expect(resolveOpenCodeGoApiKey({})).resolves.toEqual(expected)
|
||||
await accounts.select('opencode', id)
|
||||
await expect(resolveOpenCodeGoApiKey({})).resolves.toEqual({
|
||||
status: 'found',
|
||||
key: backend === 'v1' ? 'selected-auth-placeholder' : 'selected-table-placeholder',
|
||||
tier: backend === 'v1' ? 'opencode-auth-file' : 'opencode-credential-database'
|
||||
})
|
||||
await accounts.select('opencode', null)
|
||||
await expect(resolveOpenCodeGoApiKey({})).resolves.toEqual(expected)
|
||||
expect(process.env).toEqual(inherited)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['malformed', 'unknown', 'unreadable'] as const)(
|
||||
'rejects %s metadata before probing or falling back, while a manual key still wins',
|
||||
async (failure) => {
|
||||
mkdirSync(join(root, 'managed-data-accounts', 'opencode'), { recursive: true })
|
||||
if (failure === 'unreadable') {
|
||||
mkdirSync(metadataPath)
|
||||
} else {
|
||||
writeFileSync(
|
||||
metadataPath,
|
||||
failure === 'malformed'
|
||||
? '{invalid'
|
||||
: JSON.stringify({
|
||||
accounts: [],
|
||||
activeAccountId: randomUUID()
|
||||
})
|
||||
)
|
||||
}
|
||||
const before = failure === 'unreadable' ? null : readFileSync(metadataPath, 'utf8')
|
||||
const accounts = getManagedDataAccountService()
|
||||
const restore = vi.spyOn(accounts, 'restoreOriginalEnvironment')
|
||||
const selection = vi.spyOn(accounts, 'launchEnvironment')
|
||||
const inherited = { ...process.env }
|
||||
|
||||
await expect(
|
||||
resolveOpenCodeGoApiKey({ settingsOverride: ' manual-placeholder ' })
|
||||
).resolves.toEqual({
|
||||
status: 'found',
|
||||
key: 'manual-placeholder',
|
||||
tier: 'settings'
|
||||
})
|
||||
expect(restore).not.toHaveBeenCalled()
|
||||
expect(selection).not.toHaveBeenCalled()
|
||||
expect(process.env).toEqual(inherited)
|
||||
await expect(resolveOpenCodeGoApiKey({})).rejects.toThrow()
|
||||
expect(detectOpenCodeCredentialBackend).not.toHaveBeenCalled()
|
||||
if (before !== null) {
|
||||
expect(readFileSync(metadataPath, 'utf8')).toBe(before)
|
||||
}
|
||||
expect(process.env).toEqual(inherited)
|
||||
}
|
||||
)
|
||||
|
||||
it('returns a manual key without resolving the host service or mutating the inherited environment', async () => {
|
||||
const inherited = { ...process.env }
|
||||
vi.spyOn(getAppEnvironment(), 'getPath').mockImplementation(() => {
|
||||
throw new Error('Host metadata must not be resolved')
|
||||
})
|
||||
await expect(
|
||||
resolveOpenCodeGoApiKey({ settingsOverride: 'manual-placeholder' })
|
||||
).resolves.toEqual({
|
||||
status: 'found',
|
||||
key: 'manual-placeholder',
|
||||
tier: 'settings'
|
||||
})
|
||||
expect(detectOpenCodeCredentialBackend).not.toHaveBeenCalled()
|
||||
expect(process.env).toEqual(inherited)
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user