Skip to content
Merged
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
115 changes: 115 additions & 0 deletions src/account-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export interface StorePaths {
codexDir: string;
codexTeamDir: string;
currentAuthPath: string;
currentConfigPath: string;
accountsDir: string;
backupsDir: string;
statePath: string;
Expand All @@ -58,6 +59,7 @@ export interface StoreState {
export interface ManagedAccount extends SnapshotMeta {
authPath: string;
metaPath: string;
configPath: string | null;
duplicateAccountId: boolean;
}

Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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,
Expand All @@ -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<string> {
const configPath = this.accountConfigPath(name);
await atomicWriteFile(configPath, "");
return configPath;
}

private async syncCurrentAuthIfMatching(snapshot: AuthSnapshot): Promise<void> {
if (!(await pathExists(this.paths.currentAuthPath))) {
return;
Expand Down Expand Up @@ -357,6 +421,7 @@ export class AccountStore {
...meta,
authPath,
metaPath,
configPath: (await pathExists(this.accountConfigPath(name))) ? this.accountConfigPath(name) : null,
duplicateAccountId: false,
};
}
Expand Down Expand Up @@ -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))
Expand All @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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.`,
Expand Down
118 changes: 117 additions & 1 deletion tests/account-store.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -11,7 +11,9 @@ import {
createTempHome,
jsonResponse,
readCurrentAuth,
readCurrentConfig,
textResponse,
writeCurrentConfig,
writeCurrentApiKeyAuth,
writeCurrentAuth,
} from "./test-helpers.js";
Expand Down Expand Up @@ -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();
Expand All @@ -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);
}
Expand Down
10 changes: 10 additions & 0 deletions tests/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
jsonResponse,
readCurrentAuth,
textResponse,
writeCurrentConfig,
writeCurrentApiKeyAuth,
writeCurrentAuth,
} from "./test-helpers.js";
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading