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:
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 {