From 7bf02898d196421530812bfdd1be186a7c38afe3 Mon Sep 17 00:00:00 2001 From: alexey Date: Tue, 3 Nov 2026 17:52:03 +0200 Subject: [PATCH 1/2] fix: resolve Go API keys from Console credentials Check opencode after opencode-go in the database and legacy auth store. Skip OAuth entries and preserve existing credential precedence. Add isolated credential tests and document the lookup order. --- README.md | 19 +++- package.json | 1 + src/credentials.ts | 42 ++++++-- tests/credentials.test.ts | 206 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 252 insertions(+), 16 deletions(-) create mode 100644 tests/credentials.test.ts diff --git a/README.md b/README.md index b44329c..f1ffe2a 100644 --- a/README.md +++ b/README.md @@ -80,11 +80,20 @@ In order: 1. `OPENCODE_GO_API_KEY` environment variable. 2. The active `opencode-go` credential in OpenCode's local database (`credential` table). -3. The `opencode-go` entry in `auth.json` (legacy layout). - -The database is checked first on purpose. Current OpenCode builds keep the live -credential there, and `auth.json` can still hold an expired copy, which returns -`401 api_key_expired`. +3. The active `opencode` (OpenCode Console) credential in the same database. +4. The `opencode-go` entry in `auth.json` (legacy layout). +5. The `opencode` (OpenCode Console) entry in `auth.json`. + +Within each store, the resolver checks `opencode-go` before `opencode`. It checks +the database before `auth.json` because that is where OpenCode keeps the +credential it currently uses; `auth.json` can still hold an expired copy, which +returns `401 api_key_expired`. + +The resolver accepts the first non-empty string in `key`, `apiKey`, `access` or +`token`, and also accepts an array of entries (older layouts). It skips entries +with `type: "oauth"`, because it returns only an API-key style credential, not +an OAuth access token. It does not test a candidate against the usage endpoint. +If the endpoint returns HTTP 401, the plugin shows `credential rejected`. ## Troubleshooting diff --git a/package.json b/package.json index 91808d7..15956ef 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "LICENSE" ], "scripts": { + "test": "bun test", "typecheck": "tsc --noEmit" }, "keywords": [ diff --git a/src/credentials.ts b/src/credentials.ts index c80336a..be0882a 100644 --- a/src/credentials.ts +++ b/src/credentials.ts @@ -8,14 +8,20 @@ import { join } from "node:path" * Order: * 1. `OPENCODE_GO_API_KEY` environment variable. * 2. The active `opencode-go` credential in OpenCode's local database. - * 3. The `opencode-go` entry in `auth.json` (legacy layout). + * 3. The active `opencode` (OpenCode Console) credential in the same database. + * 4. The `opencode-go` entry in `auth.json` (legacy layout). + * 5. The `opencode` (OpenCode Console) entry in `auth.json`. * - * The database is the authoritative source in current OpenCode builds: a key - * that was rotated or re-issued through the Console shows up there first, while - * `auth.json` may still hold an expired copy. + * The database is checked before `auth.json` because it holds the credential + * that OpenCode currently uses for the provider, while `auth.json` may still + * hold an expired copy. + * + * `opencode` is the provider id of the OpenCode Console integration. It is + * checked after `opencode-go` in both stores. */ -const PROVIDER_ID = "opencode-go" +/** Provider ids to check, in priority order. */ +const PROVIDER_IDS = ["opencode-go", "opencode"] as const /** Candidate OpenCode data directories, most specific first. */ export function dataDirectories(): string[] { @@ -45,6 +51,9 @@ export function extractCredential(value: unknown): string | undefined { } const entry = value as Record + // OAuth entries carry short-lived access tokens, not API keys. + if (entry.type === "oauth") return undefined + for (const field of ["key", "apiKey", "access", "token"]) { const candidate = entry[field] if (typeof candidate === "string" && candidate.length > 0) return candidate @@ -68,12 +77,17 @@ async function fromDatabase(directory: string): Promise { let db: import("bun:sqlite").Database | undefined try { db = new Database(join(directory, "opencode.db"), { readonly: true }) - const row = db - .query("select value from credential where integration_id = ? and active = 1 limit 1") - .get(PROVIDER_ID) as { value?: string } | null + const statement = db.query("select value from credential where integration_id = ? and active = 1 limit 1") + + for (const providerID of PROVIDER_IDS) { + const row = statement.get(providerID) as { value?: string } | null + if (!row?.value) continue + + const credential = extractCredential(JSON.parse(row.value)) + if (credential) return credential + } - if (!row?.value) return undefined - return extractCredential(JSON.parse(row.value)) + return undefined } catch { return undefined } finally { @@ -89,7 +103,13 @@ async function fromAuthFile(directory: string): Promise { try { const raw = await readFile(join(directory, "auth.json"), "utf8") const store = JSON.parse(raw) as Record - return extractCredential(store[PROVIDER_ID]) + + for (const providerID of PROVIDER_IDS) { + const credential = extractCredential(store[providerID]) + if (credential) return credential + } + + return undefined } catch { return undefined } diff --git a/tests/credentials.test.ts b/tests/credentials.test.ts new file mode 100644 index 0000000..9a2a3f4 --- /dev/null +++ b/tests/credentials.test.ts @@ -0,0 +1,206 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { Database } from "bun:sqlite" +import { mkdtemp, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" + +import { extractCredential, resolveApiKey } from "../src/credentials.ts" + +const initialApiKey = process.env.OPENCODE_GO_API_KEY +const temporaryDirectories: string[] = [] + +type CredentialRow = { + integrationId: string + value: unknown + active?: boolean +} + +beforeEach(() => { + delete process.env.OPENCODE_GO_API_KEY +}) + +afterEach(async () => { + if (initialApiKey === undefined) delete process.env.OPENCODE_GO_API_KEY + else process.env.OPENCODE_GO_API_KEY = initialApiKey + + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ) +}) + +async function makeDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), "opencode-go-credentials-")) + temporaryDirectories.push(directory) + return directory +} + +async function writeAuthFile(directory: string, store: unknown): Promise { + await writeFile(join(directory, "auth.json"), JSON.stringify(store)) +} + +function writeDatabase(directory: string, rows: CredentialRow[]): void { + const database = new Database(join(directory, "opencode.db")) + try { + database.run("create table credential (integration_id text not null, value text not null, active integer not null)") + const insert = database.query("insert into credential (integration_id, value, active) values (?, ?, ?)") + for (const row of rows) { + insert.run(row.integrationId, JSON.stringify(row.value), row.active === false ? 0 : 1) + } + } finally { + database.close() + } +} + +describe("extractCredential", () => { + test("reads every supported credential field", () => { + expect(extractCredential({ key: "from-key" })).toBe("from-key") + expect(extractCredential({ apiKey: "from-api-key" })).toBe("from-api-key") + expect(extractCredential({ access: "from-access" })).toBe("from-access") + expect(extractCredential({ token: "from-token" })).toBe("from-token") + }) + + test("prefers key over the legacy fields", () => { + expect( + extractCredential({ token: "token", access: "access", apiKey: "api-key", key: "key" }), + ).toBe("key") + }) + + test("returns the first usable entry from an array", () => { + expect( + extractCredential([null, {}, { key: "" }, { apiKey: "second" }, { key: "third" }]), + ).toBe("second") + }) + + test("skips OAuth entries even when they carry an access token", () => { + expect(extractCredential({ type: "oauth", access: "oauth-token", key: "api-key" })).toBeUndefined() + }) + + test("skips OAuth entries inside arrays", () => { + expect( + extractCredential([{ type: "oauth", access: "oauth-token" }, { key: "api-key" }]), + ).toBe("api-key") + }) + + test("ignores non-objects and empty values", () => { + expect(extractCredential(undefined)).toBeUndefined() + expect(extractCredential(null)).toBeUndefined() + expect(extractCredential("key")).toBeUndefined() + expect(extractCredential(42)).toBeUndefined() + expect(extractCredential({})).toBeUndefined() + expect(extractCredential({ key: "" })).toBeUndefined() + }) +}) + +describe("resolveApiKey", () => { + test("returns OPENCODE_GO_API_KEY before any stored credential", async () => { + const directory = await makeDirectory() + writeDatabase(directory, [{ integrationId: "opencode-go", value: { key: "database-key" } }]) + await writeAuthFile(directory, { "opencode-go": { key: "auth-key" } }) + + process.env.OPENCODE_GO_API_KEY = "environment-key" + + expect(await resolveApiKey([directory])).toBe("environment-key") + }) + + test("falls through an empty environment variable", async () => { + const directory = await makeDirectory() + await writeAuthFile(directory, { "opencode-go": { key: "auth-key" } }) + + process.env.OPENCODE_GO_API_KEY = "" + + expect(await resolveApiKey([directory])).toBe("auth-key") + }) + + test("prefers the active opencode-go database credential over opencode", async () => { + const directory = await makeDirectory() + writeDatabase(directory, [ + { integrationId: "opencode-go", value: { key: "go-key" } }, + { integrationId: "opencode", value: { key: "console-key" } }, + ]) + + expect(await resolveApiKey([directory])).toBe("go-key") + }) + + test("falls back to the active opencode Console database credential", async () => { + const directory = await makeDirectory() + writeDatabase(directory, [{ integrationId: "opencode", value: { key: "console-key" } }]) + + expect(await resolveApiKey([directory])).toBe("console-key") + }) + + test("ignores inactive database rows", async () => { + const directory = await makeDirectory() + writeDatabase(directory, [ + { integrationId: "opencode-go", value: { key: "inactive-go-key" }, active: false }, + { integrationId: "opencode", value: { key: "active-console-key" } }, + ]) + + expect(await resolveApiKey([directory])).toBe("active-console-key") + }) + + test("skips OAuth database entries and continues to the next provider", async () => { + const directory = await makeDirectory() + writeDatabase(directory, [ + { integrationId: "opencode-go", value: { type: "oauth", access: "oauth-token" } }, + { integrationId: "opencode", value: { key: "console-key" } }, + ]) + + expect(await resolveApiKey([directory])).toBe("console-key") + }) + + test("prefers the database over auth.json", async () => { + const directory = await makeDirectory() + writeDatabase(directory, [{ integrationId: "opencode", value: { key: "console-database-key" } }]) + await writeAuthFile(directory, { "opencode-go": { key: "auth-key" } }) + + expect(await resolveApiKey([directory])).toBe("console-database-key") + }) + + test("reads the opencode-go credential from auth.json", async () => { + const directory = await makeDirectory() + await writeAuthFile(directory, { "opencode-go": { key: "auth-go-key" } }) + + expect(await resolveApiKey([directory])).toBe("auth-go-key") + }) + + test("falls back to the opencode Console credential in auth.json", async () => { + const directory = await makeDirectory() + await writeAuthFile(directory, { opencode: { key: "auth-console-key" } }) + + expect(await resolveApiKey([directory])).toBe("auth-console-key") + }) + + test("prefers opencode-go over opencode inside auth.json", async () => { + const directory = await makeDirectory() + await writeAuthFile(directory, { + "opencode-go": { key: "auth-go-key" }, + opencode: { key: "auth-console-key" }, + }) + + expect(await resolveApiKey([directory])).toBe("auth-go-key") + }) + + test("skips OAuth auth.json entries and continues to the next provider", async () => { + const directory = await makeDirectory() + await writeAuthFile(directory, { + "opencode-go": { type: "oauth", access: "oauth-token" }, + opencode: { key: "auth-console-key" }, + }) + + expect(await resolveApiKey([directory])).toBe("auth-console-key") + }) + + test("returns undefined when the stores are missing", async () => { + const directory = await makeDirectory() + expect(await resolveApiKey([directory])).toBeUndefined() + expect(await resolveApiKey([join(directory, "missing")])).toBeUndefined() + }) + + test("returns undefined when the stores hold no usable credential", async () => { + const directory = await makeDirectory() + writeDatabase(directory, [{ integrationId: "other-provider", value: { key: "other-key" } }]) + await writeAuthFile(directory, { "opencode-go": { key: "" }, opencode: { type: "oauth" } }) + + expect(await resolveApiKey([directory])).toBeUndefined() + }) +}) From 0cc863ecaf6550572fd20fb3e8c4f63289071800 Mon Sep 17 00:00:00 2001 From: alexey Date: Tue, 3 Nov 2026 21:50:11 +0200 Subject: [PATCH 2/2] test: align the test script with upstream --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 15956ef..520a5c8 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,8 @@ "LICENSE" ], "scripts": { - "test": "bun test", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "bun test --preload @opentui/solid/preload tests" }, "keywords": [ "opencode",