import fs from "node:fs"; import { resolveOAuthPath } from "../../config/paths.js"; import { coerceSecretRef } from "../../config/types.secrets.js"; import { withFileLock } from "../../infra/file-lock.js"; import { loadJsonFile, saveJsonFile } from "../../infra/json-file.js"; import { AUTH_STORE_LOCK_OPTIONS, AUTH_STORE_VERSION, EXTERNAL_CLI_SYNC_TTL_MS, log, } from "./constants.js"; import { syncExternalCliCredentials } from "./external-cli-sync.js"; import { ensureAuthStoreFile, resolveAuthStorePath, resolveLegacyAuthStorePath } from "./paths.js"; import type { AuthProfileCredential, AuthProfileStore, OAuthCredentials, ProfileUsageStats, } from "./types.js"; type LegacyAuthStore = Record; type CredentialRejectReason = "non_object" | "invalid_type" | "missing_provider"; type RejectedCredentialEntry = { key: string; reason: CredentialRejectReason }; type LoadAuthProfileStoreOptions = { allowKeychainPrompt?: boolean; readOnly?: boolean; }; const AUTH_PROFILE_TYPES = new Set(["api_key", "oauth", "token"]); const runtimeAuthStoreSnapshots = new Map(); const loadedAuthStoreCache = new Map< string, { mtimeMs: number | null; syncedAtMs: number; store: AuthProfileStore } >(); function resolveRuntimeStoreKey(agentDir?: string): string { return resolveAuthStorePath(agentDir); } function cloneAuthProfileStore(store: AuthProfileStore): AuthProfileStore { return structuredClone(store); } function resolveRuntimeAuthProfileStore(agentDir?: string): AuthProfileStore | null { if (runtimeAuthStoreSnapshots.size === 0) { return null; } const mainKey = resolveRuntimeStoreKey(undefined); const requestedKey = resolveRuntimeStoreKey(agentDir); const mainStore = runtimeAuthStoreSnapshots.get(mainKey); const requestedStore = runtimeAuthStoreSnapshots.get(requestedKey); if (!agentDir || requestedKey === mainKey) { if (!mainStore) { return null; } return cloneAuthProfileStore(mainStore); } if (mainStore && requestedStore) { return mergeAuthProfileStores( cloneAuthProfileStore(mainStore), cloneAuthProfileStore(requestedStore), ); } if (requestedStore) { return cloneAuthProfileStore(requestedStore); } if (mainStore) { return cloneAuthProfileStore(mainStore); } return null; } export function replaceRuntimeAuthProfileStoreSnapshots( entries: Array<{ agentDir?: string; store: AuthProfileStore }>, ): void { runtimeAuthStoreSnapshots.clear(); for (const entry of entries) { runtimeAuthStoreSnapshots.set( resolveRuntimeStoreKey(entry.agentDir), cloneAuthProfileStore(entry.store), ); } } export function clearRuntimeAuthProfileStoreSnapshots(): void { runtimeAuthStoreSnapshots.clear(); loadedAuthStoreCache.clear(); } function readAuthStoreMtimeMs(authPath: string): number | null { try { return fs.statSync(authPath).mtimeMs; } catch { return null; } } function readCachedAuthProfileStore( authPath: string, mtimeMs: number | null, ): AuthProfileStore | null { const cached = loadedAuthStoreCache.get(authPath); if (!cached || cached.mtimeMs !== mtimeMs) { return null; } if (Date.now() - cached.syncedAtMs >= EXTERNAL_CLI_SYNC_TTL_MS) { return null; } return cloneAuthProfileStore(cached.store); } function writeCachedAuthProfileStore( authPath: string, mtimeMs: number | null, store: AuthProfileStore, ): void { loadedAuthStoreCache.set(authPath, { mtimeMs, syncedAtMs: Date.now(), store: cloneAuthProfileStore(store), }); } function normalizeSecretBackedField(params: { entry: Record; valueField: "key" | "token"; refField: "keyRef" | "tokenRef"; }): void { const value = params.entry[params.valueField]; if (value == null || typeof value === "string") { return; } const ref = coerceSecretRef(value); if (ref && !coerceSecretRef(params.entry[params.refField])) { params.entry[params.refField] = ref; } delete params.entry[params.valueField]; } export async function updateAuthProfileStoreWithLock(params: { agentDir?: string; updater: (store: AuthProfileStore) => boolean; }): Promise { const authPath = resolveAuthStorePath(params.agentDir); ensureAuthStoreFile(authPath); try { return await withFileLock(authPath, AUTH_STORE_LOCK_OPTIONS, async () => { // Locked writers must reload from disk, not from any runtime snapshot. // Otherwise a live gateway can overwrite fresher CLI/config-auth writes // with stale in-memory auth state during usage/cooldown updates. const store = loadAuthProfileStoreForAgent(params.agentDir); const shouldSave = params.updater(store); if (shouldSave) { saveAuthProfileStore(store, params.agentDir); } return store; }); } catch { return null; } } /** * Normalise a raw auth-profiles.json credential entry. * * The official format uses `type` and (for api_key credentials) `key`. * A common mistake — caused by the similarity with the `openclaw.json` * `auth.profiles` section which uses `mode` — is to write `mode` instead of * `type` and `apiKey` instead of `key`. Accept both spellings so users don't * silently lose their credentials. */ function normalizeRawCredentialEntry(raw: Record): Partial { const entry = { ...raw } as Record; // mode → type alias (openclaw.json uses "mode"; auth-profiles.json uses "type") if (!("type" in entry) && typeof entry["mode"] === "string") { entry["type"] = entry["mode"]; } // apiKey → key alias for ApiKeyCredential if (!("key" in entry) && typeof entry["apiKey"] === "string") { entry["key"] = entry["apiKey"]; } // Ensure `key` is a string. Users sometimes write a SecretRef object into // the `key` field instead of `keyRef`. When that happens every downstream // consumer that calls `cred.key?.trim()` throws a TypeError because the // value is truthy (an object) but has no `.trim()` method. Migrate the // misplaced ref to `keyRef` so the secret-resolution pipeline can pick it // up, and clear the invalid `key` so callers never see a non-string value. normalizeSecretBackedField({ entry, valueField: "key", refField: "keyRef" }); // Same treatment for `token` on TokenCredential entries. normalizeSecretBackedField({ entry, valueField: "token", refField: "tokenRef" }); return entry as Partial; } function parseCredentialEntry( raw: unknown, fallbackProvider?: string, ): { ok: true; credential: AuthProfileCredential } | { ok: false; reason: CredentialRejectReason } { if (!raw || typeof raw !== "object") { return { ok: false, reason: "non_object" }; } const typed = normalizeRawCredentialEntry(raw as Record); if (!AUTH_PROFILE_TYPES.has(typed.type as AuthProfileCredential["type"])) { return { ok: false, reason: "invalid_type" }; } const provider = typed.provider ?? fallbackProvider; if (typeof provider !== "string" || provider.trim().length === 0) { return { ok: false, reason: "missing_provider" }; } return { ok: true, credential: { ...typed, provider, } as AuthProfileCredential, }; } function warnRejectedCredentialEntries(source: string, rejected: RejectedCredentialEntry[]): void { if (rejected.length === 0) { return; } const reasons = rejected.reduce( (acc, current) => { acc[current.reason] = (acc[current.reason] ?? 0) + 1; return acc; }, {} as Partial>, ); log.warn("ignored invalid auth profile entries during store load", { source, dropped: rejected.length, reasons, keys: rejected.slice(0, 10).map((entry) => entry.key), }); } function coerceLegacyStore(raw: unknown): LegacyAuthStore | null { if (!raw || typeof raw !== "object") { return null; } const record = raw as Record; if ("profiles" in record) { return null; } const entries: LegacyAuthStore = {}; const rejected: RejectedCredentialEntry[] = []; for (const [key, value] of Object.entries(record)) { const parsed = parseCredentialEntry(value, key); if (!parsed.ok) { rejected.push({ key, reason: parsed.reason }); continue; } entries[key] = parsed.credential; } warnRejectedCredentialEntries("auth.json", rejected); 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; if (!record.profiles || typeof record.profiles !== "object") { return null; } const profiles = record.profiles as Record; const normalized: Record = {}; const rejected: RejectedCredentialEntry[] = []; for (const [key, value] of Object.entries(profiles)) { const parsed = parseCredentialEntry(value); if (!parsed.ok) { rejected.push({ key, reason: parsed.reason }); continue; } normalized[key] = parsed.credential; } warnRejectedCredentialEntries("auth-profiles.json", rejected); const order = record.order && typeof record.order === "object" ? Object.entries(record.order as Record).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, ) : undefined; return { version: Number(record.version ?? AUTH_STORE_VERSION), profiles: normalized, order, lastGood: record.lastGood && typeof record.lastGood === "object" ? (record.lastGood as Record) : undefined, usageStats: record.usageStats && typeof record.usageStats === "object" ? (record.usageStats as Record) : undefined, }; } function mergeRecord( base?: Record, override?: Record, ): Record | 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; 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; } function applyLegacyStore(store: AuthProfileStore, legacy: LegacyAuthStore): void { 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 } : {}), }; continue; } 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 } : {}), }; continue; } 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 } : {}), }; } } function loadCoercedStore(authPath: string): AuthProfileStore | null { const raw = loadJsonFile(authPath); return coerceAuthStore(raw); } function shouldLogAuthStoreTiming(): boolean { return process.env.OPENCLAW_DEBUG_INGRESS_TIMING === "1"; } function syncExternalCliCredentialsTimed( store: AuthProfileStore, options?: Parameters[1], ): boolean { if (!shouldLogAuthStoreTiming()) { return syncExternalCliCredentials(store, options); } const startMs = Date.now(); const mutated = syncExternalCliCredentials(store, options); log.info( `auth-store stage=external-cli-sync elapsedMs=${Date.now() - startMs} mutated=${mutated}`, ); return mutated; } export function loadAuthProfileStore(): AuthProfileStore { const authPath = resolveAuthStorePath(); const asStore = loadCoercedStore(authPath); if (asStore) { // Sync from external CLI tools on every load. const synced = syncExternalCliCredentialsTimed(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: {}, }; applyLegacyStore(store, legacy); syncExternalCliCredentialsTimed(store); return store; } const store: AuthProfileStore = { version: AUTH_STORE_VERSION, profiles: {} }; syncExternalCliCredentialsTimed(store); return store; } function loadAuthProfileStoreForAgent( agentDir?: string, options?: LoadAuthProfileStoreOptions, ): AuthProfileStore { const readOnly = options?.readOnly === true; const authPath = resolveAuthStorePath(agentDir); if (!readOnly) { const cached = readCachedAuthProfileStore(authPath, readAuthStoreMtimeMs(authPath)); if (cached) { return cached; } } const asStore = loadCoercedStore(authPath); if (asStore) { // Runtime secret activation must remain read-only: // sync external CLI credentials in-memory, but never persist while readOnly. const synced = syncExternalCliCredentialsTimed(asStore, { log: !readOnly }); if (synced && !readOnly) { saveJsonFile(authPath, asStore); } if (!readOnly) { writeCachedAuthProfileStore(authPath, readAuthStoreMtimeMs(authPath), asStore); } return asStore; } // Fallback: inherit auth-profiles from main agent if subagent has none if (agentDir && !readOnly) { 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 }); writeCachedAuthProfileStore(authPath, readAuthStoreMtimeMs(authPath), mainStore); return mainStore; } } const legacyRaw = loadJsonFile(resolveLegacyAuthStorePath(agentDir)); const legacy = coerceLegacyStore(legacyRaw); const store: AuthProfileStore = { version: AUTH_STORE_VERSION, profiles: {}, }; if (legacy) { applyLegacyStore(store, legacy); } const mergedOAuth = mergeOAuthFileIntoStore(store); // Keep external CLI credentials visible in runtime even during read-only loads. const syncedCli = syncExternalCliCredentialsTimed(store, { log: !readOnly }); const forceReadOnly = process.env.OPENCLAW_AUTH_STORE_READONLY === "1"; const shouldWrite = !readOnly && !forceReadOnly && (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, }); } } } if (!readOnly) { writeCachedAuthProfileStore(authPath, readAuthStoreMtimeMs(authPath), store); } return store; } export function loadAuthProfileStoreForRuntime( agentDir?: string, options?: LoadAuthProfileStoreOptions, ): AuthProfileStore { const store = loadAuthProfileStoreForAgent(agentDir, options); const authPath = resolveAuthStorePath(agentDir); const mainAuthPath = resolveAuthStorePath(); if (!agentDir || authPath === mainAuthPath) { return store; } const mainStore = loadAuthProfileStoreForAgent(undefined, options); return mergeAuthProfileStores(mainStore, store); } export function loadAuthProfileStoreForSecretsRuntime(agentDir?: string): AuthProfileStore { return loadAuthProfileStoreForRuntime(agentDir, { readOnly: true, allowKeychainPrompt: false }); } export function ensureAuthProfileStore( agentDir?: string, options?: { allowKeychainPrompt?: boolean }, ): AuthProfileStore { const runtimeStore = resolveRuntimeAuthProfileStore(agentDir); if (runtimeStore) { return runtimeStore; } 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); return merged; } export function saveAuthProfileStore(store: AuthProfileStore, agentDir?: string): void { const authPath = resolveAuthStorePath(agentDir); const runtimeKey = resolveRuntimeStoreKey(agentDir); const profiles = Object.fromEntries( Object.entries(store.profiles).map(([profileId, credential]) => { if (credential.type === "api_key" && credential.keyRef && credential.key !== undefined) { const sanitized = { ...credential } as Record; delete sanitized.key; return [profileId, sanitized]; } if (credential.type === "token" && credential.tokenRef && credential.token !== undefined) { const sanitized = { ...credential } as Record; delete sanitized.token; return [profileId, sanitized]; } return [profileId, credential]; }), ) as AuthProfileStore["profiles"]; const payload = { version: AUTH_STORE_VERSION, profiles, order: store.order ?? undefined, lastGood: store.lastGood ?? undefined, usageStats: store.usageStats ?? undefined, } satisfies AuthProfileStore; saveJsonFile(authPath, payload); writeCachedAuthProfileStore(authPath, readAuthStoreMtimeMs(authPath), payload); if (runtimeAuthStoreSnapshots.has(runtimeKey)) { runtimeAuthStoreSnapshots.set(runtimeKey, cloneAuthProfileStore(payload)); } }