Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
"LICENSE"
],
"scripts": {
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test": "bun test --preload @opentui/solid/preload tests"
},
"keywords": [
"opencode",
Expand Down
42 changes: 31 additions & 11 deletions src/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] {
Expand Down Expand Up @@ -45,6 +51,9 @@ export function extractCredential(value: unknown): string | undefined {
}

const entry = value as Record<string, unknown>
// 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
Expand All @@ -68,12 +77,17 @@ async function fromDatabase(directory: string): Promise<string | undefined> {
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 {
Expand All @@ -89,7 +103,13 @@ async function fromAuthFile(directory: string): Promise<string | undefined> {
try {
const raw = await readFile(join(directory, "auth.json"), "utf8")
const store = JSON.parse(raw) as Record<string, unknown>
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
}
Expand Down
206 changes: 206 additions & 0 deletions tests/credentials.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
const directory = await mkdtemp(join(tmpdir(), "opencode-go-credentials-"))
temporaryDirectories.push(directory)
return directory
}

async function writeAuthFile(directory: string, store: unknown): Promise<void> {
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()
})
})
Loading