Files
openclaw/src/agents/auth-profiles/store.ts
2026-04-01 13:37:37 +01:00

624 lines
20 KiB
TypeScript

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<string, AuthProfileCredential>;
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<AuthProfileCredential["type"]>(["api_key", "oauth", "token"]);
const runtimeAuthStoreSnapshots = new Map<string, AuthProfileStore>();
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<string, unknown>;
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<AuthProfileStore | null> {
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<string, unknown>): Partial<AuthProfileCredential> {
const entry = { ...raw } as Record<string, unknown>;
// 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<AuthProfileCredential>;
}
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<string, unknown>);
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<Record<CredentialRejectReason, number>>,
);
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<string, unknown>;
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<string, unknown>;
if (!record.profiles || typeof record.profiles !== "object") {
return null;
}
const profiles = record.profiles as Record<string, unknown>;
const normalized: Record<string, AuthProfileCredential> = {};
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<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;
}
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<typeof syncExternalCliCredentials>[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<string, unknown>;
delete sanitized.key;
return [profileId, sanitized];
}
if (credential.type === "token" && credential.tokenRef && credential.token !== undefined) {
const sanitized = { ...credential } as Record<string, unknown>;
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));
}
}