diff --git a/package.json b/package.json index 60de160..00dde7b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-team", - "version": "0.0.6", + "version": "0.0.7", "description": "Manage multiple Codex ChatGPT auth snapshots and quota usage from the command line.", "license": "MIT", "type": "module", diff --git a/src/account-store.ts b/src/account-store.ts index 7a32e84..ce28d23 100644 --- a/src/account-store.ts +++ b/src/account-store.ts @@ -44,6 +44,7 @@ export interface StorePaths { codexDir: string; codexTeamDir: string; currentAuthPath: string; + currentConfigPath: string; accountsDir: string; backupsDir: string; statePath: string; @@ -58,6 +59,7 @@ export interface StoreState { export interface ManagedAccount extends SnapshotMeta { authPath: string; metaPath: string; + configPath: string | null; duplicateAccountId: boolean; } @@ -122,6 +124,7 @@ function defaultPaths(homeDir = homedir()): StorePaths { codexDir, codexTeamDir, currentAuthPath: join(codexDir, "auth.json"), + currentConfigPath: join(codexDir, "config.toml"), accountsDir: join(codexTeamDir, "accounts"), backupsDir: join(codexTeamDir, "backups"), statePath: join(codexTeamDir, "state.json"), @@ -248,6 +251,10 @@ export class AccountStore { return join(this.accountDirectory(name), "meta.json"); } + private accountConfigPath(name: string): string { + return join(this.accountDirectory(name), "config.toml"); + } + private async writeAccountAuthSnapshot( name: string, snapshot: AuthSnapshot, @@ -262,6 +269,63 @@ export class AccountStore { await atomicWriteFile(this.accountMetaPath(name), stringifyJson(meta)); } + private validateConfigSnapshot(name: string, snapshot: AuthSnapshot, rawConfig: string | null): void { + if (snapshot.auth_mode !== "apikey") { + return; + } + + if (!rawConfig) { + throw new Error(`Current ~/.codex/config.toml is required to save apikey account "${name}".`); + } + + if (!/^\s*model_provider\s*=\s*["'][^"']+["']/mu.test(rawConfig)) { + throw new Error(`Current ~/.codex/config.toml is missing model_provider for apikey account "${name}".`); + } + + if (!/^\s*base_url\s*=\s*["'][^"']+["']/mu.test(rawConfig)) { + throw new Error(`Current ~/.codex/config.toml is missing base_url for apikey account "${name}".`); + } + } + + private sanitizeConfigForAccountAuth(rawConfig: string): string { + const lines = rawConfig.split(/\r?\n/u); + const result: string[] = []; + let skippingProviderSection = false; + + for (const line of lines) { + const trimmed = line.trim(); + + if (trimmed.startsWith("[") && trimmed.endsWith("]")) { + skippingProviderSection = /^\[model_providers\.[^\]]+\]$/u.test(trimmed); + if (skippingProviderSection) { + continue; + } + } + + if (skippingProviderSection) { + continue; + } + + if (/^\s*model_provider\s*=/u.test(line)) { + continue; + } + + if (/^\s*preferred_auth_method\s*=\s*["']apikey["']\s*$/u.test(line)) { + continue; + } + + result.push(line); + } + + return `${result.join("\n").replace(/\n{3,}/gu, "\n\n").trimEnd()}\n`; + } + + private async ensureEmptyAccountConfigSnapshot(name: string): Promise { + const configPath = this.accountConfigPath(name); + await atomicWriteFile(configPath, ""); + return configPath; + } + private async syncCurrentAuthIfMatching(snapshot: AuthSnapshot): Promise { if (!(await pathExists(this.paths.currentAuthPath))) { return; @@ -357,6 +421,7 @@ export class AccountStore { ...meta, authPath, metaPath, + configPath: (await pathExists(this.accountConfigPath(name))) ? this.accountConfigPath(name) : null, duplicateAccountId: false, }; } @@ -440,9 +505,14 @@ export class AccountStore { const rawSnapshot = await readJsonFile(this.paths.currentAuthPath); const snapshot = parseAuthSnapshot(rawSnapshot); + const rawConfig = + (await pathExists(this.paths.currentConfigPath)) + ? await readJsonFile(this.paths.currentConfigPath) + : null; const accountDir = this.accountDirectory(name); const authPath = this.accountAuthPath(name); const metaPath = this.accountMetaPath(name); + const configPath = this.accountConfigPath(name); const accountExists = await pathExists(accountDir); const existingMeta = accountExists && (await pathExists(metaPath)) @@ -453,8 +523,14 @@ export class AccountStore { throw new Error(`Account "${name}" already exists. Use --force to overwrite it.`); } + this.validateConfigSnapshot(name, snapshot, rawConfig); await ensureDirectory(accountDir, DIRECTORY_MODE); await atomicWriteFile(authPath, `${rawSnapshot.trimEnd()}\n`); + if (snapshot.auth_mode === "apikey" && rawConfig) { + await atomicWriteFile(configPath, rawConfig.endsWith("\n") ? rawConfig : `${rawConfig}\n`); + } else if (await pathExists(configPath)) { + await rm(configPath, { force: true }); + } const meta = createSnapshotMeta( name, snapshot, @@ -492,13 +568,26 @@ export class AccountStore { const name = current.matched_accounts[0]; const currentRawSnapshot = await readJsonFile(this.paths.currentAuthPath); const currentSnapshot = parseAuthSnapshot(currentRawSnapshot); + const currentRawConfig = + (await pathExists(this.paths.currentConfigPath)) + ? await readJsonFile(this.paths.currentConfigPath) + : null; const metaPath = this.accountMetaPath(name); const existingMeta = parseSnapshotMeta(await readJsonFile(metaPath)); + this.validateConfigSnapshot(name, currentSnapshot, currentRawConfig); await atomicWriteFile( this.accountAuthPath(name), `${currentRawSnapshot.trimEnd()}\n`, ); + if (currentSnapshot.auth_mode === "apikey" && currentRawConfig) { + await atomicWriteFile( + this.accountConfigPath(name), + currentRawConfig.endsWith("\n") ? currentRawConfig : `${currentRawConfig}\n`, + ); + } else if (await pathExists(this.accountConfigPath(name))) { + await rm(this.accountConfigPath(name), { force: true }); + } await atomicWriteFile( metaPath, stringifyJson( @@ -535,9 +624,32 @@ export class AccountStore { await copyFile(this.paths.currentAuthPath, backupPath); await chmodIfPossible(backupPath, FILE_MODE); } + if (await pathExists(this.paths.currentConfigPath)) { + const configBackupPath = join(this.paths.backupsDir, "last-active-config.toml"); + await copyFile(this.paths.currentConfigPath, configBackupPath); + await chmodIfPossible(configBackupPath, FILE_MODE); + } const rawAuth = await readJsonFile(account.authPath); await atomicWriteFile(this.paths.currentAuthPath, `${rawAuth.trimEnd()}\n`); + if (account.auth_mode === "apikey" && account.configPath) { + const rawConfig = await readJsonFile(account.configPath); + await atomicWriteFile( + this.paths.currentConfigPath, + rawConfig.endsWith("\n") ? rawConfig : `${rawConfig}\n`, + ); + } else if (account.auth_mode === "apikey") { + await this.ensureEmptyAccountConfigSnapshot(name); + warnings.push( + `Saved apikey account "${name}" was missing config.toml snapshot. Created an empty snapshot; configure baseUrl manually if needed.`, + ); + } else if (await pathExists(this.paths.currentConfigPath)) { + const currentRawConfig = await readJsonFile(this.paths.currentConfigPath); + await atomicWriteFile( + this.paths.currentConfigPath, + this.sanitizeConfigForAccountAuth(currentRawConfig), + ); + } const writtenSnapshot = await readAuthSnapshotFile(this.paths.currentAuthPath); if (getSnapshotIdentity(writtenSnapshot) !== account.account_id) { @@ -780,6 +892,9 @@ export class AccountStore { if ((metaStat.mode & 0o777) !== FILE_MODE) { issues.push(`Account "${account.name}" metadata permissions must be 600.`); } + if (account.auth_mode === "apikey" && !account.configPath) { + issues.push(`Account "${account.name}" is missing config.toml snapshot required for apikey auth.`); + } if (account.duplicateAccountId) { warnings.push( `Account "${account.name}" shares identity ${account.account_id} with another saved account.`, diff --git a/tests/account-store.test.ts b/tests/account-store.test.ts index 6a94b8e..5998865 100644 --- a/tests/account-store.test.ts +++ b/tests/account-store.test.ts @@ -1,4 +1,4 @@ -import { chmod, readFile, stat } from "node:fs/promises"; +import { chmod, readFile, rm, stat } from "node:fs/promises"; import { join } from "node:path"; import { describe, expect, test } from "@rstest/core"; @@ -11,7 +11,9 @@ import { createTempHome, jsonResponse, readCurrentAuth, + readCurrentConfig, textResponse, + writeCurrentConfig, writeCurrentApiKeyAuth, writeCurrentAuth, } from "./test-helpers.js"; @@ -133,13 +135,27 @@ describe("AccountStore", () => { test("saves and switches apikey-managed accounts", async () => { const homeDir = await createTempHome(); + const alphaConfig = `model_provider = "custom" + +[model_providers.custom] +base_url = "https://proxy-alpha.example/v1" +wire_api = "responses" +`; + const betaConfig = `model_provider = "custom" + +[model_providers.custom] +base_url = "https://proxy-beta.example/v1" +wire_api = "responses" +`; try { const store = createAccountStore(homeDir); await writeCurrentApiKeyAuth(homeDir, "sk-alpha"); + await writeCurrentConfig(homeDir, alphaConfig); const alpha = await store.saveCurrentAccount("alpha"); await writeCurrentApiKeyAuth(homeDir, "sk-beta"); + await writeCurrentConfig(homeDir, betaConfig); await store.saveCurrentAccount("beta"); const current = await store.getCurrentStatus(); @@ -154,6 +170,106 @@ describe("AccountStore", () => { const switched = await readCurrentAuth(homeDir); expect(switched.auth_mode).toBe("apikey"); expect(switched.OPENAI_API_KEY).toBe("sk-alpha"); + expect(await readCurrentConfig(homeDir)).toContain('base_url = "https://proxy-alpha.example/v1"'); + } finally { + await cleanupTempHome(homeDir); + } + }); + + test("requires config.toml with base_url for apikey accounts", async () => { + const homeDir = await createTempHome(); + + try { + const store = createAccountStore(homeDir); + await writeCurrentApiKeyAuth(homeDir, "sk-alpha"); + await writeCurrentConfig(homeDir, 'model_provider = "custom"\n'); + + await expect(store.saveCurrentAccount("alpha")).rejects.toThrow(/missing base_url/); + } finally { + await cleanupTempHome(homeDir); + } + }); + + test("creates an empty config snapshot when switching a legacy apikey account without config", async () => { + const homeDir = await createTempHome(); + + try { + const store = createAccountStore(homeDir); + await writeCurrentApiKeyAuth(homeDir, "sk-alpha"); + await writeCurrentConfig( + homeDir, + `model_provider = "custom" + +[model_providers.custom] +base_url = "https://proxy-alpha.example/v1" +wire_api = "responses" +`, + ); + await store.saveCurrentAccount("alpha"); + await readFile(join(homeDir, ".codex-team", "accounts", "alpha", "config.toml"), "utf8"); + await rm(join(homeDir, ".codex-team", "accounts", "alpha", "config.toml")); + + const result = await store.switchAccount("alpha"); + expect(result.warnings).toContain( + 'Saved apikey account "alpha" was missing config.toml snapshot. Created an empty snapshot; configure baseUrl manually if needed.', + ); + expect( + await readFile(join(homeDir, ".codex-team", "accounts", "alpha", "config.toml"), "utf8"), + ).toBe(""); + } finally { + await cleanupTempHome(homeDir); + } + }); + + test("does not require or persist config snapshots for chatgpt accounts", async () => { + const homeDir = await createTempHome(); + + try { + const store = createAccountStore(homeDir); + await writeCurrentAuth(homeDir, "acct-chatgpt"); + await writeCurrentConfig(homeDir, 'model_provider = "custom"\n'); + + await store.saveCurrentAccount("main"); + + await expect( + readFile(join(homeDir, ".codex-team", "accounts", "main", "config.toml"), "utf8"), + ).rejects.toThrow(); + } finally { + await cleanupTempHome(homeDir); + } + }); + + test("removes apikey provider config when switching back to chatgpt auth", async () => { + const homeDir = await createTempHome(); + + try { + const store = createAccountStore(homeDir); + await writeCurrentAuth(homeDir, "acct-chatgpt"); + await store.saveCurrentAccount("main"); + + await writeCurrentApiKeyAuth(homeDir, "sk-alpha"); + await writeCurrentConfig( + homeDir, + `model_provider = "custom" + +[model_providers.custom] +base_url = "https://proxy-alpha.example/v1" +wire_api = "responses" + +[projects."/Users/bytedance/.codex"] +preferred_auth_method = "apikey" +`, + ); + + await store.switchAccount("main"); + + const current = await readCurrentAuth(homeDir); + expect(current.auth_mode).toBe("chatgpt"); + const currentConfig = await readCurrentConfig(homeDir); + expect(currentConfig).not.toContain("base_url"); + expect(currentConfig).not.toContain('[model_providers.custom]'); + expect(currentConfig).not.toContain('model_provider = "custom"'); + expect(currentConfig).not.toContain('preferred_auth_method = "apikey"'); } finally { await cleanupTempHome(homeDir); } diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 0be86bd..6e4d644 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -14,6 +14,7 @@ import { jsonResponse, readCurrentAuth, textResponse, + writeCurrentConfig, writeCurrentApiKeyAuth, writeCurrentAuth, } from "./test-helpers.js"; @@ -142,6 +143,15 @@ describe("CLI", () => { try { const store = createAccountStore(homeDir); await writeCurrentApiKeyAuth(homeDir, "sk-cli-primary"); + await writeCurrentConfig( + homeDir, + `model_provider = "custom" + +[model_providers.custom] +base_url = "https://proxy-cli.example/v1" +wire_api = "responses" +`, + ); const saveCode = await runCli(["save", "cli-key", "--json"], { store, diff --git a/tests/test-helpers.ts b/tests/test-helpers.ts index c068917..481ddac 100644 --- a/tests/test-helpers.ts +++ b/tests/test-helpers.ts @@ -95,6 +95,23 @@ export async function writeCurrentApiKeyAuth( ); } +export async function writeCurrentConfig( + homeDir: string, + rawConfig: string, +): Promise { + const codexDir = join(homeDir, ".codex"); + await mkdir(codexDir, { recursive: true, mode: 0o700 }); + await writeFile( + join(codexDir, "config.toml"), + rawConfig.endsWith("\n") ? rawConfig : `${rawConfig}\n`, + { mode: 0o600 }, + ); +} + +export async function readCurrentConfig(homeDir: string): Promise { + return readFile(join(homeDir, ".codex", "config.toml"), "utf8"); +} + export async function readCurrentAuth(homeDir: string): Promise { const raw = await readFile(join(homeDir, ".codex", "auth.json"), "utf8"); return parseAuthSnapshot(raw);