diff --git a/src/plugins/current-plugin-metadata-snapshot.test.ts b/src/plugins/current-plugin-metadata-snapshot.test.ts index 454c0e285cd..7f29d9bc0c3 100644 --- a/src/plugins/current-plugin-metadata-snapshot.test.ts +++ b/src/plugins/current-plugin-metadata-snapshot.test.ts @@ -177,6 +177,34 @@ describe("current plugin metadata snapshot", () => { expect(getCurrentPluginMetadataSnapshot({ config, env: requestedEnv })).toBeUndefined(); }); + it("rejects an exact cached config object after in-place policy changes", () => { + const config = { plugins: { allow: ["demo"] } }; + const snapshot = createSnapshot({ config }); + setCurrentPluginMetadataSnapshot(snapshot, { config }); + + expect(getCurrentPluginMetadataSnapshot({ config })).toBe(snapshot); + + config.plugins.allow = ["other"]; + + expect(getCurrentPluginMetadataSnapshot({ config })).toBeUndefined(); + }); + + it("rejects an exact cached env object after in-place root changes", () => { + const config = {}; + const snapshot = createSnapshot({ config }); + const env = { + HOME: "/home/snapshot", + OPENCLAW_HOME: undefined, + } as NodeJS.ProcessEnv; + setCurrentPluginMetadataSnapshot(snapshot, { config, env }); + + expect(getCurrentPluginMetadataSnapshot({ config, env })).toBe(snapshot); + + env.HOME = "/home/requested"; + + expect(getCurrentPluginMetadataSnapshot({ config, env })).toBeUndefined(); + }); + it("keeps source-policy compatibility when storing an auto-enabled runtime config", () => { const sourceConfig = { channels: { telegram: { botToken: "token" } } }; const autoEnabledConfig = { diff --git a/src/plugins/current-plugin-metadata-snapshot.ts b/src/plugins/current-plugin-metadata-snapshot.ts index 97a680c002d..6a198c0f249 100644 --- a/src/plugins/current-plugin-metadata-snapshot.ts +++ b/src/plugins/current-plugin-metadata-snapshot.ts @@ -10,8 +10,20 @@ import { type ResolvePluginControlPlaneContextParams, } from "./plugin-control-plane-context.js"; import type { PluginMetadataSnapshot } from "./plugin-metadata-snapshot.types.js"; +import { resolvePluginCacheInputs } from "./roots.js"; type CurrentPluginMetadataSnapshotState = ReturnType; +type CurrentPluginMetadataConfigCacheEntry = { + configFingerprint: string; + discoveryKey: string; + policyHash: string; + policyKey: string; +}; + +let currentPluginMetadataConfigIdentityCache = new WeakMap< + OpenClawConfig, + CurrentPluginMetadataConfigCacheEntry +>(); export function resolvePluginMetadataControlPlaneFingerprint( config?: OpenClawConfig, @@ -29,6 +41,65 @@ export function isReusableCurrentPluginMetadataSnapshot( return true; } +function resolveConfiguredPluginLoadPaths( + config: OpenClawConfig | undefined, +): readonly string[] | undefined { + const paths = config?.plugins?.load?.paths; + return Array.isArray(paths) ? paths : undefined; +} + +function resolveCurrentPluginMetadataDiscoveryKey(params: { + config?: OpenClawConfig; + env: NodeJS.ProcessEnv; + workspaceDir?: string; +}): string { + return JSON.stringify( + resolvePluginCacheInputs({ + env: params.env, + workspaceDir: params.workspaceDir, + loadPaths: [...(resolveConfiguredPluginLoadPaths(params.config) ?? [])], + }), + ); +} + +function resolveCurrentPluginMetadataPolicyKey(config: OpenClawConfig | undefined): string { + const plugins = config?.plugins; + const rawEntries = plugins?.entries; + const entries: Record = {}; + if (rawEntries && typeof rawEntries === "object" && !Array.isArray(rawEntries)) { + for (const [pluginId, entry] of Object.entries(rawEntries)) { + if (entry && typeof entry === "object" && !Array.isArray(entry)) { + const enabled = (entry as Record).enabled; + if (enabled !== undefined) { + entries[pluginId] = enabled; + } + } + } + } + const channelPolicy: Record = {}; + const channels = config?.channels; + if (channels && typeof channels === "object" && !Array.isArray(channels)) { + for (const [channelId, value] of Object.entries(channels)) { + if (value && typeof value === "object" && !Array.isArray(value)) { + const enabled = (value as Record).enabled; + if (typeof enabled === "boolean") { + channelPolicy[channelId] = enabled; + } + } + } + } + return JSON.stringify({ + plugins: { + enabled: plugins?.enabled, + allow: plugins?.allow, + deny: plugins?.deny, + slots: plugins?.slots, + entries, + }, + channels: channelPolicy, + }); +} + // Single-slot Gateway-owned handoff. Replace or clear it at lifecycle boundaries; // never accumulate historical metadata snapshots here. export function setCurrentPluginMetadataSnapshot( @@ -40,6 +111,7 @@ export function setCurrentPluginMetadataSnapshot( workspaceDir?: string; } = {}, ): void { + currentPluginMetadataConfigIdentityCache = new WeakMap(); const compatiblePolicyHashes = snapshot ? options.compatibleConfigs?.map((config) => resolveInstalledPluginIndexPolicyHash(config)) : undefined; @@ -66,9 +138,52 @@ export function setCurrentPluginMetadataSnapshot( compatiblePolicyHashes, compatibleConfigFingerprints, ); + if (!snapshot) { + return; + } + const env = options.env ?? process.env; + const workspaceDir = options.workspaceDir ?? snapshot.workspaceDir; + if (options.config) { + const policyHash = resolveInstalledPluginIndexPolicyHash(options.config); + const policyKey = resolveCurrentPluginMetadataPolicyKey(options.config); + const discoveryKey = resolveCurrentPluginMetadataDiscoveryKey({ + config: options.config, + env, + workspaceDir, + }); + currentPluginMetadataConfigIdentityCache.set(options.config, { + policyHash, + policyKey, + discoveryKey, + configFingerprint: resolvePluginMetadataControlPlaneFingerprint(options.config, { + env, + index: snapshot.index, + policyHash, + workspaceDir, + }), + }); + } + for (const [index, config] of (options.compatibleConfigs ?? []).entries()) { + const policyHash = compatiblePolicyHashes?.[index]; + const configFingerprint = compatibleConfigFingerprints?.[index]; + if (!policyHash || !configFingerprint) { + continue; + } + currentPluginMetadataConfigIdentityCache.set(config, { + policyHash, + policyKey: resolveCurrentPluginMetadataPolicyKey(config), + configFingerprint, + discoveryKey: resolveCurrentPluginMetadataDiscoveryKey({ + config, + env, + workspaceDir, + }), + }); + } } export function clearCurrentPluginMetadataSnapshot(): void { + currentPluginMetadataConfigIdentityCache = new WeakMap(); clearCurrentPluginMetadataSnapshotState(); } @@ -79,6 +194,7 @@ export function captureCurrentPluginMetadataSnapshotState(): CurrentPluginMetada export function restoreCurrentPluginMetadataSnapshotState( state: CurrentPluginMetadataSnapshotState, ): void { + currentPluginMetadataConfigIdentityCache = new WeakMap(); setCurrentPluginMetadataSnapshotState( state.snapshot, state.configFingerprint, @@ -106,25 +222,47 @@ export function getCurrentPluginMetadataSnapshot( if (!snapshot) { return undefined; } - const requestedPolicyHash = params.config - ? resolveInstalledPluginIndexPolicyHash(params.config) - : undefined; + const env = params.env ?? process.env; + const requestedWorkspaceDir = + params.workspaceDir ?? + (params.allowWorkspaceScopedSnapshot === true ? snapshot.workspaceDir : undefined); + const cachedConfig = params.config && currentPluginMetadataConfigIdentityCache.get(params.config); + const requestedPolicyKey = + params.config && cachedConfig + ? resolveCurrentPluginMetadataPolicyKey(params.config) + : undefined; + const requestedDiscoveryKey = + params.config && cachedConfig + ? resolveCurrentPluginMetadataDiscoveryKey({ + config: params.config, + env, + workspaceDir: requestedWorkspaceDir, + }) + : undefined; + const canReuseCachedConfig = + cachedConfig !== undefined && + cachedConfig.policyKey === requestedPolicyKey && + cachedConfig.discoveryKey === requestedDiscoveryKey; + const requestedPolicyHash = canReuseCachedConfig + ? cachedConfig.policyHash + : params.config + ? resolveInstalledPluginIndexPolicyHash(params.config) + : undefined; if (requestedPolicyHash && snapshot.policyHash !== requestedPolicyHash) { const compatiblePolicies = new Set(compatiblePolicyHashes ?? []); if (!compatiblePolicies.has(requestedPolicyHash)) { return undefined; } } - const requestedWorkspaceDir = - params.workspaceDir ?? - (params.allowWorkspaceScopedSnapshot === true ? snapshot.workspaceDir : undefined); if (params.config) { - const requestedConfigFingerprint = resolvePluginMetadataControlPlaneFingerprint(params.config, { - env: params.env, - index: snapshot.index, - policyHash: requestedPolicyHash, - workspaceDir: requestedWorkspaceDir, - }); + const requestedConfigFingerprint = canReuseCachedConfig + ? cachedConfig.configFingerprint + : resolvePluginMetadataControlPlaneFingerprint(params.config, { + env, + index: snapshot.index, + policyHash: requestedPolicyHash, + workspaceDir: requestedWorkspaceDir, + }); const compatibleFingerprints = new Set(compatibleConfigFingerprints ?? []); const fingerprintMatches = configFingerprint === requestedConfigFingerprint ||