mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 06:11:37 +00:00
1040 lines
38 KiB
TypeScript
1040 lines
38 KiB
TypeScript
/** Holds active secrets runtime snapshots, refresh context, and cleanup hooks. */
|
|
import { isDeepStrictEqual } from "node:util";
|
|
import {
|
|
clearRuntimeAuthProfileStoreSnapshots,
|
|
getRuntimeAuthProfileStoreSnapshot,
|
|
getRuntimeAuthProfileStoreCredentialMutationToken,
|
|
getRuntimeAuthProfileStoreCredentialsRevision,
|
|
getRuntimeAuthProfileStoreProfileSetMutationToken,
|
|
getRuntimeAuthProfileStoreStateMutationToken,
|
|
listRuntimeAuthProfileStoreSnapshots,
|
|
replaceRuntimeAuthProfileStoreSnapshots,
|
|
} from "../agents/auth-profiles/runtime-snapshots.js";
|
|
import type { RuntimeAuthProfileStoreMutationToken } from "../agents/auth-profiles/runtime-snapshots.js";
|
|
import type {
|
|
AuthProfileCredential,
|
|
AuthProfileStore,
|
|
RuntimeAuthProfileStore,
|
|
} from "../agents/auth-profiles/types.js";
|
|
import {
|
|
clearRuntimeConfigSnapshot,
|
|
setRuntimeConfigSourceSnapshotIfCurrent,
|
|
setRuntimeConfigSnapshot,
|
|
setRuntimeConfigSnapshotRefreshHandler,
|
|
type RuntimeConfigSnapshotRefreshHandler,
|
|
} from "../config/runtime-snapshot.js";
|
|
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
|
import { coerceSecretRef, isSecretRef, type SecretRef } from "../config/types.secrets.js";
|
|
import type { PluginManifestRegistry } from "../plugins/manifest-registry.js";
|
|
import type { PluginOrigin } from "../plugins/plugin-origin.types.js";
|
|
import { isRecord } from "../utils.js";
|
|
import {
|
|
setActiveDegradedSecretOwners,
|
|
type DegradedSecretOwner,
|
|
} from "./runtime-degraded-state.js";
|
|
import type { SecretResolverWarning } from "./runtime-shared.js";
|
|
import {
|
|
clearActiveRuntimeWebToolsMetadata,
|
|
setActiveRuntimeWebToolsMetadata,
|
|
} from "./runtime-web-tools-state.js";
|
|
import type { RuntimeWebToolsMetadata } from "./runtime-web-tools.types.js";
|
|
|
|
/** Prepared secrets runtime snapshot activated for fast secret resolution. */
|
|
export type PreparedSecretsRuntimeSnapshot = {
|
|
sourceConfig: OpenClawConfig;
|
|
config: OpenClawConfig;
|
|
authStores: Array<{ agentDir: string; store: RuntimeAuthProfileStore }>;
|
|
authStoreCredentialsRevision: number;
|
|
warnings: SecretResolverWarning[];
|
|
degradedOwners?: DegradedSecretOwner[];
|
|
webTools: RuntimeWebToolsMetadata;
|
|
};
|
|
|
|
/** Context needed to refresh active secrets runtime snapshots without losing plugin origin data. */
|
|
export type SecretsRuntimeRefreshContext = {
|
|
env: Record<string, string | undefined>;
|
|
explicitAgentDirs: string[] | null;
|
|
includeConfigRefs?: boolean;
|
|
includeAuthStoreRefs: boolean;
|
|
loadAuthStore?: (agentDir?: string) => AuthProfileStore;
|
|
loadablePluginOrigins: ReadonlyMap<string, PluginOrigin>;
|
|
manifestRegistry?: Pick<PluginManifestRegistry, "plugins">;
|
|
};
|
|
|
|
let activeSnapshot: PreparedSecretsRuntimeSnapshot | null = null;
|
|
let activeSnapshotRevision = 0;
|
|
let activeSnapshotLineageStartRevision = 0;
|
|
// Capture auth truth at candidate publication; descendant credential refreshes keep this base so
|
|
// rollback can distinguish pre-activation auth writes from candidate-owned resolved values.
|
|
let activeSnapshotLineageAuthStores: PreparedSecretsRuntimeSnapshot["authStores"] = [];
|
|
let activeSnapshotLineageAuthMutations: Record<
|
|
string,
|
|
{
|
|
store: {
|
|
baseline: StoreMutationLineage;
|
|
candidate: StoreMutationLineage;
|
|
};
|
|
state: { token: RuntimeAuthProfileStoreMutationToken; includeMain: boolean };
|
|
profiles: Record<
|
|
string,
|
|
{
|
|
baseline: ProfileOwnerMutationLineage;
|
|
candidate: ProfileOwnerMutationLineage;
|
|
}
|
|
>;
|
|
}
|
|
> = {};
|
|
let activeRefreshContext: SecretsRuntimeRefreshContext | null = null;
|
|
const clearHooks = new Set<() => void>();
|
|
const preparedSnapshotRefreshContext = new WeakMap<
|
|
PreparedSecretsRuntimeSnapshot,
|
|
SecretsRuntimeRefreshContext
|
|
>();
|
|
|
|
type ProfileOwner = "absent" | "external" | "inherited" | "local";
|
|
type ProfileOwnerMutationLineage = {
|
|
owner: ProfileOwner;
|
|
token: RuntimeAuthProfileStoreMutationToken;
|
|
};
|
|
type StoreMutationLineage = {
|
|
mainProfileSetToken?: RuntimeAuthProfileStoreMutationToken;
|
|
token: RuntimeAuthProfileStoreMutationToken;
|
|
};
|
|
|
|
/**
|
|
* Clones refresh context while preserving callback identity and isolating mutable maps/config.
|
|
*/
|
|
function cloneSecretsRuntimeRefreshContext(
|
|
context: SecretsRuntimeRefreshContext,
|
|
): SecretsRuntimeRefreshContext {
|
|
const cloned: SecretsRuntimeRefreshContext = {
|
|
env: { ...context.env },
|
|
explicitAgentDirs: context.explicitAgentDirs ? [...context.explicitAgentDirs] : null,
|
|
includeConfigRefs: context.includeConfigRefs ?? true,
|
|
includeAuthStoreRefs: context.includeAuthStoreRefs,
|
|
loadablePluginOrigins: new Map(context.loadablePluginOrigins),
|
|
...(context.manifestRegistry
|
|
? { manifestRegistry: structuredClone(context.manifestRegistry) }
|
|
: {}),
|
|
};
|
|
if (context.loadAuthStore) {
|
|
cloned.loadAuthStore = context.loadAuthStore;
|
|
}
|
|
return cloned;
|
|
}
|
|
|
|
function cloneSnapshot(snapshot: PreparedSecretsRuntimeSnapshot): PreparedSecretsRuntimeSnapshot {
|
|
return {
|
|
sourceConfig: structuredClone(snapshot.sourceConfig),
|
|
config: structuredClone(snapshot.config),
|
|
authStores: snapshot.authStores.map((entry) => ({
|
|
agentDir: entry.agentDir,
|
|
store: structuredClone(entry.store),
|
|
})),
|
|
authStoreCredentialsRevision: snapshot.authStoreCredentialsRevision,
|
|
warnings: snapshot.warnings.map((warning) => ({ ...warning })),
|
|
degradedOwners: (snapshot.degradedOwners ?? []).map((owner) => ({
|
|
ownerKind: owner.ownerKind,
|
|
ownerId: owner.ownerId,
|
|
state: owner.state,
|
|
paths: [...owner.paths],
|
|
refKeys: [...owner.refKeys],
|
|
reason: owner.reason,
|
|
})),
|
|
webTools: structuredClone(snapshot.webTools),
|
|
};
|
|
}
|
|
|
|
function mergeLiveAuthStoreBookkeeping(
|
|
authStores: PreparedSecretsRuntimeSnapshot["authStores"],
|
|
): PreparedSecretsRuntimeSnapshot["authStores"] {
|
|
return authStores.map((entry) => {
|
|
const live = getRuntimeAuthProfileStoreSnapshot(entry.agentDir);
|
|
if (!live) {
|
|
return entry;
|
|
}
|
|
return {
|
|
agentDir: entry.agentDir,
|
|
store: {
|
|
...entry.store,
|
|
order: live.order,
|
|
lastGood: live.lastGood,
|
|
usageStats: live.usageStats,
|
|
},
|
|
};
|
|
});
|
|
}
|
|
|
|
function profileOwner(store: RuntimeAuthProfileStore | undefined, profileId: string): ProfileOwner {
|
|
if (!store?.profiles[profileId]) {
|
|
return "absent";
|
|
}
|
|
if (store.runtimeExternalProfileIds?.includes(profileId)) {
|
|
return "external";
|
|
}
|
|
return store.runtimeLocalProfileIds?.includes(profileId) ? "local" : "inherited";
|
|
}
|
|
|
|
function captureProfileOwnerMutationLineage(
|
|
agentDir: string,
|
|
store: RuntimeAuthProfileStore | undefined,
|
|
profileId: string,
|
|
): ProfileOwnerMutationLineage {
|
|
const owner = profileOwner(store, profileId);
|
|
return {
|
|
owner,
|
|
token:
|
|
owner === "external"
|
|
? { revision: 0, known: true }
|
|
: getRuntimeAuthProfileStoreCredentialMutationToken(agentDir, profileId, {
|
|
includeMain: owner === "absent" || owner === "inherited",
|
|
}),
|
|
};
|
|
}
|
|
|
|
function captureStoreMutationLineage(
|
|
agentDir: string,
|
|
store: RuntimeAuthProfileStore | undefined,
|
|
): StoreMutationLineage {
|
|
const includeMain =
|
|
!store ||
|
|
Object.keys(store.profiles).length === 0 ||
|
|
Object.keys(store.profiles).some((profileId) => profileOwner(store, profileId) === "inherited");
|
|
return {
|
|
...(includeMain
|
|
? { mainProfileSetToken: getRuntimeAuthProfileStoreProfileSetMutationToken() }
|
|
: {}),
|
|
token: getRuntimeAuthProfileStoreCredentialMutationToken(agentDir),
|
|
};
|
|
}
|
|
|
|
function captureAuthStoreMutationLineage(
|
|
baselineAuthStores: PreparedSecretsRuntimeSnapshot["authStores"],
|
|
candidateAuthStores: PreparedSecretsRuntimeSnapshot["authStores"],
|
|
): typeof activeSnapshotLineageAuthMutations {
|
|
const baseline = Object.fromEntries(
|
|
baselineAuthStores.map((entry) => [entry.agentDir, entry.store]),
|
|
);
|
|
const candidate = Object.fromEntries(
|
|
candidateAuthStores.map((entry) => [entry.agentDir, entry.store]),
|
|
);
|
|
const agentDirs = new Set([...Object.keys(baseline), ...Object.keys(candidate)]);
|
|
return Object.fromEntries(
|
|
[...agentDirs].map((agentDir) => {
|
|
const baselineStore = baseline[agentDir];
|
|
const candidateStore = candidate[agentDir];
|
|
const effectiveStore = candidateStore ?? baselineStore;
|
|
const profileIds = new Set([
|
|
...Object.keys(baselineStore?.profiles ?? {}),
|
|
...Object.keys(candidateStore?.profiles ?? {}),
|
|
]);
|
|
return [
|
|
agentDir,
|
|
{
|
|
store: {
|
|
baseline: captureStoreMutationLineage(agentDir, baselineStore),
|
|
candidate: captureStoreMutationLineage(agentDir, candidateStore),
|
|
},
|
|
state: {
|
|
token: getRuntimeAuthProfileStoreStateMutationToken(agentDir, {
|
|
includeMain: effectiveStore?.runtimeInheritsMainState === true,
|
|
}),
|
|
includeMain: effectiveStore?.runtimeInheritsMainState === true,
|
|
},
|
|
profiles: Object.fromEntries(
|
|
[...profileIds].map((profileId) => [
|
|
profileId,
|
|
{
|
|
baseline: captureProfileOwnerMutationLineage(agentDir, baselineStore, profileId),
|
|
candidate: captureProfileOwnerMutationLineage(agentDir, candidateStore, profileId),
|
|
},
|
|
]),
|
|
),
|
|
},
|
|
];
|
|
}),
|
|
);
|
|
}
|
|
|
|
function mergeRollbackValue(previous: unknown, candidate: unknown, current: unknown): unknown {
|
|
if (isDeepStrictEqual(candidate, current)) {
|
|
return structuredClone(previous);
|
|
}
|
|
if (isDeepStrictEqual(candidate, previous)) {
|
|
return structuredClone(current);
|
|
}
|
|
if (!isRecord(previous) || !isRecord(candidate) || !isRecord(current)) {
|
|
return structuredClone(previous);
|
|
}
|
|
const merged: Record<string, unknown> = {};
|
|
const keys = new Set([
|
|
...Object.keys(previous),
|
|
...Object.keys(candidate),
|
|
...Object.keys(current),
|
|
]);
|
|
for (const key of keys) {
|
|
const value = mergeRollbackValue(previous[key], candidate[key], current[key]);
|
|
if (value !== undefined) {
|
|
merged[key] = value;
|
|
}
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
function hasSameSecretProviderDefinition(ref: SecretRef, configs: OpenClawConfig[]): boolean {
|
|
const definition = configs[0]?.secrets?.providers?.[ref.provider];
|
|
if (
|
|
!configs.every((config) =>
|
|
isDeepStrictEqual(config.secrets?.providers?.[ref.provider], definition),
|
|
)
|
|
) {
|
|
return false;
|
|
}
|
|
if (!definition || !("pluginIntegration" in definition)) {
|
|
return true;
|
|
}
|
|
// Plugin integration ownership is not fully normalized to one entry. Preserve a resolved value
|
|
// only across an unchanged plugin/channel snapshot, or rollback can pair it with rejected owner state.
|
|
const dependency = (config: OpenClawConfig) => ({
|
|
plugins: config.plugins,
|
|
channels: config.channels,
|
|
});
|
|
const previous = dependency(configs[0]!);
|
|
return configs.every((config) => isDeepStrictEqual(dependency(config), previous));
|
|
}
|
|
|
|
function preserveResolvedSecretRefValues(
|
|
source: unknown,
|
|
currentSource: unknown,
|
|
current: unknown,
|
|
restored: unknown,
|
|
sourceConfig: OpenClawConfig,
|
|
currentSourceConfig: OpenClawConfig,
|
|
): unknown {
|
|
const sourceRef = coerceSecretRef(source, sourceConfig.secrets?.defaults);
|
|
if (sourceRef) {
|
|
const currentRef = coerceSecretRef(currentSource, currentSourceConfig.secrets?.defaults);
|
|
return currentRef &&
|
|
isDeepStrictEqual(sourceRef, currentRef) &&
|
|
hasSameSecretProviderDefinition(sourceRef, [sourceConfig, currentSourceConfig])
|
|
? structuredClone(current)
|
|
: restored;
|
|
}
|
|
if (Array.isArray(source) && Array.isArray(current) && Array.isArray(restored)) {
|
|
const next = [...restored];
|
|
for (const [index, value] of source.entries()) {
|
|
next[index] = preserveResolvedSecretRefValues(
|
|
value,
|
|
Array.isArray(currentSource) ? currentSource[index] : undefined,
|
|
current[index],
|
|
next[index],
|
|
sourceConfig,
|
|
currentSourceConfig,
|
|
);
|
|
}
|
|
return next;
|
|
}
|
|
if (isRecord(source) && isRecord(current) && isRecord(restored)) {
|
|
const next = { ...restored };
|
|
for (const [key, value] of Object.entries(source)) {
|
|
next[key] = preserveResolvedSecretRefValues(
|
|
value,
|
|
isRecord(currentSource) ? currentSource[key] : undefined,
|
|
current[key],
|
|
next[key],
|
|
sourceConfig,
|
|
currentSourceConfig,
|
|
);
|
|
}
|
|
return next;
|
|
}
|
|
return restored;
|
|
}
|
|
|
|
function preserveResolvedAuthStoreSecretValues(
|
|
previous: Record<string, AuthProfileStore>,
|
|
candidate: Record<string, AuthProfileStore>,
|
|
restored: Record<string, AuthProfileStore>,
|
|
current: Record<string, AuthProfileStore>,
|
|
previousConfig: OpenClawConfig,
|
|
candidateConfig: OpenClawConfig,
|
|
currentConfig: OpenClawConfig,
|
|
): Record<string, AuthProfileStore> {
|
|
const next = structuredClone(restored);
|
|
for (const [agentDir, store] of Object.entries(next)) {
|
|
const previousStore = previous[agentDir];
|
|
const candidateStore = candidate[agentDir];
|
|
const currentStore = current[agentDir];
|
|
if (!previousStore || !candidateStore || !currentStore) {
|
|
continue;
|
|
}
|
|
for (const [profileId, credential] of Object.entries(store.profiles)) {
|
|
const previousCredential = previousStore.profiles[profileId];
|
|
const candidateCredential = candidateStore.profiles[profileId];
|
|
const currentCredential = currentStore.profiles[profileId];
|
|
if (
|
|
credential.type === "api_key" &&
|
|
previousCredential?.type === "api_key" &&
|
|
candidateCredential?.type === "api_key" &&
|
|
currentCredential?.type === "api_key" &&
|
|
isSecretRef(credential.keyRef) &&
|
|
isDeepStrictEqual(credential.keyRef, previousCredential.keyRef) &&
|
|
isDeepStrictEqual(credential.keyRef, candidateCredential.keyRef) &&
|
|
isDeepStrictEqual(credential.keyRef, currentCredential.keyRef) &&
|
|
hasSameSecretProviderDefinition(credential.keyRef, [
|
|
previousConfig,
|
|
candidateConfig,
|
|
currentConfig,
|
|
]) &&
|
|
currentCredential.key !== undefined
|
|
) {
|
|
store.profiles[profileId] = { ...credential, key: currentCredential.key };
|
|
} else if (
|
|
credential.type === "token" &&
|
|
previousCredential?.type === "token" &&
|
|
candidateCredential?.type === "token" &&
|
|
currentCredential?.type === "token" &&
|
|
isSecretRef(credential.tokenRef) &&
|
|
isDeepStrictEqual(credential.tokenRef, previousCredential.tokenRef) &&
|
|
isDeepStrictEqual(credential.tokenRef, candidateCredential.tokenRef) &&
|
|
isDeepStrictEqual(credential.tokenRef, currentCredential.tokenRef) &&
|
|
hasSameSecretProviderDefinition(credential.tokenRef, [
|
|
previousConfig,
|
|
candidateConfig,
|
|
currentConfig,
|
|
]) &&
|
|
currentCredential.token !== undefined
|
|
) {
|
|
store.profiles[profileId] = { ...credential, token: currentCredential.token };
|
|
}
|
|
}
|
|
}
|
|
return next;
|
|
}
|
|
|
|
function preserveLiveAuthStoreBookkeeping(
|
|
restored: Record<string, AuthProfileStore>,
|
|
current: Record<string, AuthProfileStore>,
|
|
): Record<string, AuthProfileStore> {
|
|
const next = structuredClone(restored);
|
|
for (const [agentDir, store] of Object.entries(next)) {
|
|
const currentStore = current[agentDir];
|
|
if (!currentStore) {
|
|
continue;
|
|
}
|
|
if (currentStore.order === undefined) {
|
|
delete store.order;
|
|
} else {
|
|
store.order = structuredClone(currentStore.order);
|
|
}
|
|
if (currentStore.lastGood === undefined) {
|
|
delete store.lastGood;
|
|
} else {
|
|
store.lastGood = structuredClone(currentStore.lastGood);
|
|
}
|
|
if (currentStore.usageStats === undefined) {
|
|
delete store.usageStats;
|
|
} else {
|
|
store.usageStats = structuredClone(currentStore.usageStats);
|
|
}
|
|
}
|
|
return next;
|
|
}
|
|
|
|
function credentialSecretRef(credential: AuthProfileCredential | undefined): SecretRef | null {
|
|
if (credential?.type === "api_key" && isSecretRef(credential.keyRef)) {
|
|
return credential.keyRef;
|
|
}
|
|
if (credential?.type === "token" && isSecretRef(credential.tokenRef)) {
|
|
return credential.tokenRef;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function rebuildSelectedRuntimeProfileMetadata(
|
|
store: RuntimeAuthProfileStore,
|
|
selectedSources: Map<string, RuntimeAuthProfileStore>,
|
|
): void {
|
|
const profileIdsFor = (
|
|
field: "runtimeExternalProfileIds" | "runtimeLocalProfileIds" | "runtimePersistedProfileIds",
|
|
) =>
|
|
[...selectedSources]
|
|
.flatMap(([profileId, source]) => (source[field]?.includes(profileId) ? [profileId] : []))
|
|
.toSorted();
|
|
const persistedProfileIds = profileIdsFor("runtimePersistedProfileIds");
|
|
store.runtimePersistedProfileIds =
|
|
persistedProfileIds.length > 0 ? persistedProfileIds : undefined;
|
|
const localProfileIds = profileIdsFor("runtimeLocalProfileIds");
|
|
store.runtimeLocalProfileIds = localProfileIds.length > 0 ? localProfileIds : undefined;
|
|
const externalProfileIds = profileIdsFor("runtimeExternalProfileIds");
|
|
// Authority is store-wide three-way state; profile selection must not import it
|
|
// from an unrelated credential source.
|
|
const externalAuthoritative = store.runtimeExternalProfileIdsAuthoritative === true;
|
|
store.runtimeExternalProfileIds =
|
|
externalProfileIds.length > 0 || externalAuthoritative ? externalProfileIds : undefined;
|
|
store.runtimeExternalProfileIdsAuthoritative = externalAuthoritative ? true : undefined;
|
|
}
|
|
|
|
function compareMutationTokens(
|
|
captured: RuntimeAuthProfileStoreMutationToken,
|
|
current: RuntimeAuthProfileStoreMutationToken,
|
|
): "mutated" | "unchanged" | "unknown" {
|
|
if (!captured.known || !current.known) {
|
|
return "unknown";
|
|
}
|
|
return captured.revision === current.revision ? "unchanged" : "mutated";
|
|
}
|
|
|
|
function readProfileOwnerMutationToken(
|
|
agentDir: string,
|
|
profileId: string,
|
|
owner: ProfileOwner,
|
|
): RuntimeAuthProfileStoreMutationToken {
|
|
return owner === "external"
|
|
? { revision: 0, known: true }
|
|
: getRuntimeAuthProfileStoreCredentialMutationToken(agentDir, profileId, {
|
|
includeMain: owner === "absent" || owner === "inherited",
|
|
});
|
|
}
|
|
|
|
function getProfileMutationDecision(params: {
|
|
agentDir: string;
|
|
profileId: string;
|
|
mutationLineage: typeof activeSnapshotLineageAuthMutations;
|
|
}): {
|
|
baselineOwner: ProfileOwner;
|
|
candidateOwner: ProfileOwner;
|
|
candidateStatus: "mutated" | "unchanged" | "unknown";
|
|
ownerChanged: boolean;
|
|
status: "mutated" | "unchanged" | "unknown";
|
|
} {
|
|
const captured = params.mutationLineage[params.agentDir]?.profiles[params.profileId];
|
|
if (!captured) {
|
|
return {
|
|
baselineOwner: "absent",
|
|
candidateOwner: "absent",
|
|
candidateStatus: "mutated",
|
|
ownerChanged: false,
|
|
status: "mutated",
|
|
};
|
|
}
|
|
const ownerChanged = captured.baseline.owner !== captured.candidate.owner;
|
|
const relevant = ownerChanged ? captured.baseline : captured.candidate;
|
|
return {
|
|
baselineOwner: captured.baseline.owner,
|
|
candidateOwner: captured.candidate.owner,
|
|
candidateStatus: compareMutationTokens(
|
|
captured.candidate.token,
|
|
readProfileOwnerMutationToken(params.agentDir, params.profileId, captured.candidate.owner),
|
|
),
|
|
ownerChanged,
|
|
status: compareMutationTokens(
|
|
relevant.token,
|
|
readProfileOwnerMutationToken(params.agentDir, params.profileId, relevant.owner),
|
|
),
|
|
};
|
|
}
|
|
|
|
function mergeRollbackAuthStoreCredentials(
|
|
baseline: Record<string, AuthProfileStore>,
|
|
candidate: Record<string, AuthProfileStore>,
|
|
current: Record<string, AuthProfileStore>,
|
|
restored: Record<string, AuthProfileStore>,
|
|
configs: [OpenClawConfig, OpenClawConfig, OpenClawConfig],
|
|
mutationLineage: typeof activeSnapshotLineageAuthMutations,
|
|
): Record<string, AuthProfileStore> {
|
|
const next = structuredClone(restored);
|
|
const agentDirs = new Set([
|
|
...Object.keys(baseline),
|
|
...Object.keys(candidate),
|
|
...Object.keys(current),
|
|
]);
|
|
for (const agentDir of agentDirs) {
|
|
let invalidateStore = false;
|
|
const baselineStore = baseline[agentDir];
|
|
const candidateStore = candidate[agentDir];
|
|
const currentStore = current[agentDir];
|
|
const currentStoreMutationStatus = (lineage: StoreMutationLineage | undefined) => {
|
|
const ownerStatus = compareMutationTokens(
|
|
lineage?.token ?? { revision: 0, known: true },
|
|
getRuntimeAuthProfileStoreCredentialMutationToken(agentDir),
|
|
);
|
|
const mainProfileSetStatus = lineage?.mainProfileSetToken
|
|
? compareMutationTokens(
|
|
lineage.mainProfileSetToken,
|
|
getRuntimeAuthProfileStoreProfileSetMutationToken(),
|
|
)
|
|
: "unchanged";
|
|
return ownerStatus === "mutated" || mainProfileSetStatus === "mutated"
|
|
? "mutated"
|
|
: ownerStatus === "unknown" || mainProfileSetStatus === "unknown"
|
|
? "unknown"
|
|
: "unchanged";
|
|
};
|
|
const baselineStoreMutationStatus = currentStoreMutationStatus(
|
|
mutationLineage[agentDir]?.store.baseline,
|
|
);
|
|
const candidateStoreMutationStatus = currentStoreMutationStatus(
|
|
mutationLineage[agentDir]?.store.candidate,
|
|
);
|
|
const stateMutationStatus = compareMutationTokens(
|
|
mutationLineage[agentDir]?.state.token ?? { revision: 0, known: true },
|
|
getRuntimeAuthProfileStoreStateMutationToken(agentDir, {
|
|
includeMain: mutationLineage[agentDir]?.state.includeMain === true,
|
|
}),
|
|
);
|
|
const profileOwnerMutated = Object.keys(baselineStore?.profiles ?? {}).some((profileId) => {
|
|
const decision = getProfileMutationDecision({
|
|
agentDir,
|
|
profileId,
|
|
mutationLineage,
|
|
});
|
|
return decision.status !== "unchanged" || decision.candidateStatus !== "unchanged";
|
|
});
|
|
if (!currentStore) {
|
|
if (
|
|
!candidateStore &&
|
|
baselineStore &&
|
|
baselineStoreMutationStatus === "unchanged" &&
|
|
candidateStoreMutationStatus === "unchanged" &&
|
|
stateMutationStatus === "unchanged" &&
|
|
!profileOwnerMutated
|
|
) {
|
|
next[agentDir] = structuredClone(baselineStore);
|
|
} else {
|
|
delete next[agentDir];
|
|
}
|
|
continue;
|
|
}
|
|
const store = next[agentDir] ?? structuredClone(baselineStore ?? currentStore);
|
|
const profiles: AuthProfileStore["profiles"] = {};
|
|
const selectedSources = new Map<string, AuthProfileStore>();
|
|
const profileIds = new Set([
|
|
...Object.keys(baselineStore?.profiles ?? {}),
|
|
...Object.keys(candidateStore?.profiles ?? {}),
|
|
...Object.keys(currentStore.profiles),
|
|
]);
|
|
for (const profileId of profileIds) {
|
|
const baselineCredential = baselineStore?.profiles[profileId];
|
|
const candidateCredential = candidateStore?.profiles[profileId];
|
|
const currentCredential = currentStore.profiles[profileId];
|
|
const profileMutationDecision = getProfileMutationDecision({
|
|
agentDir,
|
|
profileId,
|
|
mutationLineage,
|
|
});
|
|
const profileMutationStatus = profileMutationDecision.status;
|
|
const profileMutated = profileMutationStatus === "mutated";
|
|
const currentOwner = profileOwner(currentStore, profileId);
|
|
let credential: AuthProfileCredential | undefined;
|
|
let selectedSource: AuthProfileStore | undefined;
|
|
if (currentOwner !== profileMutationDecision.candidateOwner) {
|
|
credential = currentCredential;
|
|
selectedSource = currentStore;
|
|
} else if (profileMutationDecision.ownerChanged) {
|
|
if (
|
|
profileMutationStatus !== "unchanged" ||
|
|
profileMutationDecision.candidateStatus !== "unchanged"
|
|
) {
|
|
invalidateStore = true;
|
|
} else {
|
|
credential = baselineCredential;
|
|
selectedSource = baselineStore;
|
|
}
|
|
} else if (profileMutationStatus === "unknown") {
|
|
if (isDeepStrictEqual(baselineCredential, candidateCredential)) {
|
|
credential = currentCredential;
|
|
selectedSource = currentStore;
|
|
} else {
|
|
invalidateStore = true;
|
|
}
|
|
} else {
|
|
if (isDeepStrictEqual(currentCredential, candidateCredential)) {
|
|
if (profileMutated) {
|
|
credential = currentCredential;
|
|
selectedSource = currentStore;
|
|
} else {
|
|
credential = baselineCredential;
|
|
selectedSource = baselineStore;
|
|
}
|
|
} else {
|
|
credential = currentCredential;
|
|
selectedSource = currentStore;
|
|
}
|
|
}
|
|
const baselineRef = credentialSecretRef(baselineCredential);
|
|
const candidateRef = credentialSecretRef(candidateCredential);
|
|
const currentRef = credentialSecretRef(currentCredential);
|
|
if (
|
|
currentOwner === profileMutationDecision.candidateOwner &&
|
|
profileMutationStatus === "unchanged" &&
|
|
candidateRef &&
|
|
currentRef &&
|
|
isDeepStrictEqual(candidateRef, currentRef) &&
|
|
!isDeepStrictEqual(baselineRef, candidateRef)
|
|
) {
|
|
// Candidate activation owns the ref transition. Descendant resolution may refresh the
|
|
// literal, but without a persisted write rollback still restores the previous owner/ref.
|
|
credential = baselineCredential;
|
|
selectedSource = baselineStore;
|
|
}
|
|
if (
|
|
baselineRef &&
|
|
candidateRef &&
|
|
currentRef &&
|
|
isDeepStrictEqual(baselineRef, candidateRef) &&
|
|
isDeepStrictEqual(baselineRef, currentRef) &&
|
|
!hasSameSecretProviderDefinition(baselineRef, configs)
|
|
) {
|
|
if (
|
|
currentOwner !== profileMutationDecision.candidateOwner ||
|
|
profileMutationStatus !== "unchanged"
|
|
) {
|
|
invalidateStore = true;
|
|
credential = undefined;
|
|
selectedSource = undefined;
|
|
} else {
|
|
credential = baselineCredential;
|
|
selectedSource = baselineStore;
|
|
}
|
|
}
|
|
const selectedRef = credentialSecretRef(credential);
|
|
if (
|
|
selectedSource === currentStore &&
|
|
selectedRef &&
|
|
!hasSameSecretProviderDefinition(selectedRef, [configs[0], configs[1]])
|
|
) {
|
|
invalidateStore = true;
|
|
credential = undefined;
|
|
selectedSource = undefined;
|
|
}
|
|
if (credential && selectedSource) {
|
|
profiles[profileId] = structuredClone(credential);
|
|
selectedSources.set(profileId, selectedSource);
|
|
}
|
|
}
|
|
if (invalidateStore) {
|
|
// Exact persisted ownership was evicted. Remove the runtime store so the
|
|
// next auth load reads durable truth instead of publishing a partial clone.
|
|
delete next[agentDir];
|
|
continue;
|
|
}
|
|
if (!baselineStore && Object.keys(profiles).length === 0) {
|
|
delete next[agentDir];
|
|
continue;
|
|
}
|
|
store.profiles = profiles;
|
|
rebuildSelectedRuntimeProfileMetadata(store, selectedSources);
|
|
next[agentDir] = store;
|
|
}
|
|
return next;
|
|
}
|
|
|
|
/**
|
|
* Associates a prepared snapshot with the refresh context needed after activation.
|
|
*/
|
|
export function setPreparedSecretsRuntimeSnapshotRefreshContext(
|
|
snapshot: PreparedSecretsRuntimeSnapshot,
|
|
context: SecretsRuntimeRefreshContext,
|
|
): void {
|
|
preparedSnapshotRefreshContext.set(snapshot, cloneSecretsRuntimeRefreshContext(context));
|
|
}
|
|
|
|
/**
|
|
* Returns the refresh context stored for a prepared snapshot, if any.
|
|
*/
|
|
export function getPreparedSecretsRuntimeSnapshotRefreshContext(
|
|
snapshot: PreparedSecretsRuntimeSnapshot,
|
|
): SecretsRuntimeRefreshContext | null {
|
|
const context = preparedSnapshotRefreshContext.get(snapshot);
|
|
return context ? cloneSecretsRuntimeRefreshContext(context) : null;
|
|
}
|
|
|
|
/**
|
|
* Returns the active refresh context without exposing mutable runtime state.
|
|
*/
|
|
export function getActiveSecretsRuntimeRefreshContext(): SecretsRuntimeRefreshContext | null {
|
|
return activeRefreshContext ? cloneSecretsRuntimeRefreshContext(activeRefreshContext) : null;
|
|
}
|
|
|
|
/** Retain live auth state when a one-shot config write intentionally skips auth-store refs. */
|
|
export function graftActiveSecretsRuntimeAuthState(snapshot: PreparedSecretsRuntimeSnapshot): void {
|
|
if (!activeRefreshContext) {
|
|
return;
|
|
}
|
|
snapshot.authStores = getLiveSecretsRuntimeAuthStores();
|
|
snapshot.authStoreCredentialsRevision = getRuntimeAuthProfileStoreCredentialsRevision();
|
|
setPreparedSecretsRuntimeSnapshotRefreshContext(snapshot, activeRefreshContext);
|
|
}
|
|
|
|
/**
|
|
* Returns the env used by the active runtime snapshot, falling back to process env.
|
|
*/
|
|
export function getActiveSecretsRuntimeEnv(): NodeJS.ProcessEnv {
|
|
return {
|
|
...(activeRefreshContext?.env ?? process.env),
|
|
} as NodeJS.ProcessEnv;
|
|
}
|
|
|
|
/**
|
|
* Registers cleanup hooks that run whenever the active secrets runtime snapshot is cleared.
|
|
*/
|
|
export function registerSecretsRuntimeStateClearHook(clearHook: () => void): void {
|
|
clearHooks.add(clearHook);
|
|
}
|
|
|
|
/**
|
|
* Atomically activates a prepared secrets snapshot across config, auth-store, and web-tool state.
|
|
*/
|
|
export function activateSecretsRuntimeSnapshotState(params: {
|
|
snapshot: PreparedSecretsRuntimeSnapshot;
|
|
refreshContext: SecretsRuntimeRefreshContext | null;
|
|
refreshHandler: RuntimeConfigSnapshotRefreshHandler | null;
|
|
mergeLiveAuthBookkeeping?: boolean;
|
|
preserveActivationLineage?: boolean;
|
|
}): void {
|
|
if (!hasCurrentAuthStoreCredentialsRevision(params.snapshot)) {
|
|
throw new Error(
|
|
"Cannot activate stale secrets runtime snapshot: auth credentials changed during preparation.",
|
|
);
|
|
}
|
|
const next = cloneSnapshot(params.snapshot);
|
|
if (params.mergeLiveAuthBookkeeping !== false) {
|
|
next.authStores = mergeLiveAuthStoreBookkeeping(next.authStores);
|
|
}
|
|
const activationAuthStores = structuredClone(listRuntimeAuthProfileStoreSnapshots());
|
|
const previousLineageAuthStores = activeSnapshotLineageAuthStores;
|
|
const activationAuthMutations = captureAuthStoreMutationLineage(
|
|
activationAuthStores,
|
|
next.authStores,
|
|
);
|
|
const previousLineageAuthMutations = activeSnapshotLineageAuthMutations;
|
|
const nextRefreshContext = params.refreshContext
|
|
? cloneSecretsRuntimeRefreshContext(params.refreshContext)
|
|
: null;
|
|
setRuntimeConfigSnapshot(next.config, next.sourceConfig);
|
|
replaceRuntimeAuthProfileStoreSnapshots(next.authStores);
|
|
next.authStoreCredentialsRevision = getRuntimeAuthProfileStoreCredentialsRevision();
|
|
const previousLineageStartRevision = activeSnapshotLineageStartRevision;
|
|
activeSnapshot = next;
|
|
activeSnapshotRevision += 1;
|
|
activeSnapshotLineageStartRevision = params.preserveActivationLineage
|
|
? previousLineageStartRevision
|
|
: activeSnapshotRevision;
|
|
activeSnapshotLineageAuthStores = params.preserveActivationLineage
|
|
? previousLineageAuthStores
|
|
: activationAuthStores;
|
|
activeSnapshotLineageAuthMutations = params.preserveActivationLineage
|
|
? previousLineageAuthMutations
|
|
: activationAuthMutations;
|
|
activeRefreshContext = nextRefreshContext;
|
|
if (nextRefreshContext) {
|
|
preparedSnapshotRefreshContext.set(next, cloneSecretsRuntimeRefreshContext(nextRefreshContext));
|
|
}
|
|
setActiveRuntimeWebToolsMetadata(next.webTools);
|
|
setActiveDegradedSecretOwners(next.degradedOwners ?? []);
|
|
setRuntimeConfigSnapshotRefreshHandler(params.refreshHandler);
|
|
}
|
|
|
|
/** Whether a prepared snapshot still owns the credential state it cloned. */
|
|
export function hasCurrentAuthStoreCredentialsRevision(
|
|
snapshot: PreparedSecretsRuntimeSnapshot,
|
|
): boolean {
|
|
return snapshot.authStoreCredentialsRevision === getRuntimeAuthProfileStoreCredentialsRevision();
|
|
}
|
|
|
|
/** Activates only while the caller still owns the snapshot revision it prepared against. */
|
|
export function activateSecretsRuntimeSnapshotStateIfCurrent(
|
|
params: Parameters<typeof activateSecretsRuntimeSnapshotState>[0] & {
|
|
expectedRevision: number;
|
|
},
|
|
): boolean {
|
|
if (
|
|
activeSnapshotRevision !== params.expectedRevision ||
|
|
!hasCurrentAuthStoreCredentialsRevision(params.snapshot)
|
|
) {
|
|
return false;
|
|
}
|
|
activateSecretsRuntimeSnapshotState(params);
|
|
return true;
|
|
}
|
|
|
|
/** Restores an owned predecessor while retaining changes after candidate preparation. */
|
|
export function restoreSecretsRuntimeSnapshotStateIfCurrent(
|
|
params: Parameters<typeof activateSecretsRuntimeSnapshotState>[0] & {
|
|
expectedRevision: number;
|
|
ownedSnapshot: PreparedSecretsRuntimeSnapshot;
|
|
},
|
|
): boolean {
|
|
if (!activeSnapshot || activeSnapshotLineageStartRevision !== params.expectedRevision) {
|
|
return false;
|
|
}
|
|
const baselineAuthStores = Object.fromEntries(
|
|
activeSnapshotLineageAuthStores.map((entry) => [entry.agentDir, entry.store]),
|
|
);
|
|
const candidateAuthStores = Object.fromEntries(
|
|
params.ownedSnapshot.authStores.map((entry) => [entry.agentDir, entry.store]),
|
|
);
|
|
const currentAuthStores = Object.fromEntries(
|
|
listRuntimeAuthProfileStoreSnapshots().map((entry) => [entry.agentDir, entry.store]),
|
|
);
|
|
const mergedAuthStores = mergeRollbackAuthStoreCredentials(
|
|
baselineAuthStores,
|
|
candidateAuthStores,
|
|
currentAuthStores,
|
|
mergeRollbackValue(baselineAuthStores, candidateAuthStores, currentAuthStores) as Record<
|
|
string,
|
|
AuthProfileStore
|
|
>,
|
|
[params.snapshot.sourceConfig, params.ownedSnapshot.sourceConfig, activeSnapshot.sourceConfig],
|
|
activeSnapshotLineageAuthMutations,
|
|
);
|
|
const currentCredentialsRevision = getRuntimeAuthProfileStoreCredentialsRevision();
|
|
const restoredAuthStores = preserveLiveAuthStoreBookkeeping(
|
|
preserveResolvedAuthStoreSecretValues(
|
|
baselineAuthStores,
|
|
candidateAuthStores,
|
|
mergedAuthStores,
|
|
currentAuthStores,
|
|
params.snapshot.sourceConfig,
|
|
params.ownedSnapshot.sourceConfig,
|
|
activeSnapshot.sourceConfig,
|
|
),
|
|
currentAuthStores,
|
|
);
|
|
const restoredSourceConfig = mergeRollbackValue(
|
|
params.snapshot.sourceConfig,
|
|
params.ownedSnapshot.sourceConfig,
|
|
activeSnapshot.sourceConfig,
|
|
) as OpenClawConfig;
|
|
const restoredConfig = preserveResolvedSecretRefValues(
|
|
restoredSourceConfig,
|
|
activeSnapshot.sourceConfig,
|
|
activeSnapshot.config,
|
|
mergeRollbackValue(params.snapshot.config, params.ownedSnapshot.config, activeSnapshot.config),
|
|
restoredSourceConfig,
|
|
activeSnapshot.sourceConfig,
|
|
) as OpenClawConfig;
|
|
return activateSecretsRuntimeSnapshotStateIfCurrent({
|
|
...params,
|
|
snapshot: {
|
|
...params.snapshot,
|
|
sourceConfig: restoredSourceConfig,
|
|
config: restoredConfig,
|
|
authStores: Object.entries(restoredAuthStores)
|
|
.map(([agentDir, store]) => ({ agentDir, store }))
|
|
.toSorted((left, right) => left.agentDir.localeCompare(right.agentDir)),
|
|
authStoreCredentialsRevision: currentCredentialsRevision,
|
|
},
|
|
mergeLiveAuthBookkeeping: false,
|
|
preserveActivationLineage: false,
|
|
expectedRevision: activeSnapshotRevision,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Returns a cloned active secrets runtime snapshot for callers that need mutable data.
|
|
*/
|
|
export function getActiveSecretsRuntimeSnapshot(): PreparedSecretsRuntimeSnapshot | null {
|
|
if (!activeSnapshot) {
|
|
return null;
|
|
}
|
|
const snapshot = cloneSnapshot(activeSnapshot);
|
|
snapshot.authStores = listRuntimeAuthProfileStoreSnapshots();
|
|
snapshot.authStoreCredentialsRevision = getRuntimeAuthProfileStoreCredentialsRevision();
|
|
if (activeRefreshContext) {
|
|
preparedSnapshotRefreshContext.set(
|
|
snapshot,
|
|
cloneSecretsRuntimeRefreshContext(activeRefreshContext),
|
|
);
|
|
}
|
|
return snapshot;
|
|
}
|
|
|
|
/** Stable token for compare-and-activate ownership across cloned snapshot reads. */
|
|
export function getActiveSecretsRuntimeSnapshotRevision(): number {
|
|
return activeSnapshotRevision;
|
|
}
|
|
|
|
/** Advance canonical source ownership without replacing resolved runtime or auth bytes. */
|
|
export function setSecretsRuntimeSourceSnapshotIfCurrent(params: {
|
|
expectedSecretsRevision: number;
|
|
expectedRuntimeConfigRevision: number;
|
|
runtimeSourceConfig: OpenClawConfig;
|
|
secretsSourceConfig: OpenClawConfig;
|
|
}): boolean {
|
|
if (activeSnapshotRevision !== params.expectedSecretsRevision) {
|
|
return false;
|
|
}
|
|
const nextRuntimeSourceConfig = structuredClone(params.runtimeSourceConfig);
|
|
const nextSecretsSourceConfig = structuredClone(params.secretsSourceConfig);
|
|
const currentAuthStores = structuredClone(listRuntimeAuthProfileStoreSnapshots());
|
|
const nextAuthMutations = captureAuthStoreMutationLineage(currentAuthStores, currentAuthStores);
|
|
if (
|
|
!setRuntimeConfigSourceSnapshotIfCurrent({
|
|
expectedRevision: params.expectedRuntimeConfigRevision,
|
|
sourceConfig: nextRuntimeSourceConfig,
|
|
})
|
|
) {
|
|
return false;
|
|
}
|
|
if (activeSnapshot) {
|
|
activeSnapshot.sourceConfig = nextSecretsSourceConfig;
|
|
activeSnapshotRevision += 1;
|
|
activeSnapshotLineageStartRevision = activeSnapshotRevision;
|
|
activeSnapshotLineageAuthStores = currentAuthStores;
|
|
activeSnapshotLineageAuthMutations = nextAuthMutations;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Hot-path readers only need the config pair for availability decisions.
|
|
// Return the active references and keep full snapshot clone isolation on
|
|
// getActiveSecretsRuntimeSnapshot() for callers that need mutable data.
|
|
export function getActiveSecretsRuntimeConfigSnapshot(): Pick<
|
|
PreparedSecretsRuntimeSnapshot,
|
|
"config" | "sourceConfig"
|
|
> | null {
|
|
if (!activeSnapshot) {
|
|
return null;
|
|
}
|
|
return {
|
|
config: activeSnapshot.config,
|
|
sourceConfig: activeSnapshot.sourceConfig,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Returns current auth stores, preferring live auth-store snapshots over activation-time clones.
|
|
*/
|
|
export function getLiveSecretsRuntimeAuthStores(): PreparedSecretsRuntimeSnapshot["authStores"] {
|
|
if (!activeSnapshot) {
|
|
return [];
|
|
}
|
|
return activeSnapshot.authStores.flatMap((entry) => {
|
|
const store = getRuntimeAuthProfileStoreSnapshot(entry.agentDir);
|
|
return store ? [{ agentDir: entry.agentDir, store }] : [];
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Clears active secrets runtime state and all linked config/auth/web-tool snapshots.
|
|
*/
|
|
export function clearSecretsRuntimeSnapshot(): void {
|
|
activeSnapshotRevision += 1;
|
|
activeSnapshotLineageStartRevision = 0;
|
|
activeSnapshotLineageAuthStores = [];
|
|
activeSnapshotLineageAuthMutations = {};
|
|
activeSnapshot = null;
|
|
activeRefreshContext = null;
|
|
clearActiveRuntimeWebToolsMetadata();
|
|
setActiveDegradedSecretOwners([]);
|
|
setRuntimeConfigSnapshotRefreshHandler(null);
|
|
clearRuntimeConfigSnapshot();
|
|
clearRuntimeAuthProfileStoreSnapshots();
|
|
for (const clearHook of clearHooks) {
|
|
clearHook();
|
|
}
|
|
}
|
|
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
|