Files
openclaw/src/agents/auth-profiles/store.ts
2026-04-29 15:11:40 +01:00

561 lines
17 KiB
TypeScript

import fs from "node:fs";
import { isDeepStrictEqual } from "node:util";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { withFileLock } from "../../infra/file-lock.js";
import { saveJsonFile } from "../../infra/json-file.js";
import {
AUTH_STORE_LOCK_OPTIONS,
AUTH_STORE_VERSION,
EXTERNAL_CLI_SYNC_TTL_MS,
log,
} from "./constants.js";
import { overlayExternalAuthProfiles, shouldPersistExternalAuthProfile } from "./external-auth.js";
import { isSafeToAdoptMainStoreOAuthIdentity } from "./oauth-shared.js";
import {
ensureAuthStoreFile,
resolveAuthStatePath,
resolveAuthStorePath,
resolveLegacyAuthStorePath,
} from "./paths.js";
import {
applyLegacyAuthStore,
buildPersistedAuthProfileSecretsStore,
loadLegacyAuthProfileStore,
loadPersistedAuthProfileStore,
mergeAuthProfileStores,
mergeOAuthFileIntoStore,
} from "./persisted.js";
import {
clearRuntimeAuthProfileStoreSnapshots as clearRuntimeAuthProfileStoreSnapshotsImpl,
getRuntimeAuthProfileStoreSnapshot,
hasRuntimeAuthProfileStoreSnapshot,
replaceRuntimeAuthProfileStoreSnapshots as replaceRuntimeAuthProfileStoreSnapshotsImpl,
setRuntimeAuthProfileStoreSnapshot,
} from "./runtime-snapshots.js";
import { savePersistedAuthProfileState } from "./state.js";
import type { AuthProfileStore } from "./types.js";
type LoadAuthProfileStoreOptions = {
allowKeychainPrompt?: boolean;
config?: OpenClawConfig;
readOnly?: boolean;
syncExternalCli?: boolean;
externalCliProviderIds?: Iterable<string>;
externalCliProfileIds?: Iterable<string>;
};
type SaveAuthProfileStoreOptions = {
filterExternalAuthProfiles?: boolean;
syncExternalCli?: boolean;
};
const loadedAuthStoreCache = new Map<
string,
{
authMtimeMs: number | null;
stateMtimeMs: number | null;
syncedAtMs: number;
store: AuthProfileStore;
}
>();
function cloneAuthProfileStore(store: AuthProfileStore): AuthProfileStore {
return structuredClone(store);
}
function isInheritedMainOAuthCredential(params: {
agentDir?: string;
profileId: string;
credential: AuthProfileStore["profiles"][string];
}): boolean {
if (!params.agentDir || params.credential.type !== "oauth") {
return false;
}
const authPath = resolveAuthStorePath(params.agentDir);
const mainAuthPath = resolveAuthStorePath();
if (authPath === mainAuthPath) {
return false;
}
const localStore = loadPersistedAuthProfileStore(params.agentDir);
if (localStore?.profiles[params.profileId]) {
return false;
}
const mainCredential = loadPersistedAuthProfileStore()?.profiles[params.profileId];
return (
mainCredential?.type === "oauth" &&
(isDeepStrictEqual(mainCredential, params.credential) ||
shouldUseMainOwnerForLocalOAuthCredential({
local: params.credential,
main: mainCredential,
}))
);
}
function shouldUseMainOwnerForLocalOAuthCredential(params: {
local: AuthProfileStore["profiles"][string];
main: AuthProfileStore["profiles"][string] | undefined;
}): boolean {
if (params.local.type !== "oauth" || params.main?.type !== "oauth") {
return false;
}
if (!isSafeToAdoptMainStoreOAuthIdentity(params.local, params.main)) {
return false;
}
if (isDeepStrictEqual(params.local, params.main)) {
return true;
}
return (
Number.isFinite(params.main.expires) &&
(!Number.isFinite(params.local.expires) || params.main.expires >= params.local.expires)
);
}
function resolveRuntimeAuthProfileStore(agentDir?: string): AuthProfileStore | null {
const mainKey = resolveAuthStorePath(undefined);
const requestedKey = resolveAuthStorePath(agentDir);
const mainStore = getRuntimeAuthProfileStoreSnapshot(undefined);
const requestedStore = getRuntimeAuthProfileStoreSnapshot(agentDir);
if (!agentDir || requestedKey === mainKey) {
if (!mainStore) {
return null;
}
return mainStore;
}
if (mainStore && requestedStore) {
return mergeAuthProfileStores(mainStore, requestedStore);
}
if (requestedStore) {
const persistedMainStore = loadAuthProfileStoreForAgent(undefined, {
readOnly: true,
syncExternalCli: false,
});
return mergeAuthProfileStores(persistedMainStore, requestedStore);
}
if (mainStore) {
return mainStore;
}
return null;
}
function readAuthStoreMtimeMs(authPath: string): number | null {
try {
return fs.statSync(authPath).mtimeMs;
} catch {
return null;
}
}
function readCachedAuthProfileStore(params: {
authPath: string;
authMtimeMs: number | null;
stateMtimeMs: number | null;
}): AuthProfileStore | null {
const cached = loadedAuthStoreCache.get(params.authPath);
if (
!cached ||
cached.authMtimeMs !== params.authMtimeMs ||
cached.stateMtimeMs !== params.stateMtimeMs
) {
return null;
}
if (Date.now() - cached.syncedAtMs >= EXTERNAL_CLI_SYNC_TTL_MS) {
return null;
}
return cloneAuthProfileStore(cached.store);
}
function writeCachedAuthProfileStore(params: {
authPath: string;
authMtimeMs: number | null;
stateMtimeMs: number | null;
store: AuthProfileStore;
}): void {
loadedAuthStoreCache.set(params.authPath, {
authMtimeMs: params.authMtimeMs,
stateMtimeMs: params.stateMtimeMs,
syncedAtMs: Date.now(),
store: cloneAuthProfileStore(params.store),
});
}
function shouldKeepProfileInLocalStore(params: {
store: AuthProfileStore;
profileId: string;
credential: AuthProfileStore["profiles"][string];
agentDir?: string;
options?: SaveAuthProfileStoreOptions;
}): boolean {
if (params.credential.type !== "oauth") {
return true;
}
if (
isInheritedMainOAuthCredential({
agentDir: params.agentDir,
profileId: params.profileId,
credential: params.credential,
})
) {
return false;
}
if (params.options?.filterExternalAuthProfiles === false) {
return true;
}
return shouldPersistExternalAuthProfile({
store: params.store,
profileId: params.profileId,
credential: params.credential,
agentDir: params.agentDir,
});
}
function buildLocalAuthProfileStoreForSave(params: {
store: AuthProfileStore;
agentDir?: string;
options?: SaveAuthProfileStoreOptions;
}): AuthProfileStore {
const localStore = cloneAuthProfileStore(params.store);
localStore.profiles = Object.fromEntries(
Object.entries(localStore.profiles).filter(([profileId, credential]) =>
shouldKeepProfileInLocalStore({
store: params.store,
profileId,
credential,
agentDir: params.agentDir,
options: params.options,
}),
),
);
const keptProfileIds = new Set(Object.keys(localStore.profiles));
localStore.order = localStore.order
? Object.fromEntries(
Object.entries(localStore.order)
.map(([provider, profileIds]) => [
provider,
profileIds.filter((profileId) => keptProfileIds.has(profileId)),
])
.filter(([, profileIds]) => profileIds.length > 0),
)
: undefined;
localStore.lastGood = localStore.lastGood
? Object.fromEntries(
Object.entries(localStore.lastGood).filter(([, profileId]) =>
keptProfileIds.has(profileId),
),
)
: undefined;
localStore.usageStats = localStore.usageStats
? Object.fromEntries(
Object.entries(localStore.usageStats).filter(([profileId]) =>
keptProfileIds.has(profileId),
),
)
: undefined;
return localStore;
}
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;
}
}
export function loadAuthProfileStore(): AuthProfileStore {
const asStore = loadPersistedAuthProfileStore();
if (asStore) {
return overlayExternalAuthProfiles(asStore);
}
const legacy = loadLegacyAuthProfileStore();
if (legacy) {
const store: AuthProfileStore = {
version: AUTH_STORE_VERSION,
profiles: {},
};
applyLegacyAuthStore(store, legacy);
return overlayExternalAuthProfiles(store);
}
const store: AuthProfileStore = { version: AUTH_STORE_VERSION, profiles: {} };
return overlayExternalAuthProfiles(store);
}
function loadAuthProfileStoreForAgent(
agentDir?: string,
options?: LoadAuthProfileStoreOptions,
): AuthProfileStore {
const readOnly = options?.readOnly === true;
const authPath = resolveAuthStorePath(agentDir);
const statePath = resolveAuthStatePath(agentDir);
const authMtimeMs = readAuthStoreMtimeMs(authPath);
const stateMtimeMs = readAuthStoreMtimeMs(statePath);
if (!readOnly) {
const cached = readCachedAuthProfileStore({
authPath,
authMtimeMs,
stateMtimeMs,
});
if (cached) {
return cached;
}
}
const asStore = loadPersistedAuthProfileStore(agentDir);
if (asStore) {
if (!readOnly) {
writeCachedAuthProfileStore({
authPath,
authMtimeMs: readAuthStoreMtimeMs(authPath),
stateMtimeMs: readAuthStoreMtimeMs(statePath),
store: asStore,
});
}
return asStore;
}
const legacy = loadLegacyAuthProfileStore(agentDir);
const store: AuthProfileStore = {
version: AUTH_STORE_VERSION,
profiles: {},
};
if (legacy) {
applyLegacyAuthStore(store, legacy);
}
const mergedOAuth = mergeOAuthFileIntoStore(store);
const forceReadOnly = process.env.OPENCLAW_AUTH_STORE_READONLY === "1";
const shouldWrite = !readOnly && !forceReadOnly && (legacy !== null || mergedOAuth);
if (shouldWrite) {
saveAuthProfileStore(store, agentDir);
}
// 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,
authMtimeMs: readAuthStoreMtimeMs(authPath),
stateMtimeMs: readAuthStoreMtimeMs(statePath),
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 overlayExternalAuthProfiles(store, {
agentDir,
allowKeychainPrompt: options?.allowKeychainPrompt,
config: options?.config,
externalCliProviderIds: options?.externalCliProviderIds,
externalCliProfileIds: options?.externalCliProfileIds,
});
}
const mainStore = loadAuthProfileStoreForAgent(undefined, options);
return overlayExternalAuthProfiles(mergeAuthProfileStores(mainStore, store), {
agentDir,
allowKeychainPrompt: options?.allowKeychainPrompt,
config: options?.config,
externalCliProviderIds: options?.externalCliProviderIds,
externalCliProfileIds: options?.externalCliProfileIds,
});
}
export function loadAuthProfileStoreForSecretsRuntime(agentDir?: string): AuthProfileStore {
return loadAuthProfileStoreForRuntime(agentDir, { readOnly: true, allowKeychainPrompt: false });
}
export function loadAuthProfileStoreWithoutExternalProfiles(agentDir?: string): AuthProfileStore {
const options: LoadAuthProfileStoreOptions = { readOnly: true, allowKeychainPrompt: false };
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 ensureAuthProfileStore(
agentDir?: string,
options?: {
allowKeychainPrompt?: boolean;
config?: OpenClawConfig;
externalCliProviderIds?: Iterable<string>;
externalCliProfileIds?: Iterable<string>;
},
): AuthProfileStore {
return overlayExternalAuthProfiles(
ensureAuthProfileStoreWithoutExternalProfiles(agentDir, options),
{
agentDir,
allowKeychainPrompt: options?.allowKeychainPrompt,
config: options?.config,
externalCliProviderIds: options?.externalCliProviderIds,
externalCliProfileIds: options?.externalCliProfileIds,
},
);
}
export function ensureAuthProfileStoreWithoutExternalProfiles(
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);
return mergeAuthProfileStores(mainStore, store);
}
export function findPersistedAuthProfileCredential(params: {
agentDir?: string;
profileId: string;
}): AuthProfileStore["profiles"][string] | undefined {
const requestedStore = loadPersistedAuthProfileStore(params.agentDir);
const requestedProfile = requestedStore?.profiles[params.profileId];
if (requestedProfile || !params.agentDir) {
return requestedProfile;
}
const requestedPath = resolveAuthStorePath(params.agentDir);
const mainPath = resolveAuthStorePath();
if (requestedPath === mainPath) {
return requestedProfile;
}
return loadPersistedAuthProfileStore()?.profiles[params.profileId];
}
export function resolvePersistedAuthProfileOwnerAgentDir(params: {
agentDir?: string;
profileId: string;
}): string | undefined {
if (!params.agentDir) {
return undefined;
}
const requestedStore = loadPersistedAuthProfileStore(params.agentDir);
const requestedPath = resolveAuthStorePath(params.agentDir);
const mainPath = resolveAuthStorePath();
if (requestedPath === mainPath) {
return undefined;
}
const mainStore = loadPersistedAuthProfileStore();
const requestedProfile = requestedStore?.profiles[params.profileId];
if (requestedProfile) {
return shouldUseMainOwnerForLocalOAuthCredential({
local: requestedProfile,
main: mainStore?.profiles[params.profileId],
})
? undefined
: params.agentDir;
}
return mainStore?.profiles[params.profileId] ? undefined : params.agentDir;
}
export function ensureAuthProfileStoreForLocalUpdate(agentDir?: string): AuthProfileStore {
const options: LoadAuthProfileStoreOptions = { syncExternalCli: false };
const store = loadAuthProfileStoreForAgent(agentDir, options);
const authPath = resolveAuthStorePath(agentDir);
const mainAuthPath = resolveAuthStorePath();
if (!agentDir || authPath === mainAuthPath) {
return store;
}
const mainStore = loadAuthProfileStoreForAgent(undefined, {
readOnly: true,
syncExternalCli: false,
});
return mergeAuthProfileStores(mainStore, store);
}
export { hasAnyAuthProfileStoreSource } from "./source-check.js";
export function replaceRuntimeAuthProfileStoreSnapshots(
entries: Array<{ agentDir?: string; store: AuthProfileStore }>,
): void {
replaceRuntimeAuthProfileStoreSnapshotsImpl(entries);
}
export function clearRuntimeAuthProfileStoreSnapshots(): void {
clearRuntimeAuthProfileStoreSnapshotsImpl();
loadedAuthStoreCache.clear();
}
export function saveAuthProfileStore(
store: AuthProfileStore,
agentDir?: string,
options?: SaveAuthProfileStoreOptions,
): void {
const authPath = resolveAuthStorePath(agentDir);
const statePath = resolveAuthStatePath(agentDir);
const localStore = buildLocalAuthProfileStoreForSave({ store, agentDir, options });
const payload = buildPersistedAuthProfileSecretsStore(localStore);
saveJsonFile(authPath, payload);
savePersistedAuthProfileState(localStore, agentDir);
writeCachedAuthProfileStore({
authPath,
authMtimeMs: readAuthStoreMtimeMs(authPath),
stateMtimeMs: readAuthStoreMtimeMs(statePath),
store: localStore,
});
if (hasRuntimeAuthProfileStoreSnapshot(agentDir)) {
setRuntimeAuthProfileStoreSnapshot(localStore, agentDir);
}
}