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
492 lines
16 KiB
TypeScript
492 lines
16 KiB
TypeScript
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { afterEach, beforeEach, expect, test } from "vitest";
|
|
import { discover, fetchUsage } from "./usage.js";
|
|
|
|
let home: string;
|
|
beforeEach(async () => {
|
|
home = await mkdtemp(join(tmpdir(), "usage-stores-"));
|
|
});
|
|
afterEach(async () => {
|
|
await rm(home, { recursive: true, force: true });
|
|
});
|
|
function lookup(env: NodeJS.ProcessEnv = {}) {
|
|
return { home, env, platform: "linux" as const, now: () => 1000 };
|
|
}
|
|
async function json(path: string, value: unknown) {
|
|
const { dirname } = await import("node:path");
|
|
await mkdir(dirname(path), { recursive: true });
|
|
await writeFile(path, JSON.stringify(value));
|
|
}
|
|
async function accountIdentity(
|
|
input: Parameters<typeof fetchUsage>[0],
|
|
env: NodeJS.ProcessEnv = {},
|
|
) {
|
|
const accounts = await discover({ kind: "global" }, lookup(env));
|
|
const account = accounts.find(
|
|
(candidate) => JSON.stringify(candidate.input) === JSON.stringify(input),
|
|
);
|
|
if (!account) throw new Error("Expected discovered login");
|
|
return { key: account.key, ...(account.label ? { label: account.label } : {}) };
|
|
}
|
|
async function logins(options: Parameters<typeof discover>[1]) {
|
|
return (await discover({ kind: "global" }, options)).map(
|
|
(account) => account.input as Parameters<typeof fetchUsage>[0],
|
|
);
|
|
}
|
|
async function database(path: string) {
|
|
await mkdir((await import("node:path")).dirname(path), { recursive: true });
|
|
const { DatabaseSync } = await import("node:sqlite");
|
|
const db = new DatabaseSync(path);
|
|
// Create the fixture with one durable commit instead of one per row.
|
|
db.exec("BEGIN");
|
|
db.exec(
|
|
"CREATE TABLE auth_credentials (id INTEGER PRIMARY KEY, provider TEXT, credential_type TEXT, data TEXT, disabled_cause TEXT)",
|
|
);
|
|
const insert = db.prepare("INSERT INTO auth_credentials VALUES (?, ?, ?, ?, ?)");
|
|
insert.run(
|
|
1,
|
|
"openai-codex",
|
|
"oauth",
|
|
JSON.stringify({ access: "fixture-omp", accountId: "omp-account", expires: 2000 }),
|
|
null,
|
|
);
|
|
insert.run(
|
|
2,
|
|
"openai-codex",
|
|
"oauth",
|
|
JSON.stringify({ access: "fixture-expired", expires: 500 }),
|
|
null,
|
|
);
|
|
insert.run(
|
|
3,
|
|
"openai-codex",
|
|
"oauth",
|
|
JSON.stringify({ access: "fixture-disabled", expires: 2000 }),
|
|
"revoked",
|
|
);
|
|
insert.run(
|
|
4,
|
|
"openai-codex",
|
|
"api_key",
|
|
JSON.stringify({ access: "fixture-key", expires: 2000 }),
|
|
null,
|
|
);
|
|
insert.run(5, "openai-codex", "oauth", "invalid", null);
|
|
db.exec("COMMIT");
|
|
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, {
|
|
"openai-codex": { type: "oauth", access: "fixture-first", accountId: "pi-account" },
|
|
});
|
|
await json(join(home, ".claude.json"), {
|
|
oauthAccount: { accountUuid: "other", organizationUuid: "other-org" },
|
|
});
|
|
const inputs = await logins(lookup());
|
|
expect(inputs).toHaveLength(1);
|
|
expect(await accountIdentity(inputs[0]!)).toEqual({ key: "pi-account" });
|
|
await json(path, {
|
|
"openai-codex": { type: "oauth", access: "fixture-rotated", accountId: "pi-account" },
|
|
});
|
|
const before = await readFile(path, "utf8");
|
|
expect(
|
|
(
|
|
await fetchUsage(
|
|
inputs[0]!,
|
|
async (_url, init) => {
|
|
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer fixture-rotated");
|
|
return new Response("{}", { status: 401 });
|
|
},
|
|
lookup(),
|
|
)
|
|
).status,
|
|
).toBe("unavailable");
|
|
expect(await readFile(path, "utf8")).toBe(before);
|
|
});
|
|
|
|
test("Pi honors directory override and ignores API keys and malformed files", async () => {
|
|
const directory = join(home, "custom-pi");
|
|
const env = { PI_CODING_AGENT_DIR: directory };
|
|
const path = join(directory, "auth.json");
|
|
await json(path, {
|
|
"openai-codex": { type: "oauth", access: "fixture-pi", accountId: "pi-account" },
|
|
});
|
|
expect(await logins(lookup(env))).toEqual([{ route: { store: "pi", path } }]);
|
|
await json(path, { "openai-codex": { type: "api_key", key: "fixture-key" } });
|
|
expect(await logins(lookup(env))).toEqual([]);
|
|
await writeFile(path, "broken");
|
|
expect(await logins(lookup(env))).toEqual([]);
|
|
});
|
|
|
|
test("expired Pi tokens are unavailable without calling usage or refreshing", async () => {
|
|
await json(join(home, ".pi", "agent", "auth.json"), {
|
|
"openai-codex": {
|
|
type: "oauth",
|
|
access: "fixture-expired",
|
|
accountId: "pi-account",
|
|
expires: 500,
|
|
},
|
|
});
|
|
const [input] = await logins(lookup());
|
|
expect(
|
|
(
|
|
await fetchUsage(
|
|
input!,
|
|
async () => {
|
|
throw new Error("must not fetch expired token");
|
|
},
|
|
lookup(),
|
|
)
|
|
).status,
|
|
).toBe("unavailable");
|
|
});
|
|
|
|
for (const location of ["default", "profile", "xdg", "override", "config"]) {
|
|
test(`OMP discovers enabled live OAuth rows in ${location} store read-only`, async () => {
|
|
let path = join(home, ".omp", "agent", "agent.db");
|
|
let env: NodeJS.ProcessEnv = { OMP_AUTH_BROKER_URL: "https://broker.test" };
|
|
if (location === "profile") {
|
|
env.OMP_PROFILE = "work";
|
|
path = join(home, ".omp", "profiles", "work", "agent", "agent.db");
|
|
}
|
|
if (location === "xdg") {
|
|
env.XDG_DATA_HOME = join(home, "data");
|
|
env.PI_PROFILE = "work";
|
|
path = join(home, "data", "omp", "profiles", "work", "agent.db");
|
|
}
|
|
if (location === "override") {
|
|
env.PI_CODING_AGENT_DIR = join(home, "custom");
|
|
path = join(home, "custom", "agent.db");
|
|
}
|
|
if (location === "config") {
|
|
env.PI_CONFIG_DIR = "custom-config";
|
|
path = join(home, "custom-config", "agent", "agent.db");
|
|
}
|
|
await database(path);
|
|
const before = await readFile(path);
|
|
const inputs = await logins(lookup(env));
|
|
expect(inputs).toEqual([
|
|
{ route: { store: "omp", path, credentialId: 1 } },
|
|
{ route: { store: "omp", path, credentialId: 2 } },
|
|
]);
|
|
expect(await accountIdentity(inputs[0]!, env)).toEqual({ key: "omp-account" });
|
|
expect(
|
|
(
|
|
await fetchUsage(
|
|
inputs[0]!,
|
|
async (_url, init) => {
|
|
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer fixture-omp");
|
|
return new Response("{}", { status: 401 });
|
|
},
|
|
lookup(env),
|
|
)
|
|
).status,
|
|
).toBe("unavailable");
|
|
expect(await readFile(path)).toEqual(before);
|
|
});
|
|
}
|
|
|
|
test("missing SQLite skips only OMP", async () => {
|
|
await database(join(home, ".omp", "agent", "agent.db"));
|
|
await json(join(home, ".pi", "agent", "auth.json"), {
|
|
"openai-codex": { type: "oauth", access: "fixture-pi" },
|
|
});
|
|
const inputs = await logins({ ...lookup(), sqlite: () => undefined });
|
|
expect(inputs).toEqual([
|
|
{ route: { store: "pi", path: join(home, ".pi", "agent", "auth.json") } },
|
|
]);
|
|
});
|
|
|
|
test("Codex and OpenCode discover accounts in preference order with XDG and CODEX_HOME", async () => {
|
|
const codex = join(home, "codex");
|
|
const data = join(home, "data");
|
|
const env = { CODEX_HOME: codex, XDG_DATA_HOME: data };
|
|
await json(join(codex, "auth.json"), {
|
|
tokens: { access_token: "fixture-codex", account_id: "same" },
|
|
});
|
|
// Codex CLI never reads ~/.config/codex.
|
|
await json(join(home, ".config", "codex", "auth.json"), {
|
|
tokens: { access_token: "fixture-config", account_id: "same" },
|
|
});
|
|
await json(join(home, ".codex", "auth.json"), {
|
|
tokens: { access_token: "fixture-legacy", account_id: "same" },
|
|
});
|
|
await json(join(data, "opencode", "auth.json"), {
|
|
openai: { type: "oauth", access: "fixture-open", accountId: "same" },
|
|
});
|
|
const inputs = await logins(lookup(env));
|
|
expect(inputs.map((input) => input.route.store)).toEqual(["codex", "codex", "opencode"]);
|
|
for (const input of inputs) expect(await accountIdentity(input, env)).toEqual({ key: "same" });
|
|
await json(join(data, "opencode", "auth.json"), { openai: { type: "api", key: "fixture-key" } });
|
|
expect(await logins(lookup(env))).toHaveLength(2);
|
|
});
|
|
|
|
test("OMP fetch re-reads its row after rotation and skips a newly disabled login", async () => {
|
|
const path = join(home, ".omp", "agent", "agent.db");
|
|
await database(path);
|
|
const [input] = await logins(lookup());
|
|
const { DatabaseSync } = await import("node:sqlite");
|
|
const db = new DatabaseSync(path);
|
|
db.prepare("UPDATE auth_credentials SET data = ? WHERE id = 1").run(
|
|
JSON.stringify({ access: "fixture-rotated-omp", accountId: "omp-account", expires: 2000 }),
|
|
);
|
|
expect(
|
|
(
|
|
await fetchUsage(
|
|
input!,
|
|
async (_url, init) => {
|
|
expect(new Headers(init?.headers).get("Authorization")).toBe(
|
|
"Bearer fixture-rotated-omp",
|
|
);
|
|
return new Response("{}", { status: 401 });
|
|
},
|
|
lookup(),
|
|
)
|
|
).status,
|
|
).toBe("unavailable");
|
|
db.prepare("UPDATE auth_credentials SET disabled_cause = 'revoked' WHERE id = 1").run();
|
|
db.close();
|
|
await expect(
|
|
fetchUsage(
|
|
input!,
|
|
async () => {
|
|
throw new Error("must not fetch");
|
|
},
|
|
lookup(),
|
|
),
|
|
).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" } },
|
|
]);
|
|
});
|