fix(auth): dedupe codex-cli profiles

Co-authored-by: Oliver Drobnik <oliver@cocoanetics.com>
This commit is contained in:
Peter Steinberger
2026-01-20 09:37:04 +00:00
parent e0e33e12d1
commit 9c2c4b1138
6 changed files with 344 additions and 39 deletions

View File

@@ -3,7 +3,8 @@ import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { ensureAuthProfileStore } from "./auth-profiles.js";
import { AUTH_STORE_VERSION } from "./auth-profiles/constants.js";
import { AUTH_STORE_VERSION, CODEX_CLI_PROFILE_ID } from "./auth-profiles/constants.js";
import { withTempHome } from "../../test/helpers/temp-home.js";
describe("ensureAuthProfileStore", () => {
it("migrates legacy auth.json and deletes it (PR #368)", () => {
@@ -122,4 +123,80 @@ describe("ensureAuthProfileStore", () => {
fs.rmSync(root, { recursive: true, force: true });
}
});
it("drops codex-cli from merged store when a custom openai-codex profile matches", async () => {
await withTempHome(async (tempHome) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-auth-dedup-merge-"));
const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR;
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
try {
const mainDir = path.join(root, "main-agent");
const agentDir = path.join(root, "agent-x");
fs.mkdirSync(mainDir, { recursive: true });
fs.mkdirSync(agentDir, { recursive: true });
process.env.CLAWDBOT_AGENT_DIR = mainDir;
process.env.PI_CODING_AGENT_DIR = mainDir;
process.env.HOME = tempHome;
fs.writeFileSync(
path.join(mainDir, "auth-profiles.json"),
`${JSON.stringify(
{
version: AUTH_STORE_VERSION,
profiles: {
[CODEX_CLI_PROFILE_ID]: {
type: "oauth",
provider: "openai-codex",
access: "shared-access-token",
refresh: "shared-refresh-token",
expires: Date.now() + 3600000,
},
},
},
null,
2,
)}\n`,
"utf8",
);
fs.writeFileSync(
path.join(agentDir, "auth-profiles.json"),
`${JSON.stringify(
{
version: AUTH_STORE_VERSION,
profiles: {
"openai-codex:my-custom-profile": {
type: "oauth",
provider: "openai-codex",
access: "shared-access-token",
refresh: "shared-refresh-token",
expires: Date.now() + 3600000,
},
},
},
null,
2,
)}\n`,
"utf8",
);
const store = ensureAuthProfileStore(agentDir);
expect(store.profiles[CODEX_CLI_PROFILE_ID]).toBeUndefined();
expect(store.profiles["openai-codex:my-custom-profile"]).toBeDefined();
} finally {
if (previousAgentDir === undefined) {
delete process.env.CLAWDBOT_AGENT_DIR;
} else {
process.env.CLAWDBOT_AGENT_DIR = previousAgentDir;
}
if (previousPiAgentDir === undefined) {
delete process.env.PI_CODING_AGENT_DIR;
} else {
process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
}
fs.rmSync(root, { recursive: true, force: true });
}
});
});
});

View File

@@ -0,0 +1,166 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { withTempHome } from "../../test/helpers/temp-home.js";
import { CODEX_CLI_PROFILE_ID, ensureAuthProfileStore } from "./auth-profiles.js";
describe("external CLI credential sync", () => {
it("skips codex-cli sync when credentials already exist in another openai-codex profile", async () => {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-codex-dedup-skip-"));
try {
await withTempHome(
async (tempHome) => {
const codexDir = path.join(tempHome, ".codex");
fs.mkdirSync(codexDir, { recursive: true });
const codexAuthPath = path.join(codexDir, "auth.json");
fs.writeFileSync(
codexAuthPath,
JSON.stringify({
tokens: {
access_token: "shared-access-token",
refresh_token: "shared-refresh-token",
},
}),
);
fs.utimesSync(codexAuthPath, new Date(), new Date());
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
"openai-codex:my-custom-profile": {
type: "oauth",
provider: "openai-codex",
access: "shared-access-token",
refresh: "shared-refresh-token",
expires: Date.now() + 3600000,
},
},
}),
);
const store = ensureAuthProfileStore(agentDir);
expect(store.profiles[CODEX_CLI_PROFILE_ID]).toBeUndefined();
expect(store.profiles["openai-codex:my-custom-profile"]).toBeDefined();
},
{ prefix: "clawdbot-home-" },
);
} finally {
fs.rmSync(agentDir, { recursive: true, force: true });
}
});
it("creates codex-cli profile when credentials differ from existing openai-codex profiles", async () => {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-codex-dedup-create-"));
try {
await withTempHome(
async (tempHome) => {
const codexDir = path.join(tempHome, ".codex");
fs.mkdirSync(codexDir, { recursive: true });
const codexAuthPath = path.join(codexDir, "auth.json");
fs.writeFileSync(
codexAuthPath,
JSON.stringify({
tokens: {
access_token: "unique-access-token",
refresh_token: "unique-refresh-token",
},
}),
);
fs.utimesSync(codexAuthPath, new Date(), new Date());
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
"openai-codex:my-custom-profile": {
type: "oauth",
provider: "openai-codex",
access: "different-access-token",
refresh: "different-refresh-token",
expires: Date.now() + 3600000,
},
},
}),
);
const store = ensureAuthProfileStore(agentDir);
expect(store.profiles[CODEX_CLI_PROFILE_ID]).toBeDefined();
expect((store.profiles[CODEX_CLI_PROFILE_ID] as { access: string }).access).toBe(
"unique-access-token",
);
expect(store.profiles["openai-codex:my-custom-profile"]).toBeDefined();
},
{ prefix: "clawdbot-home-" },
);
} finally {
fs.rmSync(agentDir, { recursive: true, force: true });
}
});
it("removes codex-cli profile when it duplicates another openai-codex profile", async () => {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-codex-dedup-remove-"));
try {
await withTempHome(
async (tempHome) => {
const codexDir = path.join(tempHome, ".codex");
fs.mkdirSync(codexDir, { recursive: true });
const codexAuthPath = path.join(codexDir, "auth.json");
fs.writeFileSync(
codexAuthPath,
JSON.stringify({
tokens: {
access_token: "shared-access-token",
refresh_token: "shared-refresh-token",
},
}),
);
fs.utimesSync(codexAuthPath, new Date(), new Date());
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
[CODEX_CLI_PROFILE_ID]: {
type: "oauth",
provider: "openai-codex",
access: "shared-access-token",
refresh: "shared-refresh-token",
expires: Date.now() + 3600000,
},
"openai-codex:my-custom-profile": {
type: "oauth",
provider: "openai-codex",
access: "shared-access-token",
refresh: "shared-refresh-token",
expires: Date.now() + 3600000,
},
},
}),
);
const store = ensureAuthProfileStore(agentDir);
expect(store.profiles[CODEX_CLI_PROFILE_ID]).toBeUndefined();
const saved = JSON.parse(fs.readFileSync(authPath, "utf8")) as {
profiles?: Record<string, unknown>;
};
expect(saved.profiles?.[CODEX_CLI_PROFILE_ID]).toBeUndefined();
expect(saved.profiles?.["openai-codex:my-custom-profile"]).toBeDefined();
},
{ prefix: "clawdbot-home-" },
);
} finally {
fs.rmSync(agentDir, { recursive: true, force: true });
}
});
});

View File

@@ -58,6 +58,26 @@ function isExternalProfileFresh(cred: AuthProfileCredential | undefined, now: nu
return cred.expires > now + EXTERNAL_CLI_NEAR_EXPIRY_MS;
}
/**
* Find any existing openai-codex profile (other than codex-cli) that has the same
* access and refresh tokens. This prevents creating a duplicate codex-cli profile
* when the user has already set up a custom profile with the same credentials.
*/
export function findDuplicateCodexProfile(
store: AuthProfileStore,
creds: OAuthCredential,
): string | undefined {
for (const [profileId, profile] of Object.entries(store.profiles)) {
if (profileId === CODEX_CLI_PROFILE_ID) continue;
if (profile.type !== "oauth") continue;
if (profile.provider !== "openai-codex") continue;
if (profile.access === creds.access && profile.refresh === creds.refresh) {
return profileId;
}
}
return undefined;
}
/**
* Sync OAuth credentials from external CLI tools (Claude Code CLI, Codex CLI) into the store.
* This allows clawdbot to use the same credentials as these tools without requiring
@@ -143,31 +163,55 @@ export function syncExternalCliCredentials(
// Sync from Codex CLI
const existingCodex = store.profiles[CODEX_CLI_PROFILE_ID];
const existingCodexOAuth = existingCodex?.type === "oauth" ? existingCodex : undefined;
const duplicateExistingId = existingCodexOAuth
? findDuplicateCodexProfile(store, existingCodexOAuth)
: undefined;
if (duplicateExistingId) {
delete store.profiles[CODEX_CLI_PROFILE_ID];
mutated = true;
log.info("removed codex-cli profile: credentials already exist in another profile", {
existingProfileId: duplicateExistingId,
removedProfileId: CODEX_CLI_PROFILE_ID,
});
}
const shouldSyncCodex =
!existingCodex ||
existingCodex.provider !== "openai-codex" ||
!isExternalProfileFresh(existingCodex, now);
const codexCreds = shouldSyncCodex
const codexCreds = shouldSyncCodex || duplicateExistingId
? readCodexCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS })
: null;
if (codexCreds) {
const existing = store.profiles[CODEX_CLI_PROFILE_ID];
const existingOAuth = existing?.type === "oauth" ? existing : undefined;
const duplicateProfileId = findDuplicateCodexProfile(store, codexCreds);
if (duplicateProfileId) {
if (store.profiles[CODEX_CLI_PROFILE_ID]) {
delete store.profiles[CODEX_CLI_PROFILE_ID];
mutated = true;
log.info("removed codex-cli profile: credentials already exist in another profile", {
existingProfileId: duplicateProfileId,
removedProfileId: CODEX_CLI_PROFILE_ID,
});
}
} else {
const existing = store.profiles[CODEX_CLI_PROFILE_ID];
const existingOAuth = existing?.type === "oauth" ? existing : undefined;
// Codex creds don't carry expiry; use file mtime heuristic for freshness.
const shouldUpdate =
!existingOAuth ||
existingOAuth.provider !== "openai-codex" ||
existingOAuth.expires <= now ||
codexCreds.expires > existingOAuth.expires;
// Codex creds don't carry expiry; use file mtime heuristic for freshness.
const shouldUpdate =
!existingOAuth ||
existingOAuth.provider !== "openai-codex" ||
existingOAuth.expires <= now ||
codexCreds.expires > existingOAuth.expires;
if (shouldUpdate && !shallowEqualOAuthCredentials(existingOAuth, codexCreds)) {
store.profiles[CODEX_CLI_PROFILE_ID] = codexCreds;
mutated = true;
log.info("synced openai-codex credentials from codex cli", {
profileId: CODEX_CLI_PROFILE_ID,
expires: new Date(codexCreds.expires).toISOString(),
});
if (shouldUpdate && !shallowEqualOAuthCredentials(existingOAuth, codexCreds)) {
store.profiles[CODEX_CLI_PROFILE_ID] = codexCreds;
mutated = true;
log.info("synced openai-codex credentials from codex cli", {
profileId: CODEX_CLI_PROFILE_ID,
expires: new Date(codexCreds.expires).toISOString(),
});
}
}
}

View File

@@ -3,8 +3,13 @@ import type { OAuthCredentials } from "@mariozechner/pi-ai";
import lockfile from "proper-lockfile";
import { resolveOAuthPath } from "../../config/paths.js";
import { loadJsonFile, saveJsonFile } from "../../infra/json-file.js";
import { AUTH_STORE_LOCK_OPTIONS, AUTH_STORE_VERSION, log } from "./constants.js";
import { syncExternalCliCredentials } from "./external-cli-sync.js";
import {
AUTH_STORE_LOCK_OPTIONS,
AUTH_STORE_VERSION,
CODEX_CLI_PROFILE_ID,
log,
} from "./constants.js";
import { findDuplicateCodexProfile, syncExternalCliCredentials } from "./external-cli-sync.js";
import { ensureAuthStoreFile, resolveAuthStorePath, resolveLegacyAuthStorePath } from "./paths.js";
import type { AuthProfileCredential, AuthProfileStore, ProfileUsageStats } from "./types.js";
@@ -330,7 +335,18 @@ export function ensureAuthProfileStore(
}
const mainStore = loadAuthProfileStoreForAgent(undefined, options);
return mergeAuthProfileStores(mainStore, store);
const merged = mergeAuthProfileStores(mainStore, store);
// Keep per-agent view clean even if the main store has codex-cli.
const codexProfile = merged.profiles[CODEX_CLI_PROFILE_ID];
if (codexProfile?.type === "oauth") {
const duplicateId = findDuplicateCodexProfile(merged, codexProfile);
if (duplicateId) {
delete merged.profiles[CODEX_CLI_PROFILE_ID];
}
}
return merged;
}
export function saveAuthProfileStore(store: AuthProfileStore, agentDir?: string): void {