mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
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:
committed by
GitHub
parent
91f04499f5
commit
3fb201c5a7
@@ -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] : [],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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-"));
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user