diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 3b8a28b41f85..b3ee67dac8e9 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -8f362012108b8b2db08e836883fac095f88e70a4c1ef66a713980e1361ec48b6 plugin-sdk-api-baseline.json -ddbb02207930b419658209039696e8008d76c8f839d7ecde95e1c8ba91c19c7a plugin-sdk-api-baseline.jsonl +0eb8e2e8ebfe48bbf269d6e0100e38b8c85c387651be7a3bc3d67f549a13768f plugin-sdk-api-baseline.json +f6e5dc7474a7cb54bdcde544f135656ac8e501679e18129607f4f42fec7cd791 plugin-sdk-api-baseline.jsonl diff --git a/src/agents/agent-bundle-lsp-runtime.test.ts b/src/agents/agent-bundle-lsp-runtime.test.ts index ba3f989d6455..8eb0c4db14cf 100644 --- a/src/agents/agent-bundle-lsp-runtime.test.ts +++ b/src/agents/agent-bundle-lsp-runtime.test.ts @@ -120,6 +120,23 @@ describe("bundle LSP runtime", () => { loadEmbeddedAgentLspConfigMock.mockReset(); }); + it("reuses the prepared plugin manifest registry for bundle discovery", async () => { + loadEmbeddedAgentLspConfigMock.mockReturnValue({ lspServers: {}, diagnostics: [] }); + const manifestRegistry = { plugins: [] }; + const { createBundleLspToolRuntime } = await import("./agent-bundle-lsp-runtime.js"); + + await createBundleLspToolRuntime({ + workspaceDir: "/tmp/workspace", + manifestRegistry, + }); + + expect(loadEmbeddedAgentLspConfigMock).toHaveBeenCalledWith({ + workspaceDir: "/tmp/workspace", + cfg: undefined, + manifestRegistry, + }); + }); + it("starts LSP servers in a disposable process group", async () => { configureSingleLspServer(); const child = new MockChildProcess(); diff --git a/src/agents/agent-bundle-lsp-runtime.ts b/src/agents/agent-bundle-lsp-runtime.ts index ab33785cad4a..30038e9ca6b6 100644 --- a/src/agents/agent-bundle-lsp-runtime.ts +++ b/src/agents/agent-bundle-lsp-runtime.ts @@ -9,6 +9,7 @@ import { materializeWindowsSpawnProgram, resolveWindowsSpawnProgram, } from "../plugin-sdk/windows-spawn.js"; +import type { PluginManifestRegistry } from "../plugins/manifest-registry.js"; import { setPluginToolMeta } from "../plugins/tools.js"; import { killProcessTree } from "../process/kill-tree.js"; import { loadEmbeddedAgentLspConfig } from "./embedded-agent-lsp.js"; @@ -499,10 +500,12 @@ export async function createBundleLspToolRuntime(params: { workspaceDir: string; cfg?: OpenClawConfig; reservedToolNames?: Iterable; + manifestRegistry?: Pick; }): Promise { const loaded = loadEmbeddedAgentLspConfig({ workspaceDir: params.workspaceDir, cfg: params.cfg, + manifestRegistry: params.manifestRegistry, }); for (const diagnostic of loaded.diagnostics) { logWarn(`bundle-lsp: ${diagnostic.pluginId}: ${diagnostic.message}`); diff --git a/src/agents/agent-bundle-mcp-runtime.test.ts b/src/agents/agent-bundle-mcp-runtime.test.ts index e32772fd3e9c..0260d0904d2f 100644 --- a/src/agents/agent-bundle-mcp-runtime.test.ts +++ b/src/agents/agent-bundle-mcp-runtime.test.ts @@ -1700,8 +1700,10 @@ process.on("SIGINT", shutdown);`, it("reuses repeated materialization and recreates after explicit disposal", async () => { const created: SessionMcpRuntime[] = []; + const createdManifestRegistries: unknown[] = []; const disposed: string[] = []; const createRuntime: RuntimeFactory = (params) => { + createdManifestRegistries.push(params.manifestRegistry); const runtime = makeRuntime([{ toolName: "bundle_probe", description: "Bundle MCP probe" }]); created.push(runtime); return { @@ -1716,16 +1718,19 @@ process.on("SIGINT", shutdown);`, }; }; const manager = testing.createSessionMcpRuntimeManager({ createRuntime }); + const manifestRegistry = { plugins: [] }; const runtimeA = await manager.getOrCreate({ sessionId: "session-a", sessionKey: "agent:test:session-a", workspaceDir: "/workspace", + manifestRegistry, }); const runtimeB = await manager.getOrCreate({ sessionId: "session-a", sessionKey: "agent:test:session-a", workspaceDir: "/workspace", + manifestRegistry, }); const materializedA = await materializeBundleMcpToolsForRun({ runtime: runtimeA }); @@ -1738,6 +1743,7 @@ process.on("SIGINT", shutdown);`, expect(materializedA.tools.map((tool) => tool.name)).toEqual(["bundleProbe__bundle_probe"]); expect(materializedB.tools.map((tool) => tool.name)).toEqual(["bundleProbe__bundle_probe"]); expect(created).toHaveLength(1); + expect(createdManifestRegistries).toEqual([manifestRegistry]); expect(manager.listSessionIds()).toEqual(["session-a"]); await manager.disposeSession("session-a"); @@ -1747,11 +1753,13 @@ process.on("SIGINT", shutdown);`, sessionId: "session-a", sessionKey: "agent:test:session-a", workspaceDir: "/workspace", + manifestRegistry, }); await materializeBundleMcpToolsForRun({ runtime: runtimeC }); expect(runtimeC).not.toBe(runtimeA); expect(created).toHaveLength(2); + expect(createdManifestRegistries).toEqual([manifestRegistry, manifestRegistry]); const materializedC = await materializeBundleMcpToolsForRun({ runtime: runtimeC, diff --git a/src/agents/agent-bundle-mcp-runtime.ts b/src/agents/agent-bundle-mcp-runtime.ts index 7c54121f02e5..12ced419521c 100644 --- a/src/agents/agent-bundle-mcp-runtime.ts +++ b/src/agents/agent-bundle-mcp-runtime.ts @@ -1077,6 +1077,7 @@ function createSessionMcpRuntimeManager( workspaceDir: params.workspaceDir, cfg: params.cfg, logDiagnostics: false, + manifestRegistry: params.manifestRegistry, }); const existing = runtimesBySessionId.get(params.sessionId); if (existing) { @@ -1120,6 +1121,7 @@ function createSessionMcpRuntimeManager( workspaceDir: params.workspaceDir, agentDir: params.agentDir, cfg: params.cfg, + manifestRegistry: params.manifestRegistry, configFingerprint: nextFingerprint, }), ).then((runtime) => { @@ -1212,6 +1214,7 @@ export async function getOrCreateSessionMcpRuntime(params: { workspaceDir: string; agentDir?: string; cfg?: OpenClawConfig; + manifestRegistry?: Pick; }): Promise { return await getSessionMcpRuntimeManager().getOrCreate(params); } diff --git a/src/agents/agent-bundle-mcp-types.ts b/src/agents/agent-bundle-mcp-types.ts index dc25ee62d58e..aa3f0f17405c 100644 --- a/src/agents/agent-bundle-mcp-types.ts +++ b/src/agents/agent-bundle-mcp-types.ts @@ -6,6 +6,7 @@ import type { } from "@modelcontextprotocol/sdk/types.js"; import type { TSchema } from "typebox"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { PluginManifestRegistry } from "../plugins/manifest-registry.js"; import type { AnyAgentTool } from "./tools/common.js"; /** Materialized MCP tools plus diagnostics and cleanup handle for one run. */ @@ -113,6 +114,7 @@ export type SessionMcpRuntimeManager = { workspaceDir: string; agentDir?: string; cfg?: OpenClawConfig; + manifestRegistry?: Pick; }) => Promise; bindSessionKey: (sessionKey: string, sessionId: string) => void; resolveSessionId: (sessionKey: string) => string | undefined; diff --git a/src/agents/embedded-agent-lsp.ts b/src/agents/embedded-agent-lsp.ts index 8ed66d172f4f..e8f723571c1a 100644 --- a/src/agents/embedded-agent-lsp.ts +++ b/src/agents/embedded-agent-lsp.ts @@ -4,6 +4,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { BundleLspServerConfig } from "../plugins/bundle-lsp.js"; import { loadEnabledBundleLspConfig } from "../plugins/bundle-lsp.js"; +import type { PluginManifestRegistry } from "../plugins/manifest-registry.js"; type EmbeddedAgentLspConfig = { lspServers: Record; @@ -14,10 +15,12 @@ type EmbeddedAgentLspConfig = { export function loadEmbeddedAgentLspConfig(params: { workspaceDir: string; cfg?: OpenClawConfig; + manifestRegistry?: Pick; }): EmbeddedAgentLspConfig { const bundleLsp = loadEnabledBundleLspConfig({ workspaceDir: params.workspaceDir, cfg: params.cfg, + manifestRegistry: params.manifestRegistry, }); // User-configured LSP servers could override bundle defaults here in the future. return { diff --git a/src/agents/embedded-agent-runner/run/attempt.ts b/src/agents/embedded-agent-runner/run/attempt.ts index 8b3410656628..a3b97a05d276 100644 --- a/src/agents/embedded-agent-runner/run/attempt.ts +++ b/src/agents/embedded-agent-runner/run/attempt.ts @@ -1919,6 +1919,13 @@ export async function runEmbeddedAttempt( disableTools: params.disableTools || isRawModelRun, toolsAllow: params.toolsAllow, }); + const bundleMetadataSnapshot = getCurrentAttemptPluginMetadataSnapshot(); + // Scoped registries are partial views. Bundle discovery can skip its own scan only when + // the attempt snapshot covers every plugin; otherwise MCP/LSP bundles can disappear. + const bundleManifestRegistry = + bundleMetadataSnapshot?.pluginIds === undefined + ? bundleMetadataSnapshot?.manifestRegistry + : undefined; const bundleMcpSessionRuntime = bundleMcpEnabled ? await getOrCreateSessionMcpRuntime({ sessionId: params.sessionId, @@ -1926,6 +1933,7 @@ export async function runEmbeddedAttempt( workspaceDir: effectiveWorkspace, agentDir, cfg: params.config, + manifestRegistry: bundleManifestRegistry, }) : undefined; bundleMcpRuntime = bundleMcpSessionRuntime @@ -1948,6 +1956,7 @@ export async function runEmbeddedAttempt( ? await createBundleLspToolRuntime({ workspaceDir: effectiveWorkspace, cfg: params.config, + manifestRegistry: bundleManifestRegistry, reservedToolNames: [ ...tools.map((tool) => tool.name), ...(clientTools?.map((tool) => tool.function.name) ?? []), diff --git a/src/config/plugin-auto-enable.apply.ts b/src/config/plugin-auto-enable.apply.ts index b25ab6f599d6..6ae74e506060 100644 --- a/src/config/plugin-auto-enable.apply.ts +++ b/src/config/plugin-auto-enable.apply.ts @@ -69,6 +69,16 @@ function stableFingerprintValue(value: unknown): string { .join(",")}}`; } +/** Fingerprints mutable config inputs used by plugin auto-enable detection. */ +export function fingerprintPluginAutoEnableConfig(config: OpenClawConfig): string { + return hashRuntimeConfigValue(config); +} + +/** Fingerprints mutable environment inputs used by plugin auto-enable detection. */ +export function fingerprintPluginAutoEnableEnv(env: NodeJS.ProcessEnv): string { + return stableFingerprintValue(env); +} + function createPluginAutoEnableCacheEntry(params: { config: OpenClawConfig; discovery: PluginDiscoveryResult; @@ -77,9 +87,9 @@ function createPluginAutoEnableCacheEntry(params: { result: PluginAutoEnableResult; }): PluginAutoEnableCacheEntry { return { - configFingerprint: hashRuntimeConfigValue(params.config), + configFingerprint: fingerprintPluginAutoEnableConfig(params.config), discoveryFingerprint: stableFingerprintValue(params.discovery.candidates), - envFingerprint: stableFingerprintValue(params.env), + envFingerprint: fingerprintPluginAutoEnableEnv(params.env), registryFingerprint: stableFingerprintValue(params.manifestRegistry.plugins), result: params.result, }; @@ -93,9 +103,9 @@ function isPluginAutoEnableCacheEntryFresh(params: { manifestRegistry: PluginManifestRegistry; }): boolean { return ( - params.entry.configFingerprint === hashRuntimeConfigValue(params.config) && + params.entry.configFingerprint === fingerprintPluginAutoEnableConfig(params.config) && params.entry.discoveryFingerprint === stableFingerprintValue(params.discovery.candidates) && - params.entry.envFingerprint === stableFingerprintValue(params.env) && + params.entry.envFingerprint === fingerprintPluginAutoEnableEnv(params.env) && params.entry.registryFingerprint === stableFingerprintValue(params.manifestRegistry.plugins) ); } diff --git a/src/plugins/bundle-lsp.ts b/src/plugins/bundle-lsp.ts index a3434f7aeb4d..0c2b177e4ce1 100644 --- a/src/plugins/bundle-lsp.ts +++ b/src/plugins/bundle-lsp.ts @@ -15,6 +15,7 @@ import { mergeBundlePathLists, normalizeBundlePathList, } from "./bundle-manifest.js"; +import type { PluginManifestRegistry } from "./manifest-registry.js"; import type { PluginBundleFormat } from "./manifest-types.js"; /** LSP server config block loaded from plugin bundle metadata. */ @@ -150,10 +151,12 @@ export function inspectBundleLspRuntimeSupport(params: { export function loadEnabledBundleLspConfig(params: { workspaceDir: string; cfg?: OpenClawConfig; + manifestRegistry?: Pick; }): { config: BundleLspConfig; diagnostics: Array<{ pluginId: string; message: string }> } { return loadEnabledBundleConfig({ workspaceDir: params.workspaceDir, cfg: params.cfg, + manifestRegistry: params.manifestRegistry, createEmptyConfig: () => ({ lspServers: {} }), loadBundleConfig: loadBundleLspConfig, createDiagnostic: (pluginId, message) => ({ pluginId, message }), diff --git a/src/plugins/runtime/load-context.test.ts b/src/plugins/runtime/load-context.test.ts index 1212d2a3c810..a4a26c97673c 100644 --- a/src/plugins/runtime/load-context.test.ts +++ b/src/plugins/runtime/load-context.test.ts @@ -1,5 +1,6 @@ // Load context tests cover agent and workspace context resolution for plugin runtimes. import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; const loadConfigMock = vi.fn(); const applyPluginAutoEnableMock = @@ -20,7 +21,8 @@ const metadataSnapshot = { policyHash: "policy", workspaceDir: "/resolved-workspace", }; -const loadPluginMetadataSnapshotMock = vi.fn(() => metadataSnapshot); +type MetadataSnapshotMock = typeof metadataSnapshot & { pluginIds?: readonly string[] }; +const loadPluginMetadataSnapshotMock = vi.fn((): MetadataSnapshotMock => metadataSnapshot); const isPluginMetadataSnapshotCompatibleMock = vi.fn(() => true); const getCurrentPluginMetadataSnapshotMock = vi.fn(() => undefined); const setCurrentPluginMetadataSnapshotMock = vi.fn(); @@ -190,6 +192,50 @@ describe("resolvePluginRuntimeLoadContext", () => { }); }); + it("reuses auto-enable results until Gateway config or metadata changes", () => { + const rawConfig = { plugins: {} }; + const env = process.env; + const initialSnapshot = { ...metadataSnapshot, pluginIds: ["openai"] }; + loadPluginMetadataSnapshotMock + .mockReturnValueOnce(initialSnapshot) + .mockReturnValueOnce({ ...initialSnapshot, pluginIds: ["openai"] }) + .mockReturnValueOnce({ ...initialSnapshot, policyHash: "changed" }) + .mockReturnValueOnce(initialSnapshot); + + const first = resolvePluginRuntimeLoadContext({ config: rawConfig, env }); + const second = resolvePluginRuntimeLoadContext({ config: rawConfig, env }); + resolvePluginRuntimeLoadContext({ config: rawConfig, env }); + resolvePluginRuntimeLoadContext({ config: { plugins: {} }, env }); + + expect(second.config).toBe(first.config); + expect(applyPluginAutoEnableMock).toHaveBeenCalledTimes(3); + }); + + it("invalidates auto-enable results when config or process env mutates in place", () => { + const rawConfig: OpenClawConfig = { plugins: {} }; + const env = process.env; + const envKey = "OPENCLAW_TEST_PLUGIN_AUTO_ENABLE_FINGERPRINT"; + const previousEnvValue = env[envKey]; + delete env[envKey]; + + try { + resolvePluginRuntimeLoadContext({ config: rawConfig, env }); + resolvePluginRuntimeLoadContext({ config: rawConfig, env }); + rawConfig.plugins = { entries: { demo: { enabled: true } } }; + resolvePluginRuntimeLoadContext({ config: rawConfig, env }); + env[envKey] = "changed"; + resolvePluginRuntimeLoadContext({ config: rawConfig, env }); + + expect(applyPluginAutoEnableMock).toHaveBeenCalledTimes(3); + } finally { + if (previousEnvValue === undefined) { + delete env[envKey]; + } else { + env[envKey] = previousEnvValue; + } + } + }); + it("threads install records from the metadata snapshot into the context and load options", () => { const snapshotWithRecords = { ...metadataSnapshot, diff --git a/src/plugins/runtime/load-context.ts b/src/plugins/runtime/load-context.ts index b42168c5c778..99d3933fdfcb 100644 --- a/src/plugins/runtime/load-context.ts +++ b/src/plugins/runtime/load-context.ts @@ -1,6 +1,10 @@ // Plugin runtime load context helpers resolve agent and workspace facts for runtime activation. import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { getRuntimeConfig } from "../../config/config.js"; +import { + fingerprintPluginAutoEnableConfig, + fingerprintPluginAutoEnableEnv, +} from "../../config/plugin-auto-enable.apply.js"; import { applyPluginAutoEnable } from "../../config/plugin-auto-enable.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { PluginInstallRecord } from "../../config/types.plugins.js"; @@ -14,6 +18,7 @@ import { import { extractPluginInstallRecordsFromInstalledPluginIndex } from "../installed-plugin-index-install-records.js"; import type { PluginLoadOptions } from "../loader.js"; import type { PluginManifestRegistry } from "../manifest-registry.js"; +import { registerPluginMetadataProcessMemoLifecycleClear } from "../plugin-metadata-lifecycle.js"; import { isPluginMetadataSnapshotCompatible, resolvePluginMetadataSnapshot, @@ -22,6 +27,90 @@ import type { PluginLogger } from "../types.js"; const log = createSubsystemLogger("plugins"); +type CurrentAutoEnableCacheEntry = { + config: OpenClawConfig; + env: NodeJS.ProcessEnv; + autoEnableConfigFingerprint: string; + autoEnableEnvFingerprint: string; + metadataConfigFingerprint: string | undefined; + pluginIds: readonly string[] | undefined; + policyHash: string; + result: ReturnType; + workspaceDir: string | undefined; +}; + +let currentAutoEnableCache: CurrentAutoEnableCacheEntry | undefined; + +registerPluginMetadataProcessMemoLifecycleClear(() => { + currentAutoEnableCache = undefined; +}); + +function samePluginIds( + left: readonly string[] | undefined, + right: readonly string[] | undefined, +): boolean { + return ( + left === right || + (left !== undefined && + right !== undefined && + left.length === right.length && + left.every((pluginId, index) => pluginId === right[index])) + ); +} + +function applyCurrentPluginAutoEnable(params: { + config: OpenClawConfig; + env: NodeJS.ProcessEnv; + workspaceDir?: string; + manifestRegistry: PluginManifestRegistry | undefined; + snapshot: ReturnType | undefined; +}): ReturnType { + if (!params.snapshot || !params.manifestRegistry || params.env !== process.env) { + return applyPluginAutoEnable({ + config: params.config, + env: params.env, + manifestRegistry: params.manifestRegistry, + discovery: params.snapshot?.discovery, + }); + } + // Gateway plugin metadata and config are replacement snapshots. Reuse only while + // mutable config/env content still matches; reload/close lifecycle clears the slot. + const workspaceDir = params.snapshot.workspaceDir ?? params.workspaceDir; + const autoEnableConfigFingerprint = fingerprintPluginAutoEnableConfig(params.config); + const autoEnableEnvFingerprint = fingerprintPluginAutoEnableEnv(params.env); + const cached = currentAutoEnableCache; + if ( + cached?.config === params.config && + cached.env === params.env && + cached.autoEnableConfigFingerprint === autoEnableConfigFingerprint && + cached.autoEnableEnvFingerprint === autoEnableEnvFingerprint && + cached.metadataConfigFingerprint === params.snapshot.configFingerprint && + cached.policyHash === params.snapshot.policyHash && + cached.workspaceDir === workspaceDir && + samePluginIds(cached.pluginIds, params.snapshot.pluginIds) + ) { + return cached.result; + } + const result = applyPluginAutoEnable({ + config: params.config, + env: params.env, + manifestRegistry: params.manifestRegistry, + discovery: params.snapshot.discovery, + }); + currentAutoEnableCache = { + config: params.config, + env: params.env, + autoEnableConfigFingerprint, + autoEnableEnvFingerprint, + metadataConfigFingerprint: params.snapshot.configFingerprint, + pluginIds: params.snapshot.pluginIds, + policyHash: params.snapshot.policyHash, + result, + workspaceDir, + }; + return result; +} + /** Resolved plugin runtime load context shared by runtime loader callers. */ export type PluginRuntimeLoadContext = { rawConfig: OpenClawConfig; @@ -90,11 +179,12 @@ export function resolvePluginRuntimeLoadContext( config: rawConfig, activationSourceConfig: options?.activationSourceConfig, }); - const autoEnabled = applyPluginAutoEnable({ + const autoEnabled = applyCurrentPluginAutoEnable({ config: rawConfig, env, + workspaceDir: rawWorkspaceDir, manifestRegistry, - discovery: initialMetadataSnapshot?.discovery, + snapshot: initialMetadataSnapshot, }); const config = autoEnabled.config; const workspaceDir =