Skip to content

Commit f11046e

Browse files
committed
refactor: unify plugin control-plane cache context
1 parent 8668471 commit f11046e

12 files changed

Lines changed: 414 additions & 31 deletions

src/agents/provider-auth-aliases.test.ts

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, expect, it, vi } from "vitest";
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
22

33
const pluginRegistryMocks = vi.hoisted(() => {
44
const loadManifestRegistry = vi.fn();
@@ -20,9 +20,20 @@ vi.mock("../plugins/plugin-registry.js", () => ({
2020
loadPluginRegistrySnapshot: pluginRegistryMocks.loadPluginRegistrySnapshot,
2121
}));
2222

23-
import { resolveProviderIdForAuth } from "./provider-auth-aliases.js";
23+
import {
24+
resetProviderAuthAliasMapCacheForTest,
25+
resolveProviderIdForAuth,
26+
} from "./provider-auth-aliases.js";
2427

2528
describe("provider auth aliases", () => {
29+
beforeEach(() => {
30+
resetProviderAuthAliasMapCacheForTest();
31+
pluginRegistryMocks.loadPluginManifestRegistryForInstalledIndex.mockReset();
32+
pluginRegistryMocks.loadPluginManifestRegistryForPluginRegistry.mockReset();
33+
pluginRegistryMocks.loadPluginRegistrySnapshot.mockReset();
34+
pluginRegistryMocks.loadPluginRegistrySnapshot.mockReturnValue({ plugins: [] });
35+
});
36+
2637
it("treats deprecated auth choice ids as provider auth aliases", () => {
2738
pluginRegistryMocks.loadPluginManifestRegistryForInstalledIndex.mockReturnValue({
2839
plugins: [
@@ -46,4 +57,39 @@ describe("provider auth aliases", () => {
4657
expect(resolveProviderIdForAuth("openai-codex-import")).toBe("openai-codex");
4758
expect(resolveProviderIdForAuth("openai-codex")).toBe("openai-codex");
4859
});
60+
61+
it("does not reuse aliases across env-resolved plugin roots", () => {
62+
const env = {
63+
HOME: "/home/one",
64+
OPENCLAW_HOME: undefined,
65+
} as NodeJS.ProcessEnv;
66+
pluginRegistryMocks.loadPluginManifestRegistryForPluginRegistry
67+
.mockReturnValueOnce({
68+
plugins: [
69+
{
70+
id: "one",
71+
origin: "global",
72+
providerAuthAliases: { fixture: "provider-one" },
73+
},
74+
],
75+
diagnostics: [],
76+
})
77+
.mockReturnValueOnce({
78+
plugins: [
79+
{
80+
id: "two",
81+
origin: "global",
82+
providerAuthAliases: { fixture: "provider-two" },
83+
},
84+
],
85+
diagnostics: [],
86+
});
87+
88+
expect(resolveProviderIdForAuth("fixture", { config: {}, env })).toBe("provider-one");
89+
env.HOME = "/home/two";
90+
expect(resolveProviderIdForAuth("fixture", { config: {}, env })).toBe("provider-two");
91+
expect(pluginRegistryMocks.loadPluginManifestRegistryForPluginRegistry).toHaveBeenCalledTimes(
92+
2,
93+
);
94+
});
4995
});

src/agents/provider-auth-aliases.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
isWorkspacePluginAllowedByConfig,
55
normalizePluginConfigId,
66
} from "../plugins/plugin-config-trust.js";
7+
import { resolvePluginControlPlaneFingerprint } from "../plugins/plugin-control-plane-context.js";
78
import type { PluginOrigin } from "../plugins/plugin-origin.types.js";
89
import { loadPluginManifestRegistryForPluginRegistry } from "../plugins/plugin-registry.js";
910
import { normalizeProviderId } from "./provider-id.js";
@@ -31,9 +32,16 @@ let providerAuthAliasMapCache = new WeakMap<
3132
Map<string, Record<string, string>>
3233
>();
3334

34-
function buildProviderAuthAliasMapCacheKey(params?: ProviderAuthAliasLookupParams): string {
35+
function buildProviderAuthAliasMapCacheKey(
36+
params: ProviderAuthAliasLookupParams | undefined,
37+
env: NodeJS.ProcessEnv,
38+
): string {
3539
return JSON.stringify({
36-
workspaceDir: params?.workspaceDir ?? "",
40+
pluginControlPlane: resolvePluginControlPlaneFingerprint({
41+
config: params?.config,
42+
env,
43+
workspaceDir: params?.workspaceDir,
44+
}),
3745
includeUntrustedWorkspacePlugins: params?.includeUntrustedWorkspacePlugins === true,
3846
plugins: params?.config?.plugins ?? null,
3947
});
@@ -100,7 +108,7 @@ export function resolveProviderAuthAliasMap(
100108
params?: ProviderAuthAliasLookupParams,
101109
): Record<string, string> {
102110
const env = params?.env ?? process.env;
103-
const cacheKey = buildProviderAuthAliasMapCacheKey(params);
111+
const cacheKey = buildProviderAuthAliasMapCacheKey(params, env);
104112
let envCache = providerAuthAliasMapCache.get(env);
105113
if (!envCache) {
106114
envCache = new Map<string, Record<string, string>>();

src/plugins/current-plugin-metadata-snapshot.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,23 @@ describe("current plugin metadata snapshot", () => {
117117
expect(getCurrentPluginMetadataSnapshot({ config, env: requestedEnv })).toBeUndefined();
118118
});
119119

120+
it("rejects a current snapshot when env-resolved plugin roots change", () => {
121+
const config = {};
122+
const snapshot = createSnapshot({ config });
123+
const snapshotEnv = {
124+
HOME: "/home/snapshot",
125+
OPENCLAW_HOME: undefined,
126+
} as NodeJS.ProcessEnv;
127+
const requestedEnv = {
128+
HOME: "/home/requested",
129+
OPENCLAW_HOME: undefined,
130+
} as NodeJS.ProcessEnv;
131+
setCurrentPluginMetadataSnapshot(snapshot, { config, env: snapshotEnv });
132+
133+
expect(getCurrentPluginMetadataSnapshot({ config, env: snapshotEnv })).toBe(snapshot);
134+
expect(getCurrentPluginMetadataSnapshot({ config, env: requestedEnv })).toBeUndefined();
135+
});
136+
120137
it("keeps source-policy compatibility when storing an auto-enabled runtime config", () => {
121138
const sourceConfig = { channels: { telegram: { botToken: "token" } } };
122139
const autoEnabledConfig = {

src/plugins/current-plugin-metadata-snapshot.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,16 @@ export { resolvePluginMetadataSnapshotConfigFingerprint } from "./plugin-metadat
1313
// never accumulate historical metadata snapshots here.
1414
export function setCurrentPluginMetadataSnapshot(
1515
snapshot: PluginMetadataSnapshot | undefined,
16-
options: { config?: OpenClawConfig; env?: NodeJS.ProcessEnv } = {},
16+
options: { config?: OpenClawConfig; env?: NodeJS.ProcessEnv; workspaceDir?: string } = {},
1717
): void {
1818
setCurrentPluginMetadataSnapshotState(
1919
snapshot,
2020
snapshot
2121
? resolvePluginMetadataSnapshotConfigFingerprint(options.config, {
2222
env: options.env,
23+
index: snapshot.index,
2324
policyHash: snapshot.policyHash,
25+
workspaceDir: options.workspaceDir ?? snapshot.workspaceDir,
2426
})
2527
: undefined,
2628
);
@@ -53,6 +55,9 @@ export function getCurrentPluginMetadataSnapshot(
5355
params.config,
5456
{
5557
env: params.env,
58+
index: snapshot.index,
59+
policyHash: snapshot.policyHash,
60+
workspaceDir: params.workspaceDir,
5661
},
5762
);
5863
if (configFingerprint && configFingerprint !== requestedConfigFingerprint) {

src/plugins/loader.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,10 @@ import {
102102
} from "./memory-state.js";
103103
import { unwrapDefaultModuleExport } from "./module-export.js";
104104
import { tryNativeRequireJavaScriptModule } from "./native-module-require.js";
105+
import {
106+
fingerprintPluginDiscoveryContext,
107+
resolvePluginDiscoveryContext,
108+
} from "./plugin-control-plane-context.js";
105109
import { withProfile } from "./plugin-load-profile.js";
106110
import {
107111
getCachedPluginSourceModuleLoader,
@@ -116,7 +120,6 @@ import {
116120
import { ensureOpenClawPluginSdkAlias } from "./plugin-sdk-dist-alias.js";
117121
import { createEmptyPluginRegistry } from "./registry-empty.js";
118122
import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js";
119-
import { resolvePluginCacheInputs } from "./roots.js";
120123
import {
121124
getActivePluginRegistry,
122125
getActivePluginRegistryKey,
@@ -616,11 +619,12 @@ function buildCacheKey(params: {
616619
coreGatewayMethodNames?: string[];
617620
activate?: boolean;
618621
}): string {
619-
const { roots, loadPaths } = resolvePluginCacheInputs({
622+
const discoveryContext = resolvePluginDiscoveryContext({
620623
workspaceDir: params.workspaceDir,
621624
loadPaths: params.plugins.loadPaths,
622625
env: params.env,
623626
});
627+
const { roots, loadPaths } = discoveryContext;
624628
const bundledPackage = resolveBundledPackageCacheIdentity(roots.stock);
625629
const installs = Object.fromEntries(
626630
Object.entries(params.installs ?? {}).map(([pluginId, install]) => [
@@ -655,6 +659,7 @@ function buildCacheKey(params: {
655659
const activationMode = params.activate === false ? "snapshot" : "active";
656660
return `${roots.workspace ?? ""}::${roots.global ?? ""}::${roots.stock ?? ""}::${JSON.stringify({
657661
bundledPackage,
662+
discoveryFingerprint: fingerprintPluginDiscoveryContext(discoveryContext),
658663
...params.plugins,
659664
installs,
660665
loadPaths,
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { describe, expect, it } from "vitest";
2+
import type { InstalledPluginIndex } from "./installed-plugin-index.js";
3+
import {
4+
resolvePluginControlPlaneContext,
5+
resolvePluginControlPlaneFingerprint,
6+
resolvePluginDiscoveryContext,
7+
resolvePluginDiscoveryFingerprint,
8+
} from "./plugin-control-plane-context.js";
9+
10+
function createIndex(pluginId: string): InstalledPluginIndex {
11+
return {
12+
version: 1,
13+
hostContractVersion: "test",
14+
compatRegistryVersion: "test",
15+
migrationVersion: 1,
16+
policyHash: "policy",
17+
generatedAtMs: 1,
18+
installRecords: {},
19+
diagnostics: [],
20+
plugins: [
21+
{
22+
pluginId,
23+
manifestPath: `/plugins/${pluginId}/openclaw.plugin.json`,
24+
manifestHash: `${pluginId}-manifest-hash`,
25+
rootDir: `/plugins/${pluginId}`,
26+
origin: "global",
27+
enabled: true,
28+
startup: {
29+
sidecar: false,
30+
memory: false,
31+
deferConfiguredChannelFullLoadUntilAfterListen: false,
32+
agentHarnesses: [],
33+
},
34+
compat: [],
35+
},
36+
],
37+
};
38+
}
39+
40+
describe("plugin control-plane context", () => {
41+
it("resolves env-sensitive discovery roots and load paths before fingerprinting", () => {
42+
const config = { plugins: { load: { paths: ["~/plugins", "/opt/shared"] } } };
43+
const envA = { HOME: "/home/a", OPENCLAW_HOME: "/openclaw/a" } as NodeJS.ProcessEnv;
44+
const envB = { HOME: "/home/b", OPENCLAW_HOME: "/openclaw/b" } as NodeJS.ProcessEnv;
45+
46+
const contextA = resolvePluginDiscoveryContext({ config, env: envA });
47+
const contextB = resolvePluginDiscoveryContext({ config, env: envB });
48+
49+
expect(contextA.loadPaths).toEqual(["/openclaw/a/plugins", "/opt/shared"]);
50+
expect(contextB.loadPaths).toEqual(["/openclaw/b/plugins", "/opt/shared"]);
51+
expect(resolvePluginDiscoveryFingerprint({ config, env: envA })).not.toBe(
52+
resolvePluginDiscoveryFingerprint({ config, env: envB }),
53+
);
54+
});
55+
56+
it("includes policy, inventory, and activation in one control-plane fingerprint", () => {
57+
const config = { plugins: { allow: ["demo"] } };
58+
const base = resolvePluginControlPlaneFingerprint({
59+
config,
60+
env: { HOME: "/home/a", OPENCLAW_HOME: "/openclaw/a" } as NodeJS.ProcessEnv,
61+
index: createIndex("demo"),
62+
activationFingerprint: "activation-a",
63+
});
64+
65+
expect(
66+
resolvePluginControlPlaneFingerprint({
67+
config,
68+
env: { HOME: "/home/a", OPENCLAW_HOME: "/openclaw/a" } as NodeJS.ProcessEnv,
69+
index: createIndex("other"),
70+
activationFingerprint: "activation-a",
71+
}),
72+
).not.toBe(base);
73+
expect(
74+
resolvePluginControlPlaneFingerprint({
75+
config,
76+
env: { HOME: "/home/a", OPENCLAW_HOME: "/openclaw/a" } as NodeJS.ProcessEnv,
77+
index: createIndex("demo"),
78+
activationFingerprint: "activation-b",
79+
}),
80+
).not.toBe(base);
81+
expect(
82+
resolvePluginControlPlaneFingerprint({
83+
config: { plugins: { deny: ["demo"] } },
84+
env: { HOME: "/home/a", OPENCLAW_HOME: "/openclaw/a" } as NodeJS.ProcessEnv,
85+
index: createIndex("demo"),
86+
activationFingerprint: "activation-a",
87+
}),
88+
).not.toBe(base);
89+
});
90+
91+
it("keeps the canonical context inspectable for cache diagnostics", () => {
92+
const context = resolvePluginControlPlaneContext({
93+
config: { plugins: { load: { paths: ["/opt/plugins"] } } },
94+
env: { HOME: "/home/a", OPENCLAW_HOME: "/openclaw/a" } as NodeJS.ProcessEnv,
95+
inventoryFingerprint: "inventory",
96+
policyHash: "policy",
97+
});
98+
99+
expect(context).toMatchObject({
100+
discovery: {
101+
loadPaths: ["/opt/plugins"],
102+
roots: {
103+
global: "/openclaw/a/.openclaw/extensions",
104+
},
105+
},
106+
inventoryFingerprint: "inventory",
107+
policyFingerprint: "policy",
108+
});
109+
});
110+
});
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import type { OpenClawConfig } from "../config/types.openclaw.js";
2+
import { hashJson } from "./installed-plugin-index-hash.js";
3+
import { resolveInstalledPluginIndexPolicyHash } from "./installed-plugin-index-policy.js";
4+
import type { InstalledPluginIndex } from "./installed-plugin-index.js";
5+
import { resolveInstalledManifestRegistryIndexFingerprint } from "./manifest-registry-installed.js";
6+
import { resolvePluginCacheInputs, type PluginSourceRoots } from "./roots.js";
7+
8+
export type PluginDiscoveryContext = {
9+
roots: PluginSourceRoots;
10+
loadPaths: readonly string[];
11+
};
12+
13+
export type PluginControlPlaneContext = {
14+
discovery: PluginDiscoveryContext;
15+
policyFingerprint: string;
16+
inventoryFingerprint?: string;
17+
activationFingerprint?: string;
18+
};
19+
20+
export type ResolvePluginDiscoveryContextParams = {
21+
config?: OpenClawConfig;
22+
env?: NodeJS.ProcessEnv;
23+
workspaceDir?: string;
24+
loadPaths?: readonly string[];
25+
};
26+
27+
export type ResolvePluginControlPlaneContextParams = ResolvePluginDiscoveryContextParams & {
28+
activationFingerprint?: string;
29+
index?: InstalledPluginIndex;
30+
inventoryFingerprint?: string;
31+
policyHash?: string;
32+
};
33+
34+
function resolveConfiguredPluginLoadPaths(
35+
config: OpenClawConfig | undefined,
36+
): readonly string[] | undefined {
37+
const paths = config?.plugins?.load?.paths;
38+
return Array.isArray(paths) ? paths : undefined;
39+
}
40+
41+
export function resolvePluginDiscoveryContext(
42+
params: ResolvePluginDiscoveryContextParams = {},
43+
): PluginDiscoveryContext {
44+
return resolvePluginCacheInputs({
45+
env: params.env ?? process.env,
46+
workspaceDir: params.workspaceDir,
47+
loadPaths: [...(params.loadPaths ?? resolveConfiguredPluginLoadPaths(params.config) ?? [])],
48+
});
49+
}
50+
51+
export function resolvePluginDiscoveryFingerprint(
52+
params: ResolvePluginDiscoveryContextParams = {},
53+
): string {
54+
return fingerprintPluginDiscoveryContext(resolvePluginDiscoveryContext(params));
55+
}
56+
57+
export function fingerprintPluginDiscoveryContext(context: PluginDiscoveryContext): string {
58+
return hashJson(context);
59+
}
60+
61+
export function resolvePluginControlPlaneContext(
62+
params: ResolvePluginControlPlaneContextParams = {},
63+
): PluginControlPlaneContext {
64+
const inventoryFingerprint =
65+
params.inventoryFingerprint ??
66+
(params.index ? resolveInstalledManifestRegistryIndexFingerprint(params.index) : undefined);
67+
return {
68+
discovery: resolvePluginDiscoveryContext(params),
69+
policyFingerprint: params.policyHash ?? resolveInstalledPluginIndexPolicyHash(params.config),
70+
...(inventoryFingerprint ? { inventoryFingerprint } : {}),
71+
...(params.activationFingerprint
72+
? { activationFingerprint: params.activationFingerprint }
73+
: {}),
74+
};
75+
}
76+
77+
export function resolvePluginControlPlaneFingerprint(
78+
params: ResolvePluginControlPlaneContextParams = {},
79+
): string {
80+
return fingerprintPluginControlPlaneContext(resolvePluginControlPlaneContext(params));
81+
}
82+
83+
export function fingerprintPluginControlPlaneContext(context: PluginControlPlaneContext): string {
84+
return hashJson(context);
85+
}

0 commit comments

Comments
 (0)