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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
49 changes: 5 additions & 44 deletions src/v2/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | undefined>
getBuiltinModule?: (id: string) => unknown
} | undefined
import { resolveCredentialToken } from "./credentials"

const KV_PREFIX = "cache_panel"

Expand All @@ -21,44 +17,9 @@ function extractToolParts(msg: Record<string, any>): Array<Record<string, any>>
return msg.content.filter((p: Record<string, any>) => 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<string, unknown>
const entry = auth[providerID]
if (entry && typeof entry === "object") {
const record = entry as Record<string, unknown>
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 {
Expand All @@ -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 })。 */
Expand Down
139 changes: 139 additions & 0 deletions src/v2/credentials.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined>
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<string, unknown>[]
get(...params: unknown[]): Record<string, unknown> | undefined
}
interface SqlDatabase {
query(sql: string): SqlStatement
close?(): void
}

/** 宿主数据库路径(OPENCODE_DB 覆盖,遵循 XDG_DATA_HOME)。 */
export function findCredentialDbPath(
env: Record<string, string | undefined> = 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<void> | undefined

function openDatabase(): Promise<void> {
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<string, unknown>) => 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<void> {
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<string, unknown>
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))
}
9 changes: 7 additions & 2 deletions src/v2/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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<string>(`${KV_PREFIX}.balance.${provider.id}.key`, "")
|| findOpencodeKeyV2(props.context, provider)
let key = props.api.kv.get<string>(`${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 }
Expand Down
37 changes: 37 additions & 0 deletions tests/credentials.test.ts
Original file line number Diff line number Diff line change
@@ -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")
Loading