diff --git a/package.json b/package.json index 859644c..8fae65c 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "build:tui": "node build.tui.mjs", "typecheck": "tsc --noEmit", "test:balance": "tsx tests/codex-balance.test.ts", + "test:credentials": "tsx tests/credentials.test.ts", "version": "node -e \"require('fs').writeFileSync('src/_version.ts','// auto-generated\\nexport const PLUGIN_VERSION='+JSON.stringify(require('./package.json').version)+';\\n')\"", "prepublishOnly": "tsc" }, diff --git a/src/v2/commands.ts b/src/v2/commands.ts index 6b2be54..33c52e0 100644 --- a/src/v2/commands.ts +++ b/src/v2/commands.ts @@ -3,11 +3,7 @@ import type { PanelApi, PanelSignals } from "../panel/panel-api" import { CURRENCIES, DEFAULT_RATES, visualPadEnd } from "../core" import { balanceProviders, getBalanceProvider, maskKey, type BalanceProvider } from "../balance-providers" import { LANG_META, createT, type LangCode } from "../i18n" - -declare const process: { - env: Record - getBuiltinModule?: (id: string) => unknown -} | undefined +import { resolveCredentialToken } from "./credentials" const KV_PREFIX = "cache_panel" @@ -21,44 +17,9 @@ function extractToolParts(msg: Record): Array> return msg.content.filter((p: Record) => p?.type === "tool") } -/** 读取 OpenCode auth.json 中某个 provider 的凭据: - * - `oauth` 类型 → `access` token(如 OpenAI 订阅登录) - * - `api` 类型 → `key`(如 DeepSeek 等 API key 提供商) - * V2 的 provider list 不暴露凭据(Provider.Info 无 key 字段), - * 这里作为自动复用 OpenCode 已认证凭据的兜底。 - * V2 无 state 路径 API,依次尝试 XDG data 目录、~/.local/share;全部失败返回空串。 */ -function readAuthCredential(providerID: string): string { - try { - const loader = typeof process !== "undefined" ? process?.getBuiltinModule : undefined - const fs = loader?.("node:fs") as { readFileSync(path: string, encoding: "utf8"): string } | undefined - if (!fs) return "" - const home = typeof process !== "undefined" ? (process?.env.HOME || process?.env.USERPROFILE || "") : "" - const dataHome = typeof process !== "undefined" ? process?.env.XDG_DATA_HOME : undefined - const paths = [ - dataHome ? `${dataHome}/opencode/auth.json` : "", - home ? `${home}/.local/share/opencode/auth.json` : "", - ] - for (const path of paths) { - if (!path) continue - try { - const auth = JSON.parse(fs.readFileSync(path, "utf8")) as Record - const entry = auth[providerID] - if (entry && typeof entry === "object") { - const record = entry as Record - if (record.type === "oauth" && typeof record.access === "string") return record.access - if (record.type === "api" && typeof record.key === "string") return record.key - } - } catch { /* try the next known auth path */ } - } - return "" - } catch { - return "" - } -} - /** V2 版 findOpencodeKey:优先使用 V2 provider list 暴露的 key(宿主提供时), - * 否则回退读取 auth.json 的凭据——V2 的 Provider.Info 不含 key 字段, - * provider list 拿不到 key 时以 auth.json 为准(对齐 V1 api.state.provider 的效果)。 + * 否则解析宿主已认证凭据——V2 的 Provider.Info 不含 key 字段,且凭据保存在 + * 宿主 SQLite(credential 表),auth.json 仅作迁移遗留兜底(见 credentials.ts)。 * 导出供 index.tsx 的余额轮询复用。 */ export function findOpencodeKeyV2(context: Context, provider: BalanceProvider): string { try { @@ -72,8 +33,8 @@ export function findOpencodeKeyV2(context: Context, provider: BalanceProvider): const optionKey = typeof hit.options?.apiKey === "string" ? hit.options.apiKey : "" if (optionKey) return optionKey } - } catch { /* fall through to auth.json */ } - return readAuthCredential(provider.id) + } catch { /* fall through to stored credentials */ } + return resolveCredentialToken(provider.id) } /** 当前路由 sessionID(V2 ui.router.current();Route = { type: "session", sessionID })。 */ diff --git a/src/v2/credentials.ts b/src/v2/credentials.ts new file mode 100644 index 0000000..ead9912 --- /dev/null +++ b/src/v2/credentials.ts @@ -0,0 +1,139 @@ +/** + * V2 凭据解析:宿主 SQLite 优先,auth.json 兜底。 + * + * opencode 2.x 把已认证凭据保存在 `~/.local/share/opencode/opencode.db` + * 的 `credential` 表,OAuth 刷新只更新该库;`auth.json` 是迁移前的遗留 + * 快照,刷新后不再同步——只读 auth.json 会在 token 过期后拿到陈旧凭据 + * (OpenAI 余额查询因此 401 “Invalid API Key”)。 + * + * bun:sqlite 仅 Bun 运行时可用:字符串变量让 tsc/esbuild 不做静态解析, + * 动态 import 失败(Node/CI 测试)时静默回退 auth.json,不影响面板其余功能。 + */ + +declare const process: { + env: Record + getBuiltinModule?: (id: string) => unknown +} | undefined + +const BUN_SQLITE = "bun:sqlite" + +export interface CredentialValue { + type?: string + access?: string + key?: string +} + +interface SqlStatement { + all(...params: unknown[]): Record[] + get(...params: unknown[]): Record | undefined +} +interface SqlDatabase { + query(sql: string): SqlStatement + close?(): void +} + +/** 宿主数据库路径(OPENCODE_DB 覆盖,遵循 XDG_DATA_HOME)。 */ +export function findCredentialDbPath( + env: Record = typeof process !== "undefined" ? process.env : {}, + home = typeof process !== "undefined" ? (process.env.HOME || process.env.USERPROFILE || "") : "", +): string | undefined { + if (env.OPENCODE_DB) return env.OPENCODE_DB + const base = env.XDG_DATA_HOME && env.XDG_DATA_HOME.length > 0 + ? env.XDG_DATA_HOME + : home ? `${home}/.local/share` : "" + if (!base) return undefined + return `${base}/opencode/opencode.db` +} + +/** 解析宿主持久化的凭据 JSON(oauth.access / api.key)。 */ +export function parseCredentialValue(raw: unknown): CredentialValue | undefined { + if (typeof raw !== "string" || raw.length === 0) return undefined + try { + const value = JSON.parse(raw) as CredentialValue + if (!value || typeof value !== "object") return undefined + const hasToken = (typeof value.access === "string" && value.access.length > 0) || + (typeof value.key === "string" && value.key.length > 0) + return hasToken ? value : undefined + } catch { + return undefined + } +} + +let db: SqlDatabase | undefined +let dbReady: Promise | undefined + +function openDatabase(): Promise { + if (dbReady) return dbReady + dbReady = (async () => { + const path = findCredentialDbPath() + if (!path) return + try { + const mod = (await import(BUN_SQLITE)) as { Database?: new (p: string, o?: Record) => SqlDatabase } + const Database = mod?.Database + if (!Database) return + db = new Database(path, { readonly: true }) + try { db.query("pragma query_only = on").all() } catch { /* readonly 已足够 */ } + } catch { + db = undefined + } + })() + return dbReady +} + +/** 等待凭据库初始化(模块加载即开始;未就绪时解析会先回退 auth.json)。 */ +export function credentialsDbReady(): Promise { + return openDatabase() +} + +// 模块加载即预热(Bun 宿主),首次轮询无需等完整初始化流程 +void openDatabase() + +/** 读取宿主 SQLite 中该 integration 的启用凭据(库未就绪/无记录时 undefined)。 */ +export function readDbCredential(integrationId: string): CredentialValue | undefined { + if (!db || !integrationId) return undefined + try { + const row = db.query( + "SELECT value FROM credential WHERE integration_id = ? AND active = 1 ORDER BY time_updated DESC LIMIT 1", + ).get(integrationId) + return parseCredentialValue(row?.value) + } catch { + return undefined + } +} + +/** 读取 auth.json(opencode 1.x / 迁移遗留)中某个 provider 的凭据。 */ +export function readAuthJsonCredential(providerID: string): CredentialValue | undefined { + try { + const loader = typeof process !== "undefined" ? process.getBuiltinModule : undefined + const fs = loader?.("node:fs") as { readFileSync(path: string, encoding: "utf8"): string } | undefined + if (!fs) return undefined + const home = typeof process !== "undefined" ? (process.env.HOME || process.env.USERPROFILE || "") : "" + const dataHome = typeof process !== "undefined" ? process.env.XDG_DATA_HOME : undefined + const paths = [ + dataHome ? `${dataHome}/opencode/auth.json` : "", + home ? `${home}/.local/share/opencode/auth.json` : "", + ] + for (const path of paths) { + if (!path) continue + try { + const auth = JSON.parse(fs.readFileSync(path, "utf8")) as Record + const entry = auth[providerID] + if (entry && typeof entry === "object") return entry as CredentialValue + } catch { /* try the next known auth path */ } + } + } catch { /* ignore */ } + return undefined +} + +/** 凭据 → 余额查询 token(oauth 用 access,api 用 key)。 */ +export function credentialToken(value: CredentialValue | undefined): string { + if (!value) return "" + if (value.type === "oauth" && typeof value.access === "string") return value.access + if (value.type === "api" && typeof value.key === "string") return value.key + return "" +} + +/** 统一解析:宿主 SQLite active 凭据优先(V2 的唯一实时来源),auth.json 兜底。 */ +export function resolveCredentialToken(providerID: string): string { + return credentialToken(readDbCredential(providerID)) || credentialToken(readAuthJsonCredential(providerID)) +} diff --git a/src/v2/index.tsx b/src/v2/index.tsx index 1495af4..8dcffa8 100644 --- a/src/v2/index.tsx +++ b/src/v2/index.tsx @@ -8,6 +8,7 @@ import type { BalanceState, PanelApi, PanelSignals } from "../panel/panel-api" import { StatusView } from "./status" import { mapTheme } from "./theme" import { makeCommands, findOpencodeKeyV2 } from "./commands" +import { credentialsDbReady } from "./credentials" import { getBalanceProvider } from "../balance-providers" import { LANG_META, detectLang, type LangCode } from "../i18n" @@ -96,8 +97,12 @@ function PluginRoot(props: { // ── 余额轮询(对齐 V1 tui() pollBalance):手动 key 优先,缺失时自动复用 OpenCode 已认证 key ── const pollBalance = async () => { const provider = getBalanceProvider(props.signals.balanceProviderId()) - const key = props.api.kv.get(`${KV_PREFIX}.balance.${provider.id}.key`, "") - || findOpencodeKeyV2(props.context, provider) + let key = props.api.kv.get(`${KV_PREFIX}.balance.${provider.id}.key`, "") + if (!key) { + // V2 凭据保存在宿主 SQLite:等库就绪再解析,避免首轮回退到过期的 auth.json + await credentialsDbReady() + key = findOpencodeKeyV2(props.context, provider) + } const set = props.signals.setBalanceState if (props.signals.balanceUnsupported()) { set({ status: "idle", data: null, lastFetch: 0, error: undefined, key: undefined }); return } if (!key) { set({ status: "idle", data: null, lastFetch: 0, error: undefined, key: undefined }); return } diff --git a/tests/credentials.test.ts b/tests/credentials.test.ts new file mode 100644 index 0000000..1d0d95d --- /dev/null +++ b/tests/credentials.test.ts @@ -0,0 +1,37 @@ +import assert from "node:assert/strict" +import { + credentialToken, + findCredentialDbPath, + parseCredentialValue, + resolveCredentialToken, +} from "../src/v2/credentials" + +// oauth(OpenAI / Google 等)→ access token +assert.equal( + credentialToken(parseCredentialValue(JSON.stringify({ type: "oauth", access: "eyJ.token", refresh: "r" }))), + "eyJ.token", +) + +// api key(DeepSeek / OpenRouter 等)→ key +assert.equal( + credentialToken(parseCredentialValue(JSON.stringify({ type: "api", key: "sk-test" }))), + "sk-test", +) + +// 无效输入 / 缺 token → undefined 或空串 +assert.equal(parseCredentialValue("not json"), undefined) +assert.equal(parseCredentialValue(""), undefined) +assert.equal(parseCredentialValue(JSON.stringify({ type: "oauth" })), undefined) +assert.equal(parseCredentialValue(JSON.stringify({ type: "oauth", access: "" })), undefined) +assert.equal(credentialToken(undefined), "") +assert.equal(credentialToken({ type: "oauth" }), "") + +// 库路径解析:OPENCODE_DB 优先,其次 XDG_DATA_HOME,最后 ~/.local/share +assert.equal(findCredentialDbPath({ HOME: "/h" }, "/h"), "/h/.local/share/opencode/opencode.db") +assert.equal(findCredentialDbPath({ HOME: "/h", XDG_DATA_HOME: "/x" }, "/h"), "/x/opencode/opencode.db") +assert.equal(findCredentialDbPath({ HOME: "/h", OPENCODE_DB: "/custom/db" }, "/h"), "/custom/db") + +// Node 环境(bun:sqlite 不可用):解析静默回退,不抛异常 +assert.equal(resolveCredentialToken("provider-que-nao-existe"), "") + +console.log("V2 credential resolution tests passed")