refactor: unify plugin control-plane cache context

This commit is contained in:
Peter Steinberger
2026-05-02 04:10:45 +01:00
parent 86684715b9
commit f11046e0bf
12 changed files with 414 additions and 31 deletions

View File

@@ -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,
);
});
});

View File

@@ -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<string, Record<string, string>>
>();
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<string, string> {
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<string, Record<string, string>>();

View File

@@ -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 = {

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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",
});
});
});

View File

@@ -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);
}

View File

@@ -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({

View File

@@ -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,
});
}

View File

@@ -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,

View File

@@ -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 ?? "",

View File

@@ -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,