From 37ebeb95af66cd4603ee2f00efd5124a1f6f6a75 Mon Sep 17 00:00:00 2001 From: mattpeng Date: Wed, 25 Mar 2026 14:33:02 +0800 Subject: [PATCH 1/2] feat: support apikey auth snapshots --- src/account-store.ts | 16 ++++---- src/auth-snapshot.ts | 79 ++++++++++++++++++++++++++++++++----- src/main.ts | 4 +- src/quota-client.ts | 23 ++++++----- tests/account-store.test.ts | 31 ++++++++++++++- tests/auth-snapshot.test.ts | 17 +++++++- tests/cli.test.ts | 63 +++++++++++++++++++++++++++-- tests/quota-client.test.ts | 23 +++++++++-- tests/test-helpers.ts | 20 ++++++++++ 9 files changed, 235 insertions(+), 41 deletions(-) diff --git a/src/account-store.ts b/src/account-store.ts index 246475c..7a32e84 100644 --- a/src/account-store.ts +++ b/src/account-store.ts @@ -21,6 +21,7 @@ import { QuotaWindowSnapshot, SnapshotMeta, createSnapshotMeta, + getSnapshotIdentity, parseAuthSnapshot, parseSnapshotMeta, readAuthSnapshotFile, @@ -268,7 +269,7 @@ export class AccountStore { try { const currentSnapshot = await readAuthSnapshotFile(this.paths.currentAuthPath); - if (currentSnapshot.tokens.account_id !== snapshot.tokens.account_id) { + if (getSnapshotIdentity(currentSnapshot) !== getSnapshotIdentity(snapshot)) { return; } @@ -348,7 +349,7 @@ export class AccountStore { throw new Error(`Account metadata name mismatch for "${name}".`); } - if (meta.account_id !== snapshot.tokens.account_id) { + if (meta.account_id !== getSnapshotIdentity(snapshot)) { throw new Error(`Account metadata account_id mismatch for "${name}".`); } @@ -413,14 +414,15 @@ export class AccountStore { } const snapshot = await readAuthSnapshotFile(this.paths.currentAuthPath); + const currentIdentity = getSnapshotIdentity(snapshot); const matchedAccounts = accounts - .filter((account) => account.account_id === snapshot.tokens.account_id) + .filter((account) => account.account_id === currentIdentity) .map((account) => account.name); return { exists: true, auth_mode: snapshot.auth_mode, - account_id: snapshot.tokens.account_id, + account_id: currentIdentity, matched_accounts: matchedAccounts, managed: matchedAccounts.length > 0, duplicate_match: matchedAccounts.length > 1, @@ -538,7 +540,7 @@ export class AccountStore { await atomicWriteFile(this.paths.currentAuthPath, `${rawAuth.trimEnd()}\n`); const writtenSnapshot = await readAuthSnapshotFile(this.paths.currentAuthPath); - if (writtenSnapshot.tokens.account_id !== account.account_id) { + if (getSnapshotIdentity(writtenSnapshot) !== account.account_id) { throw new Error(`Switch verification failed for account "${name}".`); } @@ -604,7 +606,7 @@ export class AccountStore { } meta.auth_mode = result.authSnapshot.auth_mode; - meta.account_id = result.authSnapshot.tokens.account_id; + meta.account_id = getSnapshotIdentity(result.authSnapshot); meta.updated_at = now.toISOString(); meta.quota = result.quota; await this.writeAccountMeta(name, meta); @@ -780,7 +782,7 @@ export class AccountStore { } if (account.duplicateAccountId) { warnings.push( - `Account "${account.name}" shares account_id ${account.account_id} with another saved account.`, + `Account "${account.name}" shares identity ${account.account_id} with another saved account.`, ); } } diff --git a/src/auth-snapshot.ts b/src/auth-snapshot.ts index 94b3c7c..9b2efe3 100644 --- a/src/auth-snapshot.ts +++ b/src/auth-snapshot.ts @@ -1,14 +1,15 @@ +import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; export interface AuthSnapshotTokens { - account_id: string; + account_id?: string; [key: string]: unknown; } export interface AuthSnapshot { auth_mode: string; OPENAI_API_KEY?: string | null; - tokens: AuthSnapshotTokens; + tokens?: AuthSnapshotTokens; last_refresh?: string; [key: string]: unknown; } @@ -87,6 +88,41 @@ function asOptionalNumber(value: unknown, fieldName: string): number | undefined return value; } +function normalizeAuthMode(authMode: string): string { + return authMode.trim().toLowerCase(); +} + +export function isApiKeyAuthMode(authMode: string): boolean { + return normalizeAuthMode(authMode) === "apikey"; +} + +export function isSupportedChatGPTAuthMode(authMode: string): boolean { + const normalized = normalizeAuthMode(authMode); + return normalized === "chatgpt" || normalized === "chatgpt_auth_tokens"; +} + +function fingerprintApiKey(apiKey: string): string { + return createHash("sha256").update(apiKey).digest("hex").slice(0, 16); +} + +export function getSnapshotIdentity(snapshot: AuthSnapshot): string { + if (isApiKeyAuthMode(snapshot.auth_mode)) { + const apiKey = snapshot.OPENAI_API_KEY; + if (typeof apiKey !== "string" || apiKey.trim() === "") { + throw new Error('Field "OPENAI_API_KEY" must be a non-empty string for apikey auth.'); + } + + return `key_${fingerprintApiKey(apiKey)}`; + } + + const accountId = snapshot.tokens?.account_id; + if (typeof accountId !== "string" || accountId.trim() === "") { + throw new Error('Field "tokens.account_id" must be a non-empty string.'); + } + + return accountId; +} + export function defaultQuotaSnapshot(): QuotaSnapshot { return { status: "stale", @@ -168,20 +204,43 @@ export function parseAuthSnapshot(raw: string): AuthSnapshot { } const authMode = asNonEmptyString(parsed.auth_mode, "auth_mode"); + const tokens = parsed.tokens; - if (!isRecord(parsed.tokens)) { + if (tokens !== undefined && tokens !== null && !isRecord(tokens)) { throw new Error('Field "tokens" must be an object.'); } - - const accountId = asNonEmptyString(parsed.tokens.account_id, "tokens.account_id"); + const apiKeyMode = isApiKeyAuthMode(authMode); + const normalizedApiKey = + parsed.OPENAI_API_KEY === null || parsed.OPENAI_API_KEY === undefined + ? parsed.OPENAI_API_KEY + : asNonEmptyString(parsed.OPENAI_API_KEY, "OPENAI_API_KEY"); + + if (apiKeyMode) { + if (typeof normalizedApiKey !== "string" || normalizedApiKey.trim() === "") { + throw new Error('Field "OPENAI_API_KEY" must be a non-empty string for apikey auth.'); + } + } else { + if (!isRecord(tokens)) { + throw new Error('Field "tokens" must be an object.'); + } + + asNonEmptyString(tokens.account_id, "tokens.account_id"); + } return { ...parsed, auth_mode: authMode, - tokens: { - ...parsed.tokens, - account_id: accountId, - }, + OPENAI_API_KEY: normalizedApiKey, + ...(isRecord(tokens) + ? { + tokens: { + ...tokens, + ...(typeof tokens.account_id === "string" && tokens.account_id.trim() !== "" + ? { account_id: tokens.account_id } + : {}), + }, + } + : {}), }; } @@ -201,7 +260,7 @@ export function createSnapshotMeta( return { name, auth_mode: snapshot.auth_mode, - account_id: snapshot.tokens.account_id, + account_id: getSnapshotIdentity(snapshot), created_at: existingCreatedAt ?? timestamp, updated_at: timestamp, last_switched_at: null, diff --git a/src/main.ts b/src/main.ts index 2db5423..b30edb7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -124,7 +124,7 @@ function describeCurrentStatus(status: Awaited { expect(switchResult.backup_path).toBe(join(homeDir, ".codex-team", "backups", "last-active-auth.json")); const current = await readCurrentAuth(homeDir); - expect(current.tokens.account_id).toBe("acct-alpha"); + expect(current.tokens?.account_id).toBe("acct-alpha"); const backupRaw = await readFile( join(homeDir, ".codex-team", "backups", "last-active-auth.json"), @@ -130,6 +131,34 @@ describe("AccountStore", () => { } }); + test("saves and switches apikey-managed accounts", async () => { + const homeDir = await createTempHome(); + + try { + const store = createAccountStore(homeDir); + await writeCurrentApiKeyAuth(homeDir, "sk-alpha"); + const alpha = await store.saveCurrentAccount("alpha"); + + await writeCurrentApiKeyAuth(homeDir, "sk-beta"); + await store.saveCurrentAccount("beta"); + + const current = await store.getCurrentStatus(); + expect(current.exists).toBe(true); + expect(current.auth_mode).toBe("apikey"); + expect(current.account_id).toMatch(/^key_[0-9a-f]{16}$/); + expect(current.matched_accounts).toEqual(["beta"]); + + const switchResult = await store.switchAccount("alpha"); + expect(switchResult.account.account_id).toBe(alpha.account_id); + + const switched = await readCurrentAuth(homeDir); + expect(switched.auth_mode).toBe("apikey"); + expect(switched.OPENAI_API_KEY).toBe("sk-alpha"); + } finally { + await cleanupTempHome(homeDir); + } + }); + test("refreshes quotas and keeps cached balance when a later refresh fails", async () => { const homeDir = await createTempHome(); let attempts = 0; diff --git a/tests/auth-snapshot.test.ts b/tests/auth-snapshot.test.ts index 7ffd303..0d7f2fe 100644 --- a/tests/auth-snapshot.test.ts +++ b/tests/auth-snapshot.test.ts @@ -2,10 +2,11 @@ import { describe, expect, test } from "@rstest/core"; import { createSnapshotMeta, + getSnapshotIdentity, parseAuthSnapshot, parseSnapshotMeta, } from "../src/auth-snapshot.js"; -import { createAuthPayload } from "./test-helpers.js"; +import { createApiKeyPayload, createAuthPayload } from "./test-helpers.js"; describe("auth snapshot parsing", () => { test("parses a valid auth snapshot", () => { @@ -13,7 +14,7 @@ describe("auth snapshot parsing", () => { const snapshot = parseAuthSnapshot(JSON.stringify(payload)); expect(snapshot.auth_mode).toBe("chatgpt"); - expect(snapshot.tokens.account_id).toBe("acct-primary"); + expect(snapshot.tokens?.account_id).toBe("acct-primary"); }); test("rejects a snapshot without auth_mode", () => { @@ -23,6 +24,18 @@ describe("auth snapshot parsing", () => { expect(() => parseAuthSnapshot(JSON.stringify(payload))).toThrow(/auth_mode/); }); + test("parses an apikey auth snapshot and derives a stable identity", () => { + const payload = createApiKeyPayload("sk-test-primary"); + const snapshot = parseAuthSnapshot(JSON.stringify(payload)); + const reparsed = parseAuthSnapshot(JSON.stringify(payload)); + + expect(snapshot.auth_mode).toBe("apikey"); + expect(snapshot.OPENAI_API_KEY).toBe("sk-test-primary"); + expect(snapshot.tokens).toBeUndefined(); + expect(getSnapshotIdentity(snapshot)).toMatch(/^key_[0-9a-f]{16}$/); + expect(getSnapshotIdentity(snapshot)).toBe(getSnapshotIdentity(reparsed)); + }); + test("creates metadata with a preserved created_at on overwrite", () => { const payload = createAuthPayload("acct-primary"); const created = createSnapshotMeta("main", payload, new Date("2026-03-18T00:00:00.000Z")); diff --git a/tests/cli.test.ts b/tests/cli.test.ts index c186548..0be86bd 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -14,6 +14,7 @@ import { jsonResponse, readCurrentAuth, textResponse, + writeCurrentApiKeyAuth, writeCurrentAuth, } from "./test-helpers.js"; @@ -135,6 +136,62 @@ describe("CLI", () => { } }); + test("supports current and list for apikey auth snapshots", async () => { + const homeDir = await createTempHome(); + + try { + const store = createAccountStore(homeDir); + await writeCurrentApiKeyAuth(homeDir, "sk-cli-primary"); + + const saveCode = await runCli(["save", "cli-key", "--json"], { + store, + stdout: captureWritable().stream, + stderr: captureWritable().stream, + }); + expect(saveCode).toBe(0); + + const currentStdout = captureWritable(); + const currentCode = await runCli(["current", "--json"], { + store, + stdout: currentStdout.stream, + stderr: captureWritable().stream, + }); + + expect(currentCode).toBe(0); + expect(JSON.parse(currentStdout.read())).toMatchObject({ + exists: true, + auth_mode: "apikey", + managed: true, + matched_accounts: ["cli-key"], + }); + + const listStdout = captureWritable(); + const listCode = await runCli(["list", "--json"], { + store, + stdout: listStdout.stream, + stderr: captureWritable().stream, + }); + + expect(listCode).toBe(0); + expect(JSON.parse(listStdout.read())).toMatchObject({ + successes: [ + { + name: "cli-key", + refresh_status: "unsupported", + available: null, + plan_type: null, + credits_balance: null, + five_hour: null, + one_week: null, + }, + ], + failures: [], + }); + } finally { + await cleanupTempHome(homeDir); + } + }); + test("supports update and rejects unmanaged current auth", async () => { const homeDir = await createTempHome(); @@ -760,7 +817,7 @@ describe("CLI", () => { }, }); - expect((await readCurrentAuth(homeDir)).tokens.account_id).toBe("acct-auto-gamma"); + expect((await readCurrentAuth(homeDir)).tokens?.account_id).toBe("acct-auto-gamma"); const switchStdout = captureWritable(); const switchCode = await runCli(["switch", "--auto", "--json"], { @@ -794,7 +851,7 @@ describe("CLI", () => { }, }); - expect((await readCurrentAuth(homeDir)).tokens.account_id).toBe("acct-auto-beta"); + expect((await readCurrentAuth(homeDir)).tokens?.account_id).toBe("acct-auto-beta"); } finally { await cleanupTempHome(homeDir); } @@ -904,7 +961,7 @@ describe("CLI", () => { }, }); - expect((await readCurrentAuth(homeDir)).tokens.account_id).toBe("acct-best-current"); + expect((await readCurrentAuth(homeDir)).tokens?.account_id).toBe("acct-best-current"); } finally { await cleanupTempHome(homeDir); } diff --git a/tests/quota-client.test.ts b/tests/quota-client.test.ts index 545bd7a..68fe389 100644 --- a/tests/quota-client.test.ts +++ b/tests/quota-client.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "@rstest/core"; import { extractChatGPTAuth, fetchQuotaSnapshot } from "../src/quota-client.js"; import { + createApiKeyPayload, createAuthPayload, installFetchMock, jsonResponse, @@ -72,7 +73,7 @@ describe("quota client", () => { reset_at: "2026-03-19T03:14:00.000Z", }, }); - expect(result.authSnapshot.tokens.account_id).toBe("acct-primary"); + expect(result.authSnapshot.tokens?.account_id).toBe("acct-primary"); } finally { restoreFetch(); } @@ -105,7 +106,7 @@ describe("quota client", () => { expect(init?.method).toBe("POST"); return jsonResponse({ access_token: "refreshed-access-token", - id_token: snapshot.tokens.id_token, + id_token: snapshot.tokens?.id_token, refresh_token: "refreshed-refresh-token", }); } @@ -120,8 +121,8 @@ describe("quota client", () => { expect(usageAttempts).toBe(2); expect(result.quota.credits_balance).toBe(9); - expect(result.authSnapshot.tokens.access_token).toBe("refreshed-access-token"); - expect(result.authSnapshot.tokens.refresh_token).toBe("refreshed-refresh-token"); + expect(result.authSnapshot.tokens?.access_token).toBe("refreshed-access-token"); + expect(result.authSnapshot.tokens?.refresh_token).toBe("refreshed-refresh-token"); } finally { restoreFetch(); } @@ -160,4 +161,18 @@ describe("quota client", () => { restoreFetch(); } }); + + test("marks apikey auth snapshots as unsupported for quota refresh", async () => { + const snapshot = createApiKeyPayload("sk-test-primary"); + + const result = await fetchQuotaSnapshot(snapshot, { + homeDir: "/tmp/codex-team-test-home", + }); + + expect(result.quota).toMatchObject({ + status: "unsupported", + plan_type: undefined, + }); + expect(result.authSnapshot.OPENAI_API_KEY).toBe("sk-test-primary"); + }); }); diff --git a/tests/test-helpers.ts b/tests/test-helpers.ts index 25de4d7..c068917 100644 --- a/tests/test-helpers.ts +++ b/tests/test-helpers.ts @@ -60,6 +60,13 @@ export function createAuthPayload( }; } +export function createApiKeyPayload(apiKey: string): AuthSnapshot { + return { + auth_mode: "apikey", + OPENAI_API_KEY: apiKey, + }; +} + export async function writeCurrentAuth( homeDir: string, accountId: string, @@ -75,6 +82,19 @@ export async function writeCurrentAuth( ); } +export async function writeCurrentApiKeyAuth( + homeDir: string, + apiKey: string, +): Promise { + const codexDir = join(homeDir, ".codex"); + await mkdir(codexDir, { recursive: true, mode: 0o700 }); + await writeFile( + join(codexDir, "auth.json"), + `${JSON.stringify(createApiKeyPayload(apiKey), null, 2)}\n`, + { mode: 0o600 }, + ); +} + export async function readCurrentAuth(homeDir: string): Promise { const raw = await readFile(join(homeDir, ".codex", "auth.json"), "utf8"); return parseAuthSnapshot(raw); From 652adfa96ebc07c1fcb0f70b0910a9f7596b443b Mon Sep 17 00:00:00 2001 From: mattpeng Date: Wed, 25 Mar 2026 14:40:48 +0800 Subject: [PATCH 2/2] chore: bump version to 0.0.6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e7a9249..60de160 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-team", - "version": "0.0.5", + "version": "0.0.6", "description": "Manage multiple Codex ChatGPT auth snapshots and quota usage from the command line.", "license": "MIT", "type": "module",