From 9c2c4b11388aeb11d596c002cce122aed9cc4684 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 20 Jan 2026 09:37:04 +0000 Subject: [PATCH] fix(auth): dedupe codex-cli profiles Co-authored-by: Oliver Drobnik --- README.md | 39 ++-- scripts/clawtributors-map.json | 1 + ...th-profiles.ensureauthprofilestore.test.ts | 79 ++++++++- ...edentials-exist-in-another-profile.test.ts | 166 ++++++++++++++++++ src/agents/auth-profiles/external-cli-sync.ts | 76 ++++++-- src/agents/auth-profiles/store.ts | 22 ++- 6 files changed, 344 insertions(+), 39 deletions(-) create mode 100644 src/agents/auth-profiles.external-cli-credential-sync.skips-codex-sync-when-credentials-exist-in-another-profile.test.ts diff --git a/README.md b/README.md index 11e6cc537..b8e0be195 100644 --- a/README.md +++ b/README.md @@ -471,24 +471,25 @@ Thanks to all clawtributors:

steipete bohdanpodvirnyi joaohlisboa mneves75 MatthieuBizien rahthakor vrknetha radek-paclt joshp123 mukhtharcm - maxsumrall xadenryan Tobias Bischoff juanpablodlc hsrvc magimetal meaningfool NicholasSpisak abhisekbasu1 claude - jamesgroat sebslight Hyaxia dantelex daveonkels mteam88 Eng. Juan Combetto dbhurley Mariano Belinky TSavo + maxsumrall xadenryan Tobias Bischoff juanpablodlc hsrvc magimetal meaningfool NicholasSpisak abhisekbasu1 sebslight + claude jamesgroat Hyaxia dantelex daveonkels mteam88 Eng. Juan Combetto dbhurley Mariano Belinky TSavo julianengel benithors timolins nachx639 sreekaransrinath gupsammy cristip73 nachoiacovino Vasanth Rao Naik Sabavat cpojer - lc0rp scald andranik-sahakyan davidguttman sleontenko sircrumpet peschee rafaelreis-r thewilloftheshadow ratulsarna - lutr0 danielz1z gumadeiras emanuelst KristijanJovanovski CashWilliams rdev osolmaz joshrad-dev kiranjd - adityashaw2 sheeek artuskg onutc manuelhettich minghinmatthewlam myfunc buddyh connorshea mcinteerj - timkrase zerone0x gerardward2007 obviyus tosh-hamburg azade-c roshanasingh4 bjesuiter cheeeee Josh Phillips - YuriNachos tyler6204 superman32432432 Yurii Chukhlib antons austinm911 blacksmith-sh[bot] dan-dr grp06 HeimdallStrategy - imfing jalehman jarvis-medmatic kkarimi mahmoudashraf93 petter-b pkrmf RandyVentures erikpr1994 jonasjancarik - Keith the Silly Goose L36 Server Marc mitschabaude-bot neist ngutman chrisrodz Friederike Seiler gabriel-trigo iamadig - Kit koala73 manmal ogulcancelik pasogott petradonka rubyrunsstuff VACInc wes-davis zats - Chris Taylor Django Navarro evalexpr henrino3 mkbehr oswalpalash pcty-nextgen-service-account sibbl Syhids Aaron Konyer - adam91holt erik-agens fcatuhe ivanrvpereira jayhickey jeffersonwarrior jeffersonwarrior Jonathan D. Rhyne (DJ-D) jverdi mickahouan - mjrussell p6l-richard philipp-spiess robaxelsen Sash Catanzarite VAC zknicker alejandro maza andrewting19 anpoirier - Asleep123 bolismauro cash-echo-bot Clawd conhecendocontato Dimitrios Ploutarchos Drake Thomsen Ghost gtsifrikas HazAT - hrdwdmrbl hugobarauna Jamie Openshaw Jarvis Jefferson Nunn Kevin Lin kitze levifig Lloyd longmaba - loukotal martinpucik Miles mrdbstn MSch Mustafa Tag Eldeen ndraiman nexty5870 prathamdby reeltimeapps - RLTCmpe rodrigouroz Rolf Fredheim Rony Kelner Samrat Jha siraht snopoke suminhthanh The Admiral thesash - Ubuntu voidserf wstock Zach Knickerbocker Alphonse-arianee Azade carlulsoe ddyo Erik latitudeki5223 - Manuel Maly Mourad Boustani pcty-nextgen-ios-builder Quentin Randy Torres rhjoh ronak-guliani William Stock + lc0rp scald gumadeiras andranik-sahakyan davidguttman sleontenko sircrumpet peschee rafaelreis-r thewilloftheshadow + ratulsarna lutr0 danielz1z bradleypriest emanuelst KristijanJovanovski CashWilliams rdev osolmaz joshrad-dev + kiranjd adityashaw2 sheeek artuskg onutc manuelhettich minghinmatthewlam myfunc buddyh connorshea + mcinteerj timkrase zerone0x gerardward2007 obviyus tosh-hamburg azade-c roshanasingh4 bjesuiter cheeeee + Josh Phillips YuriNachos chriseidhof tyler6204 superman32432432 vignesh07 Yurii Chukhlib antons austinm911 blacksmith-sh[bot] + dan-dr grp06 HeimdallStrategy imfing jalehman jarvis-medmatic kkarimi mahmoudashraf93 petter-b pkrmf + RandyVentures Ryan Lisse erikpr1994 Ghost jonasjancarik Keith the Silly Goose L36 Server Marc mitschabaude-bot neist + ngutman chrisrodz Friederike Seiler gabriel-trigo iamadig Kit koala73 manmal ogulcancelik pasogott + petradonka rubyrunsstuff VACInc wes-davis zats Chris Taylor Django Navarro evalexpr henrino3 larlyssa + mkbehr oswalpalash pcty-nextgen-service-account sibbl Syhids Aaron Konyer aaronveklabs adam91holt dougvk erik-agens + fcatuhe ivanrvpereira jayhickey jeffersonwarrior jeffersonwarrior Jonathan D. Rhyne (DJ-D) jverdi longmaba mickahouan mjrussell + p6l-richard philipp-spiess robaxelsen Sash Catanzarite T5-AndyML VAC zknicker alejandro maza andrewting19 anpoirier + Asleep123 bolismauro cash-echo-bot Clawd conhecendocontato Dimitrios Ploutarchos Drake Thomsen Felix Krause gtsifrikas HazAT + hrdwdmrbl hugobarauna Jamie Openshaw Jarvis Jefferson Nunn Kevin Lin kitze levifig Lloyd loukotal + martinpucik Miles mrdbstn MSch Mustafa Tag Eldeen ndraiman nexty5870 prathamdby reeltimeapps RLTCmpe + rodrigouroz Rolf Fredheim Rony Kelner Samrat Jha siraht snopoke suminhthanh The Admiral thesash Ubuntu + voidserf wstock Zach Knickerbocker Alphonse-arianee Azade carlulsoe ddyo Erik latitudeki5223 Manuel Maly + Mourad Boustani odrobnik pcty-nextgen-ios-builder Quentin Randy Torres rhjoh ronak-guliani William Stock

diff --git a/scripts/clawtributors-map.json b/scripts/clawtributors-map.json index 1cd820b30..d6ce6e27e 100644 --- a/scripts/clawtributors-map.json +++ b/scripts/clawtributors-map.json @@ -1,5 +1,6 @@ { "ensureLogins": [ + "odrobnik", "alphonse-arianee", "ronak-guliani", "cpojer", diff --git a/src/agents/auth-profiles.ensureauthprofilestore.test.ts b/src/agents/auth-profiles.ensureauthprofilestore.test.ts index db7d6f031..3eadb6c5b 100644 --- a/src/agents/auth-profiles.ensureauthprofilestore.test.ts +++ b/src/agents/auth-profiles.ensureauthprofilestore.test.ts @@ -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 }); + } + }); + }); }); diff --git a/src/agents/auth-profiles.external-cli-credential-sync.skips-codex-sync-when-credentials-exist-in-another-profile.test.ts b/src/agents/auth-profiles.external-cli-credential-sync.skips-codex-sync-when-credentials-exist-in-another-profile.test.ts new file mode 100644 index 000000000..6fa6734d7 --- /dev/null +++ b/src/agents/auth-profiles.external-cli-credential-sync.skips-codex-sync-when-credentials-exist-in-another-profile.test.ts @@ -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; + }; + 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 }); + } + }); +}); diff --git a/src/agents/auth-profiles/external-cli-sync.ts b/src/agents/auth-profiles/external-cli-sync.ts index 75cf0a328..8668f1d43 100644 --- a/src/agents/auth-profiles/external-cli-sync.ts +++ b/src/agents/auth-profiles/external-cli-sync.ts @@ -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(), + }); + } } } diff --git a/src/agents/auth-profiles/store.ts b/src/agents/auth-profiles/store.ts index 3ac26740a..010f0e9b7 100644 --- a/src/agents/auth-profiles/store.ts +++ b/src/agents/auth-profiles/store.ts @@ -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 {