From 3fb201c5a7612db5186ecbbc5c20e5e8845ccb23 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Jul 2026 07:48:13 -0400 Subject: [PATCH] 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 --- ...odel.static-catalog.snapshot-cache.test.ts | 295 ++++++++++++++++++ .../model.static-catalog.ts | 167 +++++++--- src/agents/provider-auth-aliases.test.ts | 31 ++ src/agents/provider-auth-aliases.ts | 10 +- src/channels/plugins/registry-loaded.ts | 45 +-- src/channels/plugins/registry.test.ts | 36 +++ src/gateway/board-host-tools.ts | 9 +- src/gateway/method-scopes.test.ts | 88 +++++- src/gateway/method-scopes.ts | 13 +- ...er-methods.plugin-gateway-dispatch.test.ts | 75 ++++- src/gateway/server-methods.ts | 15 +- .../board.plugin-capabilities.test.ts | 182 ++++++++--- .../server-methods/plugin-host-hooks.ts | 7 +- src/gateway/server-plugins.test.ts | 12 +- src/gateway/server-plugins.ts | 36 +-- src/plugin-sdk/facade-runtime.test.ts | 139 +++++++++ src/plugin-sdk/facade-runtime.ts | 24 +- .../contracts/host-hooks.contract.test.ts | 59 +++- .../session-actions.contract.test.ts | 59 +++- src/plugins/discovery.test.ts | 141 +++++++++ src/plugins/discovery.ts | 64 ++-- src/plugins/loader-load-context.ts | 33 +- src/plugins/loader.runtime-registry.test.ts | 190 ++++++++++- ...management-service.lifecycle-cache.test.ts | 71 +++++ src/plugins/management-service.ts | 3 + src/plugins/plugin-lookup-table.test.ts | 71 ++++- src/plugins/plugin-metadata-snapshot.test.ts | 219 ++++++++++++- src/plugins/plugin-metadata-snapshot.ts | 38 ++- .../plugin-module-loader-cache.test.ts | 83 ++++- src/plugins/plugin-module-loader-cache.ts | 5 +- .../plugin-sdk-native-resolver.test.ts | 146 ++++++++- src/plugins/plugin-sdk-native-resolver.ts | 45 ++- .../provider-discovery.runtime.test.ts | 2 + src/plugins/public-surface-loader.test.ts | 2 + src/plugins/tool-descriptor-cache.test.ts | 67 ++++ src/plugins/tool-descriptor-cache.ts | 22 +- 36 files changed, 2259 insertions(+), 245 deletions(-) create mode 100644 src/agents/embedded-agent-runner/model.static-catalog.snapshot-cache.test.ts create mode 100644 src/plugins/management-service.lifecycle-cache.test.ts diff --git a/src/agents/embedded-agent-runner/model.static-catalog.snapshot-cache.test.ts b/src/agents/embedded-agent-runner/model.static-catalog.snapshot-cache.test.ts new file mode 100644 index 000000000000..ced757502b3d --- /dev/null +++ b/src/agents/embedded-agent-runner/model.static-catalog.snapshot-cache.test.ts @@ -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()), + loadPluginManifest: manifestMocks.loadPluginManifest, +})); + +vi.mock("../../plugins/manifest-registry.js", async (importOriginal) => ({ + ...(await importOriginal()), + loadPluginManifestRegistry: manifestMocks.loadPluginManifestRegistry, +})); + +vi.mock("../../plugins/providers.js", async (importOriginal) => ({ + ...(await importOriginal()), + resolveActivatableProviderOwnerPluginIds: providerMocks.resolveActivatableProviderOwnerPluginIds, + resolveBundledProviderCompatPluginIds: providerMocks.resolveBundledProviderCompatPluginIds, + resolveOwningPluginIdsForProviderRef: providerMocks.resolveOwningPluginIdsForProviderRef, +})); + +vi.mock("../../plugins/provider-discovery.js", async (importOriginal) => ({ + ...(await importOriginal()), + 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(); + }); +}); diff --git a/src/agents/embedded-agent-runner/model.static-catalog.ts b/src/agents/embedded-agent-runner/model.static-catalog.ts index 69460fc64615..282ea6c12971 100644 --- a/src/agents/embedded-agent-runner/model.static-catalog.ts +++ b/src/agents/embedded-agent-runner/model.static-catalog.ts @@ -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>; +}; + +// 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 +>(); +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>(), + }; + 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>(); + 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 { 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] : [], ), ); diff --git a/src/agents/provider-auth-aliases.test.ts b/src/agents/provider-auth-aliases.test.ts index 074baea782af..68430f768725 100644 --- a/src/agents/provider-auth-aliases.test.ts +++ b/src/agents/provider-auth-aliases.test.ts @@ -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 = { diff --git a/src/agents/provider-auth-aliases.ts b/src/agents/provider-auth-aliases.ts index dee6f9daafaf..a0b181eec298 100644 --- a/src/agents/provider-auth-aliases.ts +++ b/src/agents/provider-auth-aliases.ts @@ -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>>(); } +// 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)[Symbol.for("openclaw.providerAuthAliasesTestApi")] = { - resetProviderAuthAliasMapCacheForTest, + resetProviderAuthAliasMapCacheForTest: clearProviderAuthAliasMapCache, }; } diff --git a/src/channels/plugins/registry-loaded.ts b/src/channels/plugins/registry-loaded.ts index e10e94393b59..97db6ddd0fb5 100644 --- a/src/channels/plugins/registry-loaded.ts +++ b/src/channels/plugins/registry-loaded.ts @@ -55,20 +55,6 @@ function coerceLoadedChannelPlugin( return plugin as LoadedChannelPlugin; } -function dedupeChannels(channels: LoadedChannelPlugin[]): LoadedChannelPlugin[] { - const seen = new Set(); - 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(); + const byId = new Map(); + const entriesById = new Map(); 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(); - const entriesById = new Map(); - 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. diff --git a/src/channels/plugins/registry.test.ts b/src/channels/plugins/registry.test.ts index 7571d9297a31..bc84debf6d09 100644 --- a/src/channels/plugins/registry.test.ts +++ b/src/channels/plugins/registry.test.ts @@ -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 = [ diff --git a/src/gateway/board-host-tools.ts b/src/gateway/board-host-tools.ts index 050c0b1e94f3..45f54a3addc6 100644 --- a/src/gateway/board-host-tools.ts +++ b/src/gateway/board-host-tools.ts @@ -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, invocation: GatewayHandlerInvocation, ): Promise { - const registration = getActivePluginRegistry()?.dashboardActionVerbs.get(actionId); + const registration = + getActivePluginSessionExtensionRegistry()?.dashboardActionVerbs.get(actionId); if (!registration) { throw new BoardValidationError( "invalid_operation", diff --git a/src/gateway/method-scopes.test.ts b/src/gateway/method-scopes.test.ts index fa06cf200f6f..8667d8fb4b97 100644 --- a/src/gateway/method-scopes.test.ts +++ b/src/gateway/method-scopes.test.ts @@ -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"); diff --git a/src/gateway/method-scopes.ts b/src/gateway/method-scopes.ts index 0ff65ea67026..fb8369e9229c 100644 --- a/src/gateway/method-scopes.ts +++ b/src/gateway/method-scopes.ts @@ -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) { diff --git a/src/gateway/server-methods.plugin-gateway-dispatch.test.ts b/src/gateway/server-methods.plugin-gateway-dispatch.test.ts index 642da652640a..4a1e5481c77b 100644 --- a/src/gateway/server-methods.plugin-gateway-dispatch.test.ts +++ b/src/gateway/server-methods.plugin-gateway-dispatch.test.ts @@ -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(({ respond }) => { + respond(true, { ok: true, source: "gateway" }); + }); + const scopedHandler = vi.fn(({ 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[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(); setActivePluginRegistry(createEmptyPluginRegistry()); diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index de237db150ae..108baddad1d9 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -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 diff --git a/src/gateway/server-methods/board.plugin-capabilities.test.ts b/src/gateway/server-methods/board.plugin-capabilities.test.ts index 2e1f5fc35e33..e49a17cddcd2 100644 --- a/src/gateway/server-methods/board.plugin-capabilities.test.ts +++ b/src/gateway/server-methods/board.plugin-capabilities.test.ts @@ -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(async ({ params, respond }) => { respond(true, { items: [params.filter ?? "all"] }); }); const actionHandler = vi.fn(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( + async ({ params, respond }) => { + respond(true, { owner: "gateway", items: [params.filter ?? "all"] }); + }, + ); + const gatewayActionHandler = vi.fn( + async ({ params, respond }) => { + respond(true, { owner: "gateway", refreshed: params.force }); + }, + ); + const scopedReadHandler = vi.fn(async ({ respond }) => { + respond(true, { owner: "agent" }); + }); + const scopedActionHandler = vi.fn(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(); + } + } + }); }); diff --git a/src/gateway/server-methods/plugin-host-hooks.ts b/src/gateway/server-methods/plugin-host-hooks.ts index 2db86004c56e..291dde36157d 100644 --- a/src/gateway/server-methods/plugin-host-hooks.ts +++ b/src/gateway/server-methods/plugin-host-hooks.ts @@ -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 = { 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"), ); diff --git a/src/gateway/server-plugins.test.ts b/src/gateway/server-plugins.test.ts index b88eba3bf659..0c7c29b029c6 100644 --- a/src/gateway/server-plugins.test.ts +++ b/src/gateway/server-plugins.test.ts @@ -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"]); }); diff --git a/src/gateway/server-plugins.ts b/src/gateway/server-plugins.ts index 50d89d94f0a6..bf0231e60bac 100644 --- a/src/gateway/server-plugins.ts +++ b/src/gateway/server-plugins.ts @@ -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( - method: string, - params: unknown, - options?: DispatchGatewayMethodInProcessOptions, -): Promise { - const response = await dispatchGatewayMethodInProcessRaw(method, params, options); - return unwrapGatewayMethodDispatchResponse(method, response) as T; -} - export async function dispatchGatewayMethodInProcess( method: string, params: Record, options?: DispatchGatewayMethodInProcessOptions, ): Promise { - return await dispatchGatewayMethod(method, params, options); + const response = await dispatchGatewayMethodInProcessRaw(method, params, options); + return unwrapGatewayMethodDispatchResponse(method, response) as T; } export async function dispatchTrustedPluginGatewayMethod( @@ -359,7 +352,7 @@ export async function dispatchTrustedPluginGatewayMethod( throw new Error("Gateway requests are only available to bundled or trusted official plugins."); } const syntheticScopes = normalizeOperatorScopeList(options?.scopes); - return await dispatchGatewayMethod(method, params, { + return await dispatchGatewayMethodInProcess(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( + const payload = await dispatchGatewayMethodInProcess( "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; diff --git a/src/plugin-sdk/facade-runtime.test.ts b/src/plugin-sdk/facade-runtime.test.ts index efcd5243f592..f3b80841e878 100644 --- a/src/plugin-sdk/facade-runtime.test.ts +++ b/src/plugin-sdk/facade-runtime.test.ts @@ -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); diff --git a/src/plugin-sdk/facade-runtime.ts b/src/plugin-sdk/facade-runtime.ts index ff87542817b3..8dcb51521cee 100644 --- a/src/plugin-sdk/facade-runtime.ts +++ b/src/plugin-sdk/facade-runtime.ts @@ -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(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 { 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(); diff --git a/src/plugins/contracts/session-actions.contract.test.ts b/src/plugins/contracts/session-actions.contract.test.ts index f0d4b091c8e4..3b52137a0424 100644 --- a/src/plugins/contracts/session-actions.contract.test.ts +++ b/src/plugins/contracts/session-actions.contract.test.ts @@ -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; diff --git a/src/plugins/discovery.test.ts b/src/plugins/discovery.test.ts index 656f5b7f298f..f420d210b8e7 100644 --- a/src/plugins/discovery.test.ts +++ b/src/plugins/discovery.test.ts @@ -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"); diff --git a/src/plugins/discovery.ts b/src/plugins/discovery.ts index 7e754459062d..78647110a3d7 100644 --- a/src/plugins/discovery.ts +++ b/src/plugins/discovery.ts @@ -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( + 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(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; }): 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; } diff --git a/src/plugins/loader-load-context.ts b/src/plugins/loader-load-context.ts index f07736251168..3e7d5f03288d 100644 --- a/src/plugins/loader-load-context.ts +++ b/src/plugins/loader-load-context.ts @@ -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); diff --git a/src/plugins/loader.runtime-registry.test.ts b/src/plugins/loader.runtime-registry.test.ts index fd3111522089..621d25903651 100644 --- a/src/plugins/loader.runtime-registry.test.ts +++ b/src/plugins/loader.runtime-registry.test.ts @@ -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 = { + 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 = { + 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 = { + 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(); diff --git a/src/plugins/management-service.lifecycle-cache.test.ts b/src/plugins/management-service.lifecycle-cache.test.ts new file mode 100644 index 000000000000..c5d9f6a901bf --- /dev/null +++ b/src/plugins/management-service.lifecycle-cache.test.ts @@ -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()), + loadPluginMetadataSnapshot: (...args: unknown[]) => mocks.metadata(...args), +})); + +vi.mock("./official-external-plugin-catalog.js", async (importOriginal) => ({ + ...(await importOriginal()), + 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([]); + }); +}); diff --git a/src/plugins/management-service.ts b/src/plugins/management-service.ts index 286ee3038b31..3924435b0633 100644 --- a/src/plugins/management-service.ts +++ b/src/plugins/management-service.ts @@ -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; diff --git a/src/plugins/plugin-lookup-table.test.ts b/src/plugins/plugin-lookup-table.test.ts index 7b004ec38ca5..0866b3966eab 100644 --- a/src/plugins/plugin-lookup-table.test.ts +++ b/src/plugins/plugin-lookup-table.test.ts @@ -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({ diff --git a/src/plugins/plugin-metadata-snapshot.test.ts b/src/plugins/plugin-metadata-snapshot.test.ts index 0b38d01a3572..61a92c4428e2 100644 --- a/src/plugins/plugin-metadata-snapshot.test.ts +++ b/src/plugins/plugin-metadata-snapshot.test.ts @@ -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(); @@ -24,6 +36,14 @@ vi.mock("./plugin-registry.js", async (importOriginal) => { }; }); +vi.mock("./manifest-registry.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadPluginManifestRegistry: (params: unknown) => loadPluginManifestRegistry(params), + }; +}); + vi.mock("./manifest-registry-installed.js", async (importOriginal) => { const actual = await importOriginal(); 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(); diff --git a/src/plugins/plugin-metadata-snapshot.ts b/src/plugins/plugin-metadata-snapshot.ts index 92e556d8615a..55b0ade7351f 100644 --- a/src/plugins/plugin-metadata-snapshot.ts +++ b/src/plugins/plugin-metadata-snapshot.ts @@ -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, diff --git a/src/plugins/plugin-module-loader-cache.test.ts b/src/plugins/plugin-module-loader-cache.test.ts index 29afa331ae5e..46dcb9f572d4 100644 --- a/src/plugins/plugin-module-loader-cache.test.ts +++ b/src/plugins/plugin-module-loader-cache.test.ts @@ -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) { @@ -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); diff --git a/src/plugins/plugin-module-loader-cache.ts b/src/plugins/plugin-module-loader-cache.ts index 72da7f852f97..c3ecc81d17c2 100644 --- a/src/plugins/plugin-module-loader-cache.ts +++ b/src/plugins/plugin-module-loader-cache.ts @@ -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, diff --git a/src/plugins/plugin-sdk-native-resolver.test.ts b/src/plugins/plugin-sdk-native-resolver.test.ts index 37e767394a1d..d71bcf93b288 100644 --- a/src/plugins/plugin-sdk-native-resolver.test.ts +++ b/src/plugins/plugin-sdk-native-resolver.test.ts @@ -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-")); diff --git a/src/plugins/plugin-sdk-native-resolver.ts b/src/plugins/plugin-sdk-native-resolver.ts index 6b610c41311c..292dbb8c71dd 100644 --- a/src/plugins/plugin-sdk-native-resolver.ts +++ b/src/plugins/plugin-sdk-native-resolver.ts @@ -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(); +const internalCorePackageHostRoots = new PluginLruCache(128); +const registeredInternalCorePackageHosts = new PluginLruCache(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 = {}, ): string[] { - for (const alias of listInternalCorePackageNativeAliases(options)) { - registerNativeAlias(alias); - } + registerInternalCorePackageNativeAliases(options); installResolver(); return [...pluginSdkNativeAliases.keys()].toSorted(); } diff --git a/src/plugins/provider-discovery.runtime.test.ts b/src/plugins/provider-discovery.runtime.test.ts index e339c6ab11e0..db31e59464d8 100644 --- a/src/plugins/provider-discovery.runtime.test.ts +++ b/src/plugins/provider-discovery.runtime.test.ts @@ -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 { diff --git a/src/plugins/public-surface-loader.test.ts b/src/plugins/public-surface-loader.test.ts index 210252e789ca..3fb064553d59 100644 --- a/src/plugins/public-surface-loader.test.ts +++ b/src/plugins/public-surface-loader.test.ts @@ -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"); diff --git a/src/plugins/tool-descriptor-cache.test.ts b/src/plugins/tool-descriptor-cache.test.ts index 579e6f29b6e3..57036c92b8b2 100644 --- a/src/plugins/tool-descriptor-cache.test.ts +++ b/src/plugins/tool-descriptor-cache.test.ts @@ -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({ diff --git a/src/plugins/tool-descriptor-cache.ts b/src/plugins/tool-descriptor-cache.ts index 4067e622fbd5..1c28318be0e2 100644 --- a/src/plugins/tool-descriptor-cache.ts +++ b/src/plugins/tool-descriptor-cache.ts @@ -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(), }; +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; /** 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({