From d5f19aed37ff3be2e89e57b17102e6f342537a6a Mon Sep 17 00:00:00 2001 From: Jiayang Wu <651475459@qq.com> Date: Tue, 3 Nov 2026 15:31:15 +0800 Subject: [PATCH] fix(usage): read the OpenCode v2 login store in the Codex usage source OpenCode v2 keeps its credentials in its own database and no longer writes the v1 credential file. The Codex usage source only knew the v1 stores, so on a v2 host it kept reporting the last file login it ever saw, with that file's expiry date, instead of the ChatGPT login the host actually uses. Read the active OpenAI credential from the OpenCode v2 database when it holds one, and keep the v1 file as the source for installations without that database. The database wins when both exist, because the file login is the one that goes stale. Report the v2 store with the same OpenCode harness and refresh command as the v1 file store, so a rejected or expired login still names opencode. Cover discovery, store precedence, expiry, token rotation and read-only access with tests against real temporary SQLite databases. Related to getpaseo/paseo#8645 --- .../codex-usage-source/server/stores.test.ts | 229 ++++++++++++++++++ plugins/codex-usage-source/server/stores.ts | 83 +++++++ .../codex-usage-source/server/usage.test.ts | 66 +++++ plugins/codex-usage-source/server/usage.ts | 68 ++++-- plugins/codex-usage-source/shared/input.ts | 7 + 5 files changed, 436 insertions(+), 17 deletions(-) diff --git a/plugins/codex-usage-source/server/stores.test.ts b/plugins/codex-usage-source/server/stores.test.ts index 335985bacd..9e3f2b0e75 100644 --- a/plugins/codex-usage-source/server/stores.test.ts +++ b/plugins/codex-usage-source/server/stores.test.ts @@ -78,6 +78,27 @@ async function database(path: string) { db.close(); } +async function opencodeDatabase(path: string) { + await mkdir((await import("node:path")).dirname(path), { recursive: true }); + const { DatabaseSync } = await import("node:sqlite"); + const db = new DatabaseSync(path); + db.exec( + "CREATE TABLE credential (id TEXT PRIMARY KEY, integration_id TEXT, label TEXT, value TEXT, connector_id TEXT, method_id TEXT, active INTEGER, time_created INTEGER, time_updated INTEGER)", + ); + return db; +} + +function opencodeOauth(access: string, expires: number, accountId?: string) { + return JSON.stringify({ + type: "oauth", + methodID: "chatgpt-headless", + access, + refresh: "fixture-refresh", + expires, + ...(accountId ? { metadata: { accountID: accountId } } : {}), + }); +} + test("Pi uses its own OAuth identity and re-reads rotated tokens without writing", async () => { const path = join(home, ".pi", "agent", "auth.json"); await json(path, { @@ -260,3 +281,211 @@ test("OMP fetch re-reads its row after rotation and skips a newly disabled login ), ).rejects.toThrow("login store no longer exists"); }); + +function opencodeUsageResponse() { + return Response.json({ + plan_type: "plus", + rate_limit: { + primary_window: { used_percent: 30, limit_window_seconds: 18000 }, + secondary_window: { used_percent: 81, limit_window_seconds: 604800 }, + }, + }); +} + +test("OpenCode v2 database login replaces a stale v1 file login", async () => { + const data = join(home, "data"); + const env = { XDG_DATA_HOME: data }; + const databasePath = join(data, "opencode", "opencode.db"); + await json(join(data, "opencode", "auth.json"), { + openai: { type: "oauth", access: "fixture-expired", expires: 500 }, + }); + const db = await opencodeDatabase(databasePath); + db.prepare("INSERT INTO credential VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)").run( + "cred_1", + "openai", + "OpenAI", + opencodeOauth("fixture-db", 2000, "db-account"), + null, + "chatgpt-headless", + 1, + 1, + 1, + ); + db.close(); + + const inputs = await logins(lookup(env)); + expect(inputs).toEqual([ + { route: { store: "opencode-db", path: databasePath, credentialId: "cred_1" } }, + ]); + expect(await accountIdentity(inputs[0]!, env)).toEqual({ key: "db-account" }); + const report = await fetchUsage( + inputs[0]!, + async (_url, init) => { + const headers = new Headers(init?.headers); + expect(headers.get("Authorization")).toBe("Bearer fixture-db"); + expect(headers.get("ChatGPT-Account-Id")).toBe("db-account"); + return opencodeUsageResponse(); + }, + lookup(env), + ); + expect(report).toMatchObject({ + status: "available", + planLabel: "plus", + windows: [ + { id: "five_hour", usedPct: 30 }, + { id: "weekly", usedPct: 81 }, + ], + }); +}); + +test("OpenCode v2 database login with an expired token is unavailable without a request", async () => { + const data = join(home, "data"); + const env = { XDG_DATA_HOME: data }; + const databasePath = join(data, "opencode", "opencode.db"); + const db = await opencodeDatabase(databasePath); + db.prepare("INSERT INTO credential VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)").run( + "cred_1", + "openai", + "OpenAI", + opencodeOauth("fixture-expired", 500, "db-account"), + null, + "chatgpt-headless", + 1, + 1, + 1, + ); + db.close(); + + const [input] = await logins(lookup(env)); + expect( + await fetchUsage( + input!, + async () => { + throw new Error("must not fetch an expired token"); + }, + lookup(env), + ), + ).toEqual({ + status: "unavailable", + problem: { kind: "expired", expiresAt: new Date(500).toISOString(), refreshedBy: "opencode" }, + }); +}); + +test("OpenCode v2 database is unchanged after its login is read", async () => { + const data = join(home, "data"); + const env = { XDG_DATA_HOME: data }; + const databasePath = join(data, "opencode", "opencode.db"); + const db = await opencodeDatabase(databasePath); + db.prepare("INSERT INTO credential VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)").run( + "cred_1", + "openai", + "OpenAI", + opencodeOauth("fixture-db", 2000, "db-account"), + null, + "chatgpt-headless", + 1, + 1, + 1, + ); + db.close(); + const before = await readFile(databasePath); + + const [input] = await logins(lookup(env)); + await fetchUsage(input!, async () => opencodeUsageResponse(), lookup(env)); + expect(await readFile(databasePath)).toEqual(before); +}); + +test("OpenCode v2 database login is re-read after the stored token rotates", async () => { + const data = join(home, "data"); + const env = { XDG_DATA_HOME: data }; + const databasePath = join(data, "opencode", "opencode.db"); + const db = await opencodeDatabase(databasePath); + db.prepare("INSERT INTO credential VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)").run( + "cred_1", + "openai", + "OpenAI", + opencodeOauth("fixture-first", 2000, "db-account"), + null, + "chatgpt-headless", + 1, + 1, + 1, + ); + db.close(); + + const [input] = await logins(lookup(env)); + const { DatabaseSync } = await import("node:sqlite"); + const writer = new DatabaseSync(databasePath); + writer + .prepare("UPDATE credential SET value = ? WHERE id = 'cred_1'") + .run(opencodeOauth("fixture-rotated", 2000, "db-account")); + writer.close(); + + const report = await fetchUsage( + input!, + async (_url, init) => { + expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer fixture-rotated"); + return opencodeUsageResponse(); + }, + lookup(env), + ); + expect(report.status).toBe("available"); +}); + +test.each([ + ["there is no v2 database", null], + [ + "the v2 database holds no OpenAI login", + { integration: "zenai", value: JSON.stringify({ type: "key", key: "fixture-key" }) }, + ], + [ + "the v2 OpenAI login is not active", + { + integration: "openai", + value: opencodeOauth("fixture-inactive", 2000, "db-account"), + active: 0, + }, + ], +])("OpenCode v1 file login stays when %s", async (_name, row) => { + const data = join(home, "data"); + const env = { XDG_DATA_HOME: data }; + const file = join(data, "opencode", "auth.json"); + await json(file, { openai: { type: "oauth", access: "fixture-file", expires: 2000 } }); + if (row) { + const databasePath = join(data, "opencode", "opencode.db"); + const db = await opencodeDatabase(databasePath); + db.prepare("INSERT INTO credential VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)").run( + "cred_1", + row.integration, + "OpenAI", + row.value, + null, + "chatgpt-headless", + "active" in row ? row.active : 1, + 1, + 1, + ); + db.close(); + } + expect(await logins(lookup(env))).toEqual([{ route: { store: "opencode", path: file } }]); +}); + +test("OpenCode v2 database path honours OPENCODE_DB", async () => { + const databasePath = join(home, "custom-opencode.db"); + const db = await opencodeDatabase(databasePath); + db.prepare("INSERT INTO credential VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)").run( + "cred_1", + "openai", + "OpenAI", + opencodeOauth("fixture-db", 2000, "db-account"), + null, + "chatgpt-headless", + 1, + 1, + 1, + ); + db.close(); + expect(await logins(lookup({ OPENCODE_DB: databasePath }))).toEqual([ + { route: { store: "opencode-db", path: databasePath, credentialId: "cred_1" } }, + ]); +}); diff --git a/plugins/codex-usage-source/server/stores.ts b/plugins/codex-usage-source/server/stores.ts index 7f997d7841..9dc5861706 100644 --- a/plugins/codex-usage-source/server/stores.ts +++ b/plugins/codex-usage-source/server/stores.ts @@ -27,6 +27,22 @@ export const oauthSchema = z.object({ const rowSchema = z.object({ id: z.number().int().positive(), data: z.string() }); export type Route = z.infer; +export interface OpencodeOauthLogin { + id: string; + access: string; + accountId?: string; + expires?: number; +} + +const opencodeRowSchema = z.object({ id: z.string(), value: z.string() }); +const opencodeColumnSchema = z.object({ name: z.string() }); +const opencodeCredentialSchema = z.object({ + type: z.literal("oauth"), + access: z.string().min(1), + expires: z.number().optional(), + metadata: z.object({ accountID: z.string().min(1).optional() }).optional(), +}); + export async function readJson(path: string): Promise { try { return JSON.parse(await readFile(path, "utf8")); @@ -47,6 +63,20 @@ export function piAuthPath(lookup: StoreLookup): string { return join(directory, "auth.json"); } +export interface OpencodeStorePaths { + file: string; + database: string; +} + +export function opencodeStorePaths(env: NodeJS.ProcessEnv, home: string): OpencodeStorePaths { + const dataHome = env.XDG_DATA_HOME || join(home, ".local", "share"); + const directory = join(dataHome, "opencode"); + return { + file: join(directory, "auth.json"), + database: env.OPENCODE_DB ? resolve(env.OPENCODE_DB) : join(directory, "opencode.db"), + }; +} + function ompDatabasePath(lookup: StoreLookup): string { const home = lookup.home ?? homedir(); const env = lookup.env ?? process.env; @@ -118,6 +148,59 @@ export function readOmp(route: Extract, lookup: StoreLo return ompRows(route.path, lookup).find((row) => row.id === route.credentialId)?.oauth ?? null; } +function parseCredential(value: string): unknown | null { + try { + return JSON.parse(value); + } catch (error) { + if (error instanceof SyntaxError) return null; + throw error; + } +} + +/** + * OpenCode v2 keeps its logins in its own database and no longer writes the v1 + * credential file, so the file alone cannot describe the active ChatGPT login. + */ +export function readOpencodeOauth(path: string, lookup: StoreLookup = {}): OpencodeOauthLogin[] { + const sqlite = (lookup.sqlite ?? builtinSqlite)(); + if (!sqlite || !existsSync(path)) return []; + let db: Database | undefined; + try { + db = new sqlite.DatabaseSync(path, { readOnly: true }); + const columns = db + .prepare("PRAGMA table_info(credential)") + .all() + .map((value) => opencodeColumnSchema.safeParse(value)); + if (!columns.some((column) => column.success && column.data.name === "integration_id")) { + return []; + } + const rows = db + .prepare( + "SELECT id, value FROM credential WHERE integration_id = ? AND active = 1 ORDER BY time_updated DESC, id DESC", + ) + .all("openai"); + return rows.flatMap((value) => { + const row = opencodeRowSchema.safeParse(value); + if (!row.success) return []; + const credential = opencodeCredentialSchema.safeParse(parseCredential(row.data.value)); + if (!credential.success) return []; + const accountId = credential.data.metadata?.accountID; + return [ + { + id: row.data.id, + access: credential.data.access, + ...(accountId ? { accountId } : {}), + ...(credential.data.expires !== undefined ? { expires: credential.data.expires } : {}), + }, + ]; + }); + } catch { + return []; + } finally { + db?.close(); + } +} + export async function readHarness( route: Extract, lookup: StoreLookup = {}, diff --git a/plugins/codex-usage-source/server/usage.test.ts b/plugins/codex-usage-source/server/usage.test.ts index 4cdefa2bcd..d5702eaad7 100644 --- a/plugins/codex-usage-source/server/usage.test.ts +++ b/plugins/codex-usage-source/server/usage.test.ts @@ -230,6 +230,72 @@ test("discovers a Pi OAuth login independently of the CLI login", async () => { } }); +test("OpenCode v2 database login reports usage while the v1 file login expired", async () => { + const directory = await mkdtemp(join(tmpdir(), "usage-opencode-v2-")); + try { + const data = join(directory, "data"); + await mkdir(join(data, "opencode"), { recursive: true }); + await writeFile( + join(data, "opencode", "auth.json"), + JSON.stringify({ openai: { type: "oauth", access: "fixture-expired", expires: 500 } }), + ); + const { DatabaseSync } = await import("node:sqlite"); + const db = new DatabaseSync(join(data, "opencode", "opencode.db")); + db.exec( + "CREATE TABLE credential (id TEXT PRIMARY KEY, integration_id TEXT, label TEXT, value TEXT, connector_id TEXT, method_id TEXT, active INTEGER, time_created INTEGER, time_updated INTEGER)", + ); + db.prepare("INSERT INTO credential VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)").run( + "cred_1", + "openai", + "OpenAI", + JSON.stringify({ + type: "oauth", + methodID: "chatgpt-headless", + access: "fixture-db", + expires: 2000, + metadata: { accountID: "db-account" }, + }), + null, + "chatgpt-headless", + 1, + 1, + 1, + ); + db.close(); + const lookup = { + home: directory, + env: { XDG_DATA_HOME: data }, + platform: "linux" as const, + now: () => 1000, + }; + + const accounts = await discover({ kind: "global" }, lookup); + expect(accounts.map((account) => account.input)).toEqual([ + { + route: { + store: "opencode-db", + path: join(data, "opencode", "opencode.db"), + credentialId: "cred_1", + }, + }, + ]); + const report = await fetchUsage( + inputSchema.parse(accounts[0]!.input), + async (_url, init) => { + expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer fixture-db"); + return Response.json({ + plan_type: "plus", + rate_limit: { primary_window: { used_percent: 30, limit_window_seconds: 18000 } }, + }); + }, + lookup, + ); + expect(report).toMatchObject({ status: "available", planLabel: "plus" }); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + async function accountFor(input: CodexUsageInput) { const accounts = await discover( { kind: "global" }, diff --git a/plugins/codex-usage-source/server/usage.ts b/plugins/codex-usage-source/server/usage.ts index 0b211726c6..166e993480 100644 --- a/plugins/codex-usage-source/server/usage.ts +++ b/plugins/codex-usage-source/server/usage.ts @@ -1,4 +1,12 @@ -import { discoverOmp, piAuthPath, readHarness, readJson, type StoreLookup } from "./stores.js"; +import { + discoverOmp, + opencodeStorePaths, + piAuthPath, + readHarness, + readJson, + readOpencodeOauth, + type StoreLookup, +} from "./stores.js"; import { homedir } from "node:os"; import { join } from "node:path"; import { @@ -88,19 +96,33 @@ function globalRoutes(lookup: StoreLookup): CodexUsageInput[] { const candidates: CodexUsageInput[] = [...new Set(paths)].map((path) => ({ route: { store: "codex", path }, })); + candidates.push(...opencodeRoutes(env, home, lookup)); candidates.push( - { - route: { - store: "opencode", - path: join(env.XDG_DATA_HOME || join(home, ".local", "share"), "opencode", "auth.json"), - }, - }, { route: { store: "pi", path: piAuthPath(lookup) } }, ...discoverOmp(lookup).map((route) => ({ route })), ); return candidates; } +/** + * OpenCode v2 stores its logins in a database and leaves the v1 credential file + * untouched, so a file login can be stale while the database login is current. + * The database wins whenever it holds an OpenAI login; the file stays the + * source for v1 installations that have no such database. + */ +function opencodeRoutes( + env: NodeJS.ProcessEnv, + home: string, + lookup: StoreLookup, +): CodexUsageInput[] { + const { file, database } = opencodeStorePaths(env, home); + const logins = readOpencodeOauth(database, lookup); + if (!logins.length) return [{ route: { store: "opencode", path: file } }]; + return logins.map((login) => ({ + route: { store: "opencode-db", path: database, credentialId: login.id }, + })); +} + function sessionRoutes( scope: Extract, lookup: StoreLookup, @@ -119,14 +141,7 @@ function sessionRoutes( if (scope.provider === "pi") return [{ route: { store: "pi", path: piAuthPath(lookup) } }]; if (scope.provider === "omp") return discoverOmp(lookup).map((route) => ({ route })); if (scope.provider === "opencode") - return [ - { - route: { - store: "opencode", - path: join(env.XDG_DATA_HOME || join(home, ".local", "share"), "opencode", "auth.json"), - }, - }, - ]; + return opencodeRoutes(scope.env, lookup.home ?? homedir(), lookup); return []; } @@ -135,6 +150,14 @@ export async function readAuth( lookup: StoreLookup = {}, ): Promise { const route = input.route; + if (route.store === "opencode-db") { + const login = readOpencodeOauth(route.path, lookup).find( + (candidate) => candidate.id === route.credentialId, + ); + return login + ? { token: login.access, accountId: login.accountId, expires: login.expires } + : null; + } if (route.store !== "codex") { const oauth = await readHarness(route, lookup); return oauth @@ -191,7 +214,7 @@ export async function fetchUsage( ): Promise { const auth = await readAuth(input, lookup); if (!auth) throw new Error("Codex login store no longer exists"); - const refreshedBy = input.route.store; + const refreshedBy = refreshCommand(input.route.store); if (auth.expires !== undefined && auth.expires <= (lookup.now ?? Date.now)()) return unavailable({ kind: "expired", @@ -297,6 +320,17 @@ function accountIdentity(auth: Auth, input: CodexUsageInput): { key: string; lab } function harnessLabel(input: CodexUsageInput): string { - const labels = { codex: "Codex", opencode: "OpenCode", pi: "Pi", omp: "OMP" }; + const labels = { + codex: "Codex", + opencode: "OpenCode", + "opencode-db": "OpenCode", + pi: "Pi", + omp: "OMP", + }; return labels[input.route.store]; } + +/** The CLI that refreshes the login behind each store. */ +function refreshCommand(store: CodexUsageInput["route"]["store"]): string { + return store === "opencode-db" ? "opencode" : store; +} diff --git a/plugins/codex-usage-source/shared/input.ts b/plugins/codex-usage-source/shared/input.ts index 864af1c194..d4cf147185 100644 --- a/plugins/codex-usage-source/shared/input.ts +++ b/plugins/codex-usage-source/shared/input.ts @@ -2,6 +2,13 @@ import { z } from "zod"; export const routeSchema = z.discriminatedUnion("store", [ z.object({ store: z.enum(["codex", "opencode", "pi"]), path: z.string().min(1) }).strict(), + z + .object({ + store: z.literal("opencode-db"), + path: z.string().min(1), + credentialId: z.string().min(1), + }) + .strict(), z .object({ store: z.literal("omp"),