mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-03 23:23:21 +00:00
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
215 lines
7.1 KiB
TypeScript
215 lines
7.1 KiB
TypeScript
import { existsSync } from "node:fs";
|
|
import { readFile } from "node:fs/promises";
|
|
import { homedir } from "node:os";
|
|
import { join, resolve } from "node:path";
|
|
import { z } from "zod";
|
|
import { routeSchema } from "../shared/input.js";
|
|
|
|
export interface StoreLookup {
|
|
home?: string;
|
|
env?: NodeJS.ProcessEnv;
|
|
platform?: NodeJS.Platform;
|
|
now?: () => number;
|
|
sqlite?: () => Sqlite | undefined;
|
|
}
|
|
interface Database {
|
|
prepare(sql: string): { all(...args: string[]): unknown[] };
|
|
close(): void;
|
|
}
|
|
export interface Sqlite {
|
|
DatabaseSync: new (path: string, options: { readOnly: boolean }) => Database;
|
|
}
|
|
export const oauthSchema = z.object({
|
|
access: z.string().min(1),
|
|
accountId: z.string().optional(),
|
|
expires: z.number().optional(),
|
|
});
|
|
const rowSchema = z.object({ id: z.number().int().positive(), data: z.string() });
|
|
export type Route = z.infer<typeof routeSchema>;
|
|
|
|
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<unknown> {
|
|
try {
|
|
return JSON.parse(await readFile(path, "utf8"));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function piAuthPath(lookup: StoreLookup): string {
|
|
const home = lookup.home ?? homedir();
|
|
const env = lookup.env ?? process.env;
|
|
const configured = env.PI_CODING_AGENT_DIR?.trim();
|
|
if (!configured) return join(home, ".pi", "agent", "auth.json");
|
|
if (configured === "~") return join(home, "auth.json");
|
|
const directory = configured.startsWith("~/")
|
|
? join(home, configured.slice(2))
|
|
: resolve(configured);
|
|
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;
|
|
const platform = lookup.platform ?? process.platform;
|
|
const profile = (env.OMP_PROFILE ?? env.PI_PROFILE)?.trim() || "default";
|
|
const base = join(home, env.PI_CONFIG_DIR || ".omp");
|
|
const configRoot = profile === "default" ? base : join(base, "profiles", profile);
|
|
const defaultAgentDir = join(configRoot, "agent");
|
|
const agentDir =
|
|
profile === "default" && env.PI_CODING_AGENT_DIR
|
|
? resolve(env.PI_CODING_AGENT_DIR)
|
|
: defaultAgentDir;
|
|
if (
|
|
(platform === "linux" || platform === "darwin") &&
|
|
agentDir === defaultAgentDir &&
|
|
env.XDG_DATA_HOME
|
|
) {
|
|
const xdgBase = join(env.XDG_DATA_HOME, "omp");
|
|
const xdgRoot = profile === "default" ? xdgBase : join(xdgBase, "profiles", profile);
|
|
if (existsSync(xdgRoot)) return join(xdgRoot, "agent.db");
|
|
}
|
|
return join(agentDir, "agent.db");
|
|
}
|
|
|
|
function builtinSqlite(): Sqlite | undefined {
|
|
// Node 20 must still compile/load the plugin; only OMP needs this newer builtin.
|
|
const parsed = z
|
|
.object({
|
|
DatabaseSync: z.custom<Sqlite["DatabaseSync"]>((value) => typeof value === "function"),
|
|
})
|
|
.safeParse(process.getBuiltinModule?.("node:sqlite"));
|
|
return parsed.success ? parsed.data : undefined;
|
|
}
|
|
|
|
function ompRows(path: string, lookup: StoreLookup) {
|
|
let db: Database | undefined;
|
|
try {
|
|
const sqlite = (lookup.sqlite ?? builtinSqlite)();
|
|
if (!sqlite) return [];
|
|
db = new sqlite.DatabaseSync(path, { readOnly: true });
|
|
const rows = db
|
|
.prepare(
|
|
"SELECT id, data FROM auth_credentials WHERE provider = ? AND credential_type = 'oauth' AND disabled_cause IS NULL ORDER BY id",
|
|
)
|
|
.all("openai-codex");
|
|
return rows.flatMap((value) => {
|
|
const row = rowSchema.safeParse(value);
|
|
if (!row.success) return [];
|
|
try {
|
|
const oauth = oauthSchema.parse(JSON.parse(row.data.data));
|
|
return [{ id: row.data.id, oauth }];
|
|
} catch {
|
|
return [];
|
|
}
|
|
});
|
|
} catch {
|
|
return [];
|
|
} finally {
|
|
db?.close();
|
|
}
|
|
}
|
|
|
|
export function discoverOmp(lookup: StoreLookup): Route[] {
|
|
const path = ompDatabasePath(lookup);
|
|
return ompRows(path, lookup).map(({ id }) => ({ store: "omp", path, credentialId: id }));
|
|
}
|
|
|
|
export function readOmp(route: Extract<Route, { store: "omp" }>, lookup: StoreLookup) {
|
|
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<Route, { path: string }>,
|
|
lookup: StoreLookup = {},
|
|
) {
|
|
if (route.store === "omp") return readOmp(route, lookup);
|
|
const provider = route.store === "opencode" ? "openai" : "openai-codex";
|
|
const parsed = z.record(z.string(), z.unknown()).safeParse(await readJson(route.path));
|
|
if (!parsed.success) return null;
|
|
const oauth = oauthSchema.extend({ type: z.literal("oauth") }).safeParse(parsed.data[provider]);
|
|
return oauth.success ? oauth.data : null;
|
|
}
|