mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 11:07:42 +00:00
fix(rate-limits): read OpenCode Go usage with the Go API key (#22551)
* fix(rate-limits): read OpenCode Go usage with the account API key Since OpenCode's console migration (upstream fe51b0b19a, "fix(console): restrict legacy access to Black"), an account with no Black subscription is redirected from the legacy console to /console/login, so Orca's cookie-based workspace lookup returns nothing and the Go bar stays empty. Fetch usage from GET https://opencode.ai/zen/go/v1/usage instead, which authenticates with `Authorization: Bearer <key>` and needs no console session. The key resolves in order: Orca settings override, OPENCODE_API_KEY, then whatever OpenCode itself stored on /connect -- auth.json for 1.x, the credential table for 2.x. The cookie path stays as the fallback so Black/legacy accounts keep working. A 403 EntitlementError now reads as "no OpenCode Go subscription" in the status bar instead of a generic refresh failure (#22257's reporter was misled by exactly that). * fix(rate-limits): prefer OpenCode's stored Go key over OPENCODE_API_KEY OpenCode applies the key saved on /connect after the environment, so the stored key is the one its own Go requests use. OPENCODE_API_KEY is also the Zen provider's variable, so ranking it first could read a key that OpenCode itself is not using for Go. Co-Authored-By: Claude <noreply@anthropic.com> * fix(rate-limits): name the API key when OpenCode Go usage lands on sign-in A redirected usage request arrives as a 200 sign-in page because Electron follows redirects; report it as a rejected key instead of a parse failure. The cookie path's empty workspace lookup is what non-Black accounts now hit after the console migration, so its message points at the API key rather than only the workspace override. Co-Authored-By: Claude <noreply@anthropic.com> * chore(i18n): add the OpenCode Go API key strings to the English catalog Co-Authored-By: Claude <noreply@anthropic.com> * docs(rate-limits): stop calling the credential table an OpenCode 2 marker Verified on two real Windows hosts running OpenCode 1.18.16: the `credential` table exists there too (empty, same columns), so its presence does not identify a 2.x install. Neither host had an `auth.json` at all. The resolution already probes both stores on every version, so only the comments were wrong. Says so now, and records that a 2.x install which never ran the legacy import has no `auth.json` either — which is why both tiers exist. * refactor(shared): move GhosttyImportPreview out of global-settings-types Adding `opencodeGoApiKey` pushed global-settings-types.ts one line past the 300-line ceiling, failing `oxlint` in CI. AGENTS.md forbids a max-lines suppression, so split instead: the Ghostty import preview is a distinct concern that never belonged in the settings-shape file. Re-exported from the original module so no importer changes. 293 code lines now. --------- Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com> Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import Database from '../sqlite/sync-database'
|
||||
import {
|
||||
getOpenCodeAuthFilePath,
|
||||
readOpenCodeAuthFileGoKey,
|
||||
resolveOpenCodeGoApiKey
|
||||
} from './opencode-go-api-key-source'
|
||||
|
||||
// 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
|
||||
|
||||
describe('resolveOpenCodeGoApiKey', () => {
|
||||
let dataHome: string
|
||||
let originalEnvironment: Partial<Record<(typeof ENVIRONMENT_KEYS)[number], string>>
|
||||
|
||||
function writeAuthFile(contents: unknown): void {
|
||||
mkdirSync(join(dataHome, 'opencode'), { recursive: true })
|
||||
writeFileSync(join(dataHome, 'opencode', 'auth.json'), JSON.stringify(contents))
|
||||
}
|
||||
|
||||
function writeCredentialDatabase(rows: { value: string; active: number; created: number }[]): {
|
||||
path: string
|
||||
} {
|
||||
const path = join(dataHome, 'opencode-credentials.db')
|
||||
const database = new Database(path)
|
||||
database.exec(
|
||||
'CREATE TABLE credential (id TEXT PRIMARY KEY, integration_id TEXT, label TEXT, ' +
|
||||
'value TEXT, active INTEGER, time_created INTEGER)'
|
||||
)
|
||||
rows.forEach((row, index) => {
|
||||
database
|
||||
.prepare(
|
||||
'INSERT INTO credential (id, integration_id, label, value, active, time_created) ' +
|
||||
"VALUES (?, 'opencode-go', 'API key', ?, ?, ?)"
|
||||
)
|
||||
.run(`cred_${index}`, row.value, row.active, row.created)
|
||||
})
|
||||
database.close()
|
||||
return { path }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
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
|
||||
// Keeps the credential-database tier from touching the developer's own store.
|
||||
process.env.OPENCODE_DB = ':memory:'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of ENVIRONMENT_KEYS) {
|
||||
const value = originalEnvironment[key]
|
||||
if (value === undefined) {
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = value
|
||||
}
|
||||
}
|
||||
rmSync(dataHome, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
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.
|
||||
expect(getOpenCodeAuthFilePath({}, '/home/person')).toBe(
|
||||
join('/home/person', '.local', 'share', 'opencode', 'auth.json')
|
||||
)
|
||||
})
|
||||
|
||||
it('prefers the settings override over every other tier', async () => {
|
||||
process.env.OPENCODE_API_KEY = ENVIRONMENT_KEY
|
||||
writeAuthFile({ 'opencode-go': { type: 'api', key: AUTH_FILE_KEY } })
|
||||
|
||||
await expect(
|
||||
resolveOpenCodeGoApiKey({ settingsOverride: ` ${SETTINGS_KEY} ` })
|
||||
).resolves.toEqual({ status: 'found', key: SETTINGS_KEY, tier: 'settings' })
|
||||
})
|
||||
|
||||
it('prefers the key OpenCode saved on /connect over OPENCODE_API_KEY, as OpenCode does', async () => {
|
||||
process.env.OPENCODE_API_KEY = ENVIRONMENT_KEY
|
||||
writeAuthFile({ 'opencode-go': { type: 'api', key: AUTH_FILE_KEY } })
|
||||
|
||||
await expect(resolveOpenCodeGoApiKey({ settingsOverride: ' ' })).resolves.toEqual({
|
||||
status: 'found',
|
||||
key: AUTH_FILE_KEY,
|
||||
tier: 'opencode-auth-file'
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to OPENCODE_API_KEY when OpenCode stored no key', async () => {
|
||||
process.env.OPENCODE_API_KEY = ENVIRONMENT_KEY
|
||||
writeAuthFile({ anthropic: { type: 'api', key: 'not-the-go-key' } })
|
||||
|
||||
await expect(resolveOpenCodeGoApiKey({})).resolves.toEqual({
|
||||
status: 'found',
|
||||
key: ENVIRONMENT_KEY,
|
||||
tier: 'environment'
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to 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({
|
||||
status: 'found',
|
||||
key: AUTH_FILE_KEY,
|
||||
tier: 'opencode-auth-file'
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the OpenCode 2 credential table when auth.json has no entry', async () => {
|
||||
writeAuthFile({ anthropic: { type: 'api', key: 'not-the-go-key' } })
|
||||
const { path } = writeCredentialDatabase([
|
||||
{
|
||||
value: JSON.stringify({ type: 'key', key: 'stale-placeholder-key' }),
|
||||
active: 0,
|
||||
created: 2
|
||||
},
|
||||
{ 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('reports missing when no tier holds a key', async () => {
|
||||
writeAuthFile({ 'opencode-go': { type: 'oauth', refresh: 'r', access: 'a', expires: 1 } })
|
||||
|
||||
await expect(resolveOpenCodeGoApiKey({})).resolves.toEqual({ status: 'missing' })
|
||||
})
|
||||
|
||||
it('treats a malformed auth file as "no key" rather than a failure', () => {
|
||||
mkdirSync(join(dataHome, 'opencode'), { recursive: true })
|
||||
writeFileSync(join(dataHome, 'opencode', 'auth.json'), '{not json')
|
||||
|
||||
expect(readOpenCodeAuthFileGoKey(process.env)).toBeNull()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user