diff --git a/AGENTS.md b/AGENTS.md index cf978b6..c0dcd5c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,11 +8,13 @@ This plugin hijacks `provider: "openai"` via `auth.loader`. The built-in Codex p Core auth.json stores `type: "oauth"` for the primary account so that `isCodex = true` in opencode's `llm.ts:65`, preserving exact Codex behavior parity (`options.instructions`, system prompt, `maxOutputTokens`). +Persisted account rows are keyed by the OAuth subject (`sub`) when available. `chatgpt_account_id` is stored as request metadata only and may legitimately be shared by multiple rows when those users belong to the same ChatGPT organization/team. + ### Key design decisions - JSON config lives at `~/.config/opencode/codex-pool.json`; the plugin auto-creates it with `{ "fast-mode": "auto", "fast-mode-bias": 0, "sticky-mode": "always", "sticky-strength": 1, "dormant-touch": "new-session-only" }` when missing, loads it during plugin initialization, and falls back to defaults with a warning toast if the file is invalid. - SQLite (`~/.local/share/opencode/codex-pool.db`) is the sole runtime source of truth for account tokens, cooldown state, shared usage cache, dormant-window touch suppression, and the cross-process locks that coordinate refresh and usage revalidation. -- Core `auth.json` is a mirror of the primary account only, kept in sync for `isCodex` activation, while additional accounts stay in SQLite and are represented in auth state through the inert shadow provider. +- Core `auth.json` is a mirror of the primary account only, kept in sync for `isCodex` activation, while additional accounts stay in SQLite and must not replace the primary `openai` auth entry with a shadow credential. - Auth methods expose primary login, pool-account addition, and a minimal `Edit pool accounts` manager that lists current non-primary rows and can delete a selected pool account after confirmation. - The built-in Codex `chat.headers` hook is not duplicated; it runs as-is. - 429 failover is strict priority-based (not round-robin) after request ordering has been decided. diff --git a/src/codex.ts b/src/codex.ts index 62f7d5a..ca8de56 100644 --- a/src/codex.ts +++ b/src/codex.ts @@ -68,12 +68,12 @@ export function parseJwtClaims(token: string): Claims | undefined { export function extractAccountId(tokens: TokenSet): string | undefined { if (tokens.id_token) { const claims = parseJwtClaims(tokens.id_token); - const id = claims && account(claims); + const id = claims?.sub || (claims && account(claims)); if (id) return id; } if (tokens.access_token) { const claims = parseJwtClaims(tokens.access_token); - return claims ? account(claims) : undefined; + return claims?.sub || (claims ? account(claims) : undefined); } return undefined; } diff --git a/src/index.ts b/src/index.ts index 36ad4c0..22a002c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -131,6 +131,19 @@ export function edit(client: ToastClient, store = use()) { }; } +export function primaryAuth(store = use()) { + const row = store.primary(); + if (!row) return { type: "failed" as const }; + return { + type: "success" as const, + provider: "openai", + refresh: row.refresh_token, + access: row.access_token, + expires: row.expires_at, + accountId: row.id, + }; +} + function save(tokens: TokenSet, priority: number, primary: boolean) { const store = use(); const id = extractAccountId(tokens) || crypto.randomUUID(); @@ -191,11 +204,7 @@ function browser(label: string, primary: boolean) { }; } - return { - type: "success" as const, - provider: SENTINEL_SHADOW_PROVIDER, - key: "shadow", - }; + return primaryAuth(); } finally { flow.stop(); } @@ -232,11 +241,7 @@ function device(label: string, primary: boolean) { }; } - return { - type: "success" as const, - provider: SENTINEL_SHADOW_PROVIDER, - key: "shadow", - }; + return primaryAuth(); }, }; }, diff --git a/test/codex.test.ts b/test/codex.test.ts new file mode 100644 index 0000000..fe21c69 --- /dev/null +++ b/test/codex.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test"; + +import { extractAccountId, extractAccountMeta } from "../src/codex"; +import type { TokenSet } from "../src/types"; + +function jwt(claims: Record) { + const encode = (value: Record) => + Buffer.from(JSON.stringify(value)).toString("base64url"); + return `${encode({ alg: "none", typ: "JWT" })}.${encode(claims)}.`; +} + +describe("codex token metadata", () => { + test("prefers subject for the stored account id", () => { + const tokens: TokenSet = { + access_token: jwt({ + sub: "user-1", + email: "user@example.com", + chatgpt_account_id: "org-1", + organizations: [{ id: "org-1" }], + }), + refresh_token: "refresh", + }; + + expect(extractAccountId(tokens)).toBe("user-1"); + expect(extractAccountMeta(tokens)).toEqual({ + subject: "user-1", + email: "user@example.com", + chatgpt_account_id: "org-1", + }); + }); + + test("falls back to the ChatGPT account id when subject is missing", () => { + const tokens: TokenSet = { + access_token: jwt({ + chatgpt_account_id: "org-1", + organizations: [{ id: "org-1" }], + }), + refresh_token: "refresh", + }; + + expect(extractAccountId(tokens)).toBe("org-1"); + }); +}); diff --git a/test/index.test.ts b/test/index.test.ts index c3966dd..d24bf18 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { edit } from "../src/index"; +import { edit, primaryAuth } from "../src/index"; import { open } from "../src/store"; import type { Store } from "../src/store"; import type { Account } from "../src/types"; @@ -140,3 +140,37 @@ describe("edit pool accounts auth method", () => { ]); }); }); + +describe("primaryAuth", () => { + let store: Store; + + beforeEach(() => { + store = open(":memory:"); + }); + + afterEach(() => { + store.close(); + }); + + test("returns the mirrored primary oauth auth state", () => { + store.upsert(row("primary", 0, { primary: 1, label: "primary" })); + store.setPrimary("primary"); + const item = store.get("primary"); + if (!item) throw new Error("missing primary"); + + expect(primaryAuth(store)).toEqual({ + type: "success", + provider: "openai", + refresh: "primary-refresh", + access: "primary-access", + expires: item.expires_at, + accountId: "primary", + }); + }); + + test("fails when no primary account is stored", () => { + expect(primaryAuth(store)).toEqual({ + type: "failed", + }); + }); +}); diff --git a/test/sync.test.ts b/test/sync.test.ts new file mode 100644 index 0000000..12117bb --- /dev/null +++ b/test/sync.test.ts @@ -0,0 +1,50 @@ +import type { Auth } from "@opencode-ai/sdk"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; + +import { bootstrap } from "../src/sync"; +import { open } from "../src/store"; +import type { Store } from "../src/store"; + +function jwt(claims: Record) { + const encode = (value: Record) => + Buffer.from(JSON.stringify(value)).toString("base64url"); + return `${encode({ alg: "none", typ: "JWT" })}.${encode(claims)}.`; +} + +function auth(sub: string, email: string, org: string): Auth { + return { + type: "oauth", + access: jwt({ + sub, + email, + chatgpt_account_id: org, + organizations: [{ id: org }], + }), + refresh: `${sub}-refresh`, + expires: Date.now() + 3_600_000, + }; +} + +describe("bootstrap", () => { + let store: Store; + + beforeEach(() => { + store = open(":memory:"); + }); + + afterEach(() => { + store.close(); + }); + + test("keeps separate accounts from the same ChatGPT organization", async () => { + await bootstrap(store, async () => auth("user-1", "one@example.com", "org-1")); + await bootstrap(store, async () => auth("user-2", "two@example.com", "org-1")); + + expect(store.list().map((item) => item.id).sort()).toEqual([ + "user-1", + "user-2", + ]); + expect(store.get("user-1")?.chatgpt_account_id).toBe("org-1"); + expect(store.get("user-2")?.chatgpt_account_id).toBe("org-1"); + }); +});