fix(plugins): reuse gateway lifecycle metadata and isolate plugin state (#114476)

* fix(plugins): reuse gateway metadata and preserve reload isolation

* test(plugins): split lifecycle regressions and simplify gateway dispatch

* perf(plugins): reuse proven immutable snapshot graphs

* test(plugins): isolate snapshot lifecycle regression mocks

* perf(plugins): retain one gateway metadata cache

* test(plugins): preserve frozen proxy descriptor invariants

* test(plugins): isolate lifecycle metadata mock ownership

* test(plugins): bind shared worker mocks before module evaluation
This commit is contained in:
Peter Steinberger
2026-07-27 07:48:13 -04:00
committed by GitHub
parent 91f04499f5
commit 3fb201c5a7
36 changed files with 2259 additions and 245 deletions
@@ -0,0 +1,295 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const manifestMocks = vi.hoisted(() => ({
getCurrentPluginMetadataSnapshot: vi.fn(),
listOpenClawPluginManifestMetadata: vi.fn(),
loadPluginManifest: vi.fn(),
loadPluginManifestRegistry: vi.fn(),
}));
const providerMocks = vi.hoisted(() => ({
normalizePluginDiscoveryResult: vi.fn(),
resolveActivatableProviderOwnerPluginIds: vi.fn(),
resolveBundledProviderCompatPluginIds: vi.fn(),
resolveOwningPluginIdsForProviderRef: vi.fn(),
resolveRuntimePluginDiscoveryProviders: vi.fn(),
runProviderStaticCatalog: vi.fn(),
}));
vi.mock("../../plugins/current-plugin-metadata-snapshot.js", () => ({
getCurrentPluginMetadataSnapshot: manifestMocks.getCurrentPluginMetadataSnapshot,
}));
vi.mock("../../plugins/manifest-metadata-scan.js", () => ({
listOpenClawPluginManifestMetadata: manifestMocks.listOpenClawPluginManifestMetadata,
}));
vi.mock("../../plugins/manifest.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../plugins/manifest.js")>()),
loadPluginManifest: manifestMocks.loadPluginManifest,
}));
vi.mock("../../plugins/manifest-registry.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../plugins/manifest-registry.js")>()),
loadPluginManifestRegistry: manifestMocks.loadPluginManifestRegistry,
}));
vi.mock("../../plugins/providers.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../plugins/providers.js")>()),
resolveActivatableProviderOwnerPluginIds: providerMocks.resolveActivatableProviderOwnerPluginIds,
resolveBundledProviderCompatPluginIds: providerMocks.resolveBundledProviderCompatPluginIds,
resolveOwningPluginIdsForProviderRef: providerMocks.resolveOwningPluginIdsForProviderRef,
}));
vi.mock("../../plugins/provider-discovery.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../plugins/provider-discovery.js")>()),
normalizePluginDiscoveryResult: providerMocks.normalizePluginDiscoveryResult,
resolveRuntimePluginDiscoveryProviders: providerMocks.resolveRuntimePluginDiscoveryProviders,
runProviderStaticCatalog: providerMocks.runProviderStaticCatalog,
}));
import {
bundledStaticCatalogProviderUsesRuntimeAugment,
createBundledStaticCatalogModelResolver,
loadBundledProviderStaticCatalogContextModels,
resolveBundledStaticCatalogModel,
} from "./model.static-catalog.js";
function createMistralManifestPlugin() {
return {
id: "mistral",
origin: "bundled",
providers: ["mistral"],
modelCatalog: {
providers: {
mistral: {
baseUrl: "https://api.mistral.ai/v1",
api: "openai-completions",
models: [
{
id: "mistral-medium-3-5",
name: "Mistral Medium 3.5",
contextWindow: 262144,
maxTokens: 8192,
},
],
},
},
discovery: { mistral: "static" },
},
};
}
function setCurrentManifestPlugins(plugins: unknown[]) {
const snapshot = { plugins, manifestRegistry: { plugins } };
manifestMocks.getCurrentPluginMetadataSnapshot.mockReturnValue(snapshot);
}
function setManifestPlugins(plugins: unknown[]) {
const byPluginDir = new Map(
plugins.map((plugin) => {
const id = (plugin as { id?: string }).id ?? "plugin";
return [`/fixtures/${id}`, plugin];
}),
);
manifestMocks.listOpenClawPluginManifestMetadata.mockReturnValue(
[...byPluginDir].map(([pluginDir, plugin]) => ({
pluginDir,
manifest: plugin,
origin: (plugin as { origin?: string }).origin,
})),
);
manifestMocks.loadPluginManifest.mockImplementation((pluginDir: string) => {
const plugin = byPluginDir.get(pluginDir);
return plugin
? { ok: true, manifest: plugin }
: { ok: false, error: "missing manifest", manifestPath: `${pluginDir}/openclaw.plugin.json` };
});
}
beforeEach(() => {
for (const mock of Object.values(manifestMocks)) {
mock.mockReset();
}
for (const mock of Object.values(providerMocks)) {
mock.mockReset();
}
manifestMocks.listOpenClawPluginManifestMetadata.mockReturnValue([]);
manifestMocks.loadPluginManifestRegistry.mockReturnValue({ plugins: [] });
providerMocks.resolveActivatableProviderOwnerPluginIds.mockImplementation(
({ pluginIds }: { pluginIds: string[] }) => pluginIds,
);
providerMocks.resolveBundledProviderCompatPluginIds.mockReturnValue([]);
providerMocks.resolveOwningPluginIdsForProviderRef.mockReturnValue(undefined);
providerMocks.resolveRuntimePluginDiscoveryProviders.mockResolvedValue([]);
providerMocks.runProviderStaticCatalog.mockResolvedValue(undefined);
providerMocks.normalizePluginDiscoveryResult.mockReturnValue({});
});
describe("bundled static model catalog snapshot cache", () => {
it("reuses the current plugin snapshot across separate static model lookups", () => {
const cfg = {};
setCurrentManifestPlugins([createMistralManifestPlugin()]);
expect(
resolveBundledStaticCatalogModel({
provider: "mistral",
modelId: "mistral-medium-3-5",
cfg,
})?.id,
).toBe("mistral-medium-3-5");
expect(
resolveBundledStaticCatalogModel({ provider: "mistral", modelId: "missing", cfg }),
).toBeUndefined();
expect(manifestMocks.getCurrentPluginMetadataSnapshot).toHaveBeenCalledWith({
config: cfg,
env: process.env,
workspaceDir: undefined,
allowWorkspaceScopedSnapshot: true,
});
expect(manifestMocks.listOpenClawPluginManifestMetadata).not.toHaveBeenCalled();
expect(manifestMocks.loadPluginManifest).not.toHaveBeenCalled();
});
it("observes replacement plugin generations inside a prepared model resolver", () => {
const cfg = {};
setCurrentManifestPlugins([createMistralManifestPlugin()]);
const resolveModel = createBundledStaticCatalogModelResolver({ cfg });
expect(resolveModel({ provider: "mistral", modelId: "mistral-medium-3-5" })?.id).toBe(
"mistral-medium-3-5",
);
const replacementPlugin = createMistralManifestPlugin();
replacementPlugin.modelCatalog.providers.mistral.models =
replacementPlugin.modelCatalog.providers.mistral.models.map((model) => ({
...model,
id: "mistral-medium-next",
name: "Mistral Medium Next",
}));
setCurrentManifestPlugins([replacementPlugin]);
expect(resolveModel({ provider: "mistral", modelId: "mistral-medium-3-5" })).toBeUndefined();
expect(resolveModel({ provider: "mistral", modelId: "mistral-medium-next" })?.name).toBe(
"Mistral Medium Next",
);
expect(manifestMocks.listOpenClawPluginManifestMetadata).not.toHaveBeenCalled();
expect(manifestMocks.loadPluginManifest).not.toHaveBeenCalled();
});
it("uses the matching configured workspace snapshot", () => {
const cfg = {};
const workspaceDir = "/configured-workspace";
setCurrentManifestPlugins([createMistralManifestPlugin()]);
expect(
resolveBundledStaticCatalogModel({
provider: "mistral",
modelId: "mistral-medium-3-5",
cfg,
workspaceDir,
})?.id,
).toBe("mistral-medium-3-5");
expect(manifestMocks.getCurrentPluginMetadataSnapshot).toHaveBeenCalledWith({
config: cfg,
env: process.env,
workspaceDir,
});
expect(manifestMocks.listOpenClawPluginManifestMetadata).not.toHaveBeenCalled();
});
it("requires the default discovery context for unconfigured snapshot lookups", () => {
setCurrentManifestPlugins([createMistralManifestPlugin()]);
expect(
resolveBundledStaticCatalogModel({ provider: "mistral", modelId: "mistral-medium-3-5" })?.id,
).toBe("mistral-medium-3-5");
expect(manifestMocks.getCurrentPluginMetadataSnapshot).toHaveBeenCalledWith({
config: undefined,
env: process.env,
workspaceDir: undefined,
allowWorkspaceScopedSnapshot: true,
requireDefaultDiscoveryContext: true,
});
expect(manifestMocks.listOpenClawPluginManifestMetadata).not.toHaveBeenCalled();
});
it("keeps a custom environment on its own manifest discovery path", () => {
const env = { HOME: "/custom-home" };
const plugin = createMistralManifestPlugin();
setManifestPlugins([plugin]);
setCurrentManifestPlugins([plugin]);
expect(
resolveBundledStaticCatalogModel({
provider: "mistral",
modelId: "mistral-medium-3-5",
cfg: {},
env,
})?.id,
).toBe("mistral-medium-3-5");
expect(manifestMocks.getCurrentPluginMetadataSnapshot).not.toHaveBeenCalledWith(
expect.objectContaining({ env }),
);
expect(manifestMocks.listOpenClawPluginManifestMetadata).toHaveBeenCalledWith(env);
expect(manifestMocks.loadPluginManifest).toHaveBeenCalledTimes(1);
});
it("preserves plugin enablement policy for current snapshot catalog rows", () => {
setCurrentManifestPlugins([createMistralManifestPlugin()]);
for (const cfg of [
{ plugins: { enabled: false } },
{ plugins: { entries: { mistral: { enabled: false } } } },
{ plugins: { deny: ["mistral"] } },
{ plugins: { allow: ["google"] } },
]) {
expect(
resolveBundledStaticCatalogModel({
provider: "mistral",
modelId: "mistral-medium-3-5",
cfg,
}),
).toBeUndefined();
}
expect(manifestMocks.listOpenClawPluginManifestMetadata).not.toHaveBeenCalled();
expect(manifestMocks.loadPluginManifest).not.toHaveBeenCalled();
});
it("reuses current snapshot rows for runtime-augmentation lookup", () => {
const cfg = {};
const plugin = createMistralManifestPlugin();
setCurrentManifestPlugins([
{ ...plugin, modelCatalog: { ...plugin.modelCatalog, runtimeAugment: true } },
]);
expect(bundledStaticCatalogProviderUsesRuntimeAugment({ provider: "mistral", cfg })).toBe(true);
expect(bundledStaticCatalogProviderUsesRuntimeAugment({ provider: "mistral", cfg })).toBe(true);
expect(manifestMocks.listOpenClawPluginManifestMetadata).not.toHaveBeenCalled();
expect(manifestMocks.loadPluginManifest).not.toHaveBeenCalled();
});
it("reuses current snapshot manifests for provider static context warmup", async () => {
const cfg = { plugins: { entries: { google: { enabled: true } } } };
const provider = { id: "google", pluginId: "google", label: "Google", auth: [] };
setCurrentManifestPlugins([
{
id: "google",
origin: "bundled",
providerDiscoverySource: "/fixtures/google/provider-discovery.ts",
},
]);
providerMocks.resolveBundledProviderCompatPluginIds.mockReturnValue(["google"]);
providerMocks.resolveRuntimePluginDiscoveryProviders.mockResolvedValue([provider]);
providerMocks.normalizePluginDiscoveryResult.mockReturnValue({
google: {
models: [{ id: "gemini-3.1-pro-preview", name: "Gemini Pro", contextWindow: 1_048_576 }],
},
});
await expect(loadBundledProviderStaticCatalogContextModels({ cfg })).resolves.toEqual([
expect.objectContaining({ provider: "google", contextWindow: 1_048_576 }),
]);
expect(manifestMocks.loadPluginManifestRegistry).not.toHaveBeenCalled();
});
});
@@ -7,10 +7,12 @@ import type { ModelProviderConfig } from "../../config/types.models.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { planEffectiveModelCatalogRows } from "../../model-catalog/index.js";
import { normalizePluginsConfig } from "../../plugins/config-state.js";
import { getCurrentPluginMetadataSnapshot } from "../../plugins/current-plugin-metadata-snapshot.js";
import { listOpenClawPluginManifestMetadata } from "../../plugins/manifest-metadata-scan.js";
import { passesManifestOwnerBasePolicy } from "../../plugins/manifest-owner-policy.js";
import { loadPluginManifestRegistry } from "../../plugins/manifest-registry.js";
import { loadPluginManifest } from "../../plugins/manifest.js";
import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js";
import {
normalizePluginDiscoveryResult,
resolveRuntimePluginDiscoveryProviders,
@@ -126,51 +128,113 @@ type StaticCatalogPlugin = Parameters<
typeof planEffectiveModelCatalogRows
>[0]["registry"]["plugins"][number];
function listBundledStaticCatalogPlugins(params: {
type BundledStaticCatalogParams = {
cfg?: OpenClawConfig;
env: NodeJS.ProcessEnv;
}): StaticCatalogPlugin[] {
const normalizedConfig = normalizePluginsConfig(params.cfg?.plugins);
return listOpenClawPluginManifestMetadata(params.env).flatMap((record): StaticCatalogPlugin[] => {
if (record.origin !== "bundled") {
return [];
}
const loaded = loadPluginManifest(record.pluginDir);
if (!loaded.ok || !loaded.manifest.modelCatalog) {
return [];
}
if (
!passesManifestOwnerBasePolicy({
plugin: { id: loaded.manifest.id },
normalizedConfig,
})
) {
return [];
}
return [
{
id: loaded.manifest.id,
providers: loaded.manifest.providers,
modelCatalog: loaded.manifest.modelCatalog,
},
];
workspaceDir?: string;
};
type BundledStaticCatalogState = {
plugins: StaticCatalogPlugin[];
plans: Map<string, ReturnType<typeof planEffectiveModelCatalogRows>>;
};
// Snapshot identity changes at the Gateway reload commit, so old provider plans
// cannot survive into a replacement plugin generation.
const bundledStaticCatalogStatesBySnapshot = new WeakMap<
PluginMetadataSnapshot,
WeakMap<OpenClawConfig, BundledStaticCatalogState>
>();
const defaultBundledStaticCatalogConfig: OpenClawConfig = {};
function resolveBundledStaticCatalogMetadataSnapshot(
params: BundledStaticCatalogParams,
): PluginMetadataSnapshot | undefined {
if (params.env !== process.env) {
return undefined;
}
return getCurrentPluginMetadataSnapshot({
config: params.cfg,
env: params.env,
workspaceDir: params.workspaceDir,
...(params.workspaceDir === undefined ? { allowWorkspaceScopedSnapshot: true } : {}),
...(params.cfg === undefined ? { requireDefaultDiscoveryContext: true } : {}),
});
}
function listBundledStaticCatalogPlugins(
params: BundledStaticCatalogParams,
metadataSnapshot?: PluginMetadataSnapshot,
): StaticCatalogPlugin[] {
const normalizedConfig = normalizePluginsConfig(params.cfg?.plugins);
const plugins: StaticCatalogPlugin[] = metadataSnapshot
? metadataSnapshot.plugins
.filter((plugin) => plugin.origin === "bundled")
.map(({ id, providers, modelCatalog }) => ({ id, providers, modelCatalog }))
: listOpenClawPluginManifestMetadata(params.env).flatMap((record): StaticCatalogPlugin[] => {
if (record.origin !== "bundled") {
return [];
}
const loaded = loadPluginManifest(record.pluginDir);
return loaded.ok
? [
{
id: loaded.manifest.id,
providers: loaded.manifest.providers,
modelCatalog: loaded.manifest.modelCatalog,
},
]
: [];
});
return plugins.filter(
(plugin) =>
Boolean(plugin.modelCatalog) && passesManifestOwnerBasePolicy({ plugin, normalizedConfig }),
);
}
function resolveSnapshotBundledStaticCatalogState(
params: BundledStaticCatalogParams,
metadataSnapshot: PluginMetadataSnapshot,
): BundledStaticCatalogState {
let states = bundledStaticCatalogStatesBySnapshot.get(metadataSnapshot);
if (!states) {
states = new WeakMap();
bundledStaticCatalogStatesBySnapshot.set(metadataSnapshot, states);
}
const config = params.cfg ?? defaultBundledStaticCatalogConfig;
const cached = states.get(config);
if (cached) {
return cached;
}
const state = {
plugins: listBundledStaticCatalogPlugins(params, metadataSnapshot),
plans: new Map<string, ReturnType<typeof planEffectiveModelCatalogRows>>(),
};
states.set(config, state);
return state;
}
/** Returns whether a bundled static catalog asks runtime discovery to augment its rows. */
export function bundledStaticCatalogProviderUsesRuntimeAugment(params: {
provider: string;
cfg?: OpenClawConfig;
env?: NodeJS.ProcessEnv;
workspaceDir?: string;
}): boolean {
const provider = normalizeProviderId(params.provider);
if (!provider) {
return false;
}
return listBundledStaticCatalogPlugins({
const catalogParams = {
cfg: params.cfg,
env: params.env ?? process.env,
}).some((plugin) => {
workspaceDir: params.workspaceDir,
};
const metadataSnapshot = resolveBundledStaticCatalogMetadataSnapshot(catalogParams);
const plugins = metadataSnapshot
? resolveSnapshotBundledStaticCatalogState(catalogParams, metadataSnapshot).plugins
: listBundledStaticCatalogPlugins(catalogParams);
return plugins.some((plugin) => {
const catalog = plugin.modelCatalog;
if (catalog?.runtimeAugment !== true) {
return false;
@@ -216,25 +280,37 @@ export function createBundledStaticCatalogModelResolver(params?: {
cfg?: OpenClawConfig;
env?: NodeJS.ProcessEnv;
includeRuntimeDiscovery?: boolean;
workspaceDir?: string;
}): (lookup: BundledStaticCatalogLookup) => ProviderRuntimeModel | undefined {
const bundledStaticPlugins = listBundledStaticCatalogPlugins({
const catalogParams = {
cfg: params?.cfg,
env: params?.env ?? process.env,
});
const plans = new Map<string, ReturnType<typeof planEffectiveModelCatalogRows>>();
workspaceDir: params?.workspaceDir,
};
let standaloneState: BundledStaticCatalogState | undefined;
return (lookup) => {
const provider = normalizeProviderId(lookup.provider);
if (!provider || !lookup.modelId.trim() || bundledStaticPlugins.length === 0) {
if (!provider || !lookup.modelId.trim()) {
return undefined;
}
let plan = plans.get(provider);
const metadataSnapshot = resolveBundledStaticCatalogMetadataSnapshot(catalogParams);
const state = metadataSnapshot
? resolveSnapshotBundledStaticCatalogState(catalogParams, metadataSnapshot)
: (standaloneState ??= {
plugins: listBundledStaticCatalogPlugins(catalogParams),
plans: new Map(),
});
if (state.plugins.length === 0) {
return undefined;
}
let plan = state.plans.get(provider);
if (!plan) {
plan = planEffectiveModelCatalogRows({
registry: { plugins: bundledStaticPlugins },
registry: { plugins: state.plugins },
config: params?.cfg ?? {},
providerFilter: provider,
});
plans.set(provider, plan);
state.plans.set(provider, plan);
}
for (const entry of plan.entries) {
if (
@@ -246,7 +322,7 @@ export function createBundledStaticCatalogModelResolver(params?: {
) {
continue;
}
const row = entry.rows.find((candidate) =>
const row = entry.rows.find((candidate: NormalizedModelCatalogRow) =>
rowMatchesModel({
row: candidate,
provider,
@@ -276,6 +352,7 @@ export function resolveBundledStaticCatalogModel(
...(params.includeRuntimeDiscovery !== undefined
? { includeRuntimeDiscovery: params.includeRuntimeDiscovery }
: {}),
...(params.workspaceDir !== undefined ? { workspaceDir: params.workspaceDir } : {}),
})(params);
}
@@ -367,12 +444,20 @@ export async function loadBundledProviderStaticCatalogContextModels(
params: BundledProviderStaticCatalogResolverParams = {},
): Promise<ProviderRuntimeModel[]> {
const env = params.env ?? process.env;
const metadataSnapshot = resolveBundledStaticCatalogMetadataSnapshot({
cfg: params.cfg,
env,
workspaceDir: params.workspaceDir,
});
const discoveryEntryPluginIds = new Set(
loadPluginManifestRegistry({
config: params.cfg,
workspaceDir: params.workspaceDir,
env,
}).plugins.flatMap((plugin) =>
(
metadataSnapshot?.manifestRegistry?.plugins ??
loadPluginManifestRegistry({
config: params.cfg,
workspaceDir: params.workspaceDir,
env,
}).plugins
).flatMap((plugin) =>
plugin.origin === "bundled" && plugin.providerDiscoverySource ? [plugin.id] : [],
),
);
+31
View File
@@ -52,6 +52,7 @@ import {
import { resolveInstalledPluginIndexPolicyHash } from "../plugins/installed-plugin-index-policy.js";
import type { InstalledPluginIndexRecord } from "../plugins/installed-plugin-index.js";
import type { PluginManifestRecord } from "../plugins/manifest-registry.js";
import { clearPluginMetadataLifecycleCaches } from "../plugins/plugin-metadata-lifecycle.js";
import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js";
import { resolveProviderIdForAuth } from "./provider-auth-aliases.js";
import { resetProviderAuthAliasMapCacheForTest } from "./provider-auth-aliases.test-support.js";
@@ -211,6 +212,36 @@ describe("provider auth aliases", () => {
expect(resolveProviderIdForAuth("fixture", { config, env })).toBe("provider-two");
});
it("refreshes cached aliases when plugin metadata changes without changing config or env", () => {
const config = {};
const env = { HOME: "/home/test" } as NodeJS.ProcessEnv;
const setProviderAuthAlias = (target: string) => {
setCurrentPluginMetadataSnapshot(
createPluginMetadataSnapshot({
config,
plugins: [
createPluginManifestRecord({
id: "alias-owner",
origin: "global",
providerAuthAliases: { fixture: target },
}),
],
}),
{ config, env },
);
};
setProviderAuthAlias("provider-one");
expect(resolveProviderIdForAuth("fixture", { config, env })).toBe("provider-one");
clearPluginMetadataLifecycleCaches();
setProviderAuthAlias("provider-two");
expect(resolveProviderIdForAuth("fixture", { config, env })).toBe("provider-two");
expect(pluginRegistryMocks.loadPluginMetadataSnapshot).not.toHaveBeenCalled();
});
it("uses caller-provided metadata snapshots without loading plugin metadata", () => {
const env = { HOME: "/home/test" } as NodeJS.ProcessEnv;
const metadataSnapshot = {
+7 -3
View File
@@ -13,6 +13,7 @@ import {
normalizePluginConfigId,
} from "../plugins/plugin-config-trust.js";
import { resolvePluginControlPlaneFingerprint } from "../plugins/plugin-control-plane-context.js";
import { registerPluginMetadataProcessMemoLifecycleClear } from "../plugins/plugin-metadata-lifecycle.js";
import { loadPluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js";
import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js";
import type { PluginOrigin } from "../plugins/plugin-origin.types.js";
@@ -57,15 +58,18 @@ function buildProviderAuthAliasMapCacheKey(
});
}
/** Clear provider auth alias cache for tests that mutate plugin metadata. */
function resetProviderAuthAliasMapCacheForTest(): void {
/** Clears auth aliases when their process-scoped plugin metadata is retired. */
function clearProviderAuthAliasMapCache(): void {
providerAuthAliasMapCache = new WeakMap<NodeJS.ProcessEnv, Map<string, Record<string, string>>>();
}
// Reloads can replace plugin metadata without changing the config or env cache keys.
registerPluginMetadataProcessMemoLifecycleClear(clearProviderAuthAliasMapCache);
if (process.env.VITEST || process.env.NODE_ENV === "test") {
(globalThis as Record<PropertyKey, unknown>)[Symbol.for("openclaw.providerAuthAliasesTestApi")] =
{
resetProviderAuthAliasMapCacheForTest,
resetProviderAuthAliasMapCacheForTest: clearProviderAuthAliasMapCache,
};
}
+15 -30
View File
@@ -55,20 +55,6 @@ function coerceLoadedChannelPlugin(
return plugin as LoadedChannelPlugin;
}
function dedupeChannels(channels: LoadedChannelPlugin[]): LoadedChannelPlugin[] {
const seen = new Set<string>();
const resolved: LoadedChannelPlugin[] = [];
for (const plugin of channels) {
const id = normalizeOptionalString(plugin.id) ?? "";
if (!id || seen.has(id)) {
continue;
}
seen.add(id);
resolved.push(plugin);
}
return resolved;
}
function resolveChannelPlugins(): ChannelPluginView {
const snapshot = getActivePluginChannelRegistrySnapshotFromState();
const cached = cachedChannelPluginView;
@@ -77,19 +63,28 @@ function resolveChannelPlugins(): ChannelPluginView {
}
const registry = snapshot.registry;
const channelPlugins: LoadedChannelPlugin[] = [];
const pluginEntries: LoadedChannelPluginEntry[] = [];
const seen = new Set<string>();
const byId = new Map<string, LoadedChannelPlugin>();
const entriesById = new Map<string, LoadedChannelPluginEntry>();
if (registry && Array.isArray(registry.channels)) {
for (const entry of registry.channels) {
const plugin = coerceLoadedChannelPlugin(entry?.plugin);
if (plugin) {
channelPlugins.push(plugin);
pluginEntries.push({ ...entry, plugin });
if (!plugin) {
continue;
}
const id = normalizeOptionalString(plugin.id) ?? "";
if (!id || seen.has(id)) {
continue;
}
// Channel registration is first-wins. Keep its implementation and
// provenance together so a colliding plugin cannot borrow its authority.
seen.add(id);
byId.set(plugin.id, plugin);
entriesById.set(plugin.id, { ...entry, plugin });
}
}
const sorted = dedupeChannels(channelPlugins).toSorted((a, b) => {
const sorted = [...byId.values()].toSorted((a, b) => {
const indexA = CHAT_CHANNEL_ORDER.indexOf(a.id);
const indexB = CHAT_CHANNEL_ORDER.indexOf(b.id);
// Explicit plugin order wins; known built-ins keep their product order;
@@ -101,16 +96,6 @@ function resolveChannelPlugins(): ChannelPluginView {
}
return a.id.localeCompare(b.id);
});
const byId = new Map<string, LoadedChannelPlugin>();
const entriesById = new Map<string, LoadedChannelPluginEntry>();
const unsortedEntriesById = new Map(pluginEntries.map((entry) => [entry.plugin.id, entry]));
for (const plugin of sorted) {
byId.set(plugin.id, plugin);
const entry = unsortedEntriesById.get(plugin.id);
if (entry) {
entriesById.set(plugin.id, entry);
}
}
// The runtime owns snapshot invalidation across active and pinned registry
// changes. Share one derived view until that lifecycle snapshot changes.
+36
View File
@@ -5,6 +5,7 @@ import type { PluginRegistry } from "../../plugins/registry.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../../plugins/runtime.js";
import {
getChannelPlugin,
getLoadedChannelPlugin,
listChannelPlugins,
resolveChannelPluginRegistration,
} from "./registry.js";
@@ -74,6 +75,41 @@ describe("listChannelPlugins", () => {
});
});
it("keeps the first channel implementation and provenance when channel ids collide", () => {
const registry = createEmptyPluginRegistry();
const firstPlugin = {
id: "duplicate",
meta: { label: "first" },
};
const secondPlugin = {
id: "duplicate",
meta: { label: "second" },
};
registry.channels = [
{
pluginId: "first-channel-plugin",
plugin: firstPlugin as never,
origin: "config",
source: "first",
},
{
pluginId: "second-channel-plugin",
plugin: secondPlugin as never,
origin: "bundled",
source: "second",
},
];
setActivePluginRegistry(registry);
expect(listChannelPlugins()).toEqual([firstPlugin]);
expect(getLoadedChannelPlugin("duplicate")).toBe(firstPlugin);
expect(getChannelPlugin("duplicate")).toBe(firstPlugin);
expect(resolveChannelPluginRegistration("duplicate")).toEqual({
plugin: firstPlugin,
origin: "config",
});
});
it("rebuilds channel lookups when the active registry object changes without a version bump", () => {
const first = createEmptyPluginRegistry();
first.channels = [
+6 -3
View File
@@ -1,7 +1,7 @@
import type { ErrorShape } from "../../packages/gateway-protocol/src/index.js";
import { CORE_BOARD_DATA_BINDING_IDS } from "../boards/board-host-capability-ids.js";
import { BoardValidationError } from "../boards/board-layout.js";
import { getActivePluginRegistry } from "../plugins/runtime.js";
import { getActivePluginSessionExtensionRegistry } from "../plugins/runtime.js";
import { validateJsonSchemaValue } from "../plugins/schema-validator.js";
import { agentsHandlers } from "./server-methods/agents.js";
import { cronHandlers } from "./server-methods/cron.js";
@@ -79,7 +79,9 @@ export async function readBoardDataBinding(
invocation,
);
}
const registration = getActivePluginRegistry()?.dashboardDataBindings.get(bindingId);
// Widget grants belong to the attached gateway, not an agent-scoped runtime registry.
const registration =
getActivePluginSessionExtensionRegistry()?.dashboardDataBindings.get(bindingId);
if (!registration) {
throw new BoardValidationError(
"invalid_operation",
@@ -94,7 +96,8 @@ export async function runBoardActionVerb(
params: Record<string, unknown>,
invocation: GatewayHandlerInvocation,
): Promise<unknown> {
const registration = getActivePluginRegistry()?.dashboardActionVerbs.get(actionId);
const registration =
getActivePluginSessionExtensionRegistry()?.dashboardActionVerbs.get(actionId);
if (!registration) {
throw new BoardValidationError(
"invalid_operation",
+87 -1
View File
@@ -3,7 +3,13 @@
*/
import { afterEach, describe, expect, it } from "vitest";
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import {
pinActivePluginHttpRouteRegistry,
pinActivePluginSessionExtensionRegistry,
releasePinnedPluginHttpRouteRegistry,
releasePinnedPluginSessionExtensionRegistry,
setActivePluginRegistry,
} from "../plugins/runtime.js";
import {
authorizeOperatorScopesForMethod,
isGatewayMethodClassified,
@@ -35,6 +41,8 @@ function setPluginGatewayMethodScope(
}
afterEach(() => {
releasePinnedPluginHttpRouteRegistry();
releasePinnedPluginSessionExtensionRegistry();
setActivePluginRegistry(createEmptyPluginRegistry());
});
@@ -295,6 +303,50 @@ describe("method scope resolution", () => {
).toEqual({ allowed: false, missingScope: "operator.approvals" });
});
it("keeps session action scopes pinned when an agent replaces the active registry", () => {
const gatewayRegistry = createEmptyPluginRegistry();
gatewayRegistry.sessionActions = [
{
pluginId: "scope-plugin",
pluginName: "Scope Plugin",
source: "gateway",
action: {
id: "approve",
requiredScopes: ["operator.approvals"],
handler: () => ({ result: { owner: "gateway" } }),
},
},
];
setActivePluginRegistry(gatewayRegistry);
pinActivePluginSessionExtensionRegistry(gatewayRegistry);
const scopedRegistry = createEmptyPluginRegistry();
scopedRegistry.sessionActions = [
{
pluginId: "scope-plugin",
pluginName: "Scope Plugin",
source: "agent",
action: {
id: "approve",
requiredScopes: ["operator.read"],
handler: () => ({ result: { owner: "agent" } }),
},
},
];
setActivePluginRegistry(scopedRegistry);
const params = { pluginId: "scope-plugin", actionId: "approve" };
expect(resolveLeastPrivilegeOperatorScopesForMethod("plugins.sessionAction", params)).toEqual([
"operator.approvals",
]);
expect(
authorizeOperatorScopesForMethod("plugins.sessionAction", ["operator.read"], params),
).toEqual({ allowed: false, missingScope: "operator.approvals" });
expect(
authorizeOperatorScopesForMethod("plugins.sessionAction", ["operator.approvals"], params),
).toEqual({ allowed: true });
});
it("resolves sessions.patch to write scope for chat-organization fields only", () => {
expect(
resolveLeastPrivilegeOperatorScopesForMethod("sessions.patch", {
@@ -595,6 +647,40 @@ describe("method scope resolution", () => {
]);
});
it("keeps gateway method scopes pinned when an agent replaces the active registry", () => {
const method = "fixture.gateway.inspect";
const gatewayRegistry = createEmptyPluginRegistry();
gatewayRegistry.gatewayHandlers[method] = pluginHandler;
gatewayRegistry.gatewayMethodDescriptors.push(
createPluginGatewayMethodDescriptor({
pluginId: "gateway-fixture",
name: method,
handler: pluginHandler,
scope: "operator.admin",
}),
);
setActivePluginRegistry(gatewayRegistry);
pinActivePluginHttpRouteRegistry(gatewayRegistry);
const scopedRegistry = createEmptyPluginRegistry();
scopedRegistry.gatewayHandlers[method] = pluginHandler;
scopedRegistry.gatewayMethodDescriptors.push(
createPluginGatewayMethodDescriptor({
pluginId: "agent-fixture",
name: method,
handler: pluginHandler,
scope: "operator.read",
}),
);
setActivePluginRegistry(scopedRegistry);
expect(resolveLeastPrivilegeOperatorScopesForMethod(method)).toEqual(["operator.admin"]);
expect(authorizeOperatorScopesForMethod(method, ["operator.read"])).toEqual({
allowed: false,
missingScope: "operator.admin",
});
});
it("keeps reserved admin namespaces admin-only even if a plugin scope is narrower", () => {
setPluginGatewayMethodScope(RESERVED_ADMIN_PLUGIN_METHOD, "operator.read");
+8 -5
View File
@@ -2,7 +2,10 @@
// Maps static and plugin-defined gateway methods to operator scopes.
import { normalizeOptionalString as normalizeSessionActionParam } from "@openclaw/normalization-core/string-coerce";
import { isAdminOnlyNodeInvokeCommand } from "../infra/node-commands.js";
import { getPluginRegistryState } from "../plugins/runtime-state.js";
import {
getActivePluginHttpRouteRegistry,
getActivePluginSessionExtensionRegistry,
} from "../plugins/runtime.js";
import { isIncognitoSessionKey } from "../routing/session-key.js";
import { resolveReservedGatewayMethodScope } from "../shared/gateway-method-policy.js";
import { isAgentSessionResetCommand } from "./agent-command-policy.js";
@@ -47,8 +50,8 @@ export const CLI_DEFAULT_OPERATOR_SCOPES: OperatorScope[] = [
];
function resolveScopedMethod(method: string): OperatorScope | undefined {
// Core descriptors are authoritative, then reserved namespace policy, then active plugin
// descriptors. Node/dynamic sentinels are intentionally excluded from operator scopes.
// Gateway-pinned plugin descriptors prevent agent-scoped registry loads from
// changing gateway authorization. Node/dynamic sentinels are not operator scopes.
const explicitScope = resolveCoreOperatorGatewayMethodScope(method);
if (explicitScope) {
return explicitScope;
@@ -57,7 +60,7 @@ function resolveScopedMethod(method: string): OperatorScope | undefined {
if (reservedScope) {
return reservedScope;
}
const pluginDescriptor = getPluginRegistryState()?.activeRegistry?.gatewayMethodDescriptors?.find(
const pluginDescriptor = getActivePluginHttpRouteRegistry()?.gatewayMethodDescriptors?.find(
(descriptor) => descriptor.name === method,
);
const pluginScope = pluginDescriptor?.scope;
@@ -135,7 +138,7 @@ function resolveSessionActionRegisteredScopes(params: unknown): OperatorScope[]
if (!pluginId || !actionId) {
return undefined;
}
const registration = getPluginRegistryState()?.activeRegistry?.sessionActions?.find(
const registration = getActivePluginSessionExtensionRegistry()?.sessionActions?.find(
(entry) => entry.pluginId === pluginId && entry.action.id === actionId,
);
if (!registration) {
@@ -3,7 +3,11 @@
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js";
import {
pinActivePluginHttpRouteRegistry,
resetPluginRuntimeStateForTest,
setActivePluginRegistry,
} from "../plugins/runtime.js";
import {
createGatewayMethodRegistry,
createPluginGatewayMethodDescriptor,
@@ -109,6 +113,75 @@ describe("handleGatewayRequest plugin gateway dispatch", () => {
expect(respond).toHaveBeenCalledWith(true, { ok: true, source: "attached" });
});
it("keeps fallback plugin dispatch pinned when an agent replaces the active registry", async () => {
const gatewayHandler = vi.fn<GatewayRequestHandler>(({ respond }) => {
respond(true, { ok: true, source: "gateway" });
});
const scopedHandler = vi.fn<GatewayRequestHandler>(({ respond }) => {
respond(true, { ok: true, source: "agent" });
});
const gatewayRegistry = createEmptyPluginRegistry();
gatewayRegistry.gatewayHandlers["demo.gateway"] = gatewayHandler;
gatewayRegistry.gatewayMethodDescriptors.push(
createPluginGatewayMethodDescriptor({
pluginId: "demo",
name: "demo.gateway",
handler: gatewayHandler,
scope: WRITE_SCOPE,
}),
);
const scopedRegistry = createEmptyPluginRegistry();
scopedRegistry.gatewayHandlers["demo.agent"] = scopedHandler;
scopedRegistry.gatewayMethodDescriptors.push(
createPluginGatewayMethodDescriptor({
pluginId: "demo",
name: "demo.agent",
handler: scopedHandler,
scope: WRITE_SCOPE,
}),
);
setActivePluginRegistry(gatewayRegistry);
pinActivePluginHttpRouteRegistry(gatewayRegistry);
setActivePluginRegistry(scopedRegistry);
const staleStartupRegistry = createGatewayMethodRegistry([]);
const invoke = async (method: string) => {
const respond = vi.fn();
await handleGatewayRequest({
req: { type: "req", id: `pinned-${method}`, method, params: {} },
respond,
client: {
connId: "conn-proof",
connect: {
role: "operator",
scopes: [WRITE_SCOPE],
client: { id: "cli", version: "test", platform: "linux", mode: "cli" },
minProtocol: 1,
maxProtocol: 1,
},
},
isWebchatConnect: () => false,
context: {
logGateway: { warn: vi.fn() },
} as unknown as Parameters<typeof handleGatewayRequest>[0]["context"],
methodRegistry: staleStartupRegistry,
});
return respond;
};
const rejected = await invoke("demo.agent");
expect(rejected).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({ code: "FORBIDDEN" }),
);
expect(scopedHandler).not.toHaveBeenCalled();
const dispatched = await invoke("demo.gateway");
expect(dispatched).toHaveBeenCalledWith(true, { ok: true, source: "gateway" });
expect(gatewayHandler).toHaveBeenCalledOnce();
});
it("fails closed when neither the attached snapshot nor the live registry owns the method", async () => {
const handler = vi.fn<GatewayRequestHandler>();
setActivePluginRegistry(createEmptyPluginRegistry());
+8 -7
View File
@@ -7,7 +7,7 @@ import {
gatewayStartupUnavailableDetails,
GATEWAY_STARTUP_RETRY_AFTER_MS,
} from "../../packages/gateway-protocol/src/startup-unavailable.js";
import { getPluginRegistryState } from "../plugins/runtime-state.js";
import { getActivePluginHttpRouteRegistry } from "../plugins/runtime.js";
import { withPluginRuntimeGatewayRequestScope } from "../plugins/runtime/gateway-request-scope.js";
import {
getGatewaySuspendAdmissionPhase,
@@ -898,10 +898,11 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
function createRequestGatewayMethodRegistry(
extraHandlers?: GatewayRequestHandlers,
): GatewayMethodRegistry {
const activePluginRegistry = getPluginRegistryState()?.activeRegistry;
const activePluginHandlers = activePluginRegistry?.gatewayHandlers ?? {};
// Attached gateway methods must not be shadowed by agent-scoped registry loads.
const gatewayPluginRegistry = getActivePluginHttpRouteRegistry();
const gatewayPluginHandlers = gatewayPluginRegistry?.gatewayHandlers ?? {};
const extraHandlerEntries = Object.entries(extraHandlers ?? {});
const pluginMethodNames = new Set(Object.keys(activePluginHandlers));
const pluginMethodNames = new Set(Object.keys(gatewayPluginHandlers));
const coreDescriptorHandlers = { ...coreGatewayHandlers };
for (const [method, extraHandler] of extraHandlerEntries) {
// Tests and local harnesses can override classified core methods, but plugin-provided
@@ -925,7 +926,7 @@ function createRequestGatewayMethodRegistry(
);
return createGatewayMethodRegistry([
...coreDescriptors,
...(activePluginRegistry ? createPluginGatewayMethodDescriptors(activePluginRegistry) : []),
...(gatewayPluginRegistry ? createPluginGatewayMethodDescriptors(gatewayPluginRegistry) : []),
...createGatewayMethodDescriptorsFromHandlers({
handlers: auxHandlers,
owner: { kind: "aux", area: "gateway-extra" },
@@ -941,8 +942,8 @@ export async function handleGatewayRequest(
const { req, respond, client, isWebchatConnect, context } = opts;
// Prefer the caller-attached registry when it owns the requested method so plugin dispatch
// metadata newer than global runtime state still authorizes and dispatches correctly. When the
// attached snapshot does not own the method, rebuild from the live plugin registry so plugin RPC
// methods registered after the startup snapshot stay reachable (#94127).
// attached snapshot does not own the method, rebuild from the gateway-pinned registry. Without
// a gateway pin, that registry follows active plugins so late methods remain reachable (#94127).
const methodRegistry =
opts.methodRegistry?.getHandler(req.method) !== undefined
? opts.methodRegistry
@@ -5,6 +5,8 @@ import { createPluginRecord } from "../../plugins/loader-records.js";
import { createEmptyPluginRegistry } from "../../plugins/registry-empty.js";
import {
getActivePluginRegistry,
pinActivePluginSessionExtensionRegistry,
releasePinnedPluginSessionExtensionRegistry,
resetPluginRuntimeStateForTest,
setActivePluginRegistry,
} from "../../plugins/runtime.js";
@@ -12,63 +14,71 @@ import { createPluginGatewayMethodDescriptor } from "../methods/registry.js";
import { createBoardHarness } from "./board.test-support.js";
import type { GatewayRequestHandlers } from "./types.js";
function createWorkboardCapabilityRegistry(params: {
readHandler: GatewayRequestHandlers[string];
actionHandler: GatewayRequestHandlers[string];
}) {
const registry = createEmptyPluginRegistry();
registry.gatewayHandlers["workboard.cards.list"] = params.readHandler;
registry.gatewayHandlers["workboard.cards.dispatch"] = params.actionHandler;
registry.gatewayMethodDescriptors.push(
createPluginGatewayMethodDescriptor({
pluginId: "workboard",
name: "workboard.cards.list",
handler: params.readHandler,
scope: "operator.read",
}),
createPluginGatewayMethodDescriptor({
pluginId: "workboard",
name: "workboard.cards.dispatch",
handler: params.actionHandler,
scope: "operator.write",
}),
);
const plugin = createPluginRecord({
id: "workboard",
source: "workboard-stub-plugin-fixture",
origin: "bundled",
enabled: true,
configSchema: false,
dashboard: {
dataBindings: [
{
id: "cards.list",
method: "workboard.cards.list",
description: "List fixture cards",
},
],
actionVerbs: [
{
id: "dispatch",
method: "workboard.cards.dispatch",
description: "Dispatch fixture cards",
paramShape: {
type: "object",
additionalProperties: false,
required: ["force"],
properties: { force: { type: "boolean" } },
},
},
],
},
});
registerPluginDashboardCapabilities({ record: plugin, registry });
registry.plugins.push(plugin);
return registry;
}
describe("board plugin capabilities", () => {
it("routes granted bindings and actions only while their plugin registry is active", async () => {
const previousRegistry = getActivePluginRegistry();
const registry = createEmptyPluginRegistry();
const readHandler = vi.fn<GatewayRequestHandlers[string]>(async ({ params, respond }) => {
respond(true, { items: [params.filter ?? "all"] });
});
const actionHandler = vi.fn<GatewayRequestHandlers[string]>(async ({ params, respond }) => {
respond(true, { refreshed: params.force });
});
registry.gatewayHandlers["workboard.cards.list"] = readHandler;
registry.gatewayHandlers["workboard.cards.dispatch"] = actionHandler;
registry.gatewayMethodDescriptors.push(
createPluginGatewayMethodDescriptor({
pluginId: "workboard",
name: "workboard.cards.list",
handler: readHandler,
scope: "operator.read",
}),
createPluginGatewayMethodDescriptor({
pluginId: "workboard",
name: "workboard.cards.dispatch",
handler: actionHandler,
scope: "operator.write",
}),
);
const plugin = createPluginRecord({
id: "workboard",
source: "workboard-stub-plugin-fixture",
origin: "bundled",
enabled: true,
configSchema: false,
dashboard: {
dataBindings: [
{
id: "cards.list",
method: "workboard.cards.list",
description: "List fixture cards",
},
],
actionVerbs: [
{
id: "dispatch",
method: "workboard.cards.dispatch",
description: "Dispatch fixture cards",
paramShape: {
type: "object",
additionalProperties: false,
required: ["force"],
properties: { force: { type: "boolean" } },
},
},
],
},
});
registerPluginDashboardCapabilities({ record: plugin, registry });
registry.plugins.push(plugin);
const registry = createWorkboardCapabilityRegistry({ readHandler, actionHandler });
setActivePluginRegistry(registry);
try {
@@ -139,4 +149,80 @@ describe("board plugin capabilities", () => {
}
}
});
it("keeps granted plugin capabilities pinned when an agent replaces the active registry", async () => {
const previousRegistry = getActivePluginRegistry();
const gatewayReadHandler = vi.fn<GatewayRequestHandlers[string]>(
async ({ params, respond }) => {
respond(true, { owner: "gateway", items: [params.filter ?? "all"] });
},
);
const gatewayActionHandler = vi.fn<GatewayRequestHandlers[string]>(
async ({ params, respond }) => {
respond(true, { owner: "gateway", refreshed: params.force });
},
);
const scopedReadHandler = vi.fn<GatewayRequestHandlers[string]>(async ({ respond }) => {
respond(true, { owner: "agent" });
});
const scopedActionHandler = vi.fn<GatewayRequestHandlers[string]>(async ({ respond }) => {
respond(true, { owner: "agent" });
});
const gatewayRegistry = createWorkboardCapabilityRegistry({
readHandler: gatewayReadHandler,
actionHandler: gatewayActionHandler,
});
const scopedRegistry = createWorkboardCapabilityRegistry({
readHandler: scopedReadHandler,
actionHandler: scopedActionHandler,
});
setActivePluginRegistry(gatewayRegistry);
pinActivePluginSessionExtensionRegistry(gatewayRegistry);
setActivePluginRegistry(scopedRegistry);
try {
const { invoke, store } = createBoardHarness();
await invoke("board.widget.put", {
sessionKey: "session",
name: "plugin-widget",
content: { kind: "html", html: "plugin" },
declared: { tools: ["workboard.cards.list", "workboard.dispatch"] },
});
await invoke("board.widget.grant", {
sessionKey: "session",
name: "plugin-widget",
decision: "granted",
revision: 1,
instanceId: store.getSnapshot("session").widgets[0]?.instanceId,
});
const board = await invoke("board.get", { sessionKey: "session" });
const snapshot = board.mock.calls[0]?.[1] as BoardSnapshot;
const ticket = snapshot.widgets[0]?.viewTicket;
const read = await invoke("board.data.read", {
ticket,
bindingId: "workboard.cards.list",
params: { filter: "ready" },
});
expect(read.mock.calls[0]?.[1]).toEqual({ owner: "gateway", items: ["ready"] });
const action = await invoke("board.action", {
ticket,
action: "workboard.dispatch",
params: { force: true },
});
expect(action.mock.calls[0]?.[1]).toEqual({ owner: "gateway", refreshed: true });
expect(gatewayReadHandler).toHaveBeenCalledOnce();
expect(gatewayActionHandler).toHaveBeenCalledOnce();
expect(scopedReadHandler).not.toHaveBeenCalled();
expect(scopedActionHandler).not.toHaveBeenCalled();
} finally {
releasePinnedPluginSessionExtensionRegistry(gatewayRegistry);
if (previousRegistry) {
setActivePluginRegistry(previousRegistry);
} else {
resetPluginRuntimeStateForTest();
}
}
});
});
@@ -15,7 +15,7 @@ import {
import { formatErrorMessage } from "../../infra/errors.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { isPluginJsonValue } from "../../plugins/host-hooks.js";
import { getActivePluginRegistry } from "../../plugins/runtime.js";
import { getActivePluginSessionExtensionRegistry } from "../../plugins/runtime.js";
import {
validateJsonSchemaValue,
type JsonSchemaValidationError,
@@ -56,7 +56,8 @@ export const pluginHostHookHandlers: GatewayRequestHandlers = {
) {
return;
}
const descriptors = (getActivePluginRegistry()?.controlUiDescriptors ?? []).map((entry) => {
const registry = getActivePluginSessionExtensionRegistry();
const descriptors = (registry?.controlUiDescriptors ?? []).map((entry) => {
const descriptor: Record<string, unknown> = {
id: entry.descriptor.id,
pluginId: entry.pluginId,
@@ -120,7 +121,7 @@ export const pluginHostHookHandlers: GatewayRequestHandlers = {
);
return;
}
const registry = getActivePluginRegistry();
const registry = getActivePluginSessionExtensionRegistry();
const pluginLoaded = Boolean(
registry?.plugins.some((plugin) => plugin.id === pluginId && plugin.status === "loaded"),
);
+11 -1
View File
@@ -113,6 +113,7 @@ function addLoadedPlugin(
}
function createLookUpTableForTest(params: {
installRecords?: PluginLookUpTable["index"]["installRecords"];
manifestRegistry?: PluginLookUpTable["manifestRegistry"];
pluginIds?: readonly string[];
workerProviderIds?: readonly string[];
@@ -126,7 +127,7 @@ function createLookUpTableForTest(params: {
migrationVersion: 1,
policyHash: "test",
generatedAtMs: 1,
installRecords: {},
installRecords: params.installRecords ?? {},
plugins: [],
diagnostics: [],
},
@@ -544,9 +545,17 @@ describe("loadGatewayPlugins", () => {
test("reuses a provided lookup table for startup scope and auto-enable manifests", () => {
loadOpenClawPlugins.mockReturnValue(createRegistry([]));
const manifestRegistry = { plugins: [], diagnostics: [] };
const installRecords = {
telegram: {
source: "npm" as const,
spec: "@openclaw/telegram@1.0.0",
installPath: "/tmp/plugins/telegram",
},
};
loadGatewayPluginsForTest({
pluginLookUpTable: createLookUpTableForTest({
installRecords,
manifestRegistry,
pluginIds: ["telegram"],
}),
@@ -559,6 +568,7 @@ describe("loadGatewayPlugins", () => {
manifestRegistry,
});
expect(getLastPluginLoadOption("manifestRegistry")).toBe(manifestRegistry);
expect(getLastPluginLoadOption("installRecords")).toEqual(installRecords);
expect(getLastPluginLoadOption("onlyPluginIds")).toEqual(["telegram"]);
});
+17 -19
View File
@@ -9,6 +9,7 @@ import type { AmbientEnvTriggerPolicy } from "../channels/config-presence.js";
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { normalizePluginsConfig } from "../plugins/config-state.js";
import { extractPluginInstallRecordsFromInstalledPluginIndex } from "../plugins/installed-plugin-index-install-records.js";
import { clearActivatedPluginRuntimeState, loadOpenClawPlugins } from "../plugins/loader.js";
import { loadPluginLookUpTable, type PluginLookUpTable } from "../plugins/plugin-lookup-table.js";
import { getPluginModuleLoaderStats } from "../plugins/plugin-module-loader-cache.js";
@@ -331,21 +332,13 @@ export function getInProcessGatewayRequestContext(): GatewayRequestContext | und
return getPluginRuntimeGatewayRequestScope()?.context ?? getFallbackGatewayContext();
}
async function dispatchGatewayMethod<T>(
method: string,
params: unknown,
options?: DispatchGatewayMethodInProcessOptions,
): Promise<T> {
const response = await dispatchGatewayMethodInProcessRaw(method, params, options);
return unwrapGatewayMethodDispatchResponse(method, response) as T;
}
export async function dispatchGatewayMethodInProcess<T>(
method: string,
params: Record<string, unknown>,
options?: DispatchGatewayMethodInProcessOptions,
): Promise<T> {
return await dispatchGatewayMethod<T>(method, params, options);
const response = await dispatchGatewayMethodInProcessRaw(method, params, options);
return unwrapGatewayMethodDispatchResponse(method, response) as T;
}
export async function dispatchTrustedPluginGatewayMethod<T>(
@@ -359,7 +352,7 @@ export async function dispatchTrustedPluginGatewayMethod<T>(
throw new Error("Gateway requests are only available to bundled or trusted official plugins.");
}
const syntheticScopes = normalizeOperatorScopeList(options?.scopes);
return await dispatchGatewayMethod<T>(method, params, {
return await dispatchGatewayMethodInProcess<T>(method, params, {
forceSyntheticClient: true,
pluginRuntimeOwnerId: pluginId,
...(syntheticScopes ? { syntheticScopes } : {}),
@@ -391,7 +384,7 @@ export function createGatewaySubagentRuntime(): PluginRuntime["subagent"] {
PLUGIN_SUBAGENT_SESSION_MESSAGES_MAX_LIMIT,
Math.max(1, Math.floor(params.limit)),
);
const payload = await dispatchGatewayMethod<{ messages?: unknown[] }>("sessions.get", {
const payload = await dispatchGatewayMethodInProcess<{ messages?: unknown[] }>("sessions.get", {
key: params.sessionKey,
...(limit != null && { limit }),
});
@@ -428,7 +421,7 @@ export function createGatewaySubagentRuntime(): PluginRuntime["subagent"] {
if (overrideRequested && !allowOverride) {
throw new Error("provider/model override is not authorized for this plugin subagent run.");
}
const payload = await dispatchGatewayMethod<{ runId?: string; runtime?: unknown }>(
const payload = await dispatchGatewayMethodInProcess<{ runId?: string; runtime?: unknown }>(
"agent",
{
sessionKey: params.sessionKey,
@@ -461,7 +454,7 @@ export function createGatewaySubagentRuntime(): PluginRuntime["subagent"] {
return { runId, ...(runtime ? { runtime } : {}) };
},
async waitForRun(params) {
const payload = await dispatchGatewayMethod<{ status?: string; error?: string }>(
const payload = await dispatchGatewayMethodInProcess<{ status?: string; error?: string }>(
"agent.wait",
{
runId: params.runId,
@@ -502,7 +495,7 @@ export function createGatewaySubagentRuntime(): PluginRuntime["subagent"] {
: {}),
}
: undefined;
await dispatchGatewayMethod(
await dispatchGatewayMethodInProcess(
"sessions.delete",
{
key: params.sessionKey,
@@ -517,7 +510,7 @@ export function createGatewaySubagentRuntime(): PluginRuntime["subagent"] {
export function createGatewayNodesRuntime(): PluginRuntime["nodes"] {
return {
async list(params) {
const payload = await dispatchGatewayMethod<{ nodes?: unknown[] }>("node.list", {});
const payload = await dispatchGatewayMethodInProcess<{ nodes?: unknown[] }>("node.list", {});
const nodes = Array.isArray(payload?.nodes) ? payload.nodes : [];
const filteredNodes =
params?.connected === true
@@ -545,7 +538,7 @@ export function createGatewayNodesRuntime(): PluginRuntime["nodes"] {
pluginTrustedOfficialInstall: scope?.pluginTrustedOfficialInstall,
requestedScopes: normalizeOperatorScopeList(params.scopes),
});
const payload = await dispatchGatewayMethod<unknown>(
const payload = await dispatchGatewayMethodInProcess<unknown>(
"node.invoke",
{
nodeId: params.nodeId,
@@ -701,8 +694,13 @@ export function loadGatewayPlugins(params: {
...(params.startupTrace !== undefined && {
startupTrace: params.startupTrace,
}),
...(params.pluginLookUpTable?.manifestRegistry
? { manifestRegistry: params.pluginLookUpTable.manifestRegistry }
...(params.pluginLookUpTable
? {
manifestRegistry: params.pluginLookUpTable.manifestRegistry,
installRecords: extractPluginInstallRecordsFromInstalledPluginIndex(
params.pluginLookUpTable.index,
),
}
: {}),
});
const loadMs = performance.now() - beforeLoad;
+139
View File
@@ -10,6 +10,7 @@ import {
setCurrentPluginMetadataSnapshot,
} from "../plugins/current-plugin-metadata-snapshot.js";
import { resolveInstalledPluginIndexPolicyHash } from "../plugins/installed-plugin-index-policy.js";
import { clearPluginMetadataLifecycleCaches } from "../plugins/plugin-metadata-lifecycle.js";
import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js";
import {
evaluateBundledPluginPublicSurfaceAccess,
@@ -119,6 +120,27 @@ afterEach(() => {
});
describe("plugin-sdk facade runtime", () => {
it("reuses successful facade locations without repeating filesystem probes", () => {
const dir = createBundledPluginDir("openclaw-facade-location-cache-", "cached");
useBundledPluginDirOverrideForTest(dir);
const existsSync = vi.spyOn(fs, "existsSync");
const params = {
dirName: "demo",
artifactBasename: "api.js",
};
const first = testing.resolveFacadeModuleLocation(params);
expect(first).toEqual({
modulePath: path.join(dir, "demo", "api.js"),
boundaryRoot: dir,
});
existsSync.mockClear();
expect(testing.resolveFacadeModuleLocation(params)).toBe(first);
expect(existsSync).not.toHaveBeenCalled();
});
it("honors trusted bundled plugin dir overrides", () => {
const overrideA = createBundledPluginDir("openclaw-facade-runtime-a-", "override-a");
const overrideB = createBundledPluginDir("openclaw-facade-runtime-b-", "override-b");
@@ -174,6 +196,123 @@ describe("plugin-sdk facade runtime", () => {
).toBeNull();
});
it("does not reuse enabled facade locations when bundled plugins are disabled", () => {
const dir = createBundledPluginDir("openclaw-facade-location-disabled-", "enabled");
useBundledPluginDirOverrideForTest(dir);
const params = {
dirName: "demo",
artifactBasename: "api.js",
};
expect(testing.resolveFacadeModuleLocation(params)).toEqual({
modulePath: path.join(dir, "demo", "api.js"),
boundaryRoot: dir,
});
process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS = "1";
testing.setFacadeActivationCheckRuntimeForTest({
resolveRegistryPluginModuleLocation: () => null,
} as never);
expect(testing.resolveFacadeModuleLocation(params)).toBeNull();
});
it("does not reuse installed facade locations across custom environment profiles", () => {
const profileA: NodeJS.ProcessEnv = {
OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1",
OPENCLAW_STATE_DIR: path.join(path.sep, "openclaw-facade-profile-a"),
};
const profileB: NodeJS.ProcessEnv = {
OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1",
OPENCLAW_STATE_DIR: path.join(path.sep, "openclaw-facade-profile-b"),
};
const resolveRegistryPluginModuleLocation = vi.fn(({ env }: { env?: NodeJS.ProcessEnv }) => {
const stateDir = env?.OPENCLAW_STATE_DIR;
if (!stateDir) {
return null;
}
const boundaryRoot = path.join(stateDir, "plugins", "demo");
return {
modulePath: path.join(boundaryRoot, "api.js"),
boundaryRoot,
};
});
testing.setFacadeActivationCheckRuntimeForTest({
resolveRegistryPluginModuleLocation,
} as never);
const params = { dirName: "demo", artifactBasename: "api.js" };
const profileARoot = path.join(profileA.OPENCLAW_STATE_DIR!, "plugins", "demo");
const profileBRoot = path.join(profileB.OPENCLAW_STATE_DIR!, "plugins", "demo");
expect(testing.resolveFacadeModuleLocation({ ...params, env: profileA })).toEqual({
modulePath: path.join(profileARoot, "api.js"),
boundaryRoot: profileARoot,
});
expect(testing.resolveFacadeModuleLocation({ ...params, env: profileB })).toEqual({
modulePath: path.join(profileBRoot, "api.js"),
boundaryRoot: profileBRoot,
});
expect(resolveRegistryPluginModuleLocation).toHaveBeenCalledTimes(2);
expect(resolveRegistryPluginModuleLocation).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ env: profileA }),
);
expect(resolveRegistryPluginModuleLocation).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ env: profileB }),
);
});
it("retries missing facade locations when a plugin artifact appears", () => {
const dir = createTrustedBundledFixtureRoot("openclaw-facade-location-retry-");
useBundledPluginDirOverrideForTest(dir);
testing.setFacadeActivationCheckRuntimeForTest({
resolveRegistryPluginModuleLocation: () => null,
} as never);
const params = {
dirName: "future-demo",
artifactBasename: "api.js",
};
expect(testing.resolveFacadeModuleLocation(params)).toBeNull();
const pluginDir = path.join(dir, params.dirName);
fs.mkdirSync(pluginDir, { recursive: true });
writePluginPackageJson(pluginDir, params.dirName);
fs.writeFileSync(path.join(pluginDir, "api.js"), 'export const marker = "ready";\n', "utf8");
expect(testing.resolveFacadeModuleLocation(params)).toEqual({
modulePath: path.join(pluginDir, "api.js"),
boundaryRoot: dir,
});
});
it("invalidates cached facade locations when plugin metadata changes", () => {
const dir = createBundledPluginDir("openclaw-facade-location-invalidation-", "original");
useBundledPluginDirOverrideForTest(dir);
const params = {
dirName: "demo",
artifactBasename: "api.js",
};
const first = testing.resolveFacadeModuleLocation(params);
fs.writeFileSync(
path.join(dir, "demo", "api.ts"),
'export const marker = "updated";\n',
"utf8",
);
expect(testing.resolveFacadeModuleLocation(params)).toBe(first);
clearPluginMetadataLifecycleCaches();
expect(testing.resolveFacadeModuleLocation(params)).toEqual({
modulePath: path.join(dir, "demo", "api.ts"),
boundaryRoot: dir,
});
});
it("returns the same object identity on repeated calls (sentinel consistency)", () => {
const dir = createBundledPluginDir("openclaw-facade-identity-", "identity-check");
useBundledPluginDirOverrideForTest(dir);
+23 -1
View File
@@ -3,6 +3,8 @@ import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { areBundledPluginsDisabled, resolveBundledPluginsDir } from "../plugins/bundled-dir.js";
import { PluginLruCache } from "../plugins/plugin-cache-primitives.js";
import { registerPluginMetadataProcessMemoLifecycleClear } from "../plugins/plugin-metadata-lifecycle.js";
import {
getCachedPluginSourceModuleLoader,
type PluginModuleLoaderCache,
@@ -46,6 +48,11 @@ const OPENCLAW_PACKAGE_ROOT =
}) ?? fileURLToPath(new URL("../..", import.meta.url));
const CURRENT_MODULE_PATH = fileURLToPath(import.meta.url);
const OPENCLAW_SOURCE_EXTENSIONS_ROOT = path.resolve(OPENCLAW_PACKAGE_ROOT, "extensions");
const facadeModuleLocationCache = new PluginLruCache<FacadeModuleLocation>(128);
registerPluginMetadataProcessMemoLifecycleClear(() => {
facadeModuleLocationCache.clear();
});
function createFacadeResolutionKey(params: {
dirName: string;
@@ -97,7 +104,21 @@ function resolveFacadeModuleLocation(params: {
artifactBasename: string;
env?: NodeJS.ProcessEnv;
}): { modulePath: string; boundaryRoot: string } | null {
return resolveFacadeModuleLocationUncached(params);
// Custom environments may select different installed-plugin profiles, so
// their facade locations must not enter the process-wide gateway cache.
if (params.env !== undefined && params.env !== process.env) {
return resolveFacadeModuleLocationUncached(params);
}
const resolutionKey = createFacadeResolutionKey(params);
const cached = facadeModuleLocationCache.get(resolutionKey);
if (cached) {
return cached;
}
const location = resolveFacadeModuleLocationUncached(params);
if (location) {
facadeModuleLocationCache.set(resolutionKey, location);
}
return location;
}
type BundledPluginPublicSurfaceParams = {
@@ -282,6 +303,7 @@ export async function tryLoadActivatedBundledPluginPublicSurfaceModule<T extends
/** Reset facade runtime caches and activation-check test overrides. */
export function resetFacadeRuntimeStateForTest(): void {
resetFacadeLoaderStateForTest();
facadeModuleLocationCache.clear();
facadeActivationCheckRuntimeModule = undefined;
facadeActivationCheckRuntimeLoaders.clear();
}
@@ -44,7 +44,11 @@ import {
import { buildPluginAgentTurnPrepareContext, isPluginJsonValue } from "../host-hooks.js";
import { createEmptyPluginRegistry } from "../registry-empty.js";
import { createPluginRegistry } from "../registry.js";
import { setActivePluginRegistry } from "../runtime.js";
import {
pinActivePluginSessionExtensionRegistry,
releasePinnedPluginSessionExtensionRegistry,
setActivePluginRegistry,
} from "../runtime.js";
import type { PluginRuntime } from "../runtime/types.js";
import { createPluginRecord } from "../status.test-helpers.js";
import { runTrustedToolPolicies } from "../trusted-tool-policy.js";
@@ -148,6 +152,7 @@ async function withHostHookState(
describe("host-hook fixture plugin contract", () => {
afterEach(() => {
releasePinnedPluginSessionExtensionRegistry();
setActivePluginRegistry(createEmptyPluginRegistry());
clearPluginHostRuntimeState();
resetAgentEventsForTest();
@@ -1997,6 +2002,58 @@ describe("host-hook fixture plugin contract", () => {
});
});
it("keeps gateway UI descriptors pinned across agent registry replacement", () => {
const { config, registry } = createPluginRegistryFixture();
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: "pinned-ui-fixture",
name: "Pinned UI Fixture",
}),
register(api) {
api.registerControlUiDescriptor({
id: "gateway-panel",
surface: "session",
label: "Gateway panel",
});
},
});
setActivePluginRegistry(registry.registry);
pinActivePluginSessionExtensionRegistry(registry.registry);
setActivePluginRegistry(createEmptyPluginRegistry());
const calls: Array<[boolean, unknown, unknown]> = [];
void expectDefined(
pluginHostHookHandlers["plugins.uiDescriptors"],
'pluginHostHookHandlers["plugins.uiDescriptors"] test invariant',
)({
params: {},
respond: (ok: boolean, payload: unknown, error: unknown) => {
calls.push([ok, payload, error]);
},
} as never);
expect(calls).toEqual([
[
true,
{
ok: true,
descriptors: [
{
id: "gateway-panel",
pluginId: "pinned-ui-fixture",
pluginName: "Pinned UI Fixture",
surface: "session",
label: "Gateway panel",
},
],
},
undefined,
],
]);
});
it("enforces command requiredScopes for gateway clients and command owners", async () => {
const handlerCalls: string[] = [];
const { config, registry } = createPluginRegistryFixture();
@@ -13,7 +13,11 @@ import type { GatewayClient, RespondFn } from "../../gateway/server-methods/type
import { onAgentEvent, resetAgentEventsForTest } from "../../infra/agent-events.js";
import { createEmptyPluginRegistry } from "../registry-empty.js";
import { createPluginRegistry } from "../registry.js";
import { setActivePluginRegistry } from "../runtime.js";
import {
pinActivePluginSessionExtensionRegistry,
releasePinnedPluginSessionExtensionRegistry,
setActivePluginRegistry,
} from "../runtime.js";
import { createPluginRecord } from "../status.test-fixtures.js";
import type { OpenClawPluginApi } from "../types.js";
@@ -153,6 +157,7 @@ function registerActionFixture(params: {
describe("plugin session actions", () => {
afterEach(() => {
releasePinnedPluginSessionExtensionRegistry();
setActivePluginRegistry(createEmptyPluginRegistry());
resetAgentEventsForTest();
});
@@ -612,6 +617,58 @@ describe("plugin session actions", () => {
]);
});
it("keeps session actions and their scopes pinned across agent registry replacement", async () => {
const gatewayHandler = vi.fn(() => ({ result: { owner: "gateway" } }));
const scopedHandler = vi.fn(() => ({ result: { owner: "agent" } }));
const { registry: gatewayRegistry } = registerActionFixture({
id: "pinned-action-fixture",
register(api) {
api.registerSessionAction({
id: "approve",
requiredScopes: [APPROVALS_SCOPE],
handler: gatewayHandler,
});
},
});
const { registry: scopedRegistry } = registerActionFixture({
id: "pinned-action-fixture",
register(api) {
api.registerSessionAction({
id: "approve",
requiredScopes: [READ_SCOPE],
handler: scopedHandler,
});
},
});
setActivePluginRegistry(gatewayRegistry.registry);
pinActivePluginSessionExtensionRegistry(gatewayRegistry.registry);
setActivePluginRegistry(scopedRegistry.registry);
await expect(
callRegisteredSessionActionThroughGatewayForTest({
pluginId: "pinned-action-fixture",
actionId: "approve",
scopes: [APPROVALS_SCOPE],
}),
).resolves.toEqual({
ok: true,
payload: { ok: true, result: { owner: "gateway" } },
error: undefined,
});
const denied = await callRegisteredSessionActionThroughGatewayForTest({
pluginId: "pinned-action-fixture",
actionId: "approve",
scopes: [READ_SCOPE],
});
expect(requireHookError(denied)).toMatchObject({
code: "FORBIDDEN",
message: `missing scope: ${APPROVALS_SCOPE}`,
});
expect(gatewayHandler).toHaveBeenCalledOnce();
expect(scopedHandler).not.toHaveBeenCalled();
});
it("passes a defensive copy of client scopes to session action handlers", async () => {
const registry = createEmptyPluginRegistry();
let response: { ok: boolean; payload?: unknown; error?: unknown } | undefined;
+141
View File
@@ -6,7 +6,9 @@ import { bundledDistPluginFile } from "openclaw/plugin-sdk/test-fixtures";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { PluginInstallRecord } from "../config/types.plugins.js";
import { discoverOpenClawPlugins } from "./discovery.js";
import * as pluginHardlinkPolicy from "./hardlink-policy.js";
import { listBuiltRuntimeEntryCandidates } from "./package-entrypoints.js";
import { clearPluginMetadataLifecycleCaches } from "./plugin-metadata-lifecycle.js";
import {
cleanupTrackedTempDirs,
makeTrackedTempDir,
@@ -463,6 +465,7 @@ async function expectRejectedPackageExtensionEntry(params: {
afterEach(() => {
vi.restoreAllMocks();
clearPluginMetadataLifecycleCaches();
cleanupTrackedTempDirs(tempDirs);
});
@@ -2383,6 +2386,144 @@ describe("discoverOpenClawPlugins", () => {
},
);
it("reuses bundled package manifests without repeating filesystem checks", () => {
const stateDir = makeTempDir();
const bundledDir = path.join(stateDir, "bundled");
const pluginDir = path.join(bundledDir, "cached-bundle");
createPackagePluginWithEntry({
packageDir: pluginDir,
packageName: "@openclaw/cached-bundle",
pluginId: "cached-bundle",
entryPath: "index.js",
});
const env = buildDiscoveryEnvWithOverrides(stateDir, {
OPENCLAW_BUNDLED_PLUGINS_DIR: bundledDir,
});
const packageManifestPath = path.resolve(pluginDir, "package.json");
expectCandidatePresence(discoverWithEnv({ env }), { present: ["cached-bundle"] });
const statSync = vi.spyOn(fs, "statSync");
const readFileSync = vi.spyOn(fs, "readFileSync");
expectCandidatePresence(discoverWithEnv({ env }), { present: ["cached-bundle"] });
expect(
statSync.mock.calls.filter(
([targetPath]) =>
typeof targetPath === "string" && path.resolve(targetPath) === packageManifestPath,
),
).toHaveLength(0);
expect(
readFileSync.mock.calls.filter(
([targetPath]) =>
typeof targetPath === "string" && path.resolve(targetPath) === packageManifestPath,
),
).toHaveLength(0);
});
it("refreshes same-size bundled package manifests when plugin metadata is reloaded", () => {
const stateDir = makeTempDir();
const bundledDir = path.join(stateDir, "bundled");
const pluginDir = path.join(bundledDir, "cached-bundle");
createPackagePluginWithEntry({
packageDir: pluginDir,
packageName: "@openclaw/cache-one",
pluginId: "cached-bundle",
entryPath: "index.js",
});
const env = buildDiscoveryEnvWithOverrides(stateDir, {
OPENCLAW_BUNDLED_PLUGINS_DIR: bundledDir,
});
const packageManifestPath = path.join(pluginDir, "package.json");
const unchangedTimestamp = new Date("2025-01-01T00:00:00.000Z");
fs.utimesSync(packageManifestPath, unchangedTimestamp, unchangedTimestamp);
const first = discoverWithEnv({ env });
expect(requireCandidateById(first.candidates, "cached-bundle").packageName).toBe(
"@openclaw/cache-one",
);
const originalStat = fs.statSync(packageManifestPath);
writePluginPackageManifest({
packageDir: pluginDir,
packageName: "@openclaw/cache-two",
extensions: ["./index.js"],
});
fs.utimesSync(packageManifestPath, unchangedTimestamp, unchangedTimestamp);
const replacementStat = fs.statSync(packageManifestPath);
expect(replacementStat.size).toBe(originalStat.size);
expect(replacementStat.mtimeMs).toBe(originalStat.mtimeMs);
const beforeReload = discoverWithEnv({ env });
expect(requireCandidateById(beforeReload.candidates, "cached-bundle").packageName).toBe(
"@openclaw/cache-one",
);
clearPluginMetadataLifecycleCaches();
const afterReload = discoverWithEnv({ env });
expect(requireCandidateById(afterReload.candidates, "cached-bundle").packageName).toBe(
"@openclaw/cache-two",
);
});
it("keeps strict global package manifests fresh between standalone discovery calls", () => {
const stateDir = makeTempDir();
const pluginDir = path.join(stateDir, "extensions", "fresh-package");
createPackagePluginWithEntry({
packageDir: pluginDir,
packageName: "@openclaw/cache-one",
pluginId: "fresh-package",
entryPath: "index.js",
});
const env = buildDiscoveryEnv(stateDir);
const packageManifestPath = path.join(pluginDir, "package.json");
const unchangedTimestamp = new Date("2025-01-01T00:00:00.000Z");
fs.utimesSync(packageManifestPath, unchangedTimestamp, unchangedTimestamp);
const first = discoverWithEnv({ env });
expect(requireCandidateById(first.candidates, "fresh-package").packageName).toBe(
"@openclaw/cache-one",
);
const originalStat = fs.statSync(packageManifestPath);
writePluginPackageManifest({
packageDir: pluginDir,
packageName: "@openclaw/cache-two",
extensions: ["./index.js"],
});
fs.utimesSync(packageManifestPath, unchangedTimestamp, unchangedTimestamp);
const replacementStat = fs.statSync(packageManifestPath);
expect(replacementStat.size).toBe(originalStat.size);
expect(replacementStat.mtimeMs).toBe(originalStat.mtimeMs);
const second = discoverWithEnv({ env });
expect(requireCandidateById(second.candidates, "fresh-package").packageName).toBe(
"@openclaw/cache-two",
);
});
it("does not cache missing manifests for mutable external roots with relaxed hardlink checks", () => {
const stateDir = makeTempDir();
const pluginDir = path.join(stateDir, "extensions", "fresh-package");
mkdirSafe(pluginDir);
writePluginManifest({ pluginDir, id: "fresh-package" });
writePluginEntry(path.join(pluginDir, "index.js"));
const env = buildDiscoveryEnv(stateDir);
vi.spyOn(pluginHardlinkPolicy, "shouldRejectHardlinkedPluginFiles").mockReturnValue(false);
const first = discoverWithEnv({ env });
expect(requireCandidateById(first.candidates, "fresh-package").packageName).toBeUndefined();
writePluginPackageManifest({
packageDir: pluginDir,
packageName: "@openclaw/fresh-package",
extensions: ["./index.js"],
});
const second = discoverWithEnv({ env });
expect(requireCandidateById(second.candidates, "fresh-package").packageName).toBe(
"@openclaw/fresh-package",
);
});
it("reflects plugin root changes on the next discovery call", () => {
const stateDir = makeTempDir();
const pluginDir = path.join(stateDir, "extensions", "fresh");
+27 -37
View File
@@ -34,7 +34,9 @@ import {
resolvePackageSetupSource,
} from "./package-entry-resolution.js";
import { formatPosixMode, isPathInside, safeRealpathSync, safeStatSync } from "./path-safety.js";
import { createPluginCacheKey, PluginLruCache } from "./plugin-cache-primitives.js";
import { tracePluginLifecyclePhase } from "./plugin-lifecycle-trace.js";
import { registerPluginMetadataProcessMemoLifecycleClear } from "./plugin-metadata-lifecycle.js";
import type { PluginOrigin } from "./plugin-origin.types.js";
import { withPluginScanExistenceCache } from "./plugin-scan-existence-cache.js";
import { resolvePluginSourceRoots } from "./roots.js";
@@ -57,10 +59,14 @@ const SCANNED_DIRECTORY_IGNORE_NAMES = new Set([
"node_modules",
]);
const PACKAGE_MANIFEST_CACHE_MAX_ENTRIES = 512;
const packageManifestProcessCache = new Map<
string,
{ mtimeMs: number; size: number; manifest: PackageManifest | null }
>();
const IMMUTABLE_NIX_STORE_ROOT = "/nix/store";
const packageManifestProcessCache = new PluginLruCache<PackageManifest | null>(
PACKAGE_MANIFEST_CACHE_MAX_ENTRIES,
);
registerPluginMetadataProcessMemoLifecycleClear(() => {
packageManifestProcessCache.clear();
});
/** One potential plugin root discovered before manifest validation and registry normalization. */
export type PluginCandidate = {
@@ -596,25 +602,6 @@ function readTrustedPackageManifest(dir: string): PackageManifest | null {
return tryReadJsonSync<PackageManifest>(path.join(dir, "package.json"));
}
function readPackageManifestStat(dir: string): { mtimeMs: number; size: number } | null {
try {
const stat = fs.statSync(path.join(dir, "package.json"));
return stat.isFile() ? { mtimeMs: stat.mtimeMs, size: stat.size } : null;
} catch {
return null;
}
}
function prunePackageManifestProcessCache(): void {
while (packageManifestProcessCache.size > PACKAGE_MANIFEST_CACHE_MAX_ENTRIES) {
const oldest = packageManifestProcessCache.keys().next().value;
if (oldest === undefined) {
return;
}
packageManifestProcessCache.delete(oldest);
}
}
function readCandidatePackageManifest(params: {
dir: string;
origin: PluginOrigin;
@@ -622,27 +609,31 @@ function readCandidatePackageManifest(params: {
rootRealPath?: string;
packageManifestCache?: Map<string, PackageManifest | null>;
}): PackageManifest | null {
const rootRealPath = params.rootRealPath ?? safeRealpathSync(params.dir);
const trustMode =
params.origin === "bundled"
? "trusted"
: params.rejectHardlinks
? "external-reject"
: "external-allow";
const cacheKey = `${trustMode}:${params.rootRealPath ?? path.resolve(params.dir)}`;
const cacheKey = createPluginCacheKey([trustMode, rootRealPath ?? path.resolve(params.dir)]);
const cached = params.packageManifestCache?.get(cacheKey);
if (cached !== undefined) {
return cached;
}
const canUseProcessCache = params.origin === "bundled" || !params.rejectHardlinks;
const manifestStat = readPackageManifestStat(params.dir);
if (canUseProcessCache && manifestStat !== null) {
const processCached = packageManifestProcessCache.get(cacheKey);
if (
processCached?.mtimeMs === manifestStat.mtimeMs &&
processCached.size === manifestStat.size
) {
params.packageManifestCache?.set(cacheKey, processCached.manifest);
return processCached.manifest;
// Relaxed hardlink validation does not make a mutable external root immutable.
// Only bundled plugins and verified Nix store roots survive a metadata generation.
const canUseProcessCache =
params.origin === "bundled" ||
(!params.rejectHardlinks &&
typeof rootRealPath === "string" &&
(rootRealPath === IMMUTABLE_NIX_STORE_ROOT ||
rootRealPath.startsWith(`${IMMUTABLE_NIX_STORE_ROOT}/`)));
if (canUseProcessCache) {
const processCached = packageManifestProcessCache.getResult(cacheKey);
if (processCached.hit) {
params.packageManifestCache?.set(cacheKey, processCached.value);
return processCached.value;
}
}
const manifest =
@@ -650,9 +641,8 @@ function readCandidatePackageManifest(params: {
? readTrustedPackageManifest(params.dir)
: readPackageManifest(params.dir, params.rejectHardlinks, params.rootRealPath);
params.packageManifestCache?.set(cacheKey, manifest);
if (canUseProcessCache && manifestStat !== null) {
packageManifestProcessCache.set(cacheKey, { ...manifestStat, manifest });
prunePackageManifestProcessCache();
if (canUseProcessCache) {
packageManifestProcessCache.set(cacheKey, manifest);
}
return manifest;
}
+32 -1
View File
@@ -15,7 +15,9 @@ import {
type NormalizedPluginsConfig,
type PluginActivationConfigSource,
} from "./config-state.js";
import { getCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-snapshot.js";
import { resolveOpenClawDevSourceRoot } from "./dev-source-root.js";
import { extractPluginInstallRecordsFromInstalledPluginIndex } from "./installed-plugin-index-install-records.js";
import { loadInstalledPluginIndexInstallRecordsSync } from "./installed-plugin-index-records.js";
import type { PluginLoadOptions, PluginRuntimeSubagentMode } from "./loader-types.js";
import {
@@ -345,8 +347,37 @@ export function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) {
const preferBuiltPluginArtifacts = options.preferBuiltPluginArtifacts === true;
const runtimeSubagentMode = resolveRuntimeSubagentMode(options.runtimeOptions);
const coreGatewayMethodNames = resolveCoreGatewayMethodNames(options);
// Config identity cannot prove a custom profile's environment. Only borrow
// the process-owned generation; full snapshots cover narrower loads, while
// scoped snapshots must match exactly to protect activation boundaries.
const currentMetadataSnapshot =
options.installRecords === undefined &&
!shouldResolveRawConfigEnvVars &&
(options.env === undefined || options.env === process.env)
? (getCurrentPluginMetadataSnapshot({
config: rawConfig,
env,
workspaceDir: options.workspaceDir,
}) ??
(onlyPluginIds !== undefined
? getCurrentPluginMetadataSnapshot({
config: rawConfig,
env,
workspaceDir: options.workspaceDir,
pluginIds: onlyPluginIds,
})
: undefined))
: undefined;
const preparedInstallRecords =
currentMetadataSnapshot &&
(options.manifestRegistry === undefined ||
options.manifestRegistry === currentMetadataSnapshot.manifestRegistry)
? extractPluginInstallRecordsFromInstalledPluginIndex(currentMetadataSnapshot.index)
: undefined;
const installRecords = {
...(options.installRecords ?? loadInstalledPluginIndexInstallRecordsSync({ env })),
...(options.installRecords ??
preparedInstallRecords ??
loadInstalledPluginIndexInstallRecordsSync({ env })),
...cfg.plugins?.installs,
};
const devSourceRoot = resolveOpenClawDevSourceRoot(env);
+189 -1
View File
@@ -1,16 +1,26 @@
// Verifies plugin loader runtime registry behavior.
import { afterEach, describe, expect, it } from "vitest";
import { createPluginMetadataSnapshot } from "../config/plugin-auto-enable.test-helpers.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { PluginInstallRecord } from "../config/types.plugins.js";
import { setCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-snapshot.js";
import {
loadInstalledPluginIndexInstallRecordsSync,
writePersistedInstalledPluginIndexInstallRecordsSync,
} from "./installed-plugin-index-records.js";
import { resolvePluginLoadCacheContext } from "./loader-load-context.js";
import {
clearPluginRegistryLoadCache,
loadOpenClawPlugins,
resolveRuntimePluginRegistry,
} from "./loader.js";
import { resetPluginLoaderTestStateForTest } from "./loader.test-fixtures.js";
import { makeTempDir, resetPluginLoaderTestStateForTest } from "./loader.test-fixtures.js";
import {
getMemoryEmbeddingProvider,
registerMemoryEmbeddingProvider,
} from "./memory-embedding-providers.js";
import { buildMemoryPromptSection, registerMemoryCapability } from "./memory-state.js";
import { clearPluginMetadataLifecycleCaches } from "./plugin-metadata-lifecycle.js";
import { createEmptyPluginRegistry } from "./registry.js";
import { setActivePluginRegistry } from "./runtime.js";
@@ -26,6 +36,184 @@ function requireMemoryEmbeddingProvider(providerId: string) {
return provider;
}
function setLoaderMetadataSnapshot(params: { pluginIds?: readonly string[] } = {}) {
const config: OpenClawConfig = {
plugins: {
allow: ["demo"],
slots: { memory: "none" },
},
};
const env = process.env;
const workspaceDir = makeTempDir();
const installRecords: Record<string, PluginInstallRecord> = {
demo: {
source: "npm",
spec: "demo@1.0.0",
installPath: "/tmp/plugins/demo",
},
};
const metadataSnapshot = createPluginMetadataSnapshot({
config,
manifestRegistry: { plugins: [], diagnostics: [] },
workspaceDir,
});
const snapshot = {
...metadataSnapshot,
...(params.pluginIds !== undefined ? { pluginIds: params.pluginIds } : {}),
index: {
...metadataSnapshot.index,
installRecords,
},
};
setCurrentPluginMetadataSnapshot(snapshot, { config, env, workspaceDir });
return { config, env, installRecords, snapshot, workspaceDir };
}
describe("resolvePluginLoadCacheContext", () => {
it("reuses prepared install records from the compatible metadata generation", () => {
const { config, env, installRecords, workspaceDir } = setLoaderMetadataSnapshot();
expect(resolvePluginLoadCacheContext({ config, env, workspaceDir }).installRecords).toEqual(
installRecords,
);
expect(resolvePluginLoadCacheContext({ config, workspaceDir }).installRecords).toEqual(
installRecords,
);
});
it("loads a custom profile's install records instead of reusing the process snapshot", () => {
const profileEnv = { ...process.env, OPENCLAW_STATE_DIR: makeTempDir() };
const profileInstallRecords: Record<string, PluginInstallRecord> = {
demo: {
source: "npm",
spec: "demo@2.0.0",
installPath: "/tmp/plugins/profile-b/demo",
},
};
// Writing an installed index invalidates the current metadata generation,
// so prepare the custom profile before installing the process snapshot.
writePersistedInstalledPluginIndexInstallRecordsSync(profileInstallRecords, {
env: profileEnv,
candidates: [],
});
const { config, env, installRecords, workspaceDir } = setLoaderMetadataSnapshot();
expect(resolvePluginLoadCacheContext({ config, env, workspaceDir }).installRecords).toEqual(
installRecords,
);
expect(
resolvePluginLoadCacheContext({ config, env: profileEnv, workspaceDir }).installRecords,
).toEqual(profileInstallRecords);
});
it("reuses an exact matching scoped metadata generation", () => {
const { config, env, installRecords, workspaceDir } = setLoaderMetadataSnapshot({
pluginIds: ["demo"],
});
expect(
resolvePluginLoadCacheContext({
config,
env,
workspaceDir,
onlyPluginIds: ["demo"],
}).installRecords,
).toEqual(installRecords);
});
it("prefers explicitly supplied install records over the current metadata generation", () => {
const { config, env, workspaceDir } = setLoaderMetadataSnapshot();
const installRecords: Record<string, PluginInstallRecord> = {
explicit: {
source: "npm",
spec: "explicit@2.0.0",
},
};
expect(
resolvePluginLoadCacheContext({ config, env, workspaceDir, installRecords }).installRecords,
).toEqual(installRecords);
});
it("does not reuse install records for a different workspace", () => {
const { config, env } = setLoaderMetadataSnapshot();
expect(
resolvePluginLoadCacheContext({ config, env, workspaceDir: makeTempDir() }).installRecords,
).toEqual(loadInstalledPluginIndexInstallRecordsSync({ env }));
});
it("does not reuse install records for a different plugin policy", () => {
const { env, workspaceDir } = setLoaderMetadataSnapshot();
expect(
resolvePluginLoadCacheContext({
config: {
plugins: {
allow: ["other"],
slots: { memory: "none" },
},
},
env,
workspaceDir,
}).installRecords,
).toEqual(loadInstalledPluginIndexInstallRecordsSync({ env }));
});
it("does not reuse install records for an unrelated explicit manifest registry", () => {
const { config, env, workspaceDir } = setLoaderMetadataSnapshot();
expect(
resolvePluginLoadCacheContext({
config,
env,
workspaceDir,
manifestRegistry: { plugins: [], diagnostics: [] },
}).installRecords,
).toEqual(loadInstalledPluginIndexInstallRecordsSync({ env }));
});
it("does not reuse scoped metadata for a different plugin scope", () => {
const { config, env, workspaceDir } = setLoaderMetadataSnapshot({ pluginIds: ["demo"] });
expect(
resolvePluginLoadCacheContext({
config,
env,
workspaceDir,
onlyPluginIds: ["other"],
}).installRecords,
).toEqual(loadInstalledPluginIndexInstallRecordsSync({ env }));
});
it("does not reuse metadata while resolving raw config environment variables", () => {
const { config, env, workspaceDir } = setLoaderMetadataSnapshot();
expect(
resolvePluginLoadCacheContext({
config,
env,
workspaceDir,
resolveRawConfigEnvVars: true,
}).installRecords,
).toEqual(loadInstalledPluginIndexInstallRecordsSync({ env }));
});
it("invalidates prepared install records at the plugin metadata lifecycle boundary", () => {
const { config, env, installRecords, workspaceDir } = setLoaderMetadataSnapshot();
expect(resolvePluginLoadCacheContext({ config, env, workspaceDir }).installRecords).toEqual(
installRecords,
);
clearPluginMetadataLifecycleCaches();
expect(resolvePluginLoadCacheContext({ config, env, workspaceDir }).installRecords).toEqual(
loadInstalledPluginIndexInstallRecordsSync({ env }),
);
});
});
describe("resolveRuntimePluginRegistry", () => {
it("falls back to the current active runtime when no explicit load context is provided", () => {
const registry = createEmptyPluginRegistry();
@@ -0,0 +1,71 @@
import { describe, expect, it, vi } from "vitest";
import { clearPluginMetadataLifecycleCaches } from "./plugin-metadata-lifecycle.js";
const mocks = vi.hoisted(() => ({
metadata: vi.fn(),
officialCatalog: vi.fn(),
}));
vi.mock("./plugin-metadata-snapshot.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./plugin-metadata-snapshot.js")>()),
loadPluginMetadataSnapshot: (...args: unknown[]) => mocks.metadata(...args),
}));
vi.mock("./official-external-plugin-catalog.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./official-external-plugin-catalog.js")>()),
loadConfiguredHostedOfficialExternalPluginCatalogEntries: (...args: unknown[]) =>
mocks.officialCatalog(...args),
}));
const { listManagedPlugins } = await import("./management-service.js");
describe("plugin management catalog lifecycle", () => {
it("reuses the hosted official catalog until plugin metadata is invalidated", async () => {
clearPluginMetadataLifecycleCaches();
mocks.metadata.mockReturnValue({
index: { plugins: [], installRecords: {} },
byPluginId: new Map(),
plugins: [],
diagnostics: [],
normalizePluginId: (pluginId: string) => pluginId,
});
mocks.officialCatalog
.mockResolvedValueOnce({
source: "hosted",
entries: [
{
id: "@openclaw/diffs",
title: "Diffs",
state: "available",
featured: true,
publisher: { id: "openclaw", trust: "official" },
install: {
candidates: [
{
sourceRef: "public-clawhub",
package: "@openclaw/diffs",
version: "2026.6.11",
integrity: `sha256:${"a".repeat(64)}`,
},
],
},
},
],
})
.mockResolvedValueOnce({ source: "hosted", entries: [] });
const initial = await listManagedPlugins({ config: {}, env: {} });
const cached = await listManagedPlugins({ config: {}, env: {} });
expect(initial.plugins).toEqual([expect.objectContaining({ id: "diffs" })]);
expect(cached.plugins).toEqual(initial.plugins);
expect(mocks.officialCatalog).toHaveBeenCalledTimes(1);
clearPluginMetadataLifecycleCaches();
const refreshed = await listManagedPlugins({ config: {}, env: {} });
expect(mocks.officialCatalog).toHaveBeenCalledTimes(2);
expect(refreshed.plugins).toEqual([]);
});
});
+3
View File
@@ -54,6 +54,7 @@ import {
type OfficialExternalPluginCatalogEntry,
} from "./official-external-plugin-catalog.js";
import { withPluginLifecycleLease } from "./plugin-lifecycle-lease.js";
import { registerPluginMetadataProcessMemoLifecycleClear } from "./plugin-metadata-lifecycle.js";
import { loadPluginMetadataSnapshot } from "./plugin-metadata-snapshot.js";
import { resolveManifestProviderAuthChoices } from "./provider-auth-choices.js";
import { listRecommendedToolInstalls } from "./recommended-tool-installs.js";
@@ -146,6 +147,8 @@ export function clearManagedPluginOfficialCatalogCache(): void {
officialCatalogCache = undefined;
}
registerPluginMetadataProcessMemoLifecycleClear(clearManagedPluginOfficialCatalogCache);
function resolveCatalogManifestIcon(manifest: unknown): string | undefined {
if (!manifest || typeof manifest !== "object") {
return undefined;
+68 -3
View File
@@ -5,9 +5,19 @@ import { resolveInstalledPluginIndexPolicyHash } from "./installed-plugin-index-
import type { PluginManifestRecord, PluginManifestRegistry } from "./manifest-registry.js";
import type { PluginRegistrySnapshot } from "./plugin-registry.js";
const listPotentialConfiguredChannelIds = vi.hoisted(() => vi.fn());
const listExplicitlyDisabledChannelIdsForConfig = vi.hoisted(() => vi.fn());
const loadPluginManifestRegistryForInstalledIndex = vi.hoisted(() => vi.fn());
const {
listPotentialConfiguredChannelIds,
listExplicitlyDisabledChannelIdsForConfig,
loadPluginManifestRegistryForInstalledIndex,
} = vi.hoisted(() => {
// Shared plugin workers must load the lookup graph under this file's manifest mocks.
vi.resetModules();
return {
listPotentialConfiguredChannelIds: vi.fn(),
listExplicitlyDisabledChannelIdsForConfig: vi.fn(),
loadPluginManifestRegistryForInstalledIndex: vi.fn(),
};
});
vi.mock("../channels/config-presence.js", () => ({
hasMeaningfulChannelConfig: (value: unknown) =>
@@ -263,6 +273,61 @@ describe("loadPluginLookUpTable", () => {
expect(table.startup.pluginIds).toEqual(["telegram"]);
});
it("memoizes prepared lookup tables by metadata snapshot and startup scope", async () => {
const plugins = [
createManifestRecord({
id: "telegram",
origin: "bundled",
channels: ["telegram"],
}),
];
const config = {
plugins: { slots: { memory: "none" } },
} as OpenClawConfig;
const env = { TELEGRAM_FAKE_TEST_TRIGGER: "configured" } as NodeJS.ProcessEnv;
const index = createIndex(plugins, {
policyHash: resolveInstalledPluginIndexPolicyHash(config),
});
loadPluginManifestRegistryForInstalledIndex.mockReturnValue({
plugins,
diagnostics: [],
});
listPotentialConfiguredChannelIds.mockImplementation(
(
_config: OpenClawConfig,
_env: NodeJS.ProcessEnv,
options?: { ambientEnvTriggers?: string },
) => (options?.ambientEnvTriggers === "suppress" ? [] : ["telegram"]),
);
const { loadPluginMetadataSnapshot } = await import("./plugin-metadata-snapshot.js");
const { loadPluginLookUpTable } = await import("./plugin-lookup-table.js");
const metadataSnapshot = loadPluginMetadataSnapshot({ config, env, index });
const ambient = loadPluginLookUpTable({ config, env, index, metadataSnapshot });
const repeatedAmbient = loadPluginLookUpTable({ config, env, index, metadataSnapshot });
const suppressed = loadPluginLookUpTable({
config,
env,
index,
metadataSnapshot,
ambientEnvTriggers: "suppress",
});
const repeatedSuppressed = loadPluginLookUpTable({
config,
env,
index,
metadataSnapshot,
ambientEnvTriggers: "suppress",
});
expect(repeatedAmbient).toBe(ambient);
expect(repeatedSuppressed).toBe(suppressed);
expect(suppressed).not.toBe(ambient);
expect(ambient.startup.pluginIds).toEqual(["telegram"]);
expect(suppressed.startup.pluginIds).toStrictEqual([]);
expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledOnce();
});
it("excludes ambient-only channels from the suppressed gateway startup plan", async () => {
const plugins = [
createManifestRecord({
+217 -2
View File
@@ -4,6 +4,7 @@ import {
clearCurrentPluginMetadataSnapshot,
setCurrentPluginMetadataSnapshot,
} from "./current-plugin-metadata-snapshot.js";
import type { PluginDiscoveryResult } from "./discovery.js";
import { resolveInstalledPluginIndexPolicyHash } from "./installed-plugin-index-policy.js";
import type { InstalledPluginIndex } from "./installed-plugin-index.js";
import type { PluginManifestRecord, PluginManifestRegistry } from "./manifest-registry.js";
@@ -12,8 +13,19 @@ import {
resolvePluginMetadataSnapshot,
} from "./plugin-metadata-snapshot.js";
const loadPluginRegistrySnapshotWithMetadata = vi.hoisted(() => vi.fn());
const loadPluginManifestRegistryForInstalledIndex = vi.hoisted(() => vi.fn());
const {
loadPluginRegistrySnapshotWithMetadata,
loadPluginManifestRegistry,
loadPluginManifestRegistryForInstalledIndex,
} = vi.hoisted(() => {
// Shared plugin workers must load this graph after this file's mocks are installed.
vi.resetModules();
return {
loadPluginRegistrySnapshotWithMetadata: vi.fn(),
loadPluginManifestRegistry: vi.fn(),
loadPluginManifestRegistryForInstalledIndex: vi.fn(),
};
});
vi.mock("./plugin-registry.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./plugin-registry.js")>();
@@ -24,6 +36,14 @@ vi.mock("./plugin-registry.js", async (importOriginal) => {
};
});
vi.mock("./manifest-registry.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./manifest-registry.js")>();
return {
...actual,
loadPluginManifestRegistry: (params: unknown) => loadPluginManifestRegistry(params),
};
});
vi.mock("./manifest-registry-installed.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./manifest-registry-installed.js")>();
return {
@@ -85,6 +105,8 @@ function makeManifestRegistry(pluginId = "demo"): PluginManifestRegistry {
describe("plugin metadata snapshot", () => {
beforeEach(() => {
loadPluginRegistrySnapshotWithMetadata.mockReset();
loadPluginManifestRegistry.mockReset();
loadPluginManifestRegistry.mockReturnValue({ plugins: [], diagnostics: [] });
loadPluginManifestRegistryForInstalledIndex.mockReset();
loadPluginManifestRegistryForInstalledIndex.mockReturnValue(makeManifestRegistry());
});
@@ -109,6 +131,199 @@ describe("plugin metadata snapshot", () => {
expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledTimes(2);
});
it("rewalks collection-bearing manifest graphs after prototype mutation", () => {
const index = makeIndex();
const registry = makeManifestRegistry();
const plugin = registry.plugins[0];
if (!plugin) {
throw new Error("expected manifest plugin fixture");
}
const initialMapValue = { nested: { value: "initial-map" } };
const initialSetValue = { nested: { value: "initial-set" } };
const sharedMap = new Map([["initial", initialMapValue]]);
const sharedSet = new Set([initialSetValue]);
plugin.configSchema = {
type: "object",
properties: { sharedMap, sharedSet },
};
loadPluginRegistrySnapshotWithMetadata.mockReturnValue({
source: "provided",
snapshot: index,
diagnostics: [],
});
loadPluginManifestRegistryForInstalledIndex.mockReturnValue(registry);
const first = loadPluginMetadataSnapshot({ config: {}, env: {}, index });
expect(Object.isFrozen(initialMapValue.nested)).toBe(true);
expect(Object.isFrozen(initialSetValue.nested)).toBe(true);
expect(() => sharedMap.set("blocked", initialMapValue)).toThrow(
"Plugin metadata snapshots are immutable",
);
expect(() => sharedSet.add(initialSetValue)).toThrow("Plugin metadata snapshots are immutable");
const injectedMapValue = { nested: { value: "injected-map" } };
const injectedSetValue = { nested: { value: "injected-set" } };
Map.prototype.set.call(sharedMap, "injected", injectedMapValue);
Set.prototype.add.call(sharedSet, injectedSetValue);
expect(sharedMap.get("injected")).toBe(injectedMapValue);
expect(sharedSet.has(injectedSetValue)).toBe(true);
expect(Object.isFrozen(injectedMapValue.nested)).toBe(false);
expect(Object.isFrozen(injectedSetValue.nested)).toBe(false);
const second = loadPluginMetadataSnapshot({ config: {}, env: {}, index });
expect(second).not.toBe(first);
expect(second.index).not.toBe(first.index);
expect(second.manifestRegistry).toBe(registry);
expect(Object.isFrozen(injectedMapValue)).toBe(true);
expect(Object.isFrozen(injectedMapValue.nested)).toBe(true);
expect(Object.isFrozen(injectedSetValue)).toBe(true);
expect(Object.isFrozen(injectedSetValue.nested)).toBe(true);
expect(() => {
injectedMapValue.nested.value = "mutated";
}).toThrow();
expect(() => {
injectedSetValue.nested.value = "mutated";
}).toThrow();
expect(() => sharedMap.delete("injected")).toThrow("Plugin metadata snapshots are immutable");
expect(() => sharedSet.delete(injectedSetValue)).toThrow(
"Plugin metadata snapshots are immutable",
);
});
it("rewalks enumerable accessor graphs when their closure-backed values change", () => {
const index = makeIndex();
const registry = makeManifestRegistry();
const plugin = registry.plugins[0];
if (!plugin) {
throw new Error("expected manifest plugin fixture");
}
let accessorValue = { nested: { value: "initial" } };
const accessor = {} as { current: typeof accessorValue };
Object.defineProperty(accessor, "current", {
enumerable: true,
get: () => accessorValue,
});
plugin.configSchema = {
type: "object",
properties: { accessor },
};
loadPluginRegistrySnapshotWithMetadata.mockReturnValue({
source: "provided",
snapshot: index,
diagnostics: [],
});
loadPluginManifestRegistryForInstalledIndex.mockReturnValue(registry);
const first = loadPluginMetadataSnapshot({ config: {}, env: {}, index });
expect(Object.isFrozen(accessor)).toBe(true);
expect(Object.isFrozen(accessorValue)).toBe(true);
expect(Object.isFrozen(accessorValue.nested)).toBe(true);
const replacement = { nested: { value: "replacement" } };
accessorValue = replacement;
expect(accessor.current).toBe(replacement);
expect(Object.isFrozen(replacement)).toBe(false);
expect(Object.isFrozen(replacement.nested)).toBe(false);
const second = loadPluginMetadataSnapshot({ config: {}, env: {}, index });
expect(second).not.toBe(first);
expect(second.index).not.toBe(first.index);
expect(second.manifestRegistry).toBe(registry);
expect(Object.isFrozen(replacement)).toBe(true);
expect(Object.isFrozen(replacement.nested)).toBe(true);
expect(() => {
replacement.nested.value = "mutated";
}).toThrow();
});
it("rewalks proxy graphs that forge safe descriptors before their values change", () => {
const index = makeIndex();
const registry = makeManifestRegistry();
const plugin = registry.plugins[0];
if (!plugin) {
throw new Error("expected manifest plugin fixture");
}
let currentValue = { nested: { value: "decoy" } };
const target = {} as { current: typeof currentValue };
Object.defineProperty(target, "current", {
configurable: true,
enumerable: true,
get: () => currentValue,
});
let forgedDescriptors = 0;
const proxy = new Proxy(target, {
getOwnPropertyDescriptor(proxyTarget, key) {
const descriptor = Reflect.getOwnPropertyDescriptor(proxyTarget, key);
// Preserve the real accessor during Object.freeze so later proxy reads remain valid.
if (key === "current" && descriptor?.configurable && forgedDescriptors < 1) {
forgedDescriptors += 1;
return {
configurable: true,
enumerable: true,
writable: true,
value: currentValue,
};
}
return descriptor;
},
get(proxyTarget, key, receiver) {
if (key === "current") {
return currentValue;
}
return Reflect.get(proxyTarget, key, receiver);
},
});
plugin.configSchema = {
type: "object",
properties: { proxy },
};
loadPluginRegistrySnapshotWithMetadata.mockReturnValue({
source: "provided",
snapshot: index,
diagnostics: [],
});
loadPluginManifestRegistryForInstalledIndex.mockReturnValue(registry);
const first = loadPluginMetadataSnapshot({ config: {}, env: {}, index });
expect(forgedDescriptors).toBe(1);
expect(Object.isFrozen(proxy)).toBe(true);
expect(Object.isFrozen(currentValue.nested)).toBe(true);
const replacement = { nested: { value: "real" } };
currentValue = replacement;
expect(proxy.current).toBe(replacement);
expect(Object.isFrozen(replacement)).toBe(false);
expect(Object.isFrozen(replacement.nested)).toBe(false);
const second = loadPluginMetadataSnapshot({ config: {}, env: {}, index });
expect(second).not.toBe(first);
expect(second.index).not.toBe(first.index);
expect(second.manifestRegistry).toBe(registry);
expect(Object.isFrozen(replacement)).toBe(true);
expect(Object.isFrozen(replacement.nested)).toBe(true);
expect(() => {
replacement.nested.value = "mutated";
}).toThrow();
});
it("reuses discovery from a derived empty plugin index", () => {
const index = makeIndex();
index.plugins = [];
const discovery: PluginDiscoveryResult = { candidates: [], diagnostics: [] };
loadPluginRegistrySnapshotWithMetadata.mockReturnValue({
source: "derived",
snapshot: index,
diagnostics: [],
discovery,
});
const snapshot = loadPluginMetadataSnapshot({ config: {}, env: {} });
expect(loadPluginManifestRegistry).toHaveBeenCalledWith(expect.objectContaining({ discovery }));
expect(loadPluginManifestRegistryForInstalledIndex).not.toHaveBeenCalled();
expect(snapshot.discovery).toBe(discovery);
});
it("reuses the lifecycle-owned current snapshot", () => {
const config = {};
const index = makeIndex();
+25 -13
View File
@@ -299,20 +299,31 @@ export function loadPluginMetadataSnapshot(
params: LoadPluginMetadataSnapshotParams,
): PluginMetadataSnapshot {
const activeTimelineSpan = getActiveDiagnosticsTimelineSpan();
return freezePluginMetadataSnapshot(
measureDiagnosticsTimelineSpanSync(
"plugins.metadata.scan",
() => loadPluginMetadataSnapshotImpl(params),
{
phase: activeTimelineSpan?.phase ?? "startup",
config: params.config,
env: params.env,
attributes: {
hasWorkspaceDir: params.workspaceDir !== undefined,
hasInstalledIndex: params.index !== undefined,
},
const snapshot = measureDiagnosticsTimelineSpanSync(
"plugins.metadata.scan",
() => loadPluginMetadataSnapshotImpl(params),
{
phase: activeTimelineSpan?.phase ?? "startup",
config: params.config,
env: params.env,
attributes: {
hasWorkspaceDir: params.workspaceDir !== undefined,
hasInstalledIndex: params.index !== undefined,
},
),
},
);
return measureDiagnosticsTimelineSpanSync(
"plugins.metadata.freeze",
() => freezePluginMetadataSnapshot(snapshot),
{
phase: activeTimelineSpan?.phase ?? "startup",
config: params.config,
env: params.env,
attributes: {
indexPluginCount: snapshot.index.plugins.length,
manifestPluginCount: snapshot.plugins.length,
},
},
);
}
@@ -387,6 +398,7 @@ function loadPluginMetadataSnapshotImpl(
env: params.env,
diagnostics: [...index.diagnostics],
installRecords: index.installRecords,
...(registryResult.discovery ? { discovery: registryResult.discovery } : {}),
})
: loadPluginManifestRegistryForInstalledIndex({
index,
+82 -1
View File
@@ -1,4 +1,6 @@
/** Tests plugin module loader cache keys and lifecycle reset behavior. */
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
@@ -71,7 +73,7 @@ function expectNativeOptions(mock: unknown, target: string) {
const options = requireRecord(callArg(mock, 0, 1, "native options"), "native options");
expect(options.allowWindows).toBe(true);
expect(options.fallbackOnMissingDependency).toBe(true);
expect(options.fallbackOnNativeError).toBe(true);
expect(options.fallbackOnNativeError).toBeUndefined();
}
function expectStats(value: unknown, fields: Record<string, unknown>) {
@@ -156,6 +158,43 @@ describe("getCachedPluginModuleLoader", () => {
expect(cache.size).toBe(1);
});
it("installs native internal aliases only on exact loader cache misses", async () => {
const nativeResolver = await import("./plugin-sdk-native-resolver.js");
const installNativeResolver = vi.spyOn(
nativeResolver,
"installOpenClawInternalCorePackageNativeResolver",
);
const { getCachedPluginModuleLoader } = await loadCachedPluginModuleLoader(
"native-resolver-cache-misses",
);
const cache = new Map();
const params = {
cache,
modulePath: "/repo/extensions/demo/index.ts",
importerUrl: "file:///repo/src/plugins/loader.ts",
loaderFilename: "/repo/extensions/demo/index.ts",
tryNative: false,
} as const;
const first = getCachedPluginModuleLoader(params);
expect(installNativeResolver).toHaveBeenCalledTimes(1);
expect(installNativeResolver).toHaveBeenCalledWith({ moduleUrl: params.importerUrl });
expect(getCachedPluginModuleLoader(params)).toBe(first);
expect(installNativeResolver).toHaveBeenCalledTimes(1);
const differentlyScoped = getCachedPluginModuleLoader({
...params,
cacheScopeKey: "different-loader-scope",
});
expect(differentlyScoped).not.toBe(first);
expect(installNativeResolver).toHaveBeenCalledTimes(2);
expect(installNativeResolver).toHaveBeenNthCalledWith(2, {
moduleUrl: params.importerUrl,
});
expect(cache.size).toBe(2);
});
it("creates bounded loader caches", async () => {
const { createJiti, getCachedPluginModuleLoader } =
await loadCachedPluginModuleLoader("bounded-loader-cache");
@@ -513,6 +552,48 @@ describe("getCachedPluginModuleLoader", () => {
});
});
it("propagates native plugin evaluation errors without running the plugin twice", async () => {
vi.doUnmock("./native-module-require.js");
const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-native-evaluation-"));
const modulePath = path.join(fixtureDir, "plugin.cjs");
const markerName = `openclaw.pluginModuleLoaderCache.nativeEvaluation:${fixtureDir}`;
const sideEffectMarker = Symbol.for(markerName);
const expectedError = "plugin exploded during native evaluation";
const fromSourceTransformer = vi.fn();
const createJiti = vi.fn(() => fromSourceTransformer);
try {
fs.writeFileSync(
modulePath,
[
`const marker = Symbol.for(${JSON.stringify(markerName)});`,
"globalThis[marker] = (globalThis[marker] ?? 0) + 1;",
`throw new Error(${JSON.stringify(expectedError)});`,
].join("\n"),
"utf8",
);
const { getCachedPluginModuleLoader } = await importFreshModule<
typeof import("./plugin-module-loader-cache.js")
>(import.meta.url, "./plugin-module-loader-cache.js?scope=native-evaluation-error");
const loader = getCachedPluginModuleLoader({
cache: new Map(),
modulePath,
importerUrl: import.meta.url,
loaderFilename: modulePath,
tryNative: true,
createLoader: asPluginModuleLoaderFactory(createJiti),
});
expect(() => loader(modulePath)).toThrow(expectedError);
expect(Reflect.get(globalThis, sideEffectMarker)).toBe(1);
expect(createJiti).not.toHaveBeenCalled();
expect(fromSourceTransformer).not.toHaveBeenCalled();
} finally {
Reflect.deleteProperty(globalThis, sideEffectMarker);
fs.rmSync(fixtureDir, { recursive: true, force: true });
}
});
it("does not source-transform fallback after native loading reaches a missing dependency", async () => {
const fromSourceTransformer = vi.fn();
const createJiti = vi.fn(() => fromSourceTransformer);
+3 -2
View File
@@ -258,7 +258,6 @@ function createPluginModuleLoader(params: {
allowWindows: true,
aliasMap: params.aliasMap,
fallbackOnMissingDependency: true,
fallbackOnNativeError: true,
});
if (native.ok) {
pluginModuleLoaderStats.nativeHits += 1;
@@ -277,12 +276,14 @@ export function getCachedPluginModuleLoader(
createLoader?: PluginModuleLoaderFactory;
},
): PluginModuleLoader {
installOpenClawInternalCorePackageNativeResolver({ moduleUrl: params.importerUrl });
const cacheEntry = resolvePluginModuleLoaderCacheEntry(params);
const cached = params.cache.get(cacheEntry.scopedCacheKey);
if (cached) {
return cached;
}
// Exact-key hits already own the native aliases installed with their loader;
// reinstallation would rescan the host package on every cached request.
installOpenClawInternalCorePackageNativeResolver({ moduleUrl: params.importerUrl });
const loader = createPluginModuleLoader({
loaderFilename: cacheEntry.loaderFilename,
aliasMap: cacheEntry.aliasMap,
+144 -2
View File
@@ -5,8 +5,12 @@ import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { beforeAll, describe, expect, it } from "vitest";
import { installOpenClawPluginSdkNativeResolver } from "./plugin-sdk-native-resolver.js";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { clearPluginMetadataLifecycleCaches } from "./plugin-metadata-lifecycle.js";
import {
installOpenClawInternalCorePackageNativeResolver,
installOpenClawPluginSdkNativeResolver,
} from "./plugin-sdk-native-resolver.js";
type NativeEsmLazyImportProbe = {
status: number | null;
@@ -101,6 +105,144 @@ function addFakePluginSdkDistExport(root: string, subpath: string): string {
return distPath;
}
function createInternalCoreAliasFixture(prefix: string): {
coreSourceParent: string;
loaderModulePath: string;
moduleUrl: string;
root: string;
sourcePath: string;
} {
const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
const { loaderModulePath } = writeFakeOpenClawPackage(root);
const sourcePath = writeInternalCorePackageSource(root, "markdown-core", "code-spans.ts");
const coreSourceParent = path.join(root, "src", "host-probe.js");
fs.mkdirSync(path.dirname(coreSourceParent), { recursive: true });
fs.writeFileSync(coreSourceParent, "export default {};\n", "utf8");
return {
coreSourceParent,
loaderModulePath,
moduleUrl: pathToFileURL(loaderModulePath).href,
root,
sourcePath,
};
}
describe("installOpenClawInternalCorePackageNativeResolver", () => {
it("shares one internal core alias scan between resolver installers", () => {
const fixture = createInternalCoreAliasFixture("openclaw-sdk-native-core-cache-");
const externalPluginEntry = writeExternalPluginEntry(
path.join(path.dirname(path.dirname(fixture.loaderModulePath)), "external-plugin"),
);
const existsSync = vi.spyOn(fs, "existsSync");
try {
installOpenClawPluginSdkNativeResolver({
modulePath: fixture.loaderModulePath,
pluginModulePath: externalPluginEntry,
});
expect(existsSync).toHaveBeenCalledWith(fixture.sourcePath);
existsSync.mockClear();
const aliases = installOpenClawInternalCorePackageNativeResolver({
moduleUrl: fixture.moduleUrl,
});
expect(aliases).toContain("@openclaw/markdown-core/code-spans");
expect(existsSync).not.toHaveBeenCalled();
} finally {
existsSync.mockRestore();
}
});
it("shares one internal core alias scan across importers from the same host package", () => {
const fixture = createInternalCoreAliasFixture("openclaw-sdk-native-core-shared-host-");
const secondModulePath = path.join(
path.dirname(fixture.loaderModulePath),
"provider-policy.js",
);
fs.writeFileSync(secondModulePath, "export default {};\n", "utf8");
const existsSync = vi.spyOn(fs, "existsSync");
const readFileSync = vi.spyOn(fs, "readFileSync");
try {
installOpenClawInternalCorePackageNativeResolver({ moduleUrl: fixture.moduleUrl });
expect(existsSync).toHaveBeenCalledWith(fixture.sourcePath);
existsSync.mockClear();
readFileSync.mockClear();
const secondModuleUrl = pathToFileURL(secondModulePath).href;
const aliases = installOpenClawInternalCorePackageNativeResolver({
moduleUrl: secondModuleUrl,
});
expect(aliases).toContain("@openclaw/markdown-core/code-spans");
expect(existsSync).not.toHaveBeenCalledWith(fixture.sourcePath);
expect(readFileSync).toHaveBeenCalledExactlyOnceWith(
path.join(fixture.root, "package.json"),
"utf8",
);
existsSync.mockClear();
readFileSync.mockClear();
installOpenClawInternalCorePackageNativeResolver({ moduleUrl: secondModuleUrl });
expect(existsSync).not.toHaveBeenCalled();
expect(readFileSync).not.toHaveBeenCalled();
} finally {
readFileSync.mockRestore();
existsSync.mockRestore();
}
});
it("keeps internal core alias registration isolated between host modules", () => {
const first = createInternalCoreAliasFixture("openclaw-sdk-native-core-host-a-");
const second = createInternalCoreAliasFixture("openclaw-sdk-native-core-host-b-");
const existsSync = vi.spyOn(fs, "existsSync");
try {
installOpenClawInternalCorePackageNativeResolver({ moduleUrl: first.moduleUrl });
existsSync.mockClear();
installOpenClawInternalCorePackageNativeResolver({ moduleUrl: second.moduleUrl });
expect(existsSync).toHaveBeenCalledWith(second.sourcePath);
expect(
fs.realpathSync(
createRequire(first.coreSourceParent).resolve("@openclaw/markdown-core/code-spans"),
),
).toBe(fs.realpathSync(first.sourcePath));
expect(
fs.realpathSync(
createRequire(second.coreSourceParent).resolve("@openclaw/markdown-core/code-spans"),
),
).toBe(fs.realpathSync(second.sourcePath));
} finally {
existsSync.mockRestore();
}
});
it("rescans internal core aliases after plugin metadata lifecycle invalidation", () => {
const fixture = createInternalCoreAliasFixture("openclaw-sdk-native-core-invalidation-");
const existsSync = vi.spyOn(fs, "existsSync");
try {
installOpenClawInternalCorePackageNativeResolver({ moduleUrl: fixture.moduleUrl });
existsSync.mockClear();
installOpenClawInternalCorePackageNativeResolver({ moduleUrl: fixture.moduleUrl });
expect(existsSync).not.toHaveBeenCalled();
clearPluginMetadataLifecycleCaches();
existsSync.mockClear();
installOpenClawInternalCorePackageNativeResolver({ moduleUrl: fixture.moduleUrl });
expect(existsSync).toHaveBeenCalledWith(fixture.sourcePath);
} finally {
existsSync.mockRestore();
}
});
});
describe("installOpenClawPluginSdkNativeResolver", () => {
it("resolves installed plugin SDK imports to the dev source root", () => {
const stableRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sdk-native-stable-"));
+38 -7
View File
@@ -3,6 +3,8 @@ import fs from "node:fs";
import Module from "node:module";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { PluginLruCache } from "./plugin-cache-primitives.js";
import { registerPluginMetadataProcessMemoLifecycleClear } from "./plugin-metadata-lifecycle.js";
import {
buildPluginLoaderAliasMap,
listWorkspacePackageExportAliasEntries,
@@ -117,9 +119,16 @@ const INTERNAL_CORE_PACKAGE_ALIASES = [
},
] as const;
const pluginSdkNativeAliases = new Map<string, NativeAliasEntry[]>();
const internalCorePackageHostRoots = new PluginLruCache<string>(128);
const registeredInternalCorePackageHosts = new PluginLruCache<true>(128);
let installed = false;
let previousResolveFilename: ResolveFilename | undefined;
registerPluginMetadataProcessMemoLifecycleClear(() => {
internalCorePackageHostRoots.clear();
registeredInternalCorePackageHosts.clear();
});
function resolveLoaderModulePath(options: InstallOpenClawPluginSdkNativeResolverOptions): string {
return options.modulePath ?? fileURLToPath(options.moduleUrl ?? import.meta.url);
}
@@ -210,6 +219,19 @@ function resolveLoaderPackageRootFromModulePath(modulePath: string): string {
return findNearestPackageRoot(modulePath);
}
function resolveInternalCorePackageHostRoot(modulePath: string): string {
const normalizedModulePath = path.resolve(modulePath);
const cached = internalCorePackageHostRoots.get(normalizedModulePath);
if (cached) {
return cached;
}
const packageRoot = normalizePathForBoundary(
resolveLoaderPackageRootFromModulePath(normalizedModulePath),
);
internalCorePackageHostRoots.set(normalizedModulePath, packageRoot);
return packageRoot;
}
function resolveAllowedParentRoot(modulePath: string): string {
return findBundledPluginRoot(modulePath) ?? findNearestPackageRoot(modulePath);
}
@@ -294,12 +316,12 @@ function listPluginSdkNativeAliases(
function listInternalCorePackageNativeAliases(
options: InstallOpenClawPluginSdkNativeResolverOptions,
packageRoot = resolveInternalCorePackageHostRoot(resolveLoaderModulePath(options)),
): Array<{
request: string;
target: string;
parentRoots: string[];
}> {
const packageRoot = resolveLoaderPackageRootFromModulePath(resolveLoaderModulePath(options));
const parentRoots = ["src", "scripts", "packages", "test"]
.map((segment) => path.join(packageRoot, segment))
.filter((candidate) => fs.existsSync(candidate))
@@ -398,6 +420,19 @@ function clearNativeAliasesForParentRoots(parentRoots: readonly string[]): void
}
}
function registerInternalCorePackageNativeAliases(
options: InstallOpenClawPluginSdkNativeResolverOptions,
): void {
const packageRoot = resolveInternalCorePackageHostRoot(resolveLoaderModulePath(options));
if (registeredInternalCorePackageHosts.get(packageRoot)) {
return;
}
for (const alias of listInternalCorePackageNativeAliases(options, packageRoot)) {
registerNativeAlias(alias);
}
registeredInternalCorePackageHosts.set(packageRoot, true);
}
export function installOpenClawPluginSdkNativeResolver(
options: InstallOpenClawPluginSdkNativeResolverOptions = {},
): string[] {
@@ -406,9 +441,7 @@ export function installOpenClawPluginSdkNativeResolver(
for (const [specifier, target] of listPluginSdkNativeAliases(options)) {
registerNativeAlias({ request: specifier, target, parentRoots });
}
for (const alias of listInternalCorePackageNativeAliases(options)) {
registerNativeAlias(alias);
}
registerInternalCorePackageNativeAliases(options);
installResolver();
return [...pluginSdkNativeAliases.keys()].toSorted();
}
@@ -416,9 +449,7 @@ export function installOpenClawPluginSdkNativeResolver(
export function installOpenClawInternalCorePackageNativeResolver(
options: Pick<InstallOpenClawPluginSdkNativeResolverOptions, "moduleUrl"> = {},
): string[] {
for (const alias of listInternalCorePackageNativeAliases(options)) {
registerNativeAlias(alias);
}
registerInternalCorePackageNativeAliases(options);
installResolver();
return [...pluginSdkNativeAliases.keys()].toSorted();
}
@@ -4,6 +4,8 @@ import type { PluginManifestRecord } from "./manifest-registry.js";
import type { ProviderPlugin } from "./types.js";
const mocks = vi.hoisted(() => {
// Bind provider discovery to this file's mocks in non-isolated plugin workers.
vi.resetModules();
const loadSource = vi.fn();
const loaderCache = { kind: "provider-discovery-loader-cache", clear: vi.fn() };
return {
@@ -41,6 +41,8 @@ afterEach(() => {
describe("bundled plugin public surface loader", () => {
it("keeps auto-resolved bundled roots on built public artifacts", async () => {
// The non-isolated plugin shard may have already imported the native loader.
vi.resetModules();
const tempRoot = createTempDir();
const bundledPluginsDir = path.join(tempRoot, "dist", "extensions");
const modulePath = path.join(bundledPluginsDir, "demo", "provider-policy-api.js");
+67
View File
@@ -1,4 +1,5 @@
// Covers plugin tool descriptor cache lifecycle and invalidation.
import fs from "node:fs";
import { afterEach, describe, expect, it, vi } from "vitest";
const hoisted = vi.hoisted(() => ({
@@ -15,10 +16,15 @@ vi.mock("../config/runtime-snapshot.js", () => ({
resolveRuntimeConfigCacheKey: hoisted.resolveRuntimeConfigCacheKey,
}));
import { clearPluginMetadataLifecycleCaches } from "./plugin-metadata-lifecycle.js";
import { createEmptyPluginRegistry } from "./registry-empty.js";
import {
buildPluginToolDescriptorCacheKey,
capturePluginToolDescriptor,
createPluginToolDescriptorConfigCacheKeyMemo,
pluginToolDescriptorCacheState,
readCachedPluginToolDescriptors,
writeCachedPluginToolDescriptors,
} from "./tool-descriptor-cache.js";
import { resetPluginToolDescriptorCacheForTest } from "./tools.test-fixtures.js";
@@ -61,6 +67,67 @@ describe("plugin tool descriptor cache keys", () => {
expect(hoisted.resolveRuntimeConfigCacheKey).toHaveBeenCalledTimes(1);
});
it("builds stable descriptor cache keys without polling plugin source files", () => {
const sourceStat = vi.spyOn(fs, "statSync");
try {
const params = {
pluginId: "demo",
source: "/tmp/demo.js",
rootDir: "/tmp/demo",
contractToolNames: ["demo_tool"],
ctx: { workspaceDir: "/tmp/workspace" },
};
expect(buildPluginToolDescriptorCacheKey(params)).toBe(
buildPluginToolDescriptorCacheKey(params),
);
expect(sourceStat).not.toHaveBeenCalled();
} finally {
sourceStat.mockRestore();
}
});
it("retires cached descriptors and retained registries with plugin metadata", () => {
const params = {
pluginId: "demo",
source: "/tmp/demo.js",
rootDir: "/tmp/demo",
contractToolNames: ["demo_tool"],
ctx: { workspaceDir: "/tmp/workspace" },
};
const cacheKey = buildPluginToolDescriptorCacheKey(params);
const descriptor = capturePluginToolDescriptor({
pluginId: "demo",
optional: false,
tool: {
name: "demo_tool",
label: "Demo tool",
description: "Demo tool",
parameters: { type: "object", properties: {} },
execute: async () => ({ content: [], details: {} }),
},
});
const retainedRegistry = createEmptyPluginRegistry();
const contextIdentity = {};
writeCachedPluginToolDescriptors({ cacheKey, descriptors: [descriptor] });
pluginToolDescriptorCacheState.objectIds.set(contextIdentity, 1);
pluginToolDescriptorCacheState.nextObjectId = 2;
pluginToolDescriptorCacheState.runtimeRegistries.set(descriptor, retainedRegistry);
expect(readCachedPluginToolDescriptors(cacheKey)).toEqual([descriptor]);
expect(pluginToolDescriptorCacheState.runtimeRegistries.get(descriptor)).toBe(retainedRegistry);
clearPluginMetadataLifecycleCaches();
expect(buildPluginToolDescriptorCacheKey(params)).toBe(cacheKey);
expect(readCachedPluginToolDescriptors(cacheKey)).toBeUndefined();
expect(pluginToolDescriptorCacheState.objectIds.get(contextIdentity)).toBeUndefined();
expect(pluginToolDescriptorCacheState.nextObjectId).toBe(1);
expect(pluginToolDescriptorCacheState.runtimeRegistries.get(descriptor)).toBeUndefined();
});
it("preserves required gateway client capabilities in cached descriptors", () => {
const outputSchema = { type: "object", properties: { ok: { type: "boolean" } } } as const;
const cached = capturePluginToolDescriptor({
+11 -11
View File
@@ -1,9 +1,9 @@
/** Caches plugin tool descriptors by plugin source, contract names, and runtime context. */
import fs from "node:fs";
import type { AnyAgentTool } from "../agents/tools/common.js";
import { resolveRuntimeConfigCacheKey } from "../config/runtime-snapshot.js";
import type { JsonObject, ToolDescriptor } from "../tools/types.js";
import type { PluginLoadOptions } from "./loader.js";
import { registerPluginMetadataProcessMemoLifecycleClear } from "./plugin-metadata-lifecycle.js";
import type { PluginRegistry } from "./registry-types.js";
import type { OpenClawPluginToolContext } from "./types.js";
@@ -25,6 +25,16 @@ export const pluginToolDescriptorCacheState = {
runtimeRegistries: new WeakMap<CachedPluginToolDescriptor, PluginRegistry>(),
};
function clearPluginToolDescriptorCache(): void {
pluginToolDescriptorCacheState.descriptors.clear();
pluginToolDescriptorCacheState.objectIds = new WeakMap();
pluginToolDescriptorCacheState.nextObjectId = 1;
pluginToolDescriptorCacheState.runtimeRegistries = new WeakMap();
}
// Plugin source and retained registries stay stable until their metadata lifecycle is retired.
registerPluginMetadataProcessMemoLifecycleClear(clearPluginToolDescriptorCache);
export type PluginToolDescriptorConfigCacheKeyMemo = WeakMap<object, string | number | null>;
/** Creates a memo table for config cache keys reused across descriptor cache calls. */
@@ -32,15 +42,6 @@ export function createPluginToolDescriptorConfigCacheKeyMemo(): PluginToolDescri
return new WeakMap();
}
function sourceFingerprint(source: string): string {
try {
const stat = fs.statSync(source);
return `${stat.size}:${Math.round(stat.mtimeMs)}`;
} catch {
return "missing";
}
}
function getDescriptorCacheObjectId(value: object | null | undefined): number | null {
if (!value) {
return null;
@@ -132,7 +133,6 @@ export function buildPluginToolDescriptorCacheKey(params: {
pluginId: params.pluginId,
source: params.source,
rootDir: params.rootDir ?? null,
sourceFingerprint: sourceFingerprint(params.source),
contractToolNames: [...params.contractToolNames].toSorted(),
clientCaps: [...(params.clientCaps ?? [])].toSorted(),
context: buildDescriptorContextCacheKey({