Skip to content
Open
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
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions src/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
25 changes: 15 additions & 10 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -232,11 +241,7 @@ function device(label: string, primary: boolean) {
};
}

return {
type: "success" as const,
provider: SENTINEL_SHADOW_PROVIDER,
key: "shadow",
};
return primaryAuth();
},
};
},
Expand Down
43 changes: 43 additions & 0 deletions test/codex.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) {
const encode = (value: Record<string, unknown>) =>
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");
});
});
36 changes: 35 additions & 1 deletion test/index.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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",
});
});
});
50 changes: 50 additions & 0 deletions test/sync.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) {
const encode = (value: Record<string, unknown>) =>
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");
});
});