From f11046e0bf66d54ca6064af59bd9c0e96daea24b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 04:10:45 +0100 Subject: [PATCH] refactor: unify plugin control-plane cache context --- src/agents/provider-auth-aliases.test.ts | 50 +++++++- src/agents/provider-auth-aliases.ts | 14 ++- .../current-plugin-metadata-snapshot.test.ts | 17 +++ .../current-plugin-metadata-snapshot.ts | 7 +- src/plugins/loader.ts | 9 +- .../plugin-control-plane-context.test.ts | 110 ++++++++++++++++++ src/plugins/plugin-control-plane-context.ts | 85 ++++++++++++++ src/plugins/plugin-lookup-table.test.ts | 51 ++++++++ .../plugin-metadata-config-fingerprint.ts | 49 ++++---- src/plugins/plugin-metadata-snapshot.ts | 4 + src/plugins/provider-hook-runtime.ts | 6 + src/plugins/provider-runtime.test.ts | 43 +++++++ 12 files changed, 414 insertions(+), 31 deletions(-) create mode 100644 src/plugins/plugin-control-plane-context.test.ts create mode 100644 src/plugins/plugin-control-plane-context.ts diff --git a/src/agents/provider-auth-aliases.test.ts b/src/agents/provider-auth-aliases.test.ts index f4296bb1386..c71837b80b6 100644 --- a/src/agents/provider-auth-aliases.test.ts +++ b/src/agents/provider-auth-aliases.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const pluginRegistryMocks = vi.hoisted(() => { const loadManifestRegistry = vi.fn(); @@ -20,9 +20,20 @@ vi.mock("../plugins/plugin-registry.js", () => ({ loadPluginRegistrySnapshot: pluginRegistryMocks.loadPluginRegistrySnapshot, })); -import { resolveProviderIdForAuth } from "./provider-auth-aliases.js"; +import { + resetProviderAuthAliasMapCacheForTest, + resolveProviderIdForAuth, +} from "./provider-auth-aliases.js"; describe("provider auth aliases", () => { + beforeEach(() => { + resetProviderAuthAliasMapCacheForTest(); + pluginRegistryMocks.loadPluginManifestRegistryForInstalledIndex.mockReset(); + pluginRegistryMocks.loadPluginManifestRegistryForPluginRegistry.mockReset(); + pluginRegistryMocks.loadPluginRegistrySnapshot.mockReset(); + pluginRegistryMocks.loadPluginRegistrySnapshot.mockReturnValue({ plugins: [] }); + }); + it("treats deprecated auth choice ids as provider auth aliases", () => { pluginRegistryMocks.loadPluginManifestRegistryForInstalledIndex.mockReturnValue({ plugins: [ @@ -46,4 +57,39 @@ describe("provider auth aliases", () => { expect(resolveProviderIdForAuth("openai-codex-import")).toBe("openai-codex"); expect(resolveProviderIdForAuth("openai-codex")).toBe("openai-codex"); }); + + it("does not reuse aliases across env-resolved plugin roots", () => { + const env = { + HOME: "/home/one", + OPENCLAW_HOME: undefined, + } as NodeJS.ProcessEnv; + pluginRegistryMocks.loadPluginManifestRegistryForPluginRegistry + .mockReturnValueOnce({ + plugins: [ + { + id: "one", + origin: "global", + providerAuthAliases: { fixture: "provider-one" }, + }, + ], + diagnostics: [], + }) + .mockReturnValueOnce({ + plugins: [ + { + id: "two", + origin: "global", + providerAuthAliases: { fixture: "provider-two" }, + }, + ], + diagnostics: [], + }); + + expect(resolveProviderIdForAuth("fixture", { config: {}, env })).toBe("provider-one"); + env.HOME = "/home/two"; + expect(resolveProviderIdForAuth("fixture", { config: {}, env })).toBe("provider-two"); + expect(pluginRegistryMocks.loadPluginManifestRegistryForPluginRegistry).toHaveBeenCalledTimes( + 2, + ); + }); }); diff --git a/src/agents/provider-auth-aliases.ts b/src/agents/provider-auth-aliases.ts index c71c1185f91..9603289946c 100644 --- a/src/agents/provider-auth-aliases.ts +++ b/src/agents/provider-auth-aliases.ts @@ -4,6 +4,7 @@ import { isWorkspacePluginAllowedByConfig, normalizePluginConfigId, } from "../plugins/plugin-config-trust.js"; +import { resolvePluginControlPlaneFingerprint } from "../plugins/plugin-control-plane-context.js"; import type { PluginOrigin } from "../plugins/plugin-origin.types.js"; import { loadPluginManifestRegistryForPluginRegistry } from "../plugins/plugin-registry.js"; import { normalizeProviderId } from "./provider-id.js"; @@ -31,9 +32,16 @@ let providerAuthAliasMapCache = new WeakMap< Map> >(); -function buildProviderAuthAliasMapCacheKey(params?: ProviderAuthAliasLookupParams): string { +function buildProviderAuthAliasMapCacheKey( + params: ProviderAuthAliasLookupParams | undefined, + env: NodeJS.ProcessEnv, +): string { return JSON.stringify({ - workspaceDir: params?.workspaceDir ?? "", + pluginControlPlane: resolvePluginControlPlaneFingerprint({ + config: params?.config, + env, + workspaceDir: params?.workspaceDir, + }), includeUntrustedWorkspacePlugins: params?.includeUntrustedWorkspacePlugins === true, plugins: params?.config?.plugins ?? null, }); @@ -100,7 +108,7 @@ export function resolveProviderAuthAliasMap( params?: ProviderAuthAliasLookupParams, ): Record { const env = params?.env ?? process.env; - const cacheKey = buildProviderAuthAliasMapCacheKey(params); + const cacheKey = buildProviderAuthAliasMapCacheKey(params, env); let envCache = providerAuthAliasMapCache.get(env); if (!envCache) { envCache = new Map>(); diff --git a/src/plugins/current-plugin-metadata-snapshot.test.ts b/src/plugins/current-plugin-metadata-snapshot.test.ts index 3f65a41ad31..8dbf125a76a 100644 --- a/src/plugins/current-plugin-metadata-snapshot.test.ts +++ b/src/plugins/current-plugin-metadata-snapshot.test.ts @@ -117,6 +117,23 @@ describe("current plugin metadata snapshot", () => { expect(getCurrentPluginMetadataSnapshot({ config, env: requestedEnv })).toBeUndefined(); }); + it("rejects a current snapshot when env-resolved plugin roots change", () => { + const config = {}; + const snapshot = createSnapshot({ config }); + const snapshotEnv = { + HOME: "/home/snapshot", + OPENCLAW_HOME: undefined, + } as NodeJS.ProcessEnv; + const requestedEnv = { + HOME: "/home/requested", + OPENCLAW_HOME: undefined, + } as NodeJS.ProcessEnv; + setCurrentPluginMetadataSnapshot(snapshot, { config, env: snapshotEnv }); + + expect(getCurrentPluginMetadataSnapshot({ config, env: snapshotEnv })).toBe(snapshot); + expect(getCurrentPluginMetadataSnapshot({ config, env: requestedEnv })).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 0e4d13c69e7..5a291fecdbe 100644 --- a/src/plugins/current-plugin-metadata-snapshot.ts +++ b/src/plugins/current-plugin-metadata-snapshot.ts @@ -13,14 +13,16 @@ export { resolvePluginMetadataSnapshotConfigFingerprint } from "./plugin-metadat // never accumulate historical metadata snapshots here. export function setCurrentPluginMetadataSnapshot( snapshot: PluginMetadataSnapshot | undefined, - options: { config?: OpenClawConfig; env?: NodeJS.ProcessEnv } = {}, + options: { config?: OpenClawConfig; env?: NodeJS.ProcessEnv; workspaceDir?: string } = {}, ): void { setCurrentPluginMetadataSnapshotState( snapshot, snapshot ? resolvePluginMetadataSnapshotConfigFingerprint(options.config, { env: options.env, + index: snapshot.index, policyHash: snapshot.policyHash, + workspaceDir: options.workspaceDir ?? snapshot.workspaceDir, }) : undefined, ); @@ -53,6 +55,9 @@ export function getCurrentPluginMetadataSnapshot( params.config, { env: params.env, + index: snapshot.index, + policyHash: snapshot.policyHash, + workspaceDir: params.workspaceDir, }, ); if (configFingerprint && configFingerprint !== requestedConfigFingerprint) { diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 34c8e8c44f9..dbf1845608c 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -102,6 +102,10 @@ import { } from "./memory-state.js"; import { unwrapDefaultModuleExport } from "./module-export.js"; import { tryNativeRequireJavaScriptModule } from "./native-module-require.js"; +import { + fingerprintPluginDiscoveryContext, + resolvePluginDiscoveryContext, +} from "./plugin-control-plane-context.js"; import { withProfile } from "./plugin-load-profile.js"; import { getCachedPluginSourceModuleLoader, @@ -116,7 +120,6 @@ import { import { ensureOpenClawPluginSdkAlias } from "./plugin-sdk-dist-alias.js"; import { createEmptyPluginRegistry } from "./registry-empty.js"; import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js"; -import { resolvePluginCacheInputs } from "./roots.js"; import { getActivePluginRegistry, getActivePluginRegistryKey, @@ -616,11 +619,12 @@ function buildCacheKey(params: { coreGatewayMethodNames?: string[]; activate?: boolean; }): string { - const { roots, loadPaths } = resolvePluginCacheInputs({ + const discoveryContext = resolvePluginDiscoveryContext({ workspaceDir: params.workspaceDir, loadPaths: params.plugins.loadPaths, env: params.env, }); + const { roots, loadPaths } = discoveryContext; const bundledPackage = resolveBundledPackageCacheIdentity(roots.stock); const installs = Object.fromEntries( Object.entries(params.installs ?? {}).map(([pluginId, install]) => [ @@ -655,6 +659,7 @@ function buildCacheKey(params: { const activationMode = params.activate === false ? "snapshot" : "active"; return `${roots.workspace ?? ""}::${roots.global ?? ""}::${roots.stock ?? ""}::${JSON.stringify({ bundledPackage, + discoveryFingerprint: fingerprintPluginDiscoveryContext(discoveryContext), ...params.plugins, installs, loadPaths, diff --git a/src/plugins/plugin-control-plane-context.test.ts b/src/plugins/plugin-control-plane-context.test.ts new file mode 100644 index 00000000000..07e22113b1e --- /dev/null +++ b/src/plugins/plugin-control-plane-context.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; +import type { InstalledPluginIndex } from "./installed-plugin-index.js"; +import { + resolvePluginControlPlaneContext, + resolvePluginControlPlaneFingerprint, + resolvePluginDiscoveryContext, + resolvePluginDiscoveryFingerprint, +} from "./plugin-control-plane-context.js"; + +function createIndex(pluginId: string): InstalledPluginIndex { + return { + version: 1, + hostContractVersion: "test", + compatRegistryVersion: "test", + migrationVersion: 1, + policyHash: "policy", + generatedAtMs: 1, + installRecords: {}, + diagnostics: [], + plugins: [ + { + pluginId, + manifestPath: `/plugins/${pluginId}/openclaw.plugin.json`, + manifestHash: `${pluginId}-manifest-hash`, + rootDir: `/plugins/${pluginId}`, + origin: "global", + enabled: true, + startup: { + sidecar: false, + memory: false, + deferConfiguredChannelFullLoadUntilAfterListen: false, + agentHarnesses: [], + }, + compat: [], + }, + ], + }; +} + +describe("plugin control-plane context", () => { + it("resolves env-sensitive discovery roots and load paths before fingerprinting", () => { + const config = { plugins: { load: { paths: ["~/plugins", "/opt/shared"] } } }; + const envA = { HOME: "/home/a", OPENCLAW_HOME: "/openclaw/a" } as NodeJS.ProcessEnv; + const envB = { HOME: "/home/b", OPENCLAW_HOME: "/openclaw/b" } as NodeJS.ProcessEnv; + + const contextA = resolvePluginDiscoveryContext({ config, env: envA }); + const contextB = resolvePluginDiscoveryContext({ config, env: envB }); + + expect(contextA.loadPaths).toEqual(["/openclaw/a/plugins", "/opt/shared"]); + expect(contextB.loadPaths).toEqual(["/openclaw/b/plugins", "/opt/shared"]); + expect(resolvePluginDiscoveryFingerprint({ config, env: envA })).not.toBe( + resolvePluginDiscoveryFingerprint({ config, env: envB }), + ); + }); + + it("includes policy, inventory, and activation in one control-plane fingerprint", () => { + const config = { plugins: { allow: ["demo"] } }; + const base = resolvePluginControlPlaneFingerprint({ + config, + env: { HOME: "/home/a", OPENCLAW_HOME: "/openclaw/a" } as NodeJS.ProcessEnv, + index: createIndex("demo"), + activationFingerprint: "activation-a", + }); + + expect( + resolvePluginControlPlaneFingerprint({ + config, + env: { HOME: "/home/a", OPENCLAW_HOME: "/openclaw/a" } as NodeJS.ProcessEnv, + index: createIndex("other"), + activationFingerprint: "activation-a", + }), + ).not.toBe(base); + expect( + resolvePluginControlPlaneFingerprint({ + config, + env: { HOME: "/home/a", OPENCLAW_HOME: "/openclaw/a" } as NodeJS.ProcessEnv, + index: createIndex("demo"), + activationFingerprint: "activation-b", + }), + ).not.toBe(base); + expect( + resolvePluginControlPlaneFingerprint({ + config: { plugins: { deny: ["demo"] } }, + env: { HOME: "/home/a", OPENCLAW_HOME: "/openclaw/a" } as NodeJS.ProcessEnv, + index: createIndex("demo"), + activationFingerprint: "activation-a", + }), + ).not.toBe(base); + }); + + it("keeps the canonical context inspectable for cache diagnostics", () => { + const context = resolvePluginControlPlaneContext({ + config: { plugins: { load: { paths: ["/opt/plugins"] } } }, + env: { HOME: "/home/a", OPENCLAW_HOME: "/openclaw/a" } as NodeJS.ProcessEnv, + inventoryFingerprint: "inventory", + policyHash: "policy", + }); + + expect(context).toMatchObject({ + discovery: { + loadPaths: ["/opt/plugins"], + roots: { + global: "/openclaw/a/.openclaw/extensions", + }, + }, + inventoryFingerprint: "inventory", + policyFingerprint: "policy", + }); + }); +}); diff --git a/src/plugins/plugin-control-plane-context.ts b/src/plugins/plugin-control-plane-context.ts new file mode 100644 index 00000000000..4c802af523e --- /dev/null +++ b/src/plugins/plugin-control-plane-context.ts @@ -0,0 +1,85 @@ +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { hashJson } from "./installed-plugin-index-hash.js"; +import { resolveInstalledPluginIndexPolicyHash } from "./installed-plugin-index-policy.js"; +import type { InstalledPluginIndex } from "./installed-plugin-index.js"; +import { resolveInstalledManifestRegistryIndexFingerprint } from "./manifest-registry-installed.js"; +import { resolvePluginCacheInputs, type PluginSourceRoots } from "./roots.js"; + +export type PluginDiscoveryContext = { + roots: PluginSourceRoots; + loadPaths: readonly string[]; +}; + +export type PluginControlPlaneContext = { + discovery: PluginDiscoveryContext; + policyFingerprint: string; + inventoryFingerprint?: string; + activationFingerprint?: string; +}; + +export type ResolvePluginDiscoveryContextParams = { + config?: OpenClawConfig; + env?: NodeJS.ProcessEnv; + workspaceDir?: string; + loadPaths?: readonly string[]; +}; + +export type ResolvePluginControlPlaneContextParams = ResolvePluginDiscoveryContextParams & { + activationFingerprint?: string; + index?: InstalledPluginIndex; + inventoryFingerprint?: string; + policyHash?: string; +}; + +function resolveConfiguredPluginLoadPaths( + config: OpenClawConfig | undefined, +): readonly string[] | undefined { + const paths = config?.plugins?.load?.paths; + return Array.isArray(paths) ? paths : undefined; +} + +export function resolvePluginDiscoveryContext( + params: ResolvePluginDiscoveryContextParams = {}, +): PluginDiscoveryContext { + return resolvePluginCacheInputs({ + env: params.env ?? process.env, + workspaceDir: params.workspaceDir, + loadPaths: [...(params.loadPaths ?? resolveConfiguredPluginLoadPaths(params.config) ?? [])], + }); +} + +export function resolvePluginDiscoveryFingerprint( + params: ResolvePluginDiscoveryContextParams = {}, +): string { + return fingerprintPluginDiscoveryContext(resolvePluginDiscoveryContext(params)); +} + +export function fingerprintPluginDiscoveryContext(context: PluginDiscoveryContext): string { + return hashJson(context); +} + +export function resolvePluginControlPlaneContext( + params: ResolvePluginControlPlaneContextParams = {}, +): PluginControlPlaneContext { + const inventoryFingerprint = + params.inventoryFingerprint ?? + (params.index ? resolveInstalledManifestRegistryIndexFingerprint(params.index) : undefined); + return { + discovery: resolvePluginDiscoveryContext(params), + policyFingerprint: params.policyHash ?? resolveInstalledPluginIndexPolicyHash(params.config), + ...(inventoryFingerprint ? { inventoryFingerprint } : {}), + ...(params.activationFingerprint + ? { activationFingerprint: params.activationFingerprint } + : {}), + }; +} + +export function resolvePluginControlPlaneFingerprint( + params: ResolvePluginControlPlaneContextParams = {}, +): string { + return fingerprintPluginControlPlaneContext(resolvePluginControlPlaneContext(params)); +} + +export function fingerprintPluginControlPlaneContext(context: PluginControlPlaneContext): string { + return hashJson(context); +} diff --git a/src/plugins/plugin-lookup-table.test.ts b/src/plugins/plugin-lookup-table.test.ts index 03cf0ef7be3..21ea71dff61 100644 --- a/src/plugins/plugin-lookup-table.test.ts +++ b/src/plugins/plugin-lookup-table.test.ts @@ -409,6 +409,57 @@ describe("loadPluginLookUpTable", () => { ); }); + it("rebuilds when a provided metadata snapshot has stale env-resolved plugin roots", async () => { + const plugins = [ + createManifestRecord({ + id: "telegram", + origin: "bundled", + channels: ["telegram"], + }), + ]; + const config = {} as OpenClawConfig; + const snapshotEnv = { + HOME: "/home/snapshot", + OPENCLAW_HOME: undefined, + } as NodeJS.ProcessEnv; + const requestedEnv = { + HOME: "/home/requested", + OPENCLAW_HOME: undefined, + } as NodeJS.ProcessEnv; + const policyHash = resolveInstalledPluginIndexPolicyHash(config); + const index = createIndex(plugins, { policyHash }); + const manifestRegistry: PluginManifestRegistry = { + plugins, + diagnostics: [], + }; + loadPluginManifestRegistryForInstalledIndex.mockReturnValue(manifestRegistry); + const { loadPluginMetadataSnapshot } = await import("./plugin-metadata-snapshot.js"); + const { loadPluginLookUpTable } = await import("./plugin-lookup-table.js"); + + const metadataSnapshot = loadPluginMetadataSnapshot({ + config, + env: snapshotEnv, + index, + }); + loadPluginManifestRegistryForInstalledIndex.mockClear(); + + loadPluginLookUpTable({ + config, + env: requestedEnv, + index, + metadataSnapshot, + }); + + expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledOnce(); + expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledWith( + expect.objectContaining({ + index, + config, + env: requestedEnv, + }), + ); + }); + it("rebuilds when a provided metadata snapshot has stale plugin inventory", async () => { const snapshotPlugins = [ createManifestRecord({ diff --git a/src/plugins/plugin-metadata-config-fingerprint.ts b/src/plugins/plugin-metadata-config-fingerprint.ts index 6614c17d74a..9cd594c8844 100644 --- a/src/plugins/plugin-metadata-config-fingerprint.ts +++ b/src/plugins/plugin-metadata-config-fingerprint.ts @@ -1,31 +1,34 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { resolveHomeRelativePath } from "../infra/home-dir.js"; -import { resolveInstalledPluginIndexPolicyHash } from "./installed-plugin-index-policy.js"; +import type { InstalledPluginIndex } from "./installed-plugin-index.js"; +import { resolvePluginControlPlaneFingerprint } from "./plugin-control-plane-context.js"; -function normalizeResolvedLoadPaths( - config: OpenClawConfig | undefined, - env: NodeJS.ProcessEnv, -): readonly string[] { - const paths = config?.plugins?.load?.paths; - if (!Array.isArray(paths)) { - return []; - } - return paths.flatMap((entry) => { - if (typeof entry !== "string") { - return []; - } - const trimmed = entry.trim(); - return trimmed ? [resolveHomeRelativePath(trimmed, { env })] : []; - }); -} +export { + fingerprintPluginControlPlaneContext, + fingerprintPluginDiscoveryContext, + resolvePluginControlPlaneContext, + resolvePluginControlPlaneFingerprint, + resolvePluginDiscoveryContext, + resolvePluginDiscoveryFingerprint, +} from "./plugin-control-plane-context.js"; export function resolvePluginMetadataSnapshotConfigFingerprint( config: OpenClawConfig | undefined, - options: { env?: NodeJS.ProcessEnv; policyHash?: string } = {}, + options: { + activationFingerprint?: string; + env?: NodeJS.ProcessEnv; + index?: InstalledPluginIndex; + inventoryFingerprint?: string; + policyHash?: string; + workspaceDir?: string; + } = {}, ): string { - const env = options.env ?? process.env; - return JSON.stringify({ - policyHash: options.policyHash ?? resolveInstalledPluginIndexPolicyHash(config), - pluginLoadPaths: normalizeResolvedLoadPaths(config, env), + return resolvePluginControlPlaneFingerprint({ + config, + activationFingerprint: options.activationFingerprint, + env: options.env, + index: options.index, + inventoryFingerprint: options.inventoryFingerprint, + policyHash: options.policyHash, + workspaceDir: options.workspaceDir, }); } diff --git a/src/plugins/plugin-metadata-snapshot.ts b/src/plugins/plugin-metadata-snapshot.ts index a933210e239..86abb75ffa6 100644 --- a/src/plugins/plugin-metadata-snapshot.ts +++ b/src/plugins/plugin-metadata-snapshot.ts @@ -53,7 +53,9 @@ export function isPluginMetadataSnapshotCompatible(params: { params.snapshot.configFingerprint === resolvePluginMetadataSnapshotConfigFingerprint(params.config, { env, + index: params.index ?? params.snapshot.index, policyHash: params.snapshot.policyHash, + workspaceDir: params.workspaceDir, })) && (params.snapshot.workspaceDir ?? "") === (params.workspaceDir ?? "") && indexesMatch(params.snapshot.index, params.index) @@ -185,7 +187,9 @@ function loadPluginMetadataSnapshotImpl( policyHash: index.policyHash, configFingerprint: resolvePluginMetadataSnapshotConfigFingerprint(params.config, { env: params.env, + index, policyHash: index.policyHash, + workspaceDir: params.workspaceDir, }), ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), index, diff --git a/src/plugins/provider-hook-runtime.ts b/src/plugins/provider-hook-runtime.ts index d9c9191757c..b6d74991236 100644 --- a/src/plugins/provider-hook-runtime.ts +++ b/src/plugins/provider-hook-runtime.ts @@ -1,6 +1,7 @@ import { normalizeProviderId } from "../agents/provider-id.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; +import { resolvePluginControlPlaneFingerprint } from "./plugin-control-plane-context.js"; import { resolveProviderConfigApiOwnerHint } from "./provider-config-owner.js"; import { isPluginProvidersLoadInFlight, resolvePluginProviders } from "./providers.runtime.js"; import { getActivePluginRegistryWorkspaceDirFromState } from "./runtime-state.js"; @@ -45,6 +46,11 @@ function matchesProviderId(provider: ProviderPlugin, providerId: string): boolea function resolveProviderRuntimePluginCacheKey(params: ProviderRuntimePluginLookupParams): string { return JSON.stringify({ provider: normalizeLowercaseStringOrEmpty(params.provider), + pluginControlPlane: resolvePluginControlPlaneFingerprint({ + config: params.config, + env: params.env, + workspaceDir: params.workspaceDir, + }), plugins: params.config?.plugins, models: params.config?.models?.providers, workspaceDir: params.workspaceDir ?? "", diff --git a/src/plugins/provider-runtime.test.ts b/src/plugins/provider-runtime.test.ts index bd4ebcbf8f3..19009833ea6 100644 --- a/src/plugins/provider-runtime.test.ts +++ b/src/plugins/provider-runtime.test.ts @@ -437,6 +437,49 @@ describe("provider-runtime", () => { expect(resolvePluginProvidersMock).toHaveBeenCalledTimes(2); }); + it("does not reuse runtime provider cache entries across env-resolved plugin roots", () => { + const firstProvider: ProviderPlugin = { + id: DEMO_PROVIDER_ID, + label: "Demo one", + auth: [], + }; + const secondProvider: ProviderPlugin = { + id: DEMO_PROVIDER_ID, + label: "Demo two", + auth: [], + }; + const config = {} as OpenClawConfig; + const originalHome = process.env.HOME; + const originalOpenClawHome = process.env.OPENCLAW_HOME; + try { + process.env.HOME = "/home/one"; + delete process.env.OPENCLAW_HOME; + resolvePluginProvidersMock.mockReturnValueOnce([firstProvider]); + expect(resolveProviderRuntimePlugin({ provider: DEMO_PROVIDER_ID, config })).toBe( + firstProvider, + ); + + process.env.HOME = "/home/two"; + resolvePluginProvidersMock.mockReturnValueOnce([secondProvider]); + expect(resolveProviderRuntimePlugin({ provider: DEMO_PROVIDER_ID, config })).toBe( + secondProvider, + ); + } finally { + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + if (originalOpenClawHome === undefined) { + delete process.env.OPENCLAW_HOME; + } else { + process.env.OPENCLAW_HOME = originalOpenClawHome; + } + } + + expect(resolvePluginProvidersMock).toHaveBeenCalledTimes(2); + }); + it("does not reuse auto-enabled runtime providers for synthetic auth fallback", () => { const runtimeProvider: ProviderPlugin = { id: DEMO_PROVIDER_ID,