Files
clawdbot/src/agents/auth-profiles/store.ts
Peter Steinberger 9c2c4b1138 fix(auth): dedupe codex-cli profiles
Co-authored-by: Oliver Drobnik <oliver@cocoanetics.com>
2026-01-20 09:38:56 +00:00

363 lines
12 KiB
TypeScript

import fs from "node:fs";
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,
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";
type LegacyAuthStore = Record<string, AuthProfileCredential>;
function _syncAuthProfileStore(target: AuthProfileStore, source: AuthProfileStore): void {
target.version = source.version;
target.profiles = source.profiles;
target.order = source.order;
target.lastGood = source.lastGood;
target.usageStats = source.usageStats;
}
export async function updateAuthProfileStoreWithLock(params: {
agentDir?: string;
updater: (store: AuthProfileStore) => boolean;
}): Promise<AuthProfileStore | null> {
const authPath = resolveAuthStorePath(params.agentDir);
ensureAuthStoreFile(authPath);
let release: (() => Promise<void>) | undefined;
try {
release = await lockfile.lock(authPath, AUTH_STORE_LOCK_OPTIONS);
const store = ensureAuthProfileStore(params.agentDir);
const shouldSave = params.updater(store);
if (shouldSave) {
saveAuthProfileStore(store, params.agentDir);
}
return store;
} catch {
return null;
} finally {
if (release) {
try {
await release();
} catch {
// ignore unlock errors
}
}
}
}
function coerceLegacyStore(raw: unknown): LegacyAuthStore | null {
if (!raw || typeof raw !== "object") return null;
const record = raw as Record<string, unknown>;
if ("profiles" in record) return null;
const entries: LegacyAuthStore = {};
for (const [key, value] of Object.entries(record)) {
if (!value || typeof value !== "object") continue;
const typed = value as Partial<AuthProfileCredential>;
if (typed.type !== "api_key" && typed.type !== "oauth" && typed.type !== "token") {
continue;
}
entries[key] = {
...typed,
provider: String(typed.provider ?? key),
} as AuthProfileCredential;
}
return Object.keys(entries).length > 0 ? entries : null;
}
function coerceAuthStore(raw: unknown): AuthProfileStore | null {
if (!raw || typeof raw !== "object") return null;
const record = raw as Record<string, unknown>;
if (!record.profiles || typeof record.profiles !== "object") return null;
const profiles = record.profiles as Record<string, unknown>;
const normalized: Record<string, AuthProfileCredential> = {};
for (const [key, value] of Object.entries(profiles)) {
if (!value || typeof value !== "object") continue;
const typed = value as Partial<AuthProfileCredential>;
if (typed.type !== "api_key" && typed.type !== "oauth" && typed.type !== "token") {
continue;
}
if (!typed.provider) continue;
normalized[key] = typed as AuthProfileCredential;
}
const order =
record.order && typeof record.order === "object"
? Object.entries(record.order as Record<string, unknown>).reduce(
(acc, [provider, value]) => {
if (!Array.isArray(value)) return acc;
const list = value
.map((entry) => (typeof entry === "string" ? entry.trim() : ""))
.filter(Boolean);
if (list.length === 0) return acc;
acc[provider] = list;
return acc;
},
{} as Record<string, string[]>,
)
: undefined;
return {
version: Number(record.version ?? AUTH_STORE_VERSION),
profiles: normalized,
order,
lastGood:
record.lastGood && typeof record.lastGood === "object"
? (record.lastGood as Record<string, string>)
: undefined,
usageStats:
record.usageStats && typeof record.usageStats === "object"
? (record.usageStats as Record<string, ProfileUsageStats>)
: undefined,
};
}
function mergeRecord<T>(
base?: Record<string, T>,
override?: Record<string, T>,
): Record<string, T> | undefined {
if (!base && !override) return undefined;
if (!base) return { ...override };
if (!override) return { ...base };
return { ...base, ...override };
}
function mergeAuthProfileStores(
base: AuthProfileStore,
override: AuthProfileStore,
): AuthProfileStore {
if (
Object.keys(override.profiles).length === 0 &&
!override.order &&
!override.lastGood &&
!override.usageStats
) {
return base;
}
return {
version: Math.max(base.version, override.version ?? base.version),
profiles: { ...base.profiles, ...override.profiles },
order: mergeRecord(base.order, override.order),
lastGood: mergeRecord(base.lastGood, override.lastGood),
usageStats: mergeRecord(base.usageStats, override.usageStats),
};
}
function mergeOAuthFileIntoStore(store: AuthProfileStore): boolean {
const oauthPath = resolveOAuthPath();
const oauthRaw = loadJsonFile(oauthPath);
if (!oauthRaw || typeof oauthRaw !== "object") return false;
const oauthEntries = oauthRaw as Record<string, OAuthCredentials>;
let mutated = false;
for (const [provider, creds] of Object.entries(oauthEntries)) {
if (!creds || typeof creds !== "object") continue;
const profileId = `${provider}:default`;
if (store.profiles[profileId]) continue;
store.profiles[profileId] = {
type: "oauth",
provider,
...creds,
};
mutated = true;
}
return mutated;
}
export function loadAuthProfileStore(): AuthProfileStore {
const authPath = resolveAuthStorePath();
const raw = loadJsonFile(authPath);
const asStore = coerceAuthStore(raw);
if (asStore) {
// Sync from external CLI tools on every load
const synced = syncExternalCliCredentials(asStore);
if (synced) {
saveJsonFile(authPath, asStore);
}
return asStore;
}
const legacyRaw = loadJsonFile(resolveLegacyAuthStorePath());
const legacy = coerceLegacyStore(legacyRaw);
if (legacy) {
const store: AuthProfileStore = {
version: AUTH_STORE_VERSION,
profiles: {},
};
for (const [provider, cred] of Object.entries(legacy)) {
const profileId = `${provider}:default`;
if (cred.type === "api_key") {
store.profiles[profileId] = {
type: "api_key",
provider: String(cred.provider ?? provider),
key: cred.key,
...(cred.email ? { email: cred.email } : {}),
};
} else if (cred.type === "token") {
store.profiles[profileId] = {
type: "token",
provider: String(cred.provider ?? provider),
token: cred.token,
...(typeof cred.expires === "number" ? { expires: cred.expires } : {}),
...(cred.email ? { email: cred.email } : {}),
};
} else {
store.profiles[profileId] = {
type: "oauth",
provider: String(cred.provider ?? provider),
access: cred.access,
refresh: cred.refresh,
expires: cred.expires,
...(cred.enterpriseUrl ? { enterpriseUrl: cred.enterpriseUrl } : {}),
...(cred.projectId ? { projectId: cred.projectId } : {}),
...(cred.accountId ? { accountId: cred.accountId } : {}),
...(cred.email ? { email: cred.email } : {}),
};
}
}
syncExternalCliCredentials(store);
return store;
}
const store: AuthProfileStore = { version: AUTH_STORE_VERSION, profiles: {} };
syncExternalCliCredentials(store);
return store;
}
function loadAuthProfileStoreForAgent(
agentDir?: string,
options?: { allowKeychainPrompt?: boolean },
): AuthProfileStore {
const authPath = resolveAuthStorePath(agentDir);
const raw = loadJsonFile(authPath);
const asStore = coerceAuthStore(raw);
if (asStore) {
// Sync from external CLI tools on every load
const synced = syncExternalCliCredentials(asStore, options);
if (synced) {
saveJsonFile(authPath, asStore);
}
return asStore;
}
// Fallback: inherit auth-profiles from main agent if subagent has none
if (agentDir) {
const mainAuthPath = resolveAuthStorePath(); // without agentDir = main
const mainRaw = loadJsonFile(mainAuthPath);
const mainStore = coerceAuthStore(mainRaw);
if (mainStore && Object.keys(mainStore.profiles).length > 0) {
// Clone main store to subagent directory for auth inheritance
saveJsonFile(authPath, mainStore);
log.info("inherited auth-profiles from main agent", { agentDir });
return mainStore;
}
}
const legacyRaw = loadJsonFile(resolveLegacyAuthStorePath(agentDir));
const legacy = coerceLegacyStore(legacyRaw);
const store: AuthProfileStore = {
version: AUTH_STORE_VERSION,
profiles: {},
};
if (legacy) {
for (const [provider, cred] of Object.entries(legacy)) {
const profileId = `${provider}:default`;
if (cred.type === "api_key") {
store.profiles[profileId] = {
type: "api_key",
provider: String(cred.provider ?? provider),
key: cred.key,
...(cred.email ? { email: cred.email } : {}),
};
} else if (cred.type === "token") {
store.profiles[profileId] = {
type: "token",
provider: String(cred.provider ?? provider),
token: cred.token,
...(typeof cred.expires === "number" ? { expires: cred.expires } : {}),
...(cred.email ? { email: cred.email } : {}),
};
} else {
store.profiles[profileId] = {
type: "oauth",
provider: String(cred.provider ?? provider),
access: cred.access,
refresh: cred.refresh,
expires: cred.expires,
...(cred.enterpriseUrl ? { enterpriseUrl: cred.enterpriseUrl } : {}),
...(cred.projectId ? { projectId: cred.projectId } : {}),
...(cred.accountId ? { accountId: cred.accountId } : {}),
...(cred.email ? { email: cred.email } : {}),
};
}
}
}
const mergedOAuth = mergeOAuthFileIntoStore(store);
const syncedCli = syncExternalCliCredentials(store, options);
const shouldWrite = legacy !== null || mergedOAuth || syncedCli;
if (shouldWrite) {
saveJsonFile(authPath, store);
}
// PR #368: legacy auth.json could get re-migrated from other agent dirs,
// overwriting fresh OAuth creds with stale tokens (fixes #363). Delete only
// after we've successfully written auth-profiles.json.
if (shouldWrite && legacy !== null) {
const legacyPath = resolveLegacyAuthStorePath(agentDir);
try {
fs.unlinkSync(legacyPath);
} catch (err) {
if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") {
log.warn("failed to delete legacy auth.json after migration", {
err,
legacyPath,
});
}
}
}
return store;
}
export function ensureAuthProfileStore(
agentDir?: string,
options?: { allowKeychainPrompt?: boolean },
): AuthProfileStore {
const store = loadAuthProfileStoreForAgent(agentDir, options);
const authPath = resolveAuthStorePath(agentDir);
const mainAuthPath = resolveAuthStorePath();
if (!agentDir || authPath === mainAuthPath) {
return store;
}
const mainStore = loadAuthProfileStoreForAgent(undefined, options);
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 {
const authPath = resolveAuthStorePath(agentDir);
const payload = {
version: AUTH_STORE_VERSION,
profiles: store.profiles,
order: store.order ?? undefined,
lastGood: store.lastGood ?? undefined,
usageStats: store.usageStats ?? undefined,
} satisfies AuthProfileStore;
saveJsonFile(authPath, payload);
}