diff --git a/extensions/feishu/src/outbound-delivery.test.ts b/extensions/feishu/src/outbound-delivery.test.ts index 3dd5a4c84580..12b59a67d50f 100644 --- a/extensions/feishu/src/outbound-delivery.test.ts +++ b/extensions/feishu/src/outbound-delivery.test.ts @@ -3,7 +3,7 @@ import { sendDurableMessageBatch } from "openclaw/plugin-sdk/channel-outbound"; import { createOutboundTestPlugin, createTestRegistry, - releasePinnedPluginChannelRegistry, + resetPluginRuntimeStateForTest, resetGlobalHookRunner, setActivePluginRegistry, } from "openclaw/plugin-sdk/channel-test-helpers"; @@ -56,7 +56,7 @@ describe("Feishu outbound shared delivery", () => { afterEach(() => { resetGlobalHookRunner(); - releasePinnedPluginChannelRegistry(); + resetPluginRuntimeStateForTest(); }); it("routes oversized presentation media through one media send and chunked fallback text", async () => { diff --git a/extensions/imessage/src/test-plugin.test.ts b/extensions/imessage/src/test-plugin.test.ts index 86d99573bb65..594b03260737 100644 --- a/extensions/imessage/src/test-plugin.test.ts +++ b/extensions/imessage/src/test-plugin.test.ts @@ -10,7 +10,7 @@ import { } from "openclaw/plugin-sdk/channel-outbound"; import { createTestRegistry, - releasePinnedPluginChannelRegistry, + resetPluginRuntimeStateForTest, setActivePluginRegistry, } from "openclaw/plugin-sdk/channel-test-helpers"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; @@ -483,7 +483,7 @@ describe("createIMessageTestPlugin", () => { expect(runCliJson).toHaveBeenCalledTimes(1); }); } finally { - releasePinnedPluginChannelRegistry(); + resetPluginRuntimeStateForTest(); } }); @@ -539,7 +539,7 @@ describe("createIMessageTestPlugin", () => { expect(captionRequest).not.toHaveBeenCalled(); }); } finally { - releasePinnedPluginChannelRegistry(); + resetPluginRuntimeStateForTest(); } }); diff --git a/extensions/mattermost/src/outbound-delivery.test.ts b/extensions/mattermost/src/outbound-delivery.test.ts index 99bc2d75ba3c..972fe0985c03 100644 --- a/extensions/mattermost/src/outbound-delivery.test.ts +++ b/extensions/mattermost/src/outbound-delivery.test.ts @@ -5,7 +5,7 @@ import { createEmptyPluginRegistry, createTestRegistry, initializeGlobalHookRunner, - releasePinnedPluginChannelRegistry, + resetPluginRuntimeStateForTest, resetGlobalHookRunner, setActivePluginRegistry, type PluginHookRegistration, @@ -40,7 +40,7 @@ describe("Mattermost outbound delivery", () => { afterEach(() => { resetGlobalHookRunner(); - releasePinnedPluginChannelRegistry(); + resetPluginRuntimeStateForTest(); }); it.each([ diff --git a/extensions/slack/src/outbound-delivery.test.ts b/extensions/slack/src/outbound-delivery.test.ts index 81ca39e7b879..41864fc4a88d 100644 --- a/extensions/slack/src/outbound-delivery.test.ts +++ b/extensions/slack/src/outbound-delivery.test.ts @@ -6,7 +6,7 @@ import { createOutboundTestPlugin, createTestRegistry, initializeGlobalHookRunner, - releasePinnedPluginChannelRegistry, + resetPluginRuntimeStateForTest, resetGlobalHookRunner, setActivePluginRegistry, type PluginHookRegistration, @@ -54,7 +54,7 @@ describe("slack outbound shared hook wiring", () => { afterEach(() => { resetGlobalHookRunner(); - releasePinnedPluginChannelRegistry(); + resetPluginRuntimeStateForTest(); }); it("fires message_sending once with shared routing fields", async () => { diff --git a/extensions/tlon/src/outbound-tool-trace-sanitize.test.ts b/extensions/tlon/src/outbound-tool-trace-sanitize.test.ts index 0a6f379f6d62..265533de9ffe 100644 --- a/extensions/tlon/src/outbound-tool-trace-sanitize.test.ts +++ b/extensions/tlon/src/outbound-tool-trace-sanitize.test.ts @@ -6,7 +6,7 @@ import { expectDefined } from "@openclaw/normalization-core"; import { sendDurableMessageBatch } from "openclaw/plugin-sdk/channel-outbound"; import { createTestRegistry, - releasePinnedPluginChannelRegistry, + resetPluginRuntimeStateForTest, setActivePluginRegistry, } from "openclaw/plugin-sdk/channel-test-helpers"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -82,7 +82,7 @@ describe("tlon outbound assistant-visible sanitization", () => { }); afterEach(async () => { - releasePinnedPluginChannelRegistry(); + resetPluginRuntimeStateForTest(); vi.restoreAllMocks(); server.closeAllConnections(); await new Promise((resolve, reject) => { diff --git a/extensions/whatsapp/src/send.delivery-recovery.test.ts b/extensions/whatsapp/src/send.delivery-recovery.test.ts index 4269b1b84490..6ad537286d3b 100644 --- a/extensions/whatsapp/src/send.delivery-recovery.test.ts +++ b/extensions/whatsapp/src/send.delivery-recovery.test.ts @@ -4,7 +4,7 @@ import { createEmptyPluginRegistry, createOutboundTestPlugin, createTestRegistry, - releasePinnedPluginChannelRegistry, + resetPluginRuntimeStateForTest, setActivePluginRegistry, } from "openclaw/plugin-sdk/channel-test-helpers"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; @@ -70,7 +70,7 @@ describe("WhatsApp delivery recovery", () => { afterEach(() => { runtimeContextMocks.controllers.clear(); - releasePinnedPluginChannelRegistry(); + resetPluginRuntimeStateForTest(); setActivePluginRegistry(createEmptyPluginRegistry()); }); diff --git a/src/agents/agent-tools.before-tool-call.embedded-mode.test.ts b/src/agents/agent-tools.before-tool-call.embedded-mode.test.ts index c53185557c5c..54965785022e 100644 --- a/src/agents/agent-tools.before-tool-call.embedded-mode.test.ts +++ b/src/agents/agent-tools.before-tool-call.embedded-mode.test.ts @@ -12,18 +12,10 @@ import { EmbeddedPluginApprovalBroker, setEmbeddedPluginApprovalBroker, } from "../infra/embedded-plugin-approval-broker.js"; -import { - getGlobalHookRunner, - initializeGlobalHookRunner, - resetGlobalHookRunner, -} from "../plugins/hook-runner-global.js"; +import { getGlobalHookRunner, resetGlobalHookRunner } from "../plugins/hook-runner-global.js"; import type { HookRunner } from "../plugins/hooks.js"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; -import { - pinActivePluginChannelRegistry, - releasePinnedPluginChannelRegistry, - setActivePluginRegistry, -} from "../plugins/runtime.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; import { PluginApprovalResolutions } from "../plugins/types.js"; import { runBeforeToolCallHook } from "./agent-tools.before-tool-call.js"; import { callGatewayTool } from "./tools/gateway.js"; @@ -711,94 +703,6 @@ describe("runBeforeToolCallHook — embedded mode approvals", () => { expect(runBeforeToolCallMock).not.toHaveBeenCalled(); }); - it("runs trusted policies from the global hook registry after the active registry changes", async () => { - const evaluatePolicy = vi.fn(() => ({ - block: true, - blockReason: "gateway registry policy blocked", - })); - const gatewayRegistry = createEmptyPluginRegistry(); - gatewayRegistry.trustedToolPolicies = [ - { - pluginId: "gateway-policy", - pluginName: "Gateway Policy", - source: "test", - policy: { - id: "gateway-block", - description: "Gateway policy", - evaluate: evaluatePolicy, - }, - }, - ]; - initializeGlobalHookRunner(gatewayRegistry); - setActivePluginRegistry(createEmptyPluginRegistry()); - runBeforeToolCallMock.mockResolvedValue(undefined); - - const result = await runBeforeToolCallHook({ - toolName: "bash", - params: { command: "deploy" }, - toolCallId: "call-gateway-policy", - ctx: { agentId: "main", sessionKey: "main" }, - }); - - expect(result).toEqual({ - blocked: true, - kind: "veto", - deniedReason: "plugin-before-tool-call", - reason: "gateway registry policy blocked", - params: { command: "deploy" }, - }); - expect(evaluatePolicy).toHaveBeenCalledTimes(1); - expect(runBeforeToolCallMock).not.toHaveBeenCalled(); - }); - - it("runs pinned gateway trusted policies after a later global runner initialization", async () => { - const evaluatePolicy = vi.fn(() => ({ - block: true, - blockReason: "pinned gateway policy blocked", - })); - const gatewayRegistry = createEmptyPluginRegistry(); - gatewayRegistry.trustedToolPolicies = [ - { - pluginId: "gateway-policy", - pluginName: "Gateway Policy", - source: "test", - policy: { - id: "gateway-block", - description: "Gateway policy", - evaluate: evaluatePolicy, - }, - }, - ]; - setActivePluginRegistry(gatewayRegistry); - initializeGlobalHookRunner(gatewayRegistry); - pinActivePluginChannelRegistry(gatewayRegistry); - try { - const laterRegistry = createEmptyPluginRegistry(); - setActivePluginRegistry(laterRegistry); - initializeGlobalHookRunner(laterRegistry); - runBeforeToolCallMock.mockResolvedValue(undefined); - - const result = await runBeforeToolCallHook({ - toolName: "bash", - params: { command: "deploy" }, - toolCallId: "call-pinned-gateway-policy", - ctx: { agentId: "main", sessionKey: "main" }, - }); - - expect(result).toEqual({ - blocked: true, - kind: "veto", - deniedReason: "plugin-before-tool-call", - reason: "pinned gateway policy blocked", - params: { command: "deploy" }, - }); - expect(evaluatePolicy).toHaveBeenCalledTimes(1); - expect(runBeforeToolCallMock).not.toHaveBeenCalled(); - } finally { - releasePinnedPluginChannelRegistry(gatewayRegistry); - } - }); - it("does not require skill_workshop lifecycle approval by default", async () => { (hookRunner.hasHooks as ReturnType).mockReturnValue(false); diff --git a/src/agents/conversation-capability-profile.test.ts b/src/agents/conversation-capability-profile.test.ts index c18af7368e53..3bb06b6251ab 100644 --- a/src/agents/conversation-capability-profile.test.ts +++ b/src/agents/conversation-capability-profile.test.ts @@ -1,13 +1,14 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; import { createAccountListHelpers } from "../channels/plugins/account-helpers.js"; import { replaceSessionEntry } from "../config/sessions/session-accessor.js"; import type { SessionEntry } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { createAccountCronScheduledToolPolicy } from "../cron/scheduled-tool-policy.js"; -import { PLUGIN_REGISTRY_STATE } from "../plugins/runtime-state-key.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { createTestRegistry } from "../test-utils/channel-plugins.js"; import { INTERNAL_MESSAGE_CHANNEL } from "../utils/message-channel.js"; import { resolveConversationCapabilityProfile } from "./conversation-capability-profile.js"; import { isToolAllowedByPolicyName } from "./tool-policy-match.js"; @@ -382,31 +383,21 @@ describe("resolveConversationCapabilityProfile", () => { describe("resolveConversationCapabilityProfile scheduled account authority", () => { const ownerSessionKey = "agent:main:whatsapp:group:safe-room"; - const globalState = globalThis as Record; - let previousRegistryState: unknown; - beforeAll(() => { - previousRegistryState = globalState[PLUGIN_REGISTRY_STATE]; - globalState[PLUGIN_REGISTRY_STATE] = { - channel: { - version: 1, - registry: { - channels: [ - { - plugin: { - id: "whatsapp", - meta: {}, - config: createAccountListHelpers("whatsapp"), - }, - }, - ], + beforeEach(() => { + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "whatsapp", + source: "test", + plugin: { + id: "whatsapp", + meta: {}, + config: createAccountListHelpers("whatsapp"), + }, }, - }, - }; - }); - - afterAll(() => { - globalState[PLUGIN_REGISTRY_STATE] = previousRegistryState; + ]), + ); }); function scheduledProfile(accounts: Record) { diff --git a/src/agents/harness/native-hook-relay-events.ts b/src/agents/harness/native-hook-relay-events.ts index 190906d38095..677d8d56868e 100644 --- a/src/agents/harness/native-hook-relay-events.ts +++ b/src/agents/harness/native-hook-relay-events.ts @@ -80,8 +80,7 @@ export function nativeHookRelayEventToolMatcher( if (nativePreToolUseMayRunLoopDetection(registration)) { return undefined; } - // Relay selection and policy execution must read the same composed registry - // so active, pinned, and isolated plugin sources cannot diverge. + // Relay selection and policy execution must read the same scoped/root registry. const policyRegistry = getGlobalHookRunnerRegistry(); const scope = mergePluginToolMatcherScopes([ getGlobalToolHookMatcherScope("before_tool_call"), diff --git a/src/agents/mcp-connection-resolver.test.ts b/src/agents/mcp-connection-resolver.test.ts index 326071f47aa1..db951ba28b82 100644 --- a/src/agents/mcp-connection-resolver.test.ts +++ b/src/agents/mcp-connection-resolver.test.ts @@ -1,7 +1,6 @@ /** Unit tests for requester-scoped MCP connection resolver helpers. */ import { randomUUID } from "node:crypto"; import http from "node:http"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -15,15 +14,9 @@ import { } from "../infra/restart.js"; import { isSecretValueRegisteredForRedaction } from "../logging/secret-redaction-registry.js"; import { isPluginRegistryRetired } from "../plugins/registry-lifecycle.js"; -import { createEmptyPluginRegistry, createPluginRegistry } from "../plugins/registry.js"; -import { - pinActivePluginChannelRegistry, - pinActivePluginHttpRouteRegistry, - pinActivePluginSessionExtensionRegistry, - releasePinnedPluginHttpRouteRegistry, - resetPluginRuntimeStateForTest, - setActivePluginRegistry, -} from "../plugins/runtime.js"; +import { createPluginRegistry } from "../plugins/registry.js"; +import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js"; +import { withPluginRuntimeRegistryScope } from "../plugins/runtime/gateway-request-scope.js"; import type { PluginRuntime } from "../plugins/runtime/types.js"; import { createPluginRecord } from "../plugins/status.test-fixtures.js"; import { @@ -31,7 +24,6 @@ import { getOrCreateSessionMcpRuntime, peekSessionMcpRuntime, } from "./agent-bundle-mcp-tools.js"; -import { buildCodexMcpServersConfig } from "./codex-mcp-config.js"; import { applyMcpConnectionOverride, buildMcpRequesterRuntimeCacheKey, @@ -41,7 +33,6 @@ import { resolveRequesterScopedMcpConnections, testing, } from "./mcp-connection-resolver.js"; -import { resolveMcpTransport } from "./mcp-transport.js"; import { clearCurrentProviderAuthState } from "./model-provider-auth.js"; import { resetPreparedModelRuntimeSnapshotsForTest } from "./prepared-model-runtime.test-support.js"; @@ -223,207 +214,36 @@ describe("mcp connection resolver helpers", () => { expect(requesterScopedServerNames).toEqual([]); }); - it("keeps connection resolvers from pinned live registries", async () => { - const pinnedRegistry = createEmptyPluginRegistry(); - pinnedRegistry.mcpServerConnectionResolvers.push({ - pluginId: "startup-mail", - source: "test", - resolver: { - serverName: "user-mail", - resolve: async () => ({ url: "https://mcp.example.test/startup" }), - }, + it("resolves registrations from the authoritative request registry", async () => { + const root = createMcpProofPluginRegistry(); + root.apiFor("root-mail").registerMcpServerConnectionResolver({ + serverName: "root-mail", + resolve: async () => ({ url: "https://root.example.test" }), }); - pinnedRegistry.mcpServerConnectionResolvers.push({ - pluginId: "startup-drive", - source: "test", - resolver: { - serverName: "user-drive", - resolve: async () => ({ url: "https://mcp.example.test/stale-drive" }), - }, - }); - const activeRegistry = createEmptyPluginRegistry(); - activeRegistry.mcpServerConnectionResolvers.push({ - pluginId: "active-drive", - source: "test", - resolver: { - serverName: "user-drive", - resolve: async () => ({ url: "https://mcp.example.test/active" }), - }, + const scoped = createMcpProofPluginRegistry(); + scoped.apiFor("scoped-mail").registerMcpServerConnectionResolver({ + serverName: "scoped-mail", + resolve: async () => ({ url: "https://scoped.example.test" }), }); + setActivePluginRegistry(root.registry); - setActivePluginRegistry(pinnedRegistry); - pinActivePluginHttpRouteRegistry(pinnedRegistry); - setActivePluginRegistry(activeRegistry); - - const { staticServers, requesterScopedServerNames } = partitionMcpServersByConnectionScope({ - shared: { command: "true" }, - "user-drive": { transport: "streamable-http" }, - "user-mail": { transport: "streamable-http" }, - }); - expect(Object.keys(staticServers)).toEqual(["shared"]); - expect(requesterScopedServerNames).toEqual(["user-drive", "user-mail"]); - await expect( - resolveRequesterScopedMcpConnections({ - serverNames: ["user-mail", "user-drive"], - requesterSenderId: "sender", - }), - ).resolves.toEqual( - new Map([ - ["user-drive", { url: "https://mcp.example.test/active" }], - ["user-mail", { url: "https://mcp.example.test/startup" }], - ]), - ); - }); - - it("calls authenticated owner-isolated MCP servers across a pinned registry swap", async () => { - const proof = await startAuthenticatedMcpProofServer(); - const clients: Client[] = []; - - try { - const pinned = createMcpProofPluginRegistry(); - pinned.apiFor("startup-mail").registerMcpServerConnectionResolver({ - serverName: "user-mail", - resolve: async ({ requesterSenderId }) => - requesterSenderId === "authorized-requester" - ? { - url: proof.mail.url, - headers: { Authorization: proof.mail.authorization }, - } - : null, - }); - pinned.apiFor("startup-drive").registerMcpServerConnectionResolver({ - serverName: "user-drive", - resolve: async () => ({ - url: proof.pinnedDrive.url, - headers: { Authorization: proof.pinnedDrive.authorization }, - }), - }); - pinned.apiFor("mail-hijacker").registerMcpServerConnectionResolver({ - serverName: "user-mail", - resolve: async () => ({ - url: proof.pinnedDrive.url, - headers: { Authorization: proof.pinnedDrive.authorization }, - }), - }); - expect(pinned.registry.diagnostics).toContainEqual( - expect.objectContaining({ level: "error", pluginId: "mail-hijacker" }), - ); - - setActivePluginRegistry(pinned.registry); - pinActivePluginHttpRouteRegistry(pinned.registry); - - const active = createMcpProofPluginRegistry(); - active.apiFor("startup-mail"); - expect(active.registry.plugins).toContainEqual( - expect.objectContaining({ id: "startup-mail", enabled: true, status: "loaded" }), - ); - active.apiFor("active-drive").registerMcpServerConnectionResolver({ - serverName: "user-drive", - resolve: async ({ requesterSenderId }) => - requesterSenderId === "authorized-requester" - ? { - url: proof.activeDrive.url, - headers: { Authorization: proof.activeDrive.authorization }, - } - : null, - }); - setActivePluginRegistry(active.registry); - - const configuredServers = { - shared: { command: "test-static-mcp" }, - "user-drive": { - transport: "streamable-http" as const, - url: "https://placeholder.invalid/drive", - }, - "user-mail": { - transport: "streamable-http" as const, - url: "https://placeholder.invalid/mail", - }, - }; - const partitioned = partitionMcpServersByConnectionScope(configuredServers); - expect(partitioned.staticServers).toEqual({ shared: configuredServers.shared }); - expect(partitioned.requesterScopedServerNames).toEqual(["user-drive", "user-mail"]); - expect(Object.keys(buildCodexMcpServersConfig({ mcpServers: configuredServers }))).toEqual([ - "shared", - ]); - - for (const requesterSenderId of [undefined, "unauthorized-requester"]) { - await expect( - resolveRequesterScopedMcpConnections({ - serverNames: partitioned.requesterScopedServerNames, - requesterSenderId, - }), - ).resolves.toEqual(new Map()); - } - expect(proof.mail.requests).toBe(0); - expect(proof.activeDrive.requests).toBe(0); - expect(proof.pinnedDrive.requests).toBe(0); - - const unauthorizedResponse = await fetch(proof.mail.url, { method: "POST" }); - expect(unauthorizedResponse.status).toBe(401); - expect(proof.mail.unauthorizedRequests).toBe(1); - - const connections = await resolveRequesterScopedMcpConnections({ - serverNames: partitioned.requesterScopedServerNames, - requesterSenderId: "authorized-requester", - }); - for (const [serverName, endpoint] of [ - ["user-drive", proof.activeDrive], - ["user-mail", proof.mail], - ] as const) { - const connection = connections.get(serverName); - expect(connection?.url).toBe(endpoint.url); - expect(isSecretValueRegisteredForRedaction(endpoint.authorization)).toBe(true); - if (!connection) { - throw new Error(`Missing authorized MCP connection for ${serverName}`); - } - const resolvedTransport = resolveMcpTransport( - serverName, - applyMcpConnectionOverride(configuredServers[serverName], connection), - ); - expect(resolvedTransport?.transportType).toBe("streamable-http"); - if (!resolvedTransport) { - throw new Error(`Missing streamable HTTP transport for ${serverName}`); - } - const client = new Client({ name: `openclaw-${serverName}-proof`, version: "1.0.0" }); - clients.push(client); - await client.connect(resolvedTransport.transport); - const listedTools = await client.listTools(); - expect(listedTools.tools.map((tool) => tool.name)).toEqual(["owner_probe"]); - const result = await client.callTool({ name: "owner_probe", arguments: {} }); - expect(result.content).toEqual([{ type: "text", text: endpoint.owner }]); - expect(endpoint.toolCalls).toBe(1); - expect(endpoint.streamedResponses).toBeGreaterThanOrEqual(3); - } - - expect(proof.pinnedDrive.requests).toBe(0); - expect(proof.pinnedDrive.toolCalls).toBe(0); - expect(proof.activeDrive.unauthorizedRequests).toBe(0); - expect(proof.mail.unauthorizedRequests).toBe(1); - - releasePinnedPluginHttpRouteRegistry(pinned.registry); - expect(isPluginRegistryRetired(pinned.registry)).toBe(true); + await withPluginRuntimeRegistryScope(scoped.registry, async () => { await expect( resolveRequesterScopedMcpConnections({ - serverNames: ["user-mail"], - requesterSenderId: "authorized-requester", + serverNames: ["scoped-mail"], + requesterSenderId: "requester", }), - ).resolves.toEqual(new Map()); - expect(proof.mail.toolCalls).toBe(1); - expect(proof.pinnedDrive.requests).toBe(0); - expect( - partitionMcpServersByConnectionScope({ - shared: configuredServers.shared, - "user-drive": configuredServers["user-drive"], - }).staticServers, - ).toEqual({ shared: configuredServers.shared }); - } finally { - await Promise.all(clients.map((client) => client.close())); - await proof.close(); - } + ).resolves.toEqual(new Map([["scoped-mail", { url: "https://scoped.example.test" }]])); + }); + await expect( + resolveRequesterScopedMcpConnections({ + serverNames: ["scoped-mail"], + requesterSenderId: "requester", + }), + ).resolves.toEqual(new Map()); }); - it("revokes pinned MCP credentials during a full gateway plugin-disable replacement", async () => { + it("revokes MCP credentials during a full gateway plugin-disable replacement", async () => { const proof = await startAuthenticatedMcpProofServer(); const previousExternalRestartPolicy = isGatewaySigusr1RestartExternallyAllowed(); @@ -445,9 +265,6 @@ describe("mcp connection resolver helpers", () => { }), }); setActivePluginRegistry(previous.registry); - pinActivePluginHttpRouteRegistry(previous.registry); - pinActivePluginChannelRegistry(previous.registry); - pinActivePluginSessionExtensionRegistry(previous.registry); const beforeDisable = await resolveRequesterScopedMcpConnections({ serverNames: ["user-mail"], @@ -546,11 +363,7 @@ describe("mcp connection resolver helpers", () => { async reloadPlugins({ beforeReplace, commitRuntime }) { await beforeReplace(new Set()); await commitRuntime(); - // Plugin owners publish all gateway dispatch surfaces in one replacement. setActivePluginRegistry(replacement.registry); - pinActivePluginHttpRouteRegistry(replacement.registry); - pinActivePluginSessionExtensionRegistry(replacement.registry); - pinActivePluginChannelRegistry(replacement.registry); return { restartChannels: new Set(), activeChannels: new Set() }; }, logHooks: reloadLog, diff --git a/src/agents/mcp-connection-resolver.ts b/src/agents/mcp-connection-resolver.ts index 3fdee5e7f434..06bdcea50eff 100644 --- a/src/agents/mcp-connection-resolver.ts +++ b/src/agents/mcp-connection-resolver.ts @@ -7,7 +7,8 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coe import { resolveOpenClawMcpTransportAlias } from "../config/mcp-config-normalize.js"; import { logWarn } from "../logger.js"; import { registerSecretValueForRedaction } from "../logging/secret-redaction-registry.js"; -import { collectLivePluginRegistries } from "../plugins/runtime.js"; +import { getActivePluginRegistry } from "../plugins/runtime.js"; +import { getPluginRuntimeGatewayRequestScope } from "../plugins/runtime/gateway-request-scope.js"; import type { McpServerConnectionResolved, McpServerConnectionResolveContext, @@ -137,18 +138,18 @@ function listMcpServerConnectionResolversByServerName(): Map< return new Map([...testOverrides.entries()].toSorted(([a], [b]) => a.localeCompare(b))); } const byName = new Map(); - for (const registry of collectLivePluginRegistries()) { - for (const entry of registry.mcpServerConnectionResolvers) { - const serverName = normalizeOptionalString(entry.resolver.serverName); - if (!serverName || typeof entry.resolver.resolve !== "function" || byName.has(serverName)) { - continue; - } - byName.set(serverName, { - pluginId: entry.pluginId, - serverName, - resolve: entry.resolver.resolve, - }); + const registry = + getPluginRuntimeGatewayRequestScope()?.pluginRegistry ?? getActivePluginRegistry(); + for (const entry of registry?.mcpServerConnectionResolvers ?? []) { + const serverName = normalizeOptionalString(entry.resolver.serverName); + if (!serverName || typeof entry.resolver.resolve !== "function" || byName.has(serverName)) { + continue; } + byName.set(serverName, { + pluginId: entry.pluginId, + serverName, + resolve: entry.resolver.resolve, + }); } return new Map([...byName.entries()].toSorted(([a], [b]) => a.localeCompare(b))); } diff --git a/src/agents/runtime-plugins.test.ts b/src/agents/runtime-plugins.test.ts index 0be85d559cc7..7802e2ad1b20 100644 --- a/src/agents/runtime-plugins.test.ts +++ b/src/agents/runtime-plugins.test.ts @@ -1,11 +1,9 @@ -// Verifies prepared-runtime handles and process-root runtime installation remain distinct. +// Verifies agent runtime plugin loads stay scoped to prepared-runtime handles. import { beforeEach, describe, expect, it, vi } from "vitest"; const hoisted = vi.hoisted(() => ({ getCurrentPluginMetadataSnapshot: vi.fn(), - getActivePluginRuntimeSubagentMode: vi.fn<() => "default" | "explicit" | "gateway-bindable">(), - installRuntimePluginRegistryAtProcessRoot: vi.fn(), - loadRuntimePluginRegistryHandle: vi.fn(), + loadPluginRegistryHandle: vi.fn(), resolveAgentRuntimePluginLoadPlan: vi.fn(), })); @@ -13,30 +11,20 @@ vi.mock("../plugins/current-plugin-metadata-snapshot.js", () => ({ getCurrentPluginMetadataSnapshot: hoisted.getCurrentPluginMetadataSnapshot, })); -vi.mock("../plugins/runtime/standalone-runtime-registry-loader.js", () => ({ - installRuntimePluginRegistryAtProcessRoot: hoisted.installRuntimePluginRegistryAtProcessRoot, - loadRuntimePluginRegistryHandle: hoisted.loadRuntimePluginRegistryHandle, -})); - -vi.mock("../plugins/runtime.js", () => ({ - getActivePluginRuntimeSubagentMode: hoisted.getActivePluginRuntimeSubagentMode, +vi.mock("../plugins/loader.js", () => ({ + loadPluginRegistryHandle: hoisted.loadPluginRegistryHandle, })); vi.mock("./harness/runtime-plugin-load-plan.js", () => ({ resolveAgentRuntimePluginLoadPlan: hoisted.resolveAgentRuntimePluginLoadPlan, })); -import { - installAgentRuntimePluginRegistryAtProcessRoot, - loadAgentRuntimePluginRegistryHandle, -} from "./runtime-plugins.js"; +import { loadAgentRuntimePluginRegistryHandle } from "./runtime-plugins.js"; describe("agent runtime plugin registries", () => { beforeEach(() => { hoisted.getCurrentPluginMetadataSnapshot.mockReset().mockReturnValue(undefined); - hoisted.getActivePluginRuntimeSubagentMode.mockReset().mockReturnValue("default"); - hoisted.installRuntimePluginRegistryAtProcessRoot.mockReset().mockReturnValue({ root: true }); - hoisted.loadRuntimePluginRegistryHandle.mockReset().mockReturnValue({ handle: true }); + hoisted.loadPluginRegistryHandle.mockReset().mockReturnValue({ handle: true }); hoisted.resolveAgentRuntimePluginLoadPlan.mockReset().mockImplementation(({ config }) => ({ config, pluginIds: ["codex", "memory-core"], @@ -60,59 +48,30 @@ describe("agent runtime plugin registries", () => { workspaceDir: "/tmp/workspace", selections, }); - expect(hoisted.loadRuntimePluginRegistryHandle).toHaveBeenCalledWith({ - requiredPluginIds: ["codex", "memory-core"], - loadOptions: { - config, - activationSourceConfig: config, - workspaceDir: "/tmp/workspace", - runtimeOptions: { allowGatewaySubagentBinding: true }, - }, + expect(hoisted.loadPluginRegistryHandle).toHaveBeenCalledWith({ + activate: false, + config, + activationSourceConfig: config, + workspaceDir: "/tmp/workspace", + runtimeOptions: { allowGatewaySubagentBinding: true }, }); - expect(hoisted.installRuntimePluginRegistryAtProcessRoot).not.toHaveBeenCalled(); }); - it("installs only through the explicit process-root entry point", () => { - const config = {} as never; - hoisted.getActivePluginRuntimeSubagentMode.mockReturnValue("gateway-bindable"); - - expect( - installAgentRuntimePluginRegistryAtProcessRoot({ config, workspaceDir: "/tmp/workspace" }), - ).toEqual({ root: true }); - expect(hoisted.installRuntimePluginRegistryAtProcessRoot).toHaveBeenCalledWith( - expect.objectContaining({ - loadOptions: expect.objectContaining({ - runtimeOptions: { allowGatewaySubagentBinding: true }, - }), - }), - ); - expect(hoisted.loadRuntimePluginRegistryHandle).not.toHaveBeenCalled(); - }); - - it("installs an explicit empty registry when plugins are globally disabled", () => { + it("loads an explicit empty handle when plugins are globally disabled", () => { const params = { config: { plugins: { enabled: false } } as never, workspaceDir: "/tmp/workspace", }; expect(loadAgentRuntimePluginRegistryHandle(params)).toEqual({ handle: true }); - expect(installAgentRuntimePluginRegistryAtProcessRoot(params)).toEqual({ root: true }); expect(hoisted.resolveAgentRuntimePluginLoadPlan).not.toHaveBeenCalled(); - expect(hoisted.loadRuntimePluginRegistryHandle).toHaveBeenCalledWith({ - requiredPluginIds: [], - loadOptions: { - activationSourceConfig: params.config, - config: params.config, - onlyPluginIds: [], - runtimeOptions: undefined, - workspaceDir: "/tmp/workspace", - }, + expect(hoisted.loadPluginRegistryHandle).toHaveBeenCalledWith({ + activate: false, + activationSourceConfig: params.config, + config: params.config, + onlyPluginIds: [], + runtimeOptions: undefined, + workspaceDir: "/tmp/workspace", }); - expect(hoisted.installRuntimePluginRegistryAtProcessRoot).toHaveBeenCalledWith( - expect.objectContaining({ - requiredPluginIds: [], - loadOptions: expect.objectContaining({ onlyPluginIds: [] }), - }), - ); }); it("preserves the gateway startup scope and ordering", () => { @@ -129,9 +88,9 @@ describe("agent runtime plugin registries", () => { basePluginIds: ["telegram", "memory-core"], selections: [], }); - expect(hoisted.loadRuntimePluginRegistryHandle).toHaveBeenCalledWith( + expect(hoisted.loadPluginRegistryHandle).toHaveBeenCalledWith( expect.objectContaining({ - loadOptions: expect.objectContaining({ forceFullRuntimeForChannelPlugins: true }), + forceFullRuntimeForChannelPlugins: true, }), ); }); diff --git a/src/agents/runtime-plugins.ts b/src/agents/runtime-plugins.ts index 636bfcb78c0e..3e8dd44496bc 100644 --- a/src/agents/runtime-plugins.ts +++ b/src/agents/runtime-plugins.ts @@ -1,12 +1,8 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizePluginsConfig } from "../plugins/config-state.js"; import { getCurrentPluginMetadataSnapshot } from "../plugins/current-plugin-metadata-snapshot.js"; +import { loadPluginRegistryHandle } from "../plugins/loader.js"; import type { PluginRegistry } from "../plugins/registry-types.js"; -import { getActivePluginRuntimeSubagentMode } from "../plugins/runtime.js"; -import { - installRuntimePluginRegistryAtProcessRoot, - loadRuntimePluginRegistryHandle, -} from "../plugins/runtime/standalone-runtime-registry-loader.js"; import { resolveUserPath } from "../utils.js"; import { collectConfiguredAgentHarnessRuntimes } from "./harness-runtimes.js"; import { @@ -100,20 +96,7 @@ function resolveAgentRuntimePluginRegistryLoad(params: AgentRuntimePluginRegistr /** Loads the registry handle owned by an agent prepared-runtime generation. */ export function loadAgentRuntimePluginRegistryHandle( params: AgentRuntimePluginRegistryParams, -): PluginRegistry | undefined { +): PluginRegistry { const load = resolveAgentRuntimePluginRegistryLoad(params); - return load ? loadRuntimePluginRegistryHandle(load) : undefined; -} - -/** Installs agent runtime plugins from a standalone/gateway process composition root. */ -export function installAgentRuntimePluginRegistryAtProcessRoot( - params: AgentRuntimePluginRegistryParams, -): PluginRegistry | undefined { - const load = resolveAgentRuntimePluginRegistryLoad({ - ...params, - allowGatewaySubagentBinding: - params.allowGatewaySubagentBinding === true || - getActivePluginRuntimeSubagentMode() === "gateway-bindable", - }); - return load ? installRuntimePluginRegistryAtProcessRoot(load) : undefined; + return loadPluginRegistryHandle({ ...load.loadOptions, activate: false }); } diff --git a/src/auto-reply/commands-registry.test.ts b/src/auto-reply/commands-registry.test.ts index d9c55be5378c..c650b4ec5738 100644 --- a/src/auto-reply/commands-registry.test.ts +++ b/src/auto-reply/commands-registry.test.ts @@ -1,10 +1,6 @@ /** Tests command registry definitions, native specs, aliases, and argument menus. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - pinActivePluginChannelRegistry, - resetPluginRuntimeStateForTest, - setActivePluginRegistry, -} from "../plugins/runtime.js"; +import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js"; import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; import { buildCommandText, @@ -595,61 +591,6 @@ describe("commands registry", () => { ).toBe(true); }); - it("refreshes dock commands when pinned-empty fallback active registry changes", () => { - const pinnedEmptyRegistry = createTestRegistry([]); - setActivePluginRegistry(pinnedEmptyRegistry); - pinActivePluginChannelRegistry(pinnedEmptyRegistry); - - setActivePluginRegistry(createNativeCommandsRegistry("discord")); - const discordCommandKeys = commandKeySet(listChatCommands()); - expect(discordCommandKeys.has("dock:discord")).toBe(true); - expect(discordCommandKeys.has("dock:slack")).toBe(false); - - setActivePluginRegistry(createNativeCommandsRegistry("slack")); - const slackCommandKeys = commandKeySet(listChatCommands()); - expect(slackCommandKeys.has("dock:discord")).toBe(false); - expect(slackCommandKeys.has("dock:slack")).toBe(true); - }); - - it("refreshes text-command gating when pinned-empty fallback active registry changes", () => { - const cfg = { commands: { text: false } }; - const pinnedEmptyRegistry = createTestRegistry([]); - setActivePluginRegistry(pinnedEmptyRegistry); - pinActivePluginChannelRegistry(pinnedEmptyRegistry); - - setActivePluginRegistry(createNativeCommandsRegistry("discord")); - expect( - shouldHandleTextCommands({ - cfg, - surface: "discord", - commandSource: "text", - }), - ).toBe(false); - expect( - shouldHandleTextCommands({ - cfg, - surface: "slack", - commandSource: "text", - }), - ).toBe(true); - - setActivePluginRegistry(createNativeCommandsRegistry("slack")); - expect( - shouldHandleTextCommands({ - cfg, - surface: "discord", - commandSource: "text", - }), - ).toBe(true); - expect( - shouldHandleTextCommands({ - cfg, - surface: "slack", - commandSource: "text", - }), - ).toBe(false); - }); - it("normalizes telegram-style command mentions for the current bot", () => { expect(normalizeCommandBody("/help@openclaw", { botUsername: "openclaw" })).toBe("/help"); expect( diff --git a/src/channels/plugins/read-only.test.ts b/src/channels/plugins/read-only.test.ts index 495aff01ac2c..b0febc9aa80a 100644 --- a/src/channels/plugins/read-only.test.ts +++ b/src/channels/plugins/read-only.test.ts @@ -432,7 +432,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => { expectExternalChatSetupOnlyPluginLoaded({ plugins, setupMarker, fullMarker }); expect(moduleLoaderParams).toContainEqual({ - modulePath: path.join(pluginDir, "setup-entry.cjs"), + modulePath: fs.realpathSync(path.join(pluginDir, "setup-entry.cjs")), tryNative: true, }); }); @@ -514,35 +514,6 @@ describe("listReadOnlyChannelPluginsForConfig", () => { expect(second.find((plugin) => plugin.id === "external-chat")?.meta.blurb).toBe("second"); }); - it("refreshes cached read-only channel plugins when active registry channels mutate in place", () => { - const cfg = { channels: { "external-chat": { token: "configured" } } } as never; - const registry = createTestRegistry([]); - setActivePluginRegistry(registry); - - const first = listReadOnlyChannelPluginsForConfig(cfg, { - includePersistedAuthState: false, - includeSetupFallbackPlugins: true, - }); - - const plugin = { - ...createChannelTestPluginBase({ id: "external-chat" as never }), - meta: { - ...createChannelTestPluginBase({ id: "external-chat" as never }).meta, - blurb: "mutated registry", - }, - }; - registry.channels.push({ pluginId: "mutated-plugin", plugin, source: "test" } as never); - const second = listReadOnlyChannelPluginsForConfig(cfg, { - includePersistedAuthState: false, - includeSetupFallbackPlugins: true, - }); - - expect(pluginIds(first)).not.toContain("external-chat"); - expect(second.find((entry) => entry.id === "external-chat")?.meta.blurb).toBe( - "mutated registry", - ); - }); - it("refreshes cached read-only channel plugins when ambient env changes", () => { const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin({ manifestChannelConfig: true, diff --git a/src/channels/plugins/registry-loaded.ts b/src/channels/plugins/registry-loaded.ts index dbb49adf9297..cfdb847b203f 100644 --- a/src/channels/plugins/registry-loaded.ts +++ b/src/channels/plugins/registry-loaded.ts @@ -105,7 +105,7 @@ function resolveChannelPlugins(registry?: ActivePluginChannelRegistry): ChannelP entriesById, }; if (currentRegistry) { - // Runtime snapshots invalidate the single active, pinned-registry view. + // Runtime snapshots invalidate the single process-root registry view. cachedChannelPluginView = view; } return view; diff --git a/src/channels/plugins/registry-loader.ts b/src/channels/plugins/registry-loader.ts index 1d6e1c2cf166..9d40b4a644e3 100644 --- a/src/channels/plugins/registry-loader.ts +++ b/src/channels/plugins/registry-loader.ts @@ -1,10 +1,10 @@ /** * Lazy channel registry value loader. * - * Resolves plugin sub-surfaces from active channel or full plugin registry state. + * Resolves plugin sub-surfaces from the process-root registry. */ import type { PluginChannelRegistration } from "../../plugins/registry-types.js"; -import { getActivePluginChannelRegistry, getActivePluginRegistry } from "../../plugins/runtime.js"; +import { getActivePluginRegistry } from "../../plugins/runtime.js"; import type { ChannelId } from "./channel-id.types.js"; type ChannelRegistryValueResolver = ( @@ -25,19 +25,6 @@ export function createChannelRegistryLoader( return pluginEntry ? resolveValue(pluginEntry) : undefined; }; - const channelRegistry = getActivePluginChannelRegistry(); - const channelValue = resolveFromRegistry(channelRegistry); - if (channelValue !== undefined) { - return channelValue; - } - - const activeRegistry = getActivePluginRegistry(); - if (activeRegistry && activeRegistry !== channelRegistry) { - // During startup some callers see a narrower channel registry first. - // Fall back to the full active registry when it is a distinct object. - return resolveFromRegistry(activeRegistry); - } - - return undefined; + return resolveFromRegistry(getActivePluginRegistry()); }; } diff --git a/src/channels/registry.helpers.test.ts b/src/channels/registry.helpers.test.ts index 84c9207fbd2a..a8ab3b9392be 100644 --- a/src/channels/registry.helpers.test.ts +++ b/src/channels/registry.helpers.test.ts @@ -4,7 +4,6 @@ import { expectDefined } from "@openclaw/normalization-core"; import { afterEach, describe, expect, it } from "vitest"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; import { - pinActivePluginChannelRegistry, getActivePluginChannelRegistryVersion, resetPluginRuntimeStateForTest, setActivePluginRegistry, @@ -12,12 +11,7 @@ import { import { createTestRegistry } from "../test-utils/channel-plugins.js"; import { listChatChannels } from "./chat-meta.js"; import { normalizeAnyChannelId as normalizeAnyChannelIdLight } from "./registry-normalize.js"; -import { - formatChannelSelectionLine, - getRegisteredChannelPluginMeta, - listRegisteredChannelPluginIds, - normalizeAnyChannelId, -} from "./registry.js"; +import { formatChannelSelectionLine, normalizeAnyChannelId } from "./registry.js"; describe("channel registry helpers", () => { afterEach(() => { @@ -69,31 +63,6 @@ describe("channel registry helpers", () => { expect(line).toContain("https://openclaw.ai"); }); - it("prefers the pinned channel registry when resolving registered plugin channels", () => { - const startupRegistry = createRegistryWithRegisteredChannel("openclaw-weixin", ["weixin"]); - setActivePluginRegistry(startupRegistry); - pinActivePluginChannelRegistry(startupRegistry); - - const replacementRegistry = createRegistryWithRegisteredChannel("qqbot", ["qq"]); - setActivePluginRegistry(replacementRegistry); - - expect(listRegisteredChannelPluginIds()).toEqual(["openclaw-weixin"]); - expect(normalizeAnyChannelId("weixin")).toBe("openclaw-weixin"); - expect(getRegisteredChannelPluginMeta("OPENCLAW-WEIXIN")?.aliases).toEqual(["weixin"]); - }); - - it("falls back to the active registry when the pinned channel registry has no channels", () => { - const startupRegistry = createEmptyPluginRegistry(); - setActivePluginRegistry(startupRegistry); - pinActivePluginChannelRegistry(startupRegistry); - - const replacementRegistry = createRegistryWithRegisteredChannel("qqbot", ["qq"]); - setActivePluginRegistry(replacementRegistry); - - expect(listRegisteredChannelPluginIds()).toEqual(["qqbot"]); - expect(normalizeAnyChannelId("qq")).toBe("qqbot"); - }); - it("prefers an exact channel id over an earlier plugin alias", () => { const aliasOwner = createRegistryWithRegisteredChannel("alias-owner", ["exact-id"]).channels[0]; const exactOwner = createRegistryWithRegisteredChannel("exact-id").channels[0]; @@ -108,11 +77,7 @@ describe("channel registry helpers", () => { expect(normalizeAnyChannelIdLight("exact-id")).toBe("exact-id"); }); - it("rebuilds registered channel lookups when pinned-empty fallback active registry changes", () => { - const startupRegistry = createEmptyPluginRegistry(); - setActivePluginRegistry(startupRegistry); - pinActivePluginChannelRegistry(startupRegistry); - + it("rebuilds registered channel lookups when the active registry changes", () => { const alphaRegistry = createRegistryWithRegisteredChannel("alpha", ["a"]); setActivePluginRegistry(alphaRegistry); diff --git a/src/cli/plugin-registry.test.ts b/src/cli/plugin-registry.test.ts index d2f66a347503..e5e371a312c6 100644 --- a/src/cli/plugin-registry.test.ts +++ b/src/cli/plugin-registry.test.ts @@ -1,6 +1,5 @@ -// Plugin registry CLI tests cover registry loading, command integration, and reset behavior. +// Plugin registry CLI tests cover canonical process-root load scopes. import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { createEmptyPluginRegistry } from "../plugins/registry.js"; const logger = { info: vi.fn(), @@ -80,17 +79,8 @@ function expectLoadOpenClawPluginsCall( const mocks = vi.hoisted(() => ({ loadOpenClawPlugins: vi.fn(), - resolveCompatibleRuntimePluginRegistry: - vi.fn(), - resolveRuntimePluginRegistry: - vi.fn(), - getActivePluginRegistry: vi.fn(), resolveConfiguredChannelPluginIds: vi.fn(), - resolveDiscoverableScopedChannelPluginIds: - vi.fn< - typeof import("../plugins/channel-plugin-ids.js").resolveDiscoverableScopedChannelPluginIds - >(), resolveChannelPluginIds: vi.fn(), resolveEffectivePluginIds: @@ -100,30 +90,16 @@ const mocks = vi.hoisted(() => ({ })); let ensurePluginRegistryLoaded: typeof import("./plugin-registry.js").ensurePluginRegistryLoaded; -let resetPluginRegistryLoadedForTests: typeof import("./plugin-registry.js").testing.resetPluginRegistryLoadedForTests; vi.mock("../plugins/loader.js", () => ({ loadOpenClawPlugins: (...args: Parameters) => mocks.loadOpenClawPlugins(...args), - resolveCompatibleRuntimePluginRegistry: ( - ...args: Parameters - ) => mocks.resolveCompatibleRuntimePluginRegistry(...args), - resolveRuntimePluginRegistry: (...args: Parameters) => - mocks.resolveRuntimePluginRegistry(...args), -})); - -vi.mock("../plugins/runtime.js", () => ({ - getActivePluginRegistry: (...args: Parameters) => - mocks.getActivePluginRegistry(...args), })); vi.mock("../plugins/channel-plugin-ids.js", () => ({ resolveConfiguredChannelPluginIds: ( ...args: Parameters ) => mocks.resolveConfiguredChannelPluginIds(...args), - resolveDiscoverableScopedChannelPluginIds: ( - ...args: Parameters - ) => mocks.resolveDiscoverableScopedChannelPluginIds(...args), resolveChannelPluginIds: (...args: Parameters) => mocks.resolveChannelPluginIds(...args), })); @@ -181,25 +157,14 @@ describe("ensurePluginRegistryLoaded", () => { beforeAll(async () => { const mod = await import("./plugin-registry.js"); ensurePluginRegistryLoaded = mod.ensurePluginRegistryLoaded; - resetPluginRegistryLoadedForTests = () => mod.testing.resetPluginRegistryLoadedForTests(); }); beforeEach(() => { mocks.loadOpenClawPlugins.mockReset(); - mocks.resolveCompatibleRuntimePluginRegistry.mockReset(); - mocks.resolveRuntimePluginRegistry.mockReset(); - mocks.getActivePluginRegistry.mockReset(); mocks.resolveConfiguredChannelPluginIds.mockReset(); - mocks.resolveDiscoverableScopedChannelPluginIds.mockReset(); mocks.resolveChannelPluginIds.mockReset(); mocks.resolveEffectivePluginIds.mockReset(); mocks.resolvePluginRuntimeLoadContext.mockReset(); - resetPluginRegistryLoadedForTests(); - - mocks.getActivePluginRegistry.mockReturnValue(createEmptyPluginRegistry()); - mocks.resolveCompatibleRuntimePluginRegistry.mockReturnValue(undefined); - mocks.resolveRuntimePluginRegistry.mockReturnValue(undefined); - mocks.resolveDiscoverableScopedChannelPluginIds.mockReturnValue([]); mocks.resolveEffectivePluginIds.mockReturnValue(["demo"]); mocks.resolvePluginRuntimeLoadContext.mockImplementation((options) => { const rawConfig = (options?.config ?? {}) as Record; @@ -292,110 +257,4 @@ describe("ensurePluginRegistryLoaded", () => { throwOnLoadError: true, }); }); - - it("does not treat a pre-seeded partial registry as all scope", () => { - const config = { - plugins: { enabled: true }, - channels: { "demo-channel-a": { enabled: true } }, - }; - - mocks.resolvePluginRuntimeLoadContext.mockReturnValue({ - rawConfig: config, - config, - activationSourceConfig: config, - autoEnabledReasons: {}, - workspaceDir: "/tmp/workspace", - env: process.env, - logger, - } as never); - mocks.getActivePluginRegistry.mockReturnValue({ - plugins: [], - channels: [{ plugin: { id: "demo-channel-a" } }], - tools: [], - } as never); - - ensurePluginRegistryLoaded({ scope: "all" }); - - expect(mocks.loadOpenClawPlugins).toHaveBeenCalledTimes(1); - expectLoadOpenClawPluginsCall(0, { - config, - onlyPluginIds: ["demo"], - throwOnLoadError: true, - workspaceDir: "/tmp/workspace", - }); - }); - - it("does not treat a tools-only pre-seeded registry as channel scope", () => { - const config = { - plugins: { enabled: true }, - channels: { "demo-channel-a": { enabled: true } }, - }; - const activatedConfig = withActivatedPluginIdsForTest(config, ["demo-channel-a"]); - - mocks.resolvePluginRuntimeLoadContext.mockReturnValue({ - rawConfig: config, - config, - activationSourceConfig: config, - autoEnabledReasons: {}, - workspaceDir: "/tmp/workspace", - env: process.env, - logger, - } as never); - mocks.resolveConfiguredChannelPluginIds.mockReturnValue(["demo-channel-a"]); - mocks.getActivePluginRegistry.mockReturnValue({ - plugins: [], - channels: [], - tools: [{ pluginId: "demo-tool" }], - } as never); - - ensurePluginRegistryLoaded({ scope: "configured-channels" }); - - expect(mocks.loadOpenClawPlugins).toHaveBeenCalledTimes(1); - expectLoadOpenClawPluginsCall(0, { - config: activatedConfig, - activationSourceConfig: activatedConfig, - onlyPluginIds: ["demo-channel-a"], - throwOnLoadError: true, - workspaceDir: "/tmp/workspace", - }); - }); - - it("reloads when a pre-seeded channel registry is missing the configured channel plugin ids", () => { - const config = { - plugins: { enabled: true }, - channels: { - "demo-channel-a": { - botToken: "demo-bot-token", - appToken: "demo-app-token", - }, - }, - }; - const activatedConfig = withActivatedPluginIdsForTest(config, ["demo-channel-a"]); - - mocks.resolvePluginRuntimeLoadContext.mockReturnValue({ - rawConfig: config, - config, - activationSourceConfig: config, - autoEnabledReasons: {}, - workspaceDir: "/tmp/workspace", - env: process.env, - logger, - } as never); - mocks.resolveConfiguredChannelPluginIds.mockReturnValue(["demo-channel-a"]); - mocks.getActivePluginRegistry.mockReturnValue({ - plugins: [{ id: "demo-channel-b" }], - channels: [{ plugin: { id: "demo-channel-b" } }], - tools: [], - } as never); - ensurePluginRegistryLoaded({ scope: "configured-channels" }); - - expect(mocks.loadOpenClawPlugins).toHaveBeenCalledTimes(1); - expectLoadOpenClawPluginsCall(0, { - config: activatedConfig, - activationSourceConfig: activatedConfig, - onlyPluginIds: ["demo-channel-a"], - throwOnLoadError: true, - workspaceDir: "/tmp/workspace", - }); - }); }); diff --git a/src/cli/plugin-registry.ts b/src/cli/plugin-registry.ts index 6593842e8df2..d4d0500bb93f 100644 --- a/src/cli/plugin-registry.ts +++ b/src/cli/plugin-registry.ts @@ -1,6 +1,5 @@ // CLI-facing plugin registry loader re-export. export { - testing, ensurePluginRegistryLoaded, type PluginRegistryScope, } from "../plugins/runtime/runtime-registry-loader.js"; diff --git a/src/cli/program/message/helpers.ts b/src/cli/program/message/helpers.ts index e246e325faf4..9387b053181b 100644 --- a/src/cli/program/message/helpers.ts +++ b/src/cli/program/message/helpers.ts @@ -26,7 +26,6 @@ import { withPluginRuntimeRegistryScope } from "../../../plugins/runtime/gateway import { defaultRuntime } from "../../../runtime.js"; import { runCommandWithRuntime } from "../../cli-utils.js"; import { createDefaultDeps } from "../../deps.js"; -import type { PluginRegistryScope } from "../../plugin-registry.js"; /** Shared helpers used by every message subcommand registration. */ export type MessageCliHelpers = { @@ -51,10 +50,7 @@ const STRICT_NON_NEGATIVE_INTEGER_OPTIONS = new Map([ ["deleteDays", "--delete-days"], ]); -type MessagePluginLoadOptions = { scope: PluginRegistryScope; onlyChannelIds?: string[] }; -type MessagePluginPreloadPlan = - | { preload: true; loadOptions: MessagePluginLoadOptions } - | { preload: false }; +type MessagePluginPreloadPlan = { preload: true; channelId?: string } | { preload: false }; function normalizeMessageOptions(opts: Record): Record { const { account, ...rest } = opts; @@ -136,9 +132,6 @@ function resolveMessagePluginPreloadPlan( opts: Record, ): MessagePluginPreloadPlan { const scopedChannel = resolveScopedMessageChannel(opts); - const loadOptions = scopedChannel - ? { scope: "configured-channels" as const, onlyChannelIds: [scopedChannel] } - : { scope: "configured-channels" as const }; // Gateway-owned actions can execute without loading channel plugins in the CLI process; // dry-runs, broadcasts, and local actions need registry metadata before building payloads. if ( @@ -146,7 +139,7 @@ function resolveMessagePluginPreloadPlan( ACTIONS_REQUIRING_CONFIGURED_CHANNEL_PRELOAD.has(action) || !isGatewayOwnedMessageAction(action, scopedChannel) ) { - return { preload: true, loadOptions }; + return { preload: true, ...(scopedChannel ? { channelId: scopedChannel } : {}) }; } return { preload: false }; } @@ -183,12 +176,11 @@ export function createMessageCliHelpers( const preloadPlan = resolveMessagePluginPreloadPlan(action, opts); if (preloadPlan.preload) { const config = getRuntimeConfig(); - const requestedChannelIds = preloadPlan.loadOptions.onlyChannelIds; - const pluginIds = requestedChannelIds + const pluginIds = preloadPlan.channelId ? resolveDiscoverableScopedChannelPluginIds({ config, activationSourceConfig: config, - channelIds: requestedChannelIds, + channelIds: [preloadPlan.channelId], env: process.env, }) : resolveConfiguredChannelPluginIds({ diff --git a/src/gateway/config-reload-plan.ts b/src/gateway/config-reload-plan.ts index 2c1bb3957b4e..0fd709b4124e 100644 --- a/src/gateway/config-reload-plan.ts +++ b/src/gateway/config-reload-plan.ts @@ -7,7 +7,6 @@ import { } from "../channels/plugins/index.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { - getActivePluginChannelRegistryVersion, getActivePluginHttpRouteRegistry, getActivePluginHttpRouteRegistryVersion, } from "../plugins/runtime.js"; @@ -157,25 +156,17 @@ const BASE_RELOAD_RULES_TAIL: ReloadRule[] = [ let cachedReloadRules: ReloadRule[] | null = null; let cachedRegistry: ReturnType | null = null; let cachedGatewayRegistryVersion = -1; -let cachedChannelRegistryVersion = -1; function listReloadRules(): ReloadRule[] { - // Reload metadata is Gateway policy. Agent-scoped registry activation must - // not replace the pinned Gateway surface and silently change restart rules. + // Reload metadata is gateway policy owned by the process-root registry. const registry = getActivePluginHttpRouteRegistry(); const gatewayRegistryVersion = getActivePluginHttpRouteRegistryVersion(); - const channelRegistryVersion = getActivePluginChannelRegistryVersion(); - // Plugin/channel reload rules are process-stable until the active registry + // Plugin/channel reload rules are process-stable until the root registry // version changes; cache them to keep every config diff cheap. - if ( - registry !== cachedRegistry || - gatewayRegistryVersion !== cachedGatewayRegistryVersion || - channelRegistryVersion !== cachedChannelRegistryVersion - ) { + if (registry !== cachedRegistry || gatewayRegistryVersion !== cachedGatewayRegistryVersion) { cachedReloadRules = null; cachedRegistry = registry; cachedGatewayRegistryVersion = gatewayRegistryVersion; - cachedChannelRegistryVersion = channelRegistryVersion; } if (cachedReloadRules) { return cachedReloadRules; diff --git a/src/gateway/config-reload.test.ts b/src/gateway/config-reload.test.ts index cf43772e0277..02a4b58c3494 100644 --- a/src/gateway/config-reload.test.ts +++ b/src/gateway/config-reload.test.ts @@ -16,13 +16,7 @@ import type { import { createConfigIO } from "../config/io.js"; import { hashRuntimeConfigValue } from "../config/runtime-snapshot.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; -import { - pinActivePluginChannelRegistry, - pinActivePluginHttpRouteRegistry, - releasePinnedPluginChannelRegistry, - resetPluginRuntimeStateForTest, - setActivePluginRegistry, -} from "../plugins/runtime.js"; +import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js"; import { getActiveGatewayRootWorkCount, resetGatewayWorkAdmission, @@ -498,17 +492,6 @@ describe("buildGatewayReloadPlan", () => { expect(plan.noopPaths).toStrictEqual([]); }); - it("keeps Gateway reload policy when an agent activates a scoped registry", () => { - pinActivePluginHttpRouteRegistry(registry); - setActivePluginRegistry(emptyRegistry); - - const path = "browser.profiles.sandbox.cdpUrl"; - expect(buildGatewayReloadPlan([path])).toMatchObject({ - restartGateway: false, - hotReasons: [path], - }); - }); - it("prefers channel restart prefixes over a broad no-op prefix", () => { const changedPaths = [ "channels.whatsapp.accounts.default.enabled", @@ -542,7 +525,7 @@ describe("buildGatewayReloadPlan", () => { restartChannels: new Set(), }); - pinActivePluginChannelRegistry(channelOnlyRegistry); + setActivePluginRegistry(channelOnlyRegistry); expect(buildGatewayReloadPlan(["channels.telegram.botToken"])).toMatchObject({ restartGateway: false, restartChannels: new Set(["telegram"]), @@ -2543,7 +2526,7 @@ describe("startGatewayConfigReloader", () => { { initialConfig }, ); - pinActivePluginChannelRegistry(channelRegistry); + setActivePluginRegistry(channelRegistry); try { await flushWatcherChange(harness); @@ -2551,7 +2534,7 @@ describe("startGatewayConfigReloader", () => { expect(plan.restartChannelAccounts).toEqual(new Map([["mattermost", new Set(["alpha"])]])); expect(harness.onNoopConfigCommit).not.toHaveBeenCalled(); } finally { - releasePinnedPluginChannelRegistry(channelRegistry); + resetPluginRuntimeStateForTest(); await harness.reloader.stop(); } }); diff --git a/src/gateway/control-ui-plugin-tabs.test.ts b/src/gateway/control-ui-plugin-tabs.test.ts index 35e5743635c1..e65f1db3bc1c 100644 --- a/src/gateway/control-ui-plugin-tabs.test.ts +++ b/src/gateway/control-ui-plugin-tabs.test.ts @@ -1,11 +1,7 @@ import { expectDefined } from "@openclaw/normalization-core"; import { afterEach, describe, expect, it } from "vitest"; import type { PluginControlUiDescriptor } from "../plugins/host-hooks.js"; -import { - pinActivePluginSessionExtensionRegistry, - resetPluginRuntimeStateForTest, - setActivePluginRegistry, -} from "../plugins/runtime.js"; +import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; import { listControlUiPluginTabAuthGrants, @@ -128,33 +124,6 @@ describe("listControlUiPluginTabs", () => { ]); }); - it("survives agent-turn active-registry swaps via the pinned session-extension registry", () => { - const gatewayRegistry = createTestRegistry([]); - gatewayRegistry.controlUiDescriptors = [ - { - pluginId: "workboard", - descriptor: tabDescriptor({ - id: "card", - surface: "widget", - label: "Workboard card", - requiredScopes: ["operator.read"], - }), - source: "test:workboard", - }, - { pluginId: "logbook", descriptor: tabDescriptor(), source: "test:logbook" }, - ]; - // Gateway startup pins its fully wired registry on the session-extension surface. - pinActivePluginSessionExtensionRegistry(gatewayRegistry); - - // Agent-turn standalone loads install a registry without control-UI descriptors. - setActivePluginRegistry(createTestRegistry([])); - - expect(listControlUiPluginWidgetKinds(["operator.read"]).map((kind) => kind.kind)).toEqual([ - "workboard:card", - ]); - expect(listControlUiPluginTabs(["operator.admin"]).map((tab) => tab.id)).toEqual(["logbook"]); - }); - it("grants only same-plugin gateway routes with least-privilege scopes", () => { activateDescriptors( [ diff --git a/src/gateway/control-ui-plugin-tabs.ts b/src/gateway/control-ui-plugin-tabs.ts index 70371840c8ff..e3f1113a0ccd 100644 --- a/src/gateway/control-ui-plugin-tabs.ts +++ b/src/gateway/control-ui-plugin-tabs.ts @@ -1,9 +1,6 @@ // Projects plugin "tab" Control UI descriptors into the hello payload so the // dashboard renders plugin tabs without hardcoding plugin ids in core. -// Read the session-extension registry (gateway-pinned at startup), not the -// mutable active registry: agent-turn standalone loads swap the active registry -// for one without control-UI descriptors, which would empty hello for every -// connection made after the first agent run. +// Descriptors come from the process-root registry installed by the gateway. import type { PluginControlUiDescriptor } from "../plugins/host-hooks.js"; import type { PluginRegistry } from "../plugins/registry.js"; import { getActivePluginSessionExtensionRegistry } from "../plugins/runtime.js"; diff --git a/src/gateway/gateway-acp-bind.live.test.ts b/src/gateway/gateway-acp-bind.live.test.ts index ac77a89b91b9..e08d151c508e 100644 --- a/src/gateway/gateway-acp-bind.live.test.ts +++ b/src/gateway/gateway-acp-bind.live.test.ts @@ -11,11 +11,7 @@ import { isLiveTestEnabled, readLiveTestConfig } from "../agents/live-test-helpe import { clearConfigCache, clearRuntimeConfigSnapshot } from "../config/config.js"; import { isTruthyEnvValue } from "../infra/env.js"; import { clearPluginLoaderCache } from "../plugins/loader.test-fixtures.js"; -import { - pinActivePluginChannelRegistry, - releasePinnedPluginChannelRegistry, - resetPluginRuntimeStateForTest, -} from "../plugins/runtime.js"; +import { getActivePluginRegistry, resetPluginRuntimeStateForTest } from "../plugins/runtime.js"; import { extractFirstTextBlock } from "../shared/chat-message-content.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; import { setTestEnvValue } from "../test-utils/env.js"; @@ -591,9 +587,6 @@ describeLive("gateway live (ACP bind)", () => { const memoryToken = createAcpProbePhrase("quiet cedar", randomBytes(4).toString("hex")); let server: Awaited> | undefined; let client: GatewayClient | undefined; - let pinnedChannelRegistry: - | ReturnType - | undefined; clearRuntimeConfigSnapshot(); setTestEnvValue("OPENCLAW_STATE_DIR", tempStateDir); @@ -707,9 +700,11 @@ describeLive("gateway live (ACP bind)", () => { timeoutMs: CONNECT_TIMEOUT_MS, }); logLiveStep("gateway websocket connected"); - const channelRegistry = createSlackCurrentConversationBindingRegistry(); - pinActivePluginChannelRegistry(channelRegistry); - pinnedChannelRegistry = channelRegistry; + const activeRegistry = getActivePluginRegistry(); + if (!activeRegistry) { + throw new Error("expected gateway root plugin registry"); + } + activeRegistry.channels.push(...createSlackCurrentConversationBindingRegistry().channels); const bindResult = await bindConversationAndWait({ client, @@ -1073,9 +1068,6 @@ describeLive("gateway live (ACP bind)", () => { logLiveStep("bound session created cron via MCP and CLI verification passed"); } finally { try { - if (pinnedChannelRegistry) { - releasePinnedPluginChannelRegistry(pinnedChannelRegistry); - } clearConfigCache(); clearRuntimeConfigSnapshot(); await client?.stopAndWait({ timeoutMs: 2_000 }).catch(() => {}); diff --git a/src/gateway/gateway-codex-bind.live.test.ts b/src/gateway/gateway-codex-bind.live.test.ts index d695ef2cc72c..ddbe6c56318c 100644 --- a/src/gateway/gateway-codex-bind.live.test.ts +++ b/src/gateway/gateway-codex-bind.live.test.ts @@ -18,11 +18,7 @@ import { pluginCommands } from "../plugins/command-registry-state.js"; import { getCurrentPluginConversationBinding } from "../plugins/conversation-binding.js"; import { seedPluginConversationBindingApprovalForTest } from "../plugins/conversation-binding.test-fixtures.js"; import { clearPluginLoaderCache } from "../plugins/loader.test-fixtures.js"; -import { - pinActivePluginChannelRegistry, - releasePinnedPluginChannelRegistry, - resetPluginRuntimeStateForTest, -} from "../plugins/runtime.js"; +import { getActivePluginRegistry, resetPluginRuntimeStateForTest } from "../plugins/runtime.js"; import { clearSecretsRuntimeSnapshot } from "../secrets/runtime.js"; import { extractFirstTextBlock } from "../shared/chat-message-content.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; @@ -476,9 +472,6 @@ describeLive("gateway live (native Codex conversation binding)", () => { clearSecretsRuntimeSnapshot(); let server: Awaited> | undefined; let client: Awaited> | undefined; - let pinnedChannelRegistry: - | ReturnType - | undefined; try { server = await startGatewayServer(port, { @@ -495,8 +488,11 @@ describeLive("gateway live (native Codex conversation binding)", () => { }); const activeClient = client; const channelRegistry = createSlackCurrentConversationBindingRegistry(outboundReplies); - pinActivePluginChannelRegistry(channelRegistry); - pinnedChannelRegistry = channelRegistry; + const activeRegistry = getActivePluginRegistry(); + if (!activeRegistry) { + throw new Error("expected gateway root plugin registry"); + } + activeRegistry.channels.push(...channelRegistry.channels); seedPluginConversationBindingApprovalForTest({ pluginRoot: resolveCodexPluginRoot(), @@ -660,9 +656,6 @@ describeLive("gateway live (native Codex conversation binding)", () => { await sendCodexCommand("/codex binding", "No Codex conversation binding is attached."); } finally { try { - if (pinnedChannelRegistry) { - releasePinnedPluginChannelRegistry(pinnedChannelRegistry); - } clearConfigCache(); clearRuntimeConfigSnapshot(); try { diff --git a/src/gateway/gateway-wizard-cancel.e2e.test.ts b/src/gateway/gateway-wizard-cancel.e2e.test.ts index 42d790df5dbe..29eb0f1e846d 100644 --- a/src/gateway/gateway-wizard-cancel.e2e.test.ts +++ b/src/gateway/gateway-wizard-cancel.e2e.test.ts @@ -7,7 +7,6 @@ import { clearConfigCache, clearRuntimeConfigSnapshot } from "../config/config.j import { resetConfigOverrides } from "../config/runtime-overrides.js"; import { clearSessionStoreCacheForTest } from "../config/sessions/store-writer-state.js"; import { resetAgentEventsForTest } from "../infra/agent-events.js"; -import { clearGatewaySubagentRuntime } from "../plugins/runtime/gateway-bindings.test-fixtures.js"; import { createDeferred } from "../test-utils/deferred.js"; import { captureEnv, deleteTestEnvValue, setTestEnvValue } from "../test-utils/env.js"; import { startGatewayServer } from "./server.js"; @@ -42,7 +41,6 @@ function resetGatewayTestState(): void { clearConfigCache(); clearSessionStoreCacheForTest(); resetAgentEventsForTest(); - clearGatewaySubagentRuntime(); } afterEach(() => { diff --git a/src/gateway/gateway.test.ts b/src/gateway/gateway.test.ts index 31176e8da3b4..a38d49922c68 100644 --- a/src/gateway/gateway.test.ts +++ b/src/gateway/gateway.test.ts @@ -18,7 +18,6 @@ import { resetAgentEventsForTest } from "../infra/agent-events.js"; import { loadDeviceAuthToken } from "../infra/device-auth-store.js"; import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js"; import { getPairedDevice } from "../infra/device-pairing.js"; -import { clearGatewaySubagentRuntime } from "../plugins/runtime/gateway-bindings.test-fixtures.js"; import { captureEnv, deleteTestEnvValue, setTestEnvValue } from "../test-utils/env.js"; import { callGateway } from "./call.js"; import { startGatewayServer } from "./server.js"; @@ -177,7 +176,6 @@ function resetGatewayTestState(): void { clearConfigCache(); clearSessionStoreCacheForTest(); resetAgentEventsForTest({ preserveListeners: true }); - clearGatewaySubagentRuntime(); } describe("gateway e2e", () => { diff --git a/src/gateway/method-scopes.test.ts b/src/gateway/method-scopes.test.ts index 43ea93b00229..5fc5a6ad4a05 100644 --- a/src/gateway/method-scopes.test.ts +++ b/src/gateway/method-scopes.test.ts @@ -3,13 +3,7 @@ */ import { afterEach, describe, expect, it } from "vitest"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; -import { - pinActivePluginHttpRouteRegistry, - pinActivePluginSessionExtensionRegistry, - releasePinnedPluginHttpRouteRegistry, - releasePinnedPluginSessionExtensionRegistry, - setActivePluginRegistry, -} from "../plugins/runtime.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; import { authorizeOperatorScopesForMethod, isGatewayMethodClassified, @@ -41,8 +35,6 @@ function setPluginGatewayMethodScope( } afterEach(() => { - releasePinnedPluginHttpRouteRegistry(); - releasePinnedPluginSessionExtensionRegistry(); setActivePluginRegistry(createEmptyPluginRegistry()); }); @@ -321,50 +313,6 @@ 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", { @@ -665,40 +613,6 @@ 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 7ed06eca68d0..0b0791bb5c09 100644 --- a/src/gateway/method-scopes.ts +++ b/src/gateway/method-scopes.ts @@ -55,8 +55,8 @@ export const CLI_DEFAULT_OPERATOR_SCOPES: OperatorScope[] = [ ]; function resolveScopedMethod(method: string): OperatorScope | undefined { - // Gateway-pinned plugin descriptors prevent agent-scoped registry loads from - // changing gateway authorization. Node/dynamic sentinels are not operator scopes. + // Gateway method descriptors come from the process-root registry. Node/dynamic + // sentinels are not operator scopes. const explicitScope = resolveCoreOperatorGatewayMethodScope(method); if (explicitScope) { return explicitScope; diff --git a/src/gateway/node-command-policy.test.ts b/src/gateway/node-command-policy.test.ts index 848eb98d7c52..7a5bd6f1aba1 100644 --- a/src/gateway/node-command-policy.test.ts +++ b/src/gateway/node-command-policy.test.ts @@ -8,11 +8,7 @@ import { } from "../../packages/gateway-protocol/src/client-info.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; -import { - pinActivePluginChannelRegistry, - resetPluginRuntimeStateForTest, - setActivePluginRegistry, -} from "../plugins/runtime.js"; +import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js"; import { isForegroundRestrictedPluginNodeCommand, isNodeCommandAllowed, @@ -177,34 +173,6 @@ describe("gateway/node-command-policy", () => { }); }); - it("keeps plugin node defaults from the pinned Gateway registry", () => { - const startupRegistry = installCanvasPluginDefaults(); - pinActivePluginChannelRegistry(startupRegistry); - const transientRegistry = createEmptyPluginRegistry(); - const startupPolicy = startupRegistry.nodeInvokePolicies[0]; - if (!startupPolicy) { - throw new Error("expected canvas node policy"); - } - transientRegistry.nodeInvokePolicies.push({ - ...startupPolicy, - pluginId: "transient", - policy: { - ...startupPolicy.policy, - commands: ["transient.read"], - }, - }); - setActivePluginRegistry(transientRegistry); - - const allowlist = resolveNodeCommandAllowlist({} as OpenClawConfig, { - platform: "macos", - deviceFamily: "Mac", - }); - - expect(allowlist.has("canvas.snapshot")).toBe(true); - expect(allowlist.has("canvas.present")).toBe(true); - expect(allowlist.has("transient.read")).toBe(false); - }); - it("adds explicitly defaulted plugin node-host agent tools from the active registry", () => { const registry = createEmptyPluginRegistry(); registry.nodeHostCommands.push( diff --git a/src/gateway/node-invoke-plugin-policy.test.ts b/src/gateway/node-invoke-plugin-policy.test.ts index 9fb97e46ac8d..dd21530f77ed 100644 --- a/src/gateway/node-invoke-plugin-policy.test.ts +++ b/src/gateway/node-invoke-plugin-policy.test.ts @@ -13,11 +13,7 @@ import { } from "../infra/plugin-approvals.js"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; import type { PluginRegistry } from "../plugins/registry-types.js"; -import { - pinActivePluginChannelRegistry, - resetPluginRuntimeStateForTest, - setActivePluginRegistry, -} from "../plugins/runtime.js"; +import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js"; import type { OpenClawPluginNodeInvokePolicyContext } from "../plugins/types.js"; import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { ExecApprovalManager } from "./exec-approval-manager.js"; @@ -186,11 +182,6 @@ function setDangerousDemoCommandRegistry(policies: NodeInvokePolicyRegistration[ setActivePluginRegistry(registry); } -function createPolicyRegistry(handle: NodeInvokePolicyHandler): PluginRegistry { - const registry = createEmptyPluginRegistry(); - registry.nodeInvokePolicies.push(createDemoPolicy(handle)); - return registry; -} async function invokeDemoPolicy( context: GatewayRequestContext, client: GatewayClient | null = null, @@ -523,25 +514,6 @@ describe("applyPluginNodeInvokePolicy", () => { expect(invoke).toHaveBeenCalledOnce(); }); - it("uses a matching policy from the pinned Gateway registry after an active swap", async () => { - const gatewayRegistry = createPolicyRegistry((ctx) => ctx.invokeNode()); - setActivePluginRegistry(gatewayRegistry); - pinActivePluginChannelRegistry(gatewayRegistry); - setActivePluginRegistry( - createPolicyRegistry(async () => ({ - ok: false, - code: "TRANSIENT_POLICY", - message: "agent-scoped policy must not shadow Gateway policy", - })), - ); - const { context, invoke } = createContext(); - - const result = await invokeDemoPolicy(context); - - expect(result).toStrictEqual({ ok: true, payload: { ok: true, value: 1 }, payloadJSON: null }); - expect(invoke).toHaveBeenCalledOnce(); - }); - it("binds plugin policy approval requests to the invoking client", async () => { const manager = new ExecApprovalManager(); const visibleConnIds = new Set(["conn-owner-approval"]); diff --git a/src/gateway/server-close.test.ts b/src/gateway/server-close.test.ts index 91daede0e2cc..766f6f02325e 100644 --- a/src/gateway/server-close.test.ts +++ b/src/gateway/server-close.test.ts @@ -6,6 +6,12 @@ import { once } from "node:events"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { isAgentRunRestartAbortReason } from "../agents/run-termination.js"; import type { InternalHookEvent } from "../hooks/internal-hooks.js"; +import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; +import { + getActivePluginRegistry, + resetPluginRuntimeStateForTest, + setActivePluginRegistry, +} from "../plugins/runtime.js"; type TriggerInternalHookMock = (event: InternalHookEvent) => Promise; @@ -161,6 +167,7 @@ function createGatewayCloseTestDeps( describe("createGatewayCloseHandler", () => { beforeEach(() => { + resetPluginRuntimeStateForTest(); vi.useRealTimers(); mocks.logInfo.mockClear(); mocks.logWarn.mockClear(); @@ -182,6 +189,7 @@ describe("createGatewayCloseHandler", () => { }); afterEach(() => { + resetPluginRuntimeStateForTest(); vi.useRealTimers(); if (originalRestartTraceEnv === undefined) { delete process.env.OPENCLAW_GATEWAY_RESTART_TRACE; @@ -191,6 +199,7 @@ describe("createGatewayCloseHandler", () => { }); it("still runs later teardown when cron.stopAndDrain() rejects (no listener strand)", async () => { + setActivePluginRegistry(createEmptyPluginRegistry()); const stopAndDrain = vi.fn().mockRejectedValue(new Error("stream watcher stop failed")); const httpClose = vi.fn((cb: (err?: Error | null) => void) => cb(null)); const deps = createGatewayCloseTestDeps({ @@ -208,6 +217,7 @@ describe("createGatewayCloseHandler", () => { expect(deps.heartbeatRunner.stop).toHaveBeenCalledTimes(1); expect(httpClose).toHaveBeenCalled(); expect(result.warnings.length).toBeGreaterThan(0); + expect(getActivePluginRegistry()).toBeNull(); }); it("completes a clean shutdown with a ShutdownResult", async () => { @@ -223,6 +233,15 @@ describe("createGatewayCloseHandler", () => { expect(deps.chatRunState.clear).toHaveBeenCalledTimes(1); }); + it("clears the process-root plugin registry after teardown", async () => { + setActivePluginRegistry(createEmptyPluginRegistry()); + const close = createGatewayCloseHandler(createGatewayCloseTestDeps()); + + await close({ reason: "test" }); + + expect(getActivePluginRegistry()).toBeNull(); + }); + it("joins an in-flight config reload before mutable runtime teardown", async () => { const events: string[] = []; mocks.clearSessionSuspensionTimers.mockImplementation(() => { diff --git a/src/gateway/server-close.ts b/src/gateway/server-close.ts index 1781cdcd9c2b..c5ceb4a11d06 100644 --- a/src/gateway/server-close.ts +++ b/src/gateway/server-close.ts @@ -12,6 +12,7 @@ import { createInternalHookEvent, triggerInternalHook } from "../hooks/internal- import type { HeartbeatRunner } from "../infra/heartbeat-runner.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { closePluginStateDatabase } from "../plugin-state/plugin-state-store.js"; +import { clearActivePluginRegistry } from "../plugins/runtime.js"; import type { PluginServicesHandle } from "../plugins/services.js"; import { abortTrackedChatRunById, @@ -671,7 +672,6 @@ export function createGatewayCloseHandler( params: { bonjourStop: (() => Promise) | null; tailscaleCleanup: (() => Promise) | null; - releasePluginRouteRegistry?: (() => void) | null; clearSecretsRuntimeSnapshot?: (() => void) | null; channelIds?: readonly ChannelId[]; stopChannel: (name: ChannelId, accountId?: string) => Promise; @@ -1053,11 +1053,7 @@ export function createGatewayCloseHandler( warnings, }); } finally { - try { - params.releasePluginRouteRegistry?.(); - } catch { - /* ignore */ - } + await shutdownStep("plugin-host-registry", clearActivePluginRegistry, warnings); // Channel and plugin teardown still resolve account credentials. Keep the // active snapshot until every teardown owner is done, then always scrub it. try { diff --git a/src/gateway/server-core-runtime.ts b/src/gateway/server-core-runtime.ts index 529023b9a2cf..3b1b977bb109 100644 --- a/src/gateway/server-core-runtime.ts +++ b/src/gateway/server-core-runtime.ts @@ -8,11 +8,6 @@ import { getRuntimeConfig } from "../config/io.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { createSubsystemLogger } from "../logging/subsystem.js"; import { setCurrentPluginMetadataSnapshot } from "../plugins/current-plugin-metadata-snapshot.js"; -import { - pinActivePluginChannelRegistry, - pinActivePluginHttpRouteRegistry, - pinActivePluginSessionExtensionRegistry, -} from "../plugins/runtime.js"; import type { ExecApprovalManager } from "./exec-approval-manager.js"; import { revokeAttachGrantsForSession } from "./mcp-grant-store.js"; import { ADMIN_SCOPE } from "./method-scopes.js"; @@ -357,9 +352,6 @@ export async function startGatewayCoreRuntime(input: { runtimeState.gatewayMethods.length, ...listAttachedGatewayMethods(), ); - pinActivePluginHttpRouteRegistry(pluginRuntime.registry); - pinActivePluginSessionExtensionRegistry(pluginRuntime.registry); - pinActivePluginChannelRegistry(pluginRuntime.registry); nodeRegistry.refreshNodePluginTools(); }; const refreshAttachedGatewayDiscovery = async ( diff --git a/src/gateway/server-lifecycle.ts b/src/gateway/server-lifecycle.ts index cb17ae68b3f7..72978777df58 100644 --- a/src/gateway/server-lifecycle.ts +++ b/src/gateway/server-lifecycle.ts @@ -84,7 +84,6 @@ export async function prepareGatewayLifecycle(params: { gatewayInstanceRuntimeRef, startupState, readinessEventLoopHealth, - releasePluginRouteRegistry, browserAuthRateLimiter, wss, httpServer, @@ -384,7 +383,6 @@ export async function prepareGatewayLifecycle(params: { await createGatewayCloseHandler({ bonjourStop: runtimeState.bonjourStop, tailscaleCleanup: runtimeState.tailscaleCleanup, - releasePluginRouteRegistry, clearSecretsRuntimeSnapshot, channelIds, stopChannel, diff --git a/src/gateway/server-methods.plugin-gateway-dispatch.test.ts b/src/gateway/server-methods.plugin-gateway-dispatch.test.ts index a1db51cbe6ea..a6ac9f72f7dc 100644 --- a/src/gateway/server-methods.plugin-gateway-dispatch.test.ts +++ b/src/gateway/server-methods.plugin-gateway-dispatch.test.ts @@ -4,7 +4,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; import { - pinActivePluginHttpRouteRegistry, requireActivePluginRegistry, resetPluginRuntimeStateForTest, setActivePluginRegistry, @@ -119,75 +118,6 @@ 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 3767e50e4ae5..90a08cd9abe4 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -965,8 +965,8 @@ export async function handleGatewayRequest( const { req, respond, client, isWebchatConnect, context, signal } = 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 gateway-pinned registry. Without - // a gateway pin, that registry follows active plugins so late methods remain reachable (#94127). + // attached snapshot does not own the method, rebuild from the process-root registry 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 e49a17cddcd2..41d0115db7f8 100644 --- a/src/gateway/server-methods/board.plugin-capabilities.test.ts +++ b/src/gateway/server-methods/board.plugin-capabilities.test.ts @@ -5,8 +5,6 @@ import { createPluginRecord } from "../../plugins/loader-records.js"; import { createEmptyPluginRegistry } from "../../plugins/registry-empty.js"; import { getActivePluginRegistry, - pinActivePluginSessionExtensionRegistry, - releasePinnedPluginSessionExtensionRegistry, resetPluginRuntimeStateForTest, setActivePluginRegistry, } from "../../plugins/runtime.js"; @@ -149,80 +147,4 @@ 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/session-catalog.prewarm.test.ts b/src/gateway/server-methods/session-catalog.prewarm.test.ts index a4709eeb6c2e..7712f32cddf2 100644 --- a/src/gateway/server-methods/session-catalog.prewarm.test.ts +++ b/src/gateway/server-methods/session-catalog.prewarm.test.ts @@ -7,7 +7,7 @@ const hoisted = vi.hoisted(() => ({ })); vi.mock("../../plugins/runtime.js", () => ({ - getActivePluginSessionExtensionRegistry: () => hoisted.activeRegistry, + getActivePluginRegistry: () => hoisted.activeRegistry, })); vi.mock("../../config/sessions/session-accessor.js", async (importOriginal) => ({ diff --git a/src/gateway/server-methods/session-catalog.test.ts b/src/gateway/server-methods/session-catalog.test.ts index 068f6c4086f1..0e55c7c8d685 100644 --- a/src/gateway/server-methods/session-catalog.test.ts +++ b/src/gateway/server-methods/session-catalog.test.ts @@ -1,15 +1,26 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { ErrorCodes } from "../../../packages/gateway-protocol/src/index.js"; -import { gatewaySubagentState } from "../../plugins/runtime/gateway-bindings.js"; +import { createEmptyPluginRegistry } from "../../plugins/registry-empty.js"; +import { bindPluginRegistryRuntime } from "../../plugins/registry-runtime-binding.js"; +import type { PluginRegistry } from "../../plugins/registry-types.js"; import { createPluginRuntime } from "../../plugins/runtime/index.js"; import { listSessionCatalogEntries, type SessionCatalogProvider, } from "../../plugins/session-catalog.js"; +type TestPluginRegistry = Omit & { + sessionCatalogs: Array<{ + pluginId?: string; + pluginName?: string; + provider: SessionCatalogProvider; + rootDir?: string; + source?: string; + }>; +}; + const hoisted = vi.hoisted(() => ({ - activeRegistry: { sessionCatalogs: [] as unknown[] }, - pinnedSessionExtensionRegistry: undefined as { sessionCatalogs: unknown[] } | undefined, + activeRegistry: {} as TestPluginRegistry, listSessionEntriesReadOnly: vi.fn< (scope?: { agentId?: string; clone?: boolean; projection?: "full" | "list" }) => Array<{ sessionKey: string; @@ -30,8 +41,7 @@ const conversationBindingMocks = vi.hoisted(() => ({ })); vi.mock("../../plugins/runtime.js", () => ({ - getActivePluginSessionExtensionRegistry: () => - hoisted.pinnedSessionExtensionRegistry ?? hoisted.activeRegistry, + getActivePluginRegistry: () => hoisted.activeRegistry, })); vi.mock("../../sessions/session-state-events.js", () => ({ @@ -99,8 +109,7 @@ function startCall( describe("session catalog Gateway methods", () => { beforeEach(() => { - hoisted.activeRegistry.sessionCatalogs = []; - hoisted.pinnedSessionExtensionRegistry = undefined; + hoisted.activeRegistry = createEmptyPluginRegistry() as TestPluginRegistry; hoisted.listSessionEntriesReadOnly.mockReset(); hoisted.listSessionEntriesReadOnly.mockReturnValue([]); hoisted.recordSessionStateEvent.mockClear(); @@ -512,102 +521,61 @@ describe("session catalog Gateway methods", () => { }); it("shares one lazy Gateway node snapshot across catalog providers", async () => { - const previousNodesRuntime = gatewaySubagentState.nodes; const dispatchNodeList = vi.fn(async () => ({ nodes: [{ nodeId: "shared-node", connected: true }], })); - gatewaySubagentState.nodes = { - list: dispatchNodeList, - invoke: vi.fn(async () => undefined), - }; - try { - const catalogUsingNodes = (id: string) => - provider(id, { - list: vi.fn(async ({ listNodes }) => { - expect(await listNodes?.()).toEqual({ - nodes: [{ nodeId: "shared-node", connected: true }], - }); - return []; - }), - }); - hoisted.activeRegistry.sessionCatalogs = [ - { provider: catalogUsingNodes("zeta") }, - { provider: catalogUsingNodes("alpha") }, - ]; + bindPluginRegistryRuntime( + hoisted.activeRegistry as PluginRegistry, + createPluginRuntime({ + nodes: { list: dispatchNodeList, invoke: vi.fn(async () => undefined) }, + }), + ); + const catalogUsingNodes = (id: string) => + provider(id, { + list: vi.fn(async ({ listNodes }) => { + expect(await listNodes?.()).toEqual({ + nodes: [{ nodeId: "shared-node", connected: true }], + }); + return []; + }), + }); + hoisted.activeRegistry!.sessionCatalogs = [ + { provider: catalogUsingNodes("zeta") }, + { provider: catalogUsingNodes("alpha") }, + ]; - await call("sessions.catalog.list", {}); + await call("sessions.catalog.list", {}); - expect(dispatchNodeList).toHaveBeenCalledOnce(); - } finally { - gatewaySubagentState.nodes = previousNodesRuntime; - } + expect(dispatchNodeList).toHaveBeenCalledOnce(); }); it("keeps catalog-filtered Gateway node snapshots lazy", async () => { - const previousNodesRuntime = gatewaySubagentState.nodes; const dispatchNodeList = vi.fn(async () => ({ nodes: [] })); - gatewaySubagentState.nodes = { - list: dispatchNodeList, - invoke: vi.fn(async () => undefined), - }; - try { - const selectedList = vi.fn(async () => []); - hoisted.activeRegistry.sessionCatalogs = [ - { - provider: provider("selected", { list: selectedList }), - }, - { - provider: provider("unselected", { - list: vi.fn(async ({ listNodes }) => { - await listNodes?.(); - return []; - }), - }), - }, - ]; - - await call("sessions.catalog.list", { catalogId: "selected" }); - - expect(selectedList).toHaveBeenCalledWith( - expect.objectContaining({ listNodes: expect.any(Function) }), - ); - expect(dispatchNodeList).not.toHaveBeenCalled(); - } finally { - gatewaySubagentState.nodes = previousNodesRuntime; - } - }); - - it("uses the pinned Gateway catalog runtime after active registry churn", async () => { - const previousNodesRuntime = gatewaySubagentState.nodes; - const listNodes = vi.fn(async () => ({ nodes: [] })); - gatewaySubagentState.nodes = { - list: listNodes, - invoke: vi.fn(async () => undefined), - }; - try { - const gatewayRuntime = createPluginRuntime({ allowGatewaySubagentBinding: true }); - const standaloneRuntime = createPluginRuntime(); - const catalogUsing = (runtime: ReturnType) => - provider("codex", { - list: async () => { - await runtime.nodes.list(); + bindPluginRegistryRuntime( + hoisted.activeRegistry as PluginRegistry, + createPluginRuntime({ + nodes: { list: dispatchNodeList, invoke: vi.fn(async () => undefined) }, + }), + ); + const selectedList = vi.fn(async () => []); + hoisted.activeRegistry!.sessionCatalogs = [ + { provider: provider("selected", { list: selectedList }) }, + { + provider: provider("unselected", { + list: vi.fn(async ({ listNodes }) => { + await listNodes?.(); return []; - }, - }); - hoisted.pinnedSessionExtensionRegistry = { - sessionCatalogs: [{ provider: catalogUsing(gatewayRuntime) }], - }; - hoisted.activeRegistry.sessionCatalogs = [{ provider: catalogUsing(standaloneRuntime) }]; + }), + }), + }, + ]; - const respond = await call("sessions.catalog.list", { catalogId: "codex" }); + await call("sessions.catalog.list", { catalogId: "selected" }); - expect(listNodes).toHaveBeenCalledOnce(); - expect(respond).toHaveBeenCalledWith(true, { - catalogs: [expect.objectContaining({ id: "codex", hosts: [] })], - }); - } finally { - gatewaySubagentState.nodes = previousNodesRuntime; - } + expect(selectedList).toHaveBeenCalledWith( + expect.objectContaining({ listNodes: expect.any(Function) }), + ); + expect(dispatchNodeList).not.toHaveBeenCalled(); }); it("rejects host cursors without a catalog selector", async () => { diff --git a/src/gateway/server-methods/session-catalog.ts b/src/gateway/server-methods/session-catalog.ts index cc232694f3e3..02b524166b00 100644 --- a/src/gateway/server-methods/session-catalog.ts +++ b/src/gateway/server-methods/session-catalog.ts @@ -14,9 +14,10 @@ import { validateSessionsCatalogReadParams, } from "../../../packages/gateway-protocol/src/index.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { getPluginRegistryRuntime } from "../../plugins/registry-runtime-binding.js"; import type { PluginRegistry } from "../../plugins/registry-types.js"; -import { getActivePluginSessionExtensionRegistry } from "../../plugins/runtime.js"; -import { gatewaySubagentState } from "../../plugins/runtime/gateway-bindings.js"; +import { getActivePluginRegistry } from "../../plugins/runtime.js"; +import { getPluginRuntimeGatewayRequestScope } from "../../plugins/runtime/gateway-request-scope.js"; import type { SessionCatalogCreateTarget, SessionCatalogListProviderParams, @@ -49,12 +50,14 @@ const sessionCatalogListAdmission = new SessionCatalogListAdmission( function createSessionCatalogRequestNodeSnapshot(): NonNullable< SessionCatalogListProviderParams["listNodes"] > { + const registry = resolveSessionCatalogRegistry(); + const nodes = registry ? getPluginRegistryRuntime(registry)?.nodes : undefined; let request: ReturnType> | undefined; return () => { // Every provider sees the same promise so one catalog request cannot multiply the // pairing-store scans performed by the Gateway node.list runtime. request ??= - gatewaySubagentState.nodes?.list() ?? + nodes?.list() ?? Promise.reject(new Error("Plugin node runtime is only available inside the Gateway.")); return request; }; @@ -87,8 +90,12 @@ type CatalogRegistrationSnapshot = { let cachedCatalogRegistrations: CatalogRegistrationSnapshot | undefined; +function resolveSessionCatalogRegistry(): PluginRegistry | null { + return getPluginRuntimeGatewayRequestScope()?.pluginRegistry ?? getActivePluginRegistry(); +} + function catalogRegistrationSnapshot(): CatalogRegistrationSnapshot { - const registry = getActivePluginSessionExtensionRegistry(); + const registry = resolveSessionCatalogRegistry(); const source = registry?.sessionCatalogs; if ( cachedCatalogRegistrations?.registry === registry && diff --git a/src/gateway/server-network-runtime.e2e.test.ts b/src/gateway/server-network-runtime.e2e.test.ts index e09d13164bbc..7ba40d930e16 100644 --- a/src/gateway/server-network-runtime.e2e.test.ts +++ b/src/gateway/server-network-runtime.e2e.test.ts @@ -8,7 +8,6 @@ import { clearConfigCache, clearRuntimeConfigSnapshot } from "../config/config.j import { clearSessionStoreCacheForTest } from "../config/sessions/store-writer-state.js"; import { resetAgentEventsForTest } from "../infra/agent-events.js"; import { PROXY_ENV_KEYS } from "../infra/net/proxy-env.js"; -import { clearGatewaySubagentRuntime } from "../plugins/runtime/gateway-bindings.test-fixtures.js"; import { captureEnv, deleteTestEnvValue, setTestEnvValue } from "../test-utils/env.js"; import { startGatewayServer } from "./server.js"; import { getFreeGatewayPort } from "./test-helpers.e2e.js"; @@ -52,7 +51,6 @@ describe("gateway network runtime", () => { clearRuntimeConfigSnapshot(); clearConfigCache(); clearSessionStoreCacheForTest(); - clearGatewaySubagentRuntime(); }); afterEach(() => { @@ -60,7 +58,6 @@ describe("gateway network runtime", () => { clearRuntimeConfigSnapshot(); clearConfigCache(); clearSessionStoreCacheForTest(); - clearGatewaySubagentRuntime(); }); it("bootstraps env proxy dispatching when the gateway starts directly", async () => { diff --git a/src/gateway/server-plugin-bootstrap.ts b/src/gateway/server-plugin-bootstrap.ts index b069cb7e17ec..b243ea0773e0 100644 --- a/src/gateway/server-plugin-bootstrap.ts +++ b/src/gateway/server-plugin-bootstrap.ts @@ -1,5 +1,5 @@ // Gateway plugin bootstrap helpers. -// Applies activation config, installs runtime bindings, loads and pins plugins. +// Applies activation config and loads the process-root plugin registry. import type { AmbientEnvTriggerPolicy } from "../channels/config-presence.js"; import { primeConfiguredBindingRegistry } from "../channels/plugins/binding-registry.js"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; @@ -11,27 +11,13 @@ import { findActiveDegradedPlugin, formatPluginVerificationDiagnostic, } from "../plugins/runtime-degraded-state.js"; -import { - pinActivePluginChannelRegistry, - pinActivePluginSessionExtensionRegistry, -} from "../plugins/runtime.js"; -import { - setGatewayNodesRuntime, - setGatewaySubagentRuntime, -} from "../plugins/runtime/gateway-bindings.js"; import { resolveDurableWorkerProviderAutoEnabledReasons } from "../plugins/worker-provider-registry.js"; import { mergeActivationSectionsIntoRuntimeConfig } from "./plugin-activation-runtime-config.js"; import type { GatewayRequestHandler } from "./server-methods/types.js"; -import { - createGatewayNodesRuntime, - createGatewaySubagentRuntime, - loadGatewayPlugins, - setPluginSubagentOverridePolicies, -} from "./server-plugins.js"; +import { loadGatewayPlugins, setPluginSubagentOverridePolicies } from "./server-plugins.js"; -// Gateway plugin bootstrap applies activation/auto-enable config, installs -// plugin runtime bindings, loads plugins, primes channel bindings, and pins the -// active registry for startup/reload paths. +// Gateway plugin bootstrap applies activation/auto-enable config, loads plugins, +// and primes channel bindings for startup/reload paths. type GatewayPluginBootstrapLog = { info: (msg: string) => void; warn: (msg: string) => void; @@ -58,19 +44,11 @@ type GatewayPluginBootstrapParams = { suppressPluginInfoLogs?: boolean; logDiagnostics?: boolean; startupTrace?: GatewayStartupTrace; - beforePrimeRegistry?: (pluginRegistry: PluginRegistry) => void; ambientEnvTriggers?: AmbientEnvTriggerPolicy; }; function installGatewayPluginRuntimeEnvironment(cfg: OpenClawConfig) { setPluginSubagentOverridePolicies(cfg); - setGatewaySubagentRuntime(createGatewaySubagentRuntime()); - setGatewayNodesRuntime(createGatewayNodesRuntime()); -} - -function pinGatewayPluginRuntimeRegistries(pluginRegistry: PluginRegistry): void { - pinActivePluginChannelRegistry(pluginRegistry); - pinActivePluginSessionExtensionRegistry(pluginRegistry); } // Diagnostics are logged after registry priming so startup output contains @@ -159,7 +137,6 @@ export function prepareGatewayPluginLoad(params: GatewayPluginBootstrapParams) { startupTrace: params.startupTrace, ambientEnvTriggers: params.ambientEnvTriggers, }); - params.beforePrimeRegistry?.(loaded.pluginRegistry); primeConfiguredBindingRegistry({ cfg: resolvedConfig }); if ((params.logDiagnostics ?? true) && loaded.pluginRegistry.diagnostics.length > 0) { logGatewayPluginDiagnostics({ @@ -170,25 +147,14 @@ export function prepareGatewayPluginLoad(params: GatewayPluginBootstrapParams) { return loaded; } -/** Loads and pins gateway plugins during normal gateway startup. */ -export function loadGatewayStartupPlugins( - params: Omit, -) { - return prepareGatewayPluginLoad({ - ...params, - beforePrimeRegistry: pinGatewayPluginRuntimeRegistries, - }); +/** Loads gateway plugins during normal gateway startup. */ +export function loadGatewayStartupPlugins(params: GatewayPluginBootstrapParams) { + return prepareGatewayPluginLoad(params); } /** Reloads deferred gateway plugins while preserving startup bootstrap behavior. */ export function reloadDeferredGatewayPlugins( - params: Omit< - GatewayPluginBootstrapParams, - "beforePrimeRegistry" | "preferSetupRuntimeForChannelPlugins" - >, + params: Omit, ) { - return prepareGatewayPluginLoad({ - ...params, - beforePrimeRegistry: pinGatewayPluginRuntimeRegistries, - }); + return prepareGatewayPluginLoad(params); } diff --git a/src/gateway/server-plugins.test.ts b/src/gateway/server-plugins.test.ts index 5eba9130201a..3de54824856f 100644 --- a/src/gateway/server-plugins.test.ts +++ b/src/gateway/server-plugins.test.ts @@ -12,7 +12,6 @@ import type { PluginLookUpTable } from "../plugins/plugin-lookup-table.js"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; import type { PluginRegistry } from "../plugins/registry.js"; import { setActiveDegradedPlugins } from "../plugins/runtime-degraded-state.js"; -import { clearGatewaySubagentRuntime } from "../plugins/runtime/gateway-bindings.test-fixtures.js"; import type { PluginRuntimeGatewayRequestScope } from "../plugins/runtime/gateway-request-scope.test-fixtures.js"; import type { PluginRuntime } from "../plugins/runtime/types.js"; import type { GatewayRequestContext, GatewayRequestOptions } from "./server-methods/types.js"; @@ -312,22 +311,21 @@ async function createSubagentRuntime( _serverPlugins: ServerPluginsModule, cfg: Record = {}, ): Promise { - const log = createTestLog(); loadOpenClawPlugins.mockReturnValue(createRegistry([])); - serverPluginBootstrapModule.loadGatewayStartupPlugins({ + loadGatewayStartupPluginsForTest({ cfg, - workspaceDir: "/tmp", - log, - coreGatewayHandlers: {}, - baseMethods: [], }); - const call = getLastMockFirstArg(loadOpenClawPlugins, "plugin load") as - | { runtimeOptions?: { allowGatewaySubagentBinding?: boolean } } + return createRuntimeFromLastGatewayLoad().subagent; +} + +function createRuntimeFromLastGatewayLoad(): PluginRuntime { + const runtimeOptions = getLastPluginLoadOption("runtimeOptions") as + | Parameters[0] | undefined; - if (call?.runtimeOptions?.allowGatewaySubagentBinding !== true) { - throw new Error("Expected loadGatewayPlugins to opt into gateway subagent binding"); + if (!runtimeOptions?.nodes || !runtimeOptions.subagent) { + throw new Error("Expected gateway plugin load to receive concrete node and subagent runtimes"); } - return runtimeModule.createPluginRuntime({ allowGatewaySubagentBinding: true }).subagent; + return runtimeModule.createPluginRuntime(runtimeOptions); } function registerActivePluginToolOwnership( @@ -360,7 +358,7 @@ function loadGatewayPluginsForTest( overrides: Partial[0]> = {}, ) { const log = createTestLog(); - serverPluginsModule.loadGatewayPlugins({ + const loaded = serverPluginsModule.loadGatewayPlugins({ cfg: {}, workspaceDir: "/tmp", log, @@ -368,6 +366,9 @@ function loadGatewayPluginsForTest( baseMethods: [], ...overrides, }); + // The mocked root loader returns a value without performing its production + // installation side effect, so mirror that ownership boundary in the harness. + runtimeRegistryModule.setActivePluginRegistry(loaded.pluginRegistry); return log; } @@ -375,7 +376,7 @@ function loadGatewayStartupPluginsForTest( overrides: Partial[0]> = {}, ) { const log = createTestLog(); - serverPluginBootstrapModule.loadGatewayStartupPlugins({ + const loaded = serverPluginBootstrapModule.loadGatewayStartupPlugins({ cfg: {}, workspaceDir: "/tmp", log, @@ -383,6 +384,7 @@ function loadGatewayStartupPluginsForTest( baseMethods: [], ...overrides, }); + runtimeRegistryModule.setActivePluginRegistry(loaded.pluginRegistry); return log; } @@ -406,7 +408,6 @@ beforeEach(() => { pluginRuntimeLoaderLogger.error.mockClear(); pluginRuntimeLoaderLogger.debug.mockClear(); handleGatewayRequest.mockReset(); - clearGatewaySubagentRuntime(); handleGatewayRequest.mockImplementation(async (opts: HandleGatewayRequestOptions) => { switch (opts.req.method) { case "agent": @@ -430,7 +431,6 @@ beforeEach(() => { afterEach(() => { setActiveDegradedPlugins([]); serverPluginsModule.clearFallbackGatewayContext(); - clearGatewaySubagentRuntime(); runtimeRegistryModule.resetPluginRuntimeStateForTest(); resetGlobalHookRunner(); }); @@ -576,20 +576,6 @@ describe("loadGatewayPlugins", () => { expect(getLastPluginLoadOption("onlyPluginIds")).toEqual(["telegram"]); }); - test("pins the initial startup channel registry against later active-registry churn", () => { - const startupRegistry = createRegistry([]); - loadOpenClawPlugins.mockReturnValue(startupRegistry); - - loadGatewayStartupPluginsForTest({ - pluginIds: ["slack"], - }); - - const replacementRegistry = createRegistry([]); - runtimeRegistryModule.setActivePluginRegistry(replacementRegistry); - - expect(runtimeRegistryModule.getActivePluginChannelRegistry()).toBe(startupRegistry); - }); - test("keeps the raw activation source when a precomputed startup scope is reused", () => { const rawConfig = { channels: { slack: { botToken: "x" } } }; const resolvedConfig = { @@ -1089,9 +1075,7 @@ describe("loadGatewayPlugins", () => { }); }); - const runtime = runtimeModule.createPluginRuntime({ - allowGatewaySubagentBinding: true, - }); + const runtime = createRuntimeFromLastGatewayLoad(); const result = await runtime.nodes.list({ connected: true }); expect(getLastDispatchedParams()).toStrictEqual({}); @@ -1119,7 +1103,7 @@ describe("loadGatewayPlugins", () => { }); }); - const runtime = runtimeModule.createPluginRuntime({ allowGatewaySubagentBinding: true }); + const runtime = createRuntimeFromLastGatewayLoad(); const result = await runtime.nodes.list({ connected: true }); expect(result.nodes[0]?.commands).toEqual([command]); @@ -1131,9 +1115,7 @@ describe("loadGatewayPlugins", () => { loadGatewayStartupPluginsForTest(); serverPluginsModule.setFallbackGatewayContext(createTestContext("nodes-invoke-browser-proxy")); - const runtime = runtimeModule.createPluginRuntime({ - allowGatewaySubagentBinding: true, - }); + const runtime = createRuntimeFromLastGatewayLoad(); await gatewayRequestScopeModule.withPluginRuntimePluginScope( { pluginId: "google-meet", pluginOrigin: "bundled" }, () => @@ -1164,7 +1146,7 @@ describe("loadGatewayPlugins", () => { } as GatewayRequestOptions["client"], isWebchatConnect: () => false, } satisfies PluginRuntimeGatewayRequestScope; - const runtime = runtimeModule.createPluginRuntime({ allowGatewaySubagentBinding: true }); + const runtime = createRuntimeFromLastGatewayLoad(); await gatewayRequestScopeModule.withPluginRuntimeGatewayRequestScope(scope, () => gatewayRequestScopeModule.withPluginRuntimePluginScope( @@ -1308,9 +1290,7 @@ describe("loadGatewayPlugins", () => { createTestContext("nodes-invoke-browser-proxy-no-elevate"), ); - const runtime = runtimeModule.createPluginRuntime({ - allowGatewaySubagentBinding: true, - }); + const runtime = createRuntimeFromLastGatewayLoad(); await gatewayRequestScopeModule.withPluginRuntimePluginScope( { pluginId: "third-party", pluginOrigin: "global" }, () => @@ -2020,33 +2000,6 @@ describe("loadGatewayPlugins", () => { expect(getLastPluginLoadOption("onlyPluginIds")).toEqual(["discord"]); }); - test("runs registry hook before priming configured bindings", () => { - const { prepareGatewayPluginLoad } = serverPluginBootstrapModule; - const order: string[] = []; - const pluginRegistry = createRegistry([]); - loadOpenClawPlugins.mockReturnValue(pluginRegistry); - primeConfiguredBindingRegistry.mockImplementation(() => { - order.push("prime"); - return { bindingCount: 0, channelCount: 0 }; - }); - - prepareGatewayPluginLoad({ - cfg: {}, - workspaceDir: "/tmp", - log: { - ...createTestLog(), - }, - coreGatewayHandlers: {}, - baseMethods: [], - beforePrimeRegistry: (loadedRegistry) => { - expect(loadedRegistry).toBe(pluginRegistry); - order.push("hook"); - }, - }); - - expect(order).toEqual(["hook", "prime"]); - }); - test("shares fallback context across module reloads for existing runtimes", async () => { const first = serverPluginsModule; const runtime = await createSubagentRuntime(first); diff --git a/src/gateway/server-plugins.ts b/src/gateway/server-plugins.ts index 31099788391d..3f2c40316c01 100644 --- a/src/gateway/server-plugins.ts +++ b/src/gateway/server-plugins.ts @@ -542,6 +542,19 @@ export function createGatewayNodesRuntime(): PluginRuntime["nodes"] { }; } +const GATEWAY_PLUGIN_RUNTIME_BINDINGS_KEY: unique symbol = Symbol.for( + "openclaw.gatewayPluginRuntimeBindings", +); + +function getGatewayPluginRuntimeBindings(): Pick { + // These delegates resolve the current request/fallback Gateway context per call. + // Keeping their identities process-stable preserves exact-key root load reuse. + return resolveGlobalSingleton(GATEWAY_PLUGIN_RUNTIME_BINDINGS_KEY, () => ({ + nodes: createGatewayNodesRuntime(), + subagent: createGatewaySubagentRuntime(), + })); +} + // ── Plugin loading ────────────────────────────────────────────────── function createGatewayPluginRegistrationLogger(params?: { @@ -652,6 +665,7 @@ export function loadGatewayPlugins(params: { } const beforeLoad = performance.now(); const loaderStatsBefore = getPluginModuleLoaderStats(); + const gatewayRuntimeBindings = getGatewayPluginRuntimeBindings(); const pluginRegistry = loadAndActivateRootPluginRegistry({ config: resolvedConfig, activationSourceConfig: params.activationSourceConfig ?? params.cfg, @@ -672,6 +686,7 @@ export function loadGatewayPlugins(params: { }), runtimeOptions: { allowGatewaySubagentBinding: true, + ...gatewayRuntimeBindings, }, preferSetupRuntimeForChannelPlugins: params.preferSetupRuntimeForChannelPlugins, preferBuiltPluginArtifacts: true, diff --git a/src/gateway/server-reload-handlers.test.ts b/src/gateway/server-reload-handlers.test.ts index 6eda06cf10bc..cc42fe1b6051 100644 --- a/src/gateway/server-reload-handlers.test.ts +++ b/src/gateway/server-reload-handlers.test.ts @@ -25,10 +25,7 @@ import { setGatewaySigusr1RestartPolicy, setPreRestartDeferralCheck, } from "../infra/restart.js"; -import { - pinActivePluginChannelRegistry, - releasePinnedPluginChannelRegistry, -} from "../plugins/runtime.js"; +import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js"; import { enqueueCommandInLane, getCommandLaneSnapshot, @@ -2695,11 +2692,11 @@ describe("gateway channel hot reload handlers", () => { source: "test", }, ]); - pinActivePluginChannelRegistry(registry); + setActivePluginRegistry(registry); try { await run(); } finally { - releasePinnedPluginChannelRegistry(registry); + resetPluginRuntimeStateForTest(); } } @@ -3040,7 +3037,7 @@ describe("gateway channel hot reload handlers", () => { }), }; - pinActivePluginChannelRegistry(registry); + setActivePluginRegistry(registry); try { const plan = buildGatewayReloadPlan(["channels.whatsapp.selfChatMode"]); const { applyHotReload } = createReloadHandlersForTest(undefined, channels); @@ -3051,7 +3048,7 @@ describe("gateway channel hot reload handlers", () => { expect(events).toEqual(["stop:whatsapp", "start:whatsapp"]); } finally { - releasePinnedPluginChannelRegistry(registry); + resetPluginRuntimeStateForTest(); } }); @@ -5590,7 +5587,7 @@ describe("deferred channel reload abort generation", () => { warn: vi.fn(), error: vi.fn(), }; - pinActivePluginChannelRegistry(registry); + setActivePluginRegistry(registry); const reloader = startManagedGatewayConfigReloader({ initialConfig, readSnapshot: vi.fn() as never, @@ -5637,7 +5634,7 @@ describe("deferred channel reload abort generation", () => { if (!reloaderStopped) { await reloader.stop(); } - releasePinnedPluginChannelRegistry(registry); + resetPluginRuntimeStateForTest(); } }); diff --git a/src/gateway/server-runtime-state-prepare.ts b/src/gateway/server-runtime-state-prepare.ts index bc7f6e6cf2a7..8bcff909d51a 100644 --- a/src/gateway/server-runtime-state-prepare.ts +++ b/src/gateway/server-runtime-state-prepare.ts @@ -346,7 +346,6 @@ export async function prepareGatewayRuntimeState(params: { current?: (req: IncomingMessage, res: ServerResponse) => Promise; } = {}; const { - releasePluginRouteRegistry, httpServer, httpServers, httpBindHosts, @@ -396,7 +395,6 @@ export async function prepareGatewayRuntimeState(params: { pluginRegistry: pluginRuntime.registry, getPluginRouteRegistry: () => pluginRuntime.registry, getGatewayRequestContext: () => pluginGatewayContext.current, - pinChannelRegistry: !minimalTestGateway, deps, log, logHooks, @@ -463,7 +461,6 @@ export async function prepareGatewayRuntimeState(params: { isGatewayStartupPending, pluginGatewayContext, watchNodeRequestHandler, - releasePluginRouteRegistry, httpServer, httpServers, httpBindHosts, diff --git a/src/gateway/server-runtime-state.test.ts b/src/gateway/server-runtime-state.test.ts index 9d219ca09944..6a79cc72c699 100644 --- a/src/gateway/server-runtime-state.test.ts +++ b/src/gateway/server-runtime-state.test.ts @@ -4,19 +4,7 @@ import { connect } from "node:net"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createEmptyPluginRegistry } from "../plugins/registry.js"; -import { - getActivePluginChannelRegistry, - getActivePluginSessionExtensionRegistry, - pinActivePluginHttpRouteRegistry, - pinActivePluginChannelRegistry, - pinActivePluginSessionExtensionRegistry, - releasePinnedPluginChannelRegistry, - releasePinnedPluginHttpRouteRegistry, - releasePinnedPluginSessionExtensionRegistry, - resetPluginRuntimeStateForTest, - resolveActivePluginHttpRouteRegistry, - setActivePluginRegistry, -} from "../plugins/runtime.js"; +import { resetPluginRuntimeStateForTest } from "../plugins/runtime.js"; import { createGatewayRuntimeStateForTest } from "./test-helpers.server-runtime-state.js"; const mocks = vi.hoisted(() => ({ @@ -35,19 +23,6 @@ vi.mock("./net.js", async (importOriginal) => { return { ...actual, resolveGatewayListenHosts: mocks.resolveGatewayListenHosts }; }); -function createRegistryWithRoute(path: string) { - const registry = createEmptyPluginRegistry(); - registry.httpRoutes.push({ - path, - auth: "plugin", - match: "exact", - handler: () => true, - pluginId: "demo", - source: "test", - }); - return registry; -} - async function requestPluginUpgrade(port: number, path: string): Promise { return await new Promise((resolve, reject) => { const socket = connect({ host: "127.0.0.1", port }); @@ -88,34 +63,9 @@ describe("createGatewayRuntimeState", () => { }); afterEach(() => { - releasePinnedPluginHttpRouteRegistry(); - releasePinnedPluginChannelRegistry(); - releasePinnedPluginSessionExtensionRegistry(); resetPluginRuntimeStateForTest(); }); - it("releases post-bootstrap repinned plugin registries on cleanup", async () => { - const startupRegistry = createRegistryWithRoute("/startup"); - const loadedRegistry = createRegistryWithRoute("/loaded"); - const fallbackRegistry = createRegistryWithRoute("/fallback"); - - setActivePluginRegistry(startupRegistry); - const runtimeState = await createGatewayRuntimeStateForTest(startupRegistry); - - pinActivePluginHttpRouteRegistry(loadedRegistry); - pinActivePluginSessionExtensionRegistry(loadedRegistry); - pinActivePluginChannelRegistry(loadedRegistry); - expect(resolveActivePluginHttpRouteRegistry(fallbackRegistry)).toBe(loadedRegistry); - expect(getActivePluginSessionExtensionRegistry()).toBe(loadedRegistry); - expect(getActivePluginChannelRegistry()).toBe(loadedRegistry); - - runtimeState.releasePluginRouteRegistry(); - - expect(resolveActivePluginHttpRouteRegistry(fallbackRegistry)).toBe(startupRegistry); - expect(getActivePluginSessionExtensionRegistry()).toBe(startupRegistry); - expect(getActivePluginChannelRegistry()).toBe(startupRegistry); - }); - it("delegates directly after lazily loading the plugin HTTP handler", async () => { const registry = createEmptyPluginRegistry(); const routes = registry.httpRoutes; @@ -171,7 +121,7 @@ describe("createGatewayRuntimeState", () => { } }); - it("keeps a loaded plugin upgrade handler on the repinned route registry", async () => { + it("keeps a loaded plugin upgrade handler on the current route registry", async () => { const startupRegistry = createEmptyPluginRegistry(); let runtimeRegistry = startupRegistry; let startupUpgradeCalls = 0; @@ -229,14 +179,12 @@ describe("createGatewayRuntimeState", () => { }); const emptyRegistry = createEmptyPluginRegistry(); runtimeRegistry = emptyRegistry; - pinActivePluginHttpRouteRegistry(emptyRegistry); await expect(requestPluginUpgrade(address.port, "/demo")).resolves.not.toContain( "101 Switching Protocols", ); expect(startupUpgradeCalls).toBe(1); runtimeRegistry = replacementRegistry; - pinActivePluginHttpRouteRegistry(replacementRegistry); await expect(requestPluginUpgrade(address.port, "/demo")).resolves.toContain( "101 Switching Protocols", diff --git a/src/gateway/server-runtime-state.ts b/src/gateway/server-runtime-state.ts index 1eb8960242fd..39bd679804c7 100644 --- a/src/gateway/server-runtime-state.ts +++ b/src/gateway/server-runtime-state.ts @@ -1,5 +1,5 @@ // Gateway HTTP/WebSocket runtime state factory. -// Builds one server runtime with pinned plugin registries and lazy route handlers. +// Builds one server runtime with lazy plugin route handlers. import { createServer as createHttpServer, type IncomingMessage, @@ -15,14 +15,6 @@ import { resolveCanvasNodeCapability } from "../canvas/constants.js"; import type { CliDeps } from "../cli/deps.types.js"; import type { createSubsystemLogger } from "../logging/subsystem.js"; import type { PluginRegistry } from "../plugins/registry.js"; -import { - pinActivePluginChannelRegistry, - pinActivePluginHttpRouteRegistry, - pinActivePluginSessionExtensionRegistry, - releasePinnedPluginChannelRegistry, - releasePinnedPluginHttpRouteRegistry, - releasePinnedPluginSessionExtensionRegistry, -} from "../plugins/runtime.js"; import type { AuthRateLimiter } from "./auth-rate-limit.js"; import type { ResolvedGatewayAuth } from "./auth.js"; import type { ChatAbortControllerEntry } from "./chat-abort.js"; @@ -93,7 +85,7 @@ type GatewayPluginUpgradeHandler = ( const loadGatewayPluginsHttpModule = async () => await import("./server/plugins-http.js"); -/** Creates the HTTP/WebSocket runtime state and pinned plugin registries for one gateway start. */ +/** Creates the HTTP/WebSocket runtime state for one gateway start. */ export async function createGatewayRuntimeState(params: { cfg: import("../config/config.js").OpenClawConfig; getRuntimeConfig?: () => import("../config/config.js").OpenClawConfig; @@ -117,7 +109,6 @@ export async function createGatewayRuntimeState(params: { pluginRegistry: PluginRegistry; getPluginRouteRegistry?: () => PluginRegistry; getGatewayRequestContext?: () => GatewayRequestContext | undefined; - pinChannelRegistry?: boolean; deps: CliDeps; log: { info: (msg: string) => void; warn: (msg: string) => void }; logHooks: ReturnType; @@ -127,7 +118,6 @@ export async function createGatewayRuntimeState(params: { handleWatchNodeRequest?: (req: IncomingMessage, res: ServerResponse) => Promise; workerIngressEnabled?: boolean; }): Promise<{ - releasePluginRouteRegistry: () => void; httpServer: HttpServer; httpServers: HttpServer[]; httpBindHosts: string[]; @@ -157,421 +147,394 @@ export async function createGatewayRuntimeState(params: { getMcpAppSandboxPort: () => number | undefined; ensureSandboxHostPort: () => Promise; }> { - pinActivePluginHttpRouteRegistry(params.pluginRegistry); - pinActivePluginSessionExtensionRegistry(params.pluginRegistry); - if (params.pinChannelRegistry !== false) { - pinActivePluginChannelRegistry(params.pluginRegistry); - } else { - releasePinnedPluginChannelRegistry(); - } - try { - const loadRuntimeConfig = params.getRuntimeConfig ?? (() => params.cfg); - const resolvePluginRouteRegistry = () => - params.getPluginRouteRegistry?.() ?? params.pluginRegistry; - const clients = new Set(); - const sessionEventSubscribers = createSessionEventSubscriberRegistry(); - const sessionMessageSubscribers = createSessionMessageSubscriberRegistry(); - const gatewayBroadcaster = createGatewayBroadcaster({ - clients, - sessionMessageSubscribers, - canReceiveSessionEvent: (client, sessionKeys, agentId, event, payload) => - canReceiveSessionEvent({ - cfg: loadRuntimeConfig(), - client, - sessionKeys, - agentId, - event, - payload, - }), - }); + const loadRuntimeConfig = params.getRuntimeConfig ?? (() => params.cfg); + const resolvePluginRouteRegistry = () => + params.getPluginRouteRegistry?.() ?? params.pluginRegistry; + const clients = new Set(); + const sessionEventSubscribers = createSessionEventSubscriberRegistry(); + const sessionMessageSubscribers = createSessionMessageSubscriberRegistry(); + const gatewayBroadcaster = createGatewayBroadcaster({ + clients, + sessionMessageSubscribers, + canReceiveSessionEvent: (client, sessionKeys, agentId, event, payload) => + canReceiveSessionEvent({ + cfg: loadRuntimeConfig(), + client, + sessionKeys, + agentId, + event, + payload, + }), + }); - let loadedHooksRequestHandler: HooksRequestHandler | null = null; - const handleHooksRequest: HooksRequestHandler = async (req, res) => { - const hooksConfig = params.hooksConfig(); - if (!hooksConfig) { - return false; + let loadedHooksRequestHandler: HooksRequestHandler | null = null; + const handleHooksRequest: HooksRequestHandler = async (req, res) => { + const hooksConfig = params.hooksConfig(); + if (!hooksConfig) { + return false; + } + const url = new URL(req.url ?? "/", "http://localhost"); + const basePath = hooksConfig.basePath; + if (url.pathname !== basePath && !url.pathname.startsWith(`${basePath}/`)) { + return false; + } + return await runWithGatewayHttpWorkAdmission(res, async () => { + if (!loadedHooksRequestHandler) { + // Hooks are cold for most gateway starts; create the handler only after a request + // matches the configured base path so startup avoids importing hook runtime code. + const { createGatewayHooksRequestHandler } = await import("./server/hooks.js"); + loadedHooksRequestHandler = createGatewayHooksRequestHandler({ + deps: params.deps, + getHooksConfig: params.hooksConfig, + getClientIpConfig: params.getHookClientIpConfig, + bindHost: params.bindHost, + port: params.port, + logHooks: params.logHooks, + }); } - const url = new URL(req.url ?? "/", "http://localhost"); - const basePath = hooksConfig.basePath; - if (url.pathname !== basePath && !url.pathname.startsWith(`${basePath}/`)) { - return false; + return await loadedHooksRequestHandler(req, res); + }); + }; + + let loadedPluginRequestHandler: GatewayPluginRequestHandler | null = null; + let loadedPluginUpgradeHandler: GatewayPluginUpgradeHandler | null = null; + const handlePluginRequest: GatewayPluginRequestHandler = async ( + req, + res, + pathContext, + dispatchContext, + ) => { + if (loadedPluginRequestHandler) { + return await loadedPluginRequestHandler(req, res, pathContext, dispatchContext); + } + const registry = resolvePluginRouteRegistry(); + if ((registry.httpRoutes ?? []).length === 0) { + return false; + } + // The loaded handler owns dynamic root-registry lookup; this wrapper only avoids + // importing it for route-free gateways. + const { createGatewayPluginRequestHandler } = await loadGatewayPluginsHttpModule(); + loadedPluginRequestHandler = createGatewayPluginRequestHandler({ + registry: params.pluginRegistry, + getRouteRegistry: resolvePluginRouteRegistry, + log: params.logPlugins, + getGatewayRequestContext: params.getGatewayRequestContext, + }); + return await loadedPluginRequestHandler(req, res, pathContext, dispatchContext); + }; + const handlePluginUpgrade: GatewayPluginUpgradeHandler = async ( + req, + socket, + head, + pathContext, + dispatchContext, + ) => { + if (loadedPluginUpgradeHandler) { + return await loadedPluginUpgradeHandler(req, socket, head, pathContext, dispatchContext); + } + const registry = resolvePluginRouteRegistry(); + if ((registry.httpRoutes ?? []).length === 0) { + return false; + } + // WebSocket upgrades share the loaded handler's dynamic route registry, so reloads still + // follow the active snapshot without a duplicate wrapper lookup on every upgrade. + const { createGatewayPluginUpgradeHandler } = await loadGatewayPluginsHttpModule(); + loadedPluginUpgradeHandler = createGatewayPluginUpgradeHandler({ + registry: params.pluginRegistry, + getRouteRegistry: resolvePluginRouteRegistry, + log: params.logPlugins, + getGatewayRequestContext: params.getGatewayRequestContext, + }); + return await loadedPluginUpgradeHandler(req, socket, head, pathContext, dispatchContext); + }; + const shouldEnforcePluginGatewayAuth = (pathContext: PluginRoutePathContext): boolean => { + return shouldEnforceGatewayAuthForPluginPath(resolvePluginRouteRegistry(), pathContext); + }; + const resolvePluginNodeCapabilityRoute = (pathContext: PluginRoutePathContext) => { + const coreCanvasCapability = isCoreCanvasHostEnabled(loadRuntimeConfig()) + ? resolveCanvasNodeCapability(pathContext.candidates) + : undefined; + if (coreCanvasCapability) { + return coreCanvasCapability; + } + // Plugin capability routes follow the current root registry so auth and dispatch agree. + return findMatchingPluginNodeCapabilityRoute(resolvePluginRouteRegistry(), pathContext) + ?.nodeCapability; + }; + + const bindHosts = await resolveGatewayListenHosts(params.bindHost); + if (!isLoopbackHost(params.bindHost)) { + params.log.warn( + "⚠️ Gateway is binding to a non-loopback address. " + + "Ensure authentication is configured before exposing to public networks.", + ); + } + if (params.cfg.gateway?.controlUi?.dangerouslyAllowHostHeaderOriginFallback === true) { + params.log.warn( + "⚠️ gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true is enabled. " + + "Host-header origin fallback weakens origin checks and should only be used as break-glass.", + ); + } + // Create WebSocketServer first (with noServer: true) so we can attach upgrade handlers + // before HTTP servers start listening. This prevents a race condition where connections + // arrive before the upgrade handler is attached, which causes silent 1006 errors. + const wss = new WebSocketServer({ + noServer: true, + maxPayload: MAX_PREAUTH_PAYLOAD_BYTES, + }); + const preauthConnectionBudget = createPreauthConnectionBudget(); + const workerPreauthConnectionBudget = createPreauthConnectionBudget(); + + const httpServers: HttpServer[] = []; + const gatewayHttpServers: HttpServer[] = []; + const httpBindHosts: string[] = []; + for (const _ of bindHosts) { + const httpServer = createGatewayHttpServer({ + clients, + controlUiEnabled: params.controlUiEnabled, + controlUiBasePath: params.controlUiBasePath, + controlUiRoot: params.controlUiRoot, + openAiChatCompletionsEnabled: params.openAiChatCompletionsEnabled, + openAiChatCompletionsConfig: params.openAiChatCompletionsConfig, + openResponsesEnabled: params.openResponsesEnabled, + openResponsesConfig: params.openResponsesConfig, + strictTransportSecurityHeader: params.strictTransportSecurityHeader, + handleWatchNodeRequest: params.handleWatchNodeRequest, + handleHooksRequest, + handlePluginRequest, + shouldEnforcePluginGatewayAuth, + resolvePluginNodeCapabilityRoute, + resolvedAuth: params.resolvedAuth, + getResolvedAuth: params.getResolvedAuth, + rateLimiter: params.rateLimiter, + getReadiness: params.getReadiness, + getRuntimeConfig: loadRuntimeConfig, + isTerminalEnabled: params.isTerminalEnabled, + tlsOptions: params.gatewayTls?.enabled ? params.gatewayTls.tlsOptions : undefined, + }); + // Attach upgrade handler BEFORE listening to prevent race condition + attachGatewayUpgradeHandler({ + httpServer, + wss, + handlePluginUpgrade, + shouldEnforcePluginGatewayAuth, + resolvePluginNodeCapabilityRoute, + clients, + preauthConnectionBudget, + resolvedAuth: params.resolvedAuth, + getResolvedAuth: params.getResolvedAuth, + rateLimiter: params.rateLimiter, + log: params.log, + }); + gatewayHttpServers.push(httpServer); + httpServers.push(httpServer); + } + let workerIngressPort: number | undefined; + const workerHttpServer = params.workerIngressEnabled + ? createHttpServer((_req, res) => { + res.statusCode = 404; + res.end("Not Found"); + }) + : undefined; + if (workerHttpServer) { + attachWorkerGatewayUpgradeHandler({ + httpServer: workerHttpServer, + wss, + preauthConnectionBudget: workerPreauthConnectionBudget, + log: params.log, + }); + } + const httpServer = gatewayHttpServers[0]; + if (!httpServer) { + throw new Error("Gateway HTTP server failed to start"); + } + let mcpAppSandboxPort: number | undefined; + let sandboxHostStartPromise: Promise | null = null; + let startListeningPromise: Promise | null = null; + let startListeningComplete = false; + const startSandboxHost = async (): Promise => { + if (sandboxHostStartPromise) { + return await sandboxHostStartPromise; + } + // MCP Apps retain their eager startup path. Board-only gateways defer the + // second listener until an admitted HTML widget actually needs isolation. + sandboxHostStartPromise = (async () => { + if (httpBindHosts.length === 0) { + throw new Error("Gateway listener must start before the sandbox host"); } - return await runWithGatewayHttpWorkAdmission(res, async () => { - if (!loadedHooksRequestHandler) { - // Hooks are cold for most gateway starts; create the handler only after a request - // matches the configured base path so startup avoids importing hook runtime code. - const { createGatewayHooksRequestHandler } = await import("./server/hooks.js"); - loadedHooksRequestHandler = createGatewayHooksRequestHandler({ - deps: params.deps, - getHooksConfig: params.hooksConfig, - getClientIpConfig: params.getHookClientIpConfig, - bindHost: params.bindHost, - port: params.port, - logHooks: params.logHooks, + const sandboxPort = resolveSandboxHostPort(params.port, params.cfg.mcp?.apps?.sandboxPort); + const sandboxServers = bindHosts.map(() => + createSandboxHostHttpServer( + params.gatewayTls?.enabled ? params.gatewayTls.tlsOptions : undefined, + ), + ); + // Register before binding so normal runtime cleanup closes a partially + // started multi-host listener after any later bind failure. + httpServers.push(...sandboxServers); + try { + for (const host of httpBindHosts) { + const index = bindHosts.indexOf(host); + const server = sandboxServers[index]; + if (!server) { + throw new Error(`Missing sandbox host HTTP server for bind host ${host}`); + } + await listenGatewayHttpServer({ + httpServer: server, + bindHost: host, + port: sandboxPort, + retryEaddrinuse: false, + serviceName: "MCP App sandbox", + endpointScheme: params.gatewayTls?.enabled ? "https" : "http", }); } - return await loadedHooksRequestHandler(req, res); - }); - }; - - let loadedPluginRequestHandler: GatewayPluginRequestHandler | null = null; - let loadedPluginUpgradeHandler: GatewayPluginUpgradeHandler | null = null; - const handlePluginRequest: GatewayPluginRequestHandler = async ( - req, - res, - pathContext, - dispatchContext, - ) => { - if (loadedPluginRequestHandler) { - return await loadedPluginRequestHandler(req, res, pathContext, dispatchContext); - } - const registry = resolvePluginRouteRegistry(); - if ((registry.httpRoutes ?? []).length === 0) { - return false; - } - // Route registries can be re-pinned after bootstrap; the loaded handler owns dynamic - // lookup, while this wrapper only avoids importing it for route-free gateways. - const { createGatewayPluginRequestHandler } = await loadGatewayPluginsHttpModule(); - loadedPluginRequestHandler = createGatewayPluginRequestHandler({ - registry: params.pluginRegistry, - getRouteRegistry: resolvePluginRouteRegistry, - log: params.logPlugins, - getGatewayRequestContext: params.getGatewayRequestContext, - }); - return await loadedPluginRequestHandler(req, res, pathContext, dispatchContext); - }; - const handlePluginUpgrade: GatewayPluginUpgradeHandler = async ( - req, - socket, - head, - pathContext, - dispatchContext, - ) => { - if (loadedPluginUpgradeHandler) { - return await loadedPluginUpgradeHandler(req, socket, head, pathContext, dispatchContext); - } - const registry = resolvePluginRouteRegistry(); - if ((registry.httpRoutes ?? []).length === 0) { - return false; - } - // WebSocket upgrades share the loaded handler's dynamic route registry, so reloads still - // follow the active snapshot without a duplicate wrapper lookup on every upgrade. - const { createGatewayPluginUpgradeHandler } = await loadGatewayPluginsHttpModule(); - loadedPluginUpgradeHandler = createGatewayPluginUpgradeHandler({ - registry: params.pluginRegistry, - getRouteRegistry: resolvePluginRouteRegistry, - log: params.logPlugins, - getGatewayRequestContext: params.getGatewayRequestContext, - }); - return await loadedPluginUpgradeHandler(req, socket, head, pathContext, dispatchContext); - }; - const shouldEnforcePluginGatewayAuth = (pathContext: PluginRoutePathContext): boolean => { - return shouldEnforceGatewayAuthForPluginPath(resolvePluginRouteRegistry(), pathContext); - }; - const resolvePluginNodeCapabilityRoute = (pathContext: PluginRoutePathContext) => { - const coreCanvasCapability = isCoreCanvasHostEnabled(loadRuntimeConfig()) - ? resolveCanvasNodeCapability(pathContext.candidates) - : undefined; - if (coreCanvasCapability) { - return coreCanvasCapability; - } - // Plugin capability routes follow the current pinned registry so auth and dispatch agree. - return findMatchingPluginNodeCapabilityRoute(resolvePluginRouteRegistry(), pathContext) - ?.nodeCapability; - }; - - const bindHosts = await resolveGatewayListenHosts(params.bindHost); - if (!isLoopbackHost(params.bindHost)) { - params.log.warn( - "⚠️ Gateway is binding to a non-loopback address. " + - "Ensure authentication is configured before exposing to public networks.", - ); - } - if (params.cfg.gateway?.controlUi?.dangerouslyAllowHostHeaderOriginFallback === true) { - params.log.warn( - "⚠️ gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true is enabled. " + - "Host-header origin fallback weakens origin checks and should only be used as break-glass.", - ); - } - // Create WebSocketServer first (with noServer: true) so we can attach upgrade handlers - // before HTTP servers start listening. This prevents a race condition where connections - // arrive before the upgrade handler is attached, which causes silent 1006 errors. - const wss = new WebSocketServer({ - noServer: true, - maxPayload: MAX_PREAUTH_PAYLOAD_BYTES, - }); - const preauthConnectionBudget = createPreauthConnectionBudget(); - const workerPreauthConnectionBudget = createPreauthConnectionBudget(); - - const httpServers: HttpServer[] = []; - const gatewayHttpServers: HttpServer[] = []; - const httpBindHosts: string[] = []; - for (const _ of bindHosts) { - const httpServer = createGatewayHttpServer({ - clients, - controlUiEnabled: params.controlUiEnabled, - controlUiBasePath: params.controlUiBasePath, - controlUiRoot: params.controlUiRoot, - openAiChatCompletionsEnabled: params.openAiChatCompletionsEnabled, - openAiChatCompletionsConfig: params.openAiChatCompletionsConfig, - openResponsesEnabled: params.openResponsesEnabled, - openResponsesConfig: params.openResponsesConfig, - strictTransportSecurityHeader: params.strictTransportSecurityHeader, - handleWatchNodeRequest: params.handleWatchNodeRequest, - handleHooksRequest, - handlePluginRequest, - shouldEnforcePluginGatewayAuth, - resolvePluginNodeCapabilityRoute, - resolvedAuth: params.resolvedAuth, - getResolvedAuth: params.getResolvedAuth, - rateLimiter: params.rateLimiter, - getReadiness: params.getReadiness, - getRuntimeConfig: loadRuntimeConfig, - isTerminalEnabled: params.isTerminalEnabled, - tlsOptions: params.gatewayTls?.enabled ? params.gatewayTls.tlsOptions : undefined, - }); - // Attach upgrade handler BEFORE listening to prevent race condition - attachGatewayUpgradeHandler({ - httpServer, - wss, - handlePluginUpgrade, - shouldEnforcePluginGatewayAuth, - resolvePluginNodeCapabilityRoute, - clients, - preauthConnectionBudget, - resolvedAuth: params.resolvedAuth, - getResolvedAuth: params.getResolvedAuth, - rateLimiter: params.rateLimiter, - log: params.log, - }); - gatewayHttpServers.push(httpServer); - httpServers.push(httpServer); - } - let workerIngressPort: number | undefined; - const workerHttpServer = params.workerIngressEnabled - ? createHttpServer((_req, res) => { - res.statusCode = 404; - res.end("Not Found"); - }) - : undefined; - if (workerHttpServer) { - attachWorkerGatewayUpgradeHandler({ - httpServer: workerHttpServer, - wss, - preauthConnectionBudget: workerPreauthConnectionBudget, - log: params.log, - }); - } - const httpServer = gatewayHttpServers[0]; - if (!httpServer) { - throw new Error("Gateway HTTP server failed to start"); - } - let mcpAppSandboxPort: number | undefined; - let sandboxHostStartPromise: Promise | null = null; - let startListeningPromise: Promise | null = null; - let startListeningComplete = false; - const startSandboxHost = async (): Promise => { - if (sandboxHostStartPromise) { - return await sandboxHostStartPromise; - } - // MCP Apps retain their eager startup path. Board-only gateways defer the - // second listener until an admitted HTML widget actually needs isolation. - sandboxHostStartPromise = (async () => { - if (httpBindHosts.length === 0) { - throw new Error("Gateway listener must start before the sandbox host"); - } - const sandboxPort = resolveSandboxHostPort(params.port, params.cfg.mcp?.apps?.sandboxPort); - const sandboxServers = bindHosts.map(() => - createSandboxHostHttpServer( - params.gatewayTls?.enabled ? params.gatewayTls.tlsOptions : undefined, + } catch (error) { + await Promise.all( + sandboxServers.map( + (server) => + new Promise((resolve) => { + if (!server.listening) { + resolve(); + return; + } + server.close(() => resolve()); + }), ), ); - // Register before binding so normal runtime cleanup closes a partially - // started multi-host listener after any later bind failure. - httpServers.push(...sandboxServers); - try { - for (const host of httpBindHosts) { - const index = bindHosts.indexOf(host); - const server = sandboxServers[index]; - if (!server) { - throw new Error(`Missing sandbox host HTTP server for bind host ${host}`); - } - await listenGatewayHttpServer({ - httpServer: server, - bindHost: host, - port: sandboxPort, - retryEaddrinuse: false, - serviceName: "MCP App sandbox", - endpointScheme: params.gatewayTls?.enabled ? "https" : "http", - }); + for (const server of sandboxServers) { + const index = httpServers.indexOf(server); + if (index >= 0) { + httpServers.splice(index, 1); } - } catch (error) { - await Promise.all( - sandboxServers.map( - (server) => - new Promise((resolve) => { - if (!server.listening) { - resolve(); - return; - } - server.close(() => resolve()); - }), - ), - ); - for (const server of sandboxServers) { - const index = httpServers.indexOf(server); - if (index >= 0) { - httpServers.splice(index, 1); - } - } - throw error; } - mcpAppSandboxPort = sandboxPort; - return sandboxPort; - })(); - const startAttempt = sandboxHostStartPromise; - void startAttempt.catch(() => { - // Lazy startup failures are recoverable: the next admitted widget may - // retry after an occupied port or other transient bind error clears. - if (sandboxHostStartPromise === startAttempt) { - sandboxHostStartPromise = null; - } - }); - return await startAttempt; - }; - const ensureSandboxHostPort = async (): Promise => { - if (!startListeningComplete) { - if (!startListeningPromise) { - throw new Error("Gateway listener must start before the sandbox host"); - } - // Gateway sockets begin accepting independently. Wait for every bind - // host before freezing the shared sandbox listener set. - await startListeningPromise; + throw error; } - return await startSandboxHost(); - }; - const startListening = async (): Promise => { - if (startListeningPromise) { - await startListeningPromise; - return; + mcpAppSandboxPort = sandboxPort; + return sandboxPort; + })(); + const startAttempt = sandboxHostStartPromise; + void startAttempt.catch(() => { + // Lazy startup failures are recoverable: the next admitted widget may + // retry after an occupied port or other transient bind error clears. + if (sandboxHostStartPromise === startAttempt) { + sandboxHostStartPromise = null; } - // Listening is idempotent for callers racing startup. A failure is terminal for this runtime - // state; the startup owner tears down every partially bound HTTP/WS server before retrying. - startListeningPromise = (async () => { - const requiredAlias = - params.bindHost !== "127.0.0.1" && bindHosts.includes("127.0.0.1") - ? "127.0.0.1" - : undefined; - // Claim the trusted local endpoint before exposing the selected interface. This prevents - // another loopback listener from receiving credentials while startup is still resolving. - const listenOrder = requiredAlias - ? [requiredAlias, ...bindHosts.filter((host) => host !== requiredAlias)] - : bindHosts; - const boundHosts = new Set(); - for (const host of listenOrder) { - const index = bindHosts.indexOf(host); - const server = gatewayHttpServers[index]; - if (!server) { - throw new Error(`Missing gateway HTTP server for bind host ${host}`); - } - // Specific IPv4 modes rely on this canonical local endpoint for authenticated - // helpers. A collision must fail startup instead of sending credentials to it. - const requiredLoopbackAlias = host === requiredAlias; - try { - await listenGatewayHttpServer({ - httpServer: server, - bindHost: host, - port: params.port, - retryEaddrinuse: !requiredLoopbackAlias, - }); - boundHosts.add(host); - } catch (err) { - if (host === bindHosts[0] || requiredLoopbackAlias) { - throw err; - } - params.log.warn( - `gateway: failed to bind loopback alias ${host}:${params.port} (${String(err)})`, - ); - } - } - httpBindHosts.push(...bindHosts.filter((host) => boundHosts.has(host))); - if (httpBindHosts.length === 0) { - throw new Error("Gateway HTTP server failed to start"); - } - if (params.cfg.mcp?.apps?.enabled === true) { - await startSandboxHost(); - } - if (workerHttpServer) { - await listenGatewayHttpServer({ - httpServer: workerHttpServer, - bindHost: "127.0.0.1", - port: 0, - retryEaddrinuse: false, - }); - const address = workerHttpServer.address() as AddressInfo | null; - if (!address || typeof address === "string") { - throw new Error("Worker gateway ingress failed to resolve its loopback port"); - } - workerIngressPort = address.port; - httpServers.push(workerHttpServer); - } - startListeningComplete = true; - })(); + }); + return await startAttempt; + }; + const ensureSandboxHostPort = async (): Promise => { + if (!startListeningComplete) { + if (!startListeningPromise) { + throw new Error("Gateway listener must start before the sandbox host"); + } + // Gateway sockets begin accepting independently. Wait for every bind + // host before freezing the shared sandbox listener set. await startListeningPromise; - }; - const agentRunSeq = new Map(); - const dedupe = new Map(); - const chatRunState = createChatRunState(); - const chatRunRegistry = chatRunState.registry; - const addChatRun = chatRunRegistry.add; - const removeChatRun = chatRunRegistry.remove; - const chatAbortControllers = new Map(); - const chatQueuedTurns = new Map(); - const toolEventRecipients = chatRunState.toolEventRecipients; + } + return await startSandboxHost(); + }; + const startListening = async (): Promise => { + if (startListeningPromise) { + await startListeningPromise; + return; + } + // Listening is idempotent for callers racing startup. A failure is terminal for this runtime + // state; the startup owner tears down every partially bound HTTP/WS server before retrying. + startListeningPromise = (async () => { + const requiredAlias = + params.bindHost !== "127.0.0.1" && bindHosts.includes("127.0.0.1") + ? "127.0.0.1" + : undefined; + // Claim the trusted local endpoint before exposing the selected interface. This prevents + // another loopback listener from receiving credentials while startup is still resolving. + const listenOrder = requiredAlias + ? [requiredAlias, ...bindHosts.filter((host) => host !== requiredAlias)] + : bindHosts; + const boundHosts = new Set(); + for (const host of listenOrder) { + const index = bindHosts.indexOf(host); + const server = gatewayHttpServers[index]; + if (!server) { + throw new Error(`Missing gateway HTTP server for bind host ${host}`); + } + // Specific IPv4 modes rely on this canonical local endpoint for authenticated + // helpers. A collision must fail startup instead of sending credentials to it. + const requiredLoopbackAlias = host === requiredAlias; + try { + await listenGatewayHttpServer({ + httpServer: server, + bindHost: host, + port: params.port, + retryEaddrinuse: !requiredLoopbackAlias, + }); + boundHosts.add(host); + } catch (err) { + if (host === bindHosts[0] || requiredLoopbackAlias) { + throw err; + } + params.log.warn( + `gateway: failed to bind loopback alias ${host}:${params.port} (${String(err)})`, + ); + } + } + httpBindHosts.push(...bindHosts.filter((host) => boundHosts.has(host))); + if (httpBindHosts.length === 0) { + throw new Error("Gateway HTTP server failed to start"); + } + if (params.cfg.mcp?.apps?.enabled === true) { + await startSandboxHost(); + } + if (workerHttpServer) { + await listenGatewayHttpServer({ + httpServer: workerHttpServer, + bindHost: "127.0.0.1", + port: 0, + retryEaddrinuse: false, + }); + const address = workerHttpServer.address() as AddressInfo | null; + if (!address || typeof address === "string") { + throw new Error("Worker gateway ingress failed to resolve its loopback port"); + } + workerIngressPort = address.port; + httpServers.push(workerHttpServer); + } + startListeningComplete = true; + })(); + await startListeningPromise; + }; + const agentRunSeq = new Map(); + const dedupe = new Map(); + const chatRunState = createChatRunState(); + const chatRunRegistry = chatRunState.registry; + const addChatRun = chatRunRegistry.add; + const removeChatRun = chatRunRegistry.remove; + const chatAbortControllers = new Map(); + const chatQueuedTurns = new Map(); + const toolEventRecipients = chatRunState.toolEventRecipients; - return { - releasePluginRouteRegistry: () => { - // Releases pinned HTTP-route, session-extension, and channel registries. - // Startup/reload can re-pin them to a registry that differs from bootstrap. - releasePinnedPluginHttpRouteRegistry(); - releasePinnedPluginSessionExtensionRegistry(); - // Release unconditionally (no registry arg): the channel pin may have - // been re-pinned to a deferred-reload registry that differs from the - // original params.pluginRegistry, so an identity-guarded release would - // be a no-op and leak the pin across in-process restarts. - releasePinnedPluginChannelRegistry(); - }, - httpServer, - httpServers, - httpBindHosts, - startListening, - wss, - preauthConnectionBudget, - clients, - ...gatewayBroadcaster, - agentRunSeq, - dedupe, - chatRunState, - addChatRun, - removeChatRun, - chatAbortControllers, - chatQueuedTurns, - toolEventRecipients, - sessionEventSubscribers, - sessionMessageSubscribers, - getWorkerIngressEndpoint: () => - workerIngressPort === undefined - ? undefined - : { host: "127.0.0.1" as const, port: workerIngressPort }, - getMcpAppSandboxPort: () => mcpAppSandboxPort, - ensureSandboxHostPort, - }; - } catch (err) { - // If state creation fails after pins are installed, release them immediately so later - // in-process gateway starts do not inherit a half-created plugin runtime. - releasePinnedPluginHttpRouteRegistry(); - releasePinnedPluginSessionExtensionRegistry(); - releasePinnedPluginChannelRegistry(); - throw err; - } + return { + httpServer, + httpServers, + httpBindHosts, + startListening, + wss, + preauthConnectionBudget, + clients, + ...gatewayBroadcaster, + agentRunSeq, + dedupe, + chatRunState, + addChatRun, + removeChatRun, + chatAbortControllers, + chatQueuedTurns, + toolEventRecipients, + sessionEventSubscribers, + sessionMessageSubscribers, + getWorkerIngressEndpoint: () => + workerIngressPort === undefined + ? undefined + : { host: "127.0.0.1" as const, port: workerIngressPort }, + getMcpAppSandboxPort: () => mcpAppSandboxPort, + ensureSandboxHostPort, + }; } diff --git a/src/gateway/server-startup-post-attach.test.ts b/src/gateway/server-startup-post-attach.test.ts index 3a6c9081ceda..d7f9023daf27 100644 --- a/src/gateway/server-startup-post-attach.test.ts +++ b/src/gateway/server-startup-post-attach.test.ts @@ -64,7 +64,7 @@ const hoisted = vi.hoisted(() => { const refreshPreparedModelRuntimeSnapshots = vi.fn( async (_cfg?: unknown, _options?: unknown) => {}, ); - const installAgentRuntimePluginRegistryAtProcessRoot = vi.fn(); + const loadAgentRuntimePluginRegistryHandle = vi.fn(); const ensureContextWindowCacheLoaded = vi.fn(async () => {}); const scheduleGatewayHandlerPrewarm = vi.fn(() => ({ stop: vi.fn() })); const clearCurrentProviderAuthState = vi.fn(); @@ -104,7 +104,7 @@ const hoisted = vi.hoisted(() => { getModelRefStatus, prepareModelRuntimeSnapshot, refreshPreparedModelRuntimeSnapshots, - installAgentRuntimePluginRegistryAtProcessRoot, + loadAgentRuntimePluginRegistryHandle, ensureContextWindowCacheLoaded, scheduleGatewayHandlerPrewarm, clearCurrentProviderAuthState, @@ -211,8 +211,7 @@ vi.mock("../agents/prepared-model-runtime.js", () => ({ })); vi.mock("../agents/runtime-plugins.js", () => ({ - installAgentRuntimePluginRegistryAtProcessRoot: - hoisted.installAgentRuntimePluginRegistryAtProcessRoot, + loadAgentRuntimePluginRegistryHandle: hoisted.loadAgentRuntimePluginRegistryHandle, })); vi.mock("../agents/context.js", () => ({ @@ -366,7 +365,7 @@ describe("startGatewayPostAttachRuntime", () => { hoisted.prepareModelRuntimeSnapshot.mockResolvedValue({}); hoisted.refreshPreparedModelRuntimeSnapshots.mockReset(); hoisted.refreshPreparedModelRuntimeSnapshots.mockResolvedValue(undefined); - hoisted.installAgentRuntimePluginRegistryAtProcessRoot.mockReset(); + hoisted.loadAgentRuntimePluginRegistryHandle.mockReset(); hoisted.ensureContextWindowCacheLoaded.mockReset(); hoisted.ensureContextWindowCacheLoaded.mockResolvedValue(undefined); hoisted.scheduleGatewayHandlerPrewarm.mockClear(); @@ -1157,17 +1156,17 @@ describe("startGatewayPostAttachRuntime", () => { await new Promise((resolve) => { setImmediate(resolve); }); - expect(hoisted.installAgentRuntimePluginRegistryAtProcessRoot).not.toHaveBeenCalled(); + expect(hoisted.loadAgentRuntimePluginRegistryHandle).not.toHaveBeenCalled(); releaseGatewayReady(); await waitForGatewayTestState(() => { - expect(hoisted.installAgentRuntimePluginRegistryAtProcessRoot).toHaveBeenCalledWith({ + expect(hoisted.loadAgentRuntimePluginRegistryHandle).toHaveBeenCalledWith({ config: currentConfig, workspaceDir: "/tmp/openclaw-workspace", allowGatewaySubagentBinding: true, }); }); - expect(hoisted.installAgentRuntimePluginRegistryAtProcessRoot).not.toHaveBeenCalledWith( + expect(hoisted.loadAgentRuntimePluginRegistryHandle).not.toHaveBeenCalledWith( expect.objectContaining({ config: startupConfig }), ); }); diff --git a/src/gateway/server-startup-post-attach.ts b/src/gateway/server-startup-post-attach.ts index 97d0a207712f..208a61008fda 100644 --- a/src/gateway/server-startup-post-attach.ts +++ b/src/gateway/server-startup-post-attach.ts @@ -328,13 +328,13 @@ function scheduleAgentRuntimePluginPrewarm(params: { return; } const started = performance.now(); - const { installAgentRuntimePluginRegistryAtProcessRoot } = + const { loadAgentRuntimePluginRegistryHandle } = await import("../agents/runtime-plugins.js"); const cfg = params.getConfig(); if (isStopped()) { return; } - installAgentRuntimePluginRegistryAtProcessRoot({ + loadAgentRuntimePluginRegistryHandle({ config: cfg, workspaceDir: params.workspaceDir, allowGatewaySubagentBinding: true, diff --git a/src/gateway/server.sessions.list-changed.test.ts b/src/gateway/server.sessions.list-changed.test.ts index 3d66ee6eb7e6..6911692477f4 100644 --- a/src/gateway/server.sessions.list-changed.test.ts +++ b/src/gateway/server.sessions.list-changed.test.ts @@ -3,26 +3,16 @@ */ import { expectDefined } from "@openclaw/normalization-core"; -import { - createPluginRegistryFixture, - registerTestPlugin, -} from "openclaw/plugin-sdk/plugin-test-contracts"; import { afterEach, expect, test, vi } from "vitest"; import { loadSessionEntry } from "../config/sessions/session-accessor.js"; import { subscribePluginSessionsChanged } from "../plugins/gateway-events.js"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; -import { - pinActivePluginSessionExtensionRegistry, - releasePinnedPluginSessionExtensionRegistry, - setActivePluginRegistry, -} from "../plugins/runtime.js"; -import { createPluginRecord } from "../plugins/status.test-fixtures.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; import { normalizeSessionDeliveryState, projectSessionDeliveryFields, } from "../utils/delivery-context.shared.js"; import { createGatewayBroadcaster } from "./server-broadcast.js"; -import { buildGatewaySessionRow } from "./session-utils.js"; import { embeddedRunMock, rpcReq, testState, writeSessionStore } from "./test-helpers.js"; import { setupGatewaySessionsTestHarness, @@ -42,7 +32,6 @@ const { } = setupGatewaySessionsTestHarness(); afterEach(() => { - releasePinnedPluginSessionExtensionRegistry(); setActivePluginRegistry(createEmptyPluginRegistry()); }); @@ -230,78 +219,6 @@ function expectMainPatchBroadcast( }); } -test("sessions.pluginPatch over WebSocket keeps pinned startup extensions after active churn", async () => { - const { config, registry } = createPluginRegistryFixture(); - registerTestPlugin({ - registry, - config, - record: createPluginRecord({ - id: "session-pin-ws-fixture", - name: "Session Pin WS Fixture", - }), - register(api) { - api.registerSessionExtension({ - namespace: "workflow", - description: "Pinned workflow state", - }); - }, - }); - setActivePluginRegistry(registry.registry); - pinActivePluginSessionExtensionRegistry(registry.registry); - setActivePluginRegistry(createEmptyPluginRegistry()); - - const { storePath } = await createSessionStoreDir(); - await writeSessionStore({ - entries: { - main: sessionStoreEntry("sess-main"), - }, - }); - - const { ws } = await openClient(); - const patched = await rpcReq<{ ok: boolean; key: string; value: { state: string } }>( - ws, - "sessions.pluginPatch", - { - key: "main", - pluginId: "session-pin-ws-fixture", - namespace: "workflow", - value: { state: "after-active-registry-churn" }, - }, - ); - ws.close(); - - expect(patched.ok).toBe(true); - expect(patched.payload).toEqual({ - ok: true, - key: "agent:main:main", - value: { state: "after-active-registry-churn" }, - }); - - const entry = loadSessionEntry({ - agentId: "main", - sessionKey: "agent:main:main", - storePath, - }); - expect(entry).toBeDefined(); - if (!entry) { - throw new Error("expected persisted session entry"); - } - const row = buildGatewaySessionRow({ - cfg: { session: { store: storePath } }, - entry, - key: "agent:main:main", - store: { "agent:main:main": entry }, - storePath, - }); - expect(row.pluginExtensions).toEqual([ - { - pluginId: "session-pin-ws-fixture", - namespace: "workflow", - value: { state: "after-active-registry-churn" }, - }, - ]); -}); - async function invokeSessionsCompact({ getRuntimeConfig, params, diff --git a/src/gateway/server.startup-websocket-race.test.ts b/src/gateway/server.startup-websocket-race.test.ts index 7f56a6f24dea..2243c344d058 100644 --- a/src/gateway/server.startup-websocket-race.test.ts +++ b/src/gateway/server.startup-websocket-race.test.ts @@ -59,7 +59,6 @@ describe("gateway startup websocket readiness", () => { expect(runtimeState.httpBindHosts).toEqual([]); expect(runtimeState.httpServer.listenerCount("upgrade")).toBeGreaterThan(0); } finally { - runtimeState.releasePluginRouteRegistry(); runtimeState.wss.close(); } }); diff --git a/src/gateway/server/plugins-http.runtime-scopes.test.ts b/src/gateway/server/plugins-http.runtime-scopes.test.ts index e4d0efb40266..788b9e192ed0 100644 --- a/src/gateway/server/plugins-http.runtime-scopes.test.ts +++ b/src/gateway/server/plugins-http.runtime-scopes.test.ts @@ -5,11 +5,7 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { SubsystemLogger } from "../../logging/subsystem.js"; import { createEmptyPluginRegistry } from "../../plugins/registry.js"; -import { - pinActivePluginHttpRouteRegistry, - releasePinnedPluginHttpRouteRegistry, - setActivePluginRegistry, -} from "../../plugins/runtime.js"; +import { setActivePluginRegistry } from "../../plugins/runtime.js"; import { getPluginRuntimeGatewayRequestScope } from "../../plugins/runtime/gateway-request-scope.js"; import { ExecApprovalManager } from "../exec-approval-manager.js"; import type { AuthorizedGatewayHttpRequest } from "../http-utils.js"; @@ -141,7 +137,6 @@ function expectMissingWriteScopeFailure(params: { describe("plugin HTTP route runtime scopes", () => { afterEach(() => { - releasePinnedPluginHttpRouteRegistry(); setActivePluginRegistry(createEmptyPluginRegistry()); }); @@ -280,7 +275,6 @@ describe("plugin HTTP route runtime scopes", () => { }); setActivePluginRegistry(serverBRegistry); - pinActivePluginHttpRouteRegistry(serverBRegistry); const handlerA = createGatewayPluginRequestHandler({ registry: serverARegistry, diff --git a/src/gateway/server/plugins-http.test.ts b/src/gateway/server/plugins-http.test.ts index 0b5e58d71d88..84acf21f96e8 100644 --- a/src/gateway/server/plugins-http.test.ts +++ b/src/gateway/server/plugins-http.test.ts @@ -2,13 +2,8 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import type { Duplex } from "node:stream"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { registerPluginHttpRoute } from "../../plugins/http-registry.js"; import { createEmptyPluginRegistry } from "../../plugins/registry.js"; -import { - pinActivePluginHttpRouteRegistry, - releasePinnedPluginHttpRouteRegistry, - setActivePluginRegistry, -} from "../../plugins/runtime.js"; +import { setActivePluginRegistry } from "../../plugins/runtime.js"; import { getPluginRuntimeGatewayRequestScope } from "../../plugins/runtime/gateway-request-scope.js"; import { makeMockHttpResponse } from "../test-http-response.js"; import { createTestRegistry } from "./__tests__/test-utils.js"; @@ -21,7 +16,6 @@ import { type PluginHandlerLog = Parameters[0]["log"]; -const IMESSAGE_WEBHOOK_PATH = "/imessage-webhook"; const CANVAS_WS_PATH = "/__openclaw__/canvas/ws"; function createPluginLog(): PluginHandlerLog { @@ -153,20 +147,6 @@ async function invokeRouteAndCollectRuntimeScopes(params: { return { handled, observedScopes, ...response }; } -async function invokeImessageWebhook(params: { - registry: ReturnType; - getRouteRegistry?: () => ReturnType; -}) { - const handler = createGatewayPluginRequestHandler({ - registry: params.registry, - ...(params.getRouteRegistry ? { getRouteRegistry: params.getRouteRegistry } : {}), - log: createPluginLog(), - }); - const { res } = makeMockHttpResponse(); - const handled = await handler({ url: IMESSAGE_WEBHOOK_PATH } as IncomingMessage, res); - return { handled, res }; -} - async function invokeCanvasGatewayUpgrade(params: { gatewayAuthSatisfied: boolean }) { const routeUpgradeHandler = vi.fn(async () => true); const handler = createGatewayPluginUpgradeHandler({ @@ -197,7 +177,6 @@ async function invokeCanvasGatewayUpgrade(params: { gatewayAuthSatisfied: boolea describe("createGatewayPluginRequestHandler", () => { afterEach(() => { - releasePinnedPluginHttpRouteRegistry(); setActivePluginRegistry(createEmptyPluginRegistry()); }); @@ -346,14 +325,10 @@ describe("createGatewayPluginRequestHandler", () => { res.statusCode = 200; return true; }); - const startupRegistry = createTestRegistry(); const explicitRegistry = createTestRegistry({ httpRoutes: [createRoute({ path: "/demo", auth: "plugin", handler: explicitRouteHandler })], }); - setActivePluginRegistry(startupRegistry); - pinActivePluginHttpRouteRegistry(startupRegistry); - const handler = createGatewayPluginRequestHandler({ registry: explicitRegistry, log: createPluginLog(), @@ -365,67 +340,6 @@ describe("createGatewayPluginRequestHandler", () => { expect(explicitRouteHandler).toHaveBeenCalledTimes(1); }); - it("handles routes registered into the pinned startup registry after the active registry changes", async () => { - const startupRegistry = createTestRegistry(); - const laterActiveRegistry = createTestRegistry(); - const routeHandler = vi.fn(async (_req, res: ServerResponse) => { - res.statusCode = 202; - return true; - }); - - setActivePluginRegistry(startupRegistry); - pinActivePluginHttpRouteRegistry(startupRegistry); - setActivePluginRegistry(laterActiveRegistry); - - const unregister = registerPluginHttpRoute({ - path: IMESSAGE_WEBHOOK_PATH, - auth: "plugin", - handler: routeHandler, - }); - - try { - const { handled } = await invokeImessageWebhook({ registry: startupRegistry }); - expect(handled).toBe(true); - expect(routeHandler).toHaveBeenCalledTimes(1); - expect(laterActiveRegistry.httpRoutes).toHaveLength(0); - } finally { - unregister(); - } - }); - - it("prefers the server-local route registry resolver over a stale explicit registry", async () => { - const startupRegistry = createTestRegistry(); - const staleExplicitRegistry = createTestRegistry({ - httpRoutes: [createRoute({ path: "/plugins/diffs", auth: "plugin" })], - }); - const routeHandler = vi.fn(async (_req, res: ServerResponse) => { - res.statusCode = 204; - return true; - }); - - setActivePluginRegistry(createTestRegistry()); - pinActivePluginHttpRouteRegistry(startupRegistry); - - const unregister = registerPluginHttpRoute({ - path: IMESSAGE_WEBHOOK_PATH, - auth: "plugin", - handler: routeHandler, - }); - - try { - const { handled } = await invokeImessageWebhook({ - registry: staleExplicitRegistry, - getRouteRegistry: () => startupRegistry, - }); - expect(handled).toBe(true); - expect(routeHandler).toHaveBeenCalledTimes(1); - expect(staleExplicitRegistry.httpRoutes).toHaveLength(1); - expect(startupRegistry.httpRoutes).toHaveLength(1); - } finally { - unregister(); - } - }); - it("logs and responds with 500 when a route throws", async () => { const log = createPluginLog(); const handler = createGatewayPluginRequestHandler({ @@ -606,7 +520,6 @@ describe("createGatewayPluginRequestHandler", () => { describe("createGatewayPluginUpgradeHandler", () => { afterEach(() => { - releasePinnedPluginHttpRouteRegistry(); setActivePluginRegistry(createEmptyPluginRegistry()); }); diff --git a/src/gateway/test-helpers.server.ts b/src/gateway/test-helpers.server.ts index 67eadcb80052..2e379d3e3908 100644 --- a/src/gateway/test-helpers.server.ts +++ b/src/gateway/test-helpers.server.ts @@ -40,7 +40,6 @@ import { drainSystemEvents, peekSystemEvents } from "../infra/system-events.js"; import { rawDataToString } from "../infra/ws.js"; import { resetLogger, setLoggerOverride } from "../logging.js"; import type { ChannelRouteRef } from "../plugin-sdk/channel-route.js"; -import { clearGatewaySubagentRuntime } from "../plugins/runtime/gateway-bindings.test-fixtures.js"; import { resetGatewayWorkAdmission } from "../process/gateway-work-admission.js"; import { LEGACY_IMPLICIT_AGENT_ID as DEFAULT_AGENT_ID, @@ -416,7 +415,6 @@ async function resetGatewayTestState(options: { uniqueConfigRoot: boolean }) { resetConfigRuntimeState(); invalidateSessionSharingSnapshot(); resetTestPluginRegistry(); - clearGatewaySubagentRuntime(); testTailnetIPv4.value = undefined; testTailscaleWhois.value = null; testState.gatewayBind = DEFAULT_GATEWAY_TEST_BIND; @@ -482,7 +480,6 @@ async function resetGatewayTestState(options: { uniqueConfigRoot: boolean }) { async function cleanupGatewayTestHome(options: { restoreEnv: boolean }) { vi.useRealTimers(); resetGatewayLifecycleTestState({ preserveRuntimeBindings: activeSuiteGatewayServerCount > 0 }); - clearGatewaySubagentRuntime(); resetLogger(); resetTaskRegistryForTests({ persist: false }); resetTaskFlowRegistryForTests({ persist: false }); @@ -515,7 +512,6 @@ async function resetGatewayTestRuntimeOnly() { resetConfigRuntimeState(); invalidateSessionSharingSnapshot(); resetTestPluginRegistry(); - clearGatewaySubagentRuntime(); testTailnetIPv4.value = undefined; testTailscaleWhois.value = null; testState.gatewayBind = DEFAULT_GATEWAY_TEST_BIND; diff --git a/src/hooks/internal-hooks.test.ts b/src/hooks/internal-hooks.test.ts index a6c2ae211023..cf2b8ed3fce8 100644 --- a/src/hooks/internal-hooks.test.ts +++ b/src/hooks/internal-hooks.test.ts @@ -1,11 +1,7 @@ // Internal hook tests cover dispatch for command, session, agent, and gateway hooks. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; -import { - pinActivePluginChannelRegistry, - resetPluginRuntimeStateForTest, - setActivePluginRegistry, -} from "../plugins/runtime.js"; +import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js"; import { resolveGlobalSingleton } from "../shared/global-singleton.js"; import { clearInternalHooks, @@ -517,29 +513,19 @@ describe("hooks", () => { expect(keys).toStrictEqual([]); }); - it("removes legacy hooks from active and pinned plugin registries", () => { - const pinned = createEmptyPluginRegistry(); + it("removes legacy hooks from the active plugin registry", () => { const active = createEmptyPluginRegistry(); - pinned.legacyInternalHooks.push({ - pluginId: "pinned-plugin", - name: "pinned-plugin", - event: "command:new", - handler: vi.fn(), - }); active.legacyInternalHooks.push({ pluginId: "active-plugin", name: "active-plugin", event: "command:stop", handler: vi.fn(), }); - setActivePluginRegistry(pinned); - pinActivePluginChannelRegistry(pinned); setActivePluginRegistry(active); clearInternalHooks(); expect(active.legacyInternalHooks).toStrictEqual([]); - expect(pinned.legacyInternalHooks).toStrictEqual([]); expect(getRegisteredEventKeys()).toStrictEqual([]); }); }); diff --git a/src/infra/outbound/channel-bootstrap.runtime.test.ts b/src/infra/outbound/channel-bootstrap.runtime.test.ts index 625ae60c3944..a9f3644d99cf 100644 --- a/src/infra/outbound/channel-bootstrap.runtime.test.ts +++ b/src/infra/outbound/channel-bootstrap.runtime.test.ts @@ -4,21 +4,27 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { createEmptyPluginRegistry } from "../../plugins/registry-empty.js"; import { - pinActivePluginChannelRegistry, + getActivePluginRegistry, resetPluginRuntimeStateForTest, setActivePluginRegistry, } from "../../plugins/runtime.js"; const loaderMocks = vi.hoisted(() => ({ - resolveRuntimePluginRegistry: vi.fn(), + loadPluginRegistryHandle: vi.fn(), + resolveDiscoverableScopedChannelPluginIds: vi.fn(() => ["discord"]), +})); + +vi.mock("../../plugins/channel-plugin-ids.js", () => ({ + resolveDiscoverableScopedChannelPluginIds: loaderMocks.resolveDiscoverableScopedChannelPluginIds, })); vi.mock("../../plugins/loader.js", () => ({ - resolveRuntimePluginRegistry: loaderMocks.resolveRuntimePluginRegistry, + loadPluginRegistryHandle: loaderMocks.loadPluginRegistryHandle, })); const { bootstrapOutboundChannelPlugin, resetOutboundChannelBootstrapStateForTests } = await import("./channel-bootstrap.runtime.js"); +const { resolveOutboundDurableFinalDeliverySupport } = await import("./deliver-channel.js"); const discordConfig = { channels: { @@ -32,7 +38,7 @@ const updatedDiscordConfig = { }, } satisfies OpenClawConfig; -function pinDiscordSetupShell(): void { +function installDiscordSetupShell(): void { const registry = createEmptyPluginRegistry(); registry.channels = [ { @@ -42,25 +48,25 @@ function pinDiscordSetupShell(): void { }, ] as never; setActivePluginRegistry(registry); - pinActivePluginChannelRegistry(registry); } describe("bootstrapOutboundChannelPlugin", () => { afterEach(() => { - loaderMocks.resolveRuntimePluginRegistry.mockReset(); + loaderMocks.loadPluginRegistryHandle.mockReset(); + loaderMocks.resolveDiscoverableScopedChannelPluginIds.mockClear(); resetOutboundChannelBootstrapStateForTests(); resetPluginRuntimeStateForTest(); }); it("bootstraps when the selected channel registry has only a setup shell", () => { - pinDiscordSetupShell(); + installDiscordSetupShell(); bootstrapOutboundChannelPlugin({ channel: "discord", cfg: discordConfig, }); - expect(loaderMocks.resolveRuntimePluginRegistry).toHaveBeenCalledTimes(1); + expect(loaderMocks.loadPluginRegistryHandle).toHaveBeenCalledTimes(1); }); it("skips bootstrap when the selected channel entry can already send", () => { @@ -77,27 +83,20 @@ describe("bootstrapOutboundChannelPlugin", () => { }, ] as never; setActivePluginRegistry(registry); - pinActivePluginChannelRegistry(registry); bootstrapOutboundChannelPlugin({ channel: "discord", cfg: discordConfig, }); - expect(loaderMocks.resolveRuntimePluginRegistry).not.toHaveBeenCalled(); + expect(loaderMocks.loadPluginRegistryHandle).not.toHaveBeenCalled(); }); - it("skips bootstrap when the active replacement registry can send for a pinned setup shell", () => { - const setup = createEmptyPluginRegistry(); - setup.channels = [ - { - pluginId: "discord", - plugin: { id: "discord", meta: {} }, - source: "setup", - }, - ] as never; - const runtime = createEmptyPluginRegistry(); - runtime.channels = [ + it("returns a scoped handle without replacing the process root", () => { + installDiscordSetupShell(); + const root = getActivePluginRegistry(); + const handle = createEmptyPluginRegistry(); + handle.channels = [ { pluginId: "discord", plugin: { @@ -108,99 +107,85 @@ describe("bootstrapOutboundChannelPlugin", () => { source: "runtime", }, ] as never; - setActivePluginRegistry(setup); - pinActivePluginChannelRegistry(setup); - setActivePluginRegistry(runtime); + loaderMocks.loadPluginRegistryHandle.mockReturnValue(handle); - bootstrapOutboundChannelPlugin({ - channel: "discord", - cfg: discordConfig, - }); - - expect(loaderMocks.resolveRuntimePluginRegistry).not.toHaveBeenCalled(); + expect(bootstrapOutboundChannelPlugin({ channel: "discord", cfg: discordConfig })).toBe(handle); + expect(getActivePluginRegistry()).toBe(root); + expect(loaderMocks.loadPluginRegistryHandle).toHaveBeenCalledWith( + expect.objectContaining({ onlyPluginIds: ["discord"] }), + ); }); - it("skips bootstrap when the active replacement registry has a message send surface", () => { - const setup = createEmptyPluginRegistry(); - setup.channels = [ - { - pluginId: "discord", - plugin: { id: "discord", meta: {} }, - source: "setup", - }, - ] as never; - const runtime = createEmptyPluginRegistry(); - runtime.channels = [ + it("resolves durable message capabilities inside the scoped handle", async () => { + installDiscordSetupShell(); + const handle = createEmptyPluginRegistry(); + handle.channels = [ { pluginId: "discord", plugin: { id: "discord", meta: {}, - message: { send: { text: async () => ({ messageId: "1" }) } }, + message: { + durableFinal: { capabilities: { text: true, silent: true } }, + send: { text: async () => ({ messageId: "1" }) }, + }, }, source: "runtime", }, ] as never; - setActivePluginRegistry(setup); - pinActivePluginChannelRegistry(setup); - setActivePluginRegistry(runtime); + loaderMocks.loadPluginRegistryHandle.mockReturnValue(handle); - bootstrapOutboundChannelPlugin({ - channel: "discord", - cfg: discordConfig, - }); - - expect(loaderMocks.resolveRuntimePluginRegistry).not.toHaveBeenCalled(); + await expect( + resolveOutboundDurableFinalDeliverySupport({ + channel: "discord", + cfg: discordConfig, + requirements: { text: true, silent: true }, + }), + ).resolves.toEqual({ ok: true, automaticUnknownSendReconciliation: false }); }); - it("does not retry an unusable replacement registry in the same generation", () => { - pinDiscordSetupShell(); - loaderMocks.resolveRuntimePluginRegistry.mockImplementation(() => { - setActivePluginRegistry(createEmptyPluginRegistry()); - }); + it("does not retry an unusable handle in the same generation", () => { + installDiscordSetupShell(); + loaderMocks.loadPluginRegistryHandle.mockReturnValue(createEmptyPluginRegistry()); - bootstrapOutboundChannelPlugin({ - channel: "discord", - cfg: discordConfig, - }); - bootstrapOutboundChannelPlugin({ - channel: "discord", - cfg: discordConfig, - }); + expect( + bootstrapOutboundChannelPlugin({ channel: "discord", cfg: discordConfig }), + ).toBeUndefined(); + expect( + bootstrapOutboundChannelPlugin({ channel: "discord", cfg: discordConfig }), + ).toBeUndefined(); - expect(loaderMocks.resolveRuntimePluginRegistry).toHaveBeenCalledTimes(1); + expect(loaderMocks.loadPluginRegistryHandle).toHaveBeenCalledTimes(1); }); it("does not retry a thrown bootstrap in the same generation", () => { - pinDiscordSetupShell(); - loaderMocks.resolveRuntimePluginRegistry.mockImplementation(() => { + installDiscordSetupShell(); + loaderMocks.loadPluginRegistryHandle.mockImplementation(() => { throw new Error("load failed"); }); bootstrapOutboundChannelPlugin({ channel: "discord", cfg: discordConfig }); bootstrapOutboundChannelPlugin({ channel: "discord", cfg: discordConfig }); - expect(loaderMocks.resolveRuntimePluginRegistry).toHaveBeenCalledTimes(1); + expect(loaderMocks.loadPluginRegistryHandle).toHaveBeenCalledTimes(1); }); it("retries after the runtime config changes", () => { - pinDiscordSetupShell(); + installDiscordSetupShell(); bootstrapOutboundChannelPlugin({ channel: "discord", cfg: discordConfig }); bootstrapOutboundChannelPlugin({ channel: "discord", cfg: updatedDiscordConfig }); - expect(loaderMocks.resolveRuntimePluginRegistry).toHaveBeenCalledTimes(2); + expect(loaderMocks.loadPluginRegistryHandle).toHaveBeenCalledTimes(2); }); it("retains failed attempts when distinct runtime configs interleave", () => { - pinDiscordSetupShell(); - loaderMocks.resolveRuntimePluginRegistry.mockImplementation(() => { - setActivePluginRegistry(createEmptyPluginRegistry()); - }); + installDiscordSetupShell(); + loaderMocks.loadPluginRegistryHandle.mockReturnValue(createEmptyPluginRegistry()); bootstrapOutboundChannelPlugin({ channel: "discord", cfg: discordConfig }); bootstrapOutboundChannelPlugin({ channel: "discord", cfg: updatedDiscordConfig }); bootstrapOutboundChannelPlugin({ channel: "discord", cfg: discordConfig }); - expect(loaderMocks.resolveRuntimePluginRegistry).toHaveBeenCalledTimes(2); + expect(loaderMocks.loadPluginRegistryHandle).toHaveBeenCalledTimes(2); }); }); diff --git a/src/infra/outbound/channel-bootstrap.runtime.ts b/src/infra/outbound/channel-bootstrap.runtime.ts index 3fabe087e4c5..cc3a87426833 100644 --- a/src/infra/outbound/channel-bootstrap.runtime.ts +++ b/src/infra/outbound/channel-bootstrap.runtime.ts @@ -4,54 +4,57 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/ag import { applyPluginAutoEnable } from "../../config/plugin-auto-enable.js"; import { resolveRuntimeConfigCacheKey } from "../../config/runtime-snapshot.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { resolveRuntimePluginRegistry } from "../../plugins/loader.js"; +import { withActivatedPluginIds } from "../../plugins/activation-context.js"; +import { resolveDiscoverableScopedChannelPluginIds } from "../../plugins/channel-plugin-ids.js"; +import { loadPluginRegistryHandle } from "../../plugins/loader.js"; import type { PluginChannelRegistration } from "../../plugins/registry-types.js"; -import { - getActivePluginChannelRegistry, - getActivePluginChannelRegistryVersion, - getActivePluginRegistry, - getActivePluginRegistryVersion, -} from "../../plugins/runtime.js"; +import type { PluginRegistry } from "../../plugins/registry.js"; +import { getActivePluginRegistry, getActivePluginRegistryVersion } from "../../plugins/runtime.js"; import type { DeliverableMessageChannel } from "../../utils/message-channel.js"; const MAX_BOOTSTRAP_CONFIG_GENERATIONS = 64; let bootstrapRegistryGeneration: string | undefined; -const bootstrapAttemptedChannelsByConfig = new Map>(); +const bootstrapRegistriesByConfig = new Map< + string, + Map +>(); function resolveBootstrapRegistryGeneration(): string { - return `${getActivePluginChannelRegistryVersion()}:${getActivePluginRegistryVersion()}`; + return String(getActivePluginRegistryVersion()); } -function resolveBootstrapAttemptedChannels(cfg: OpenClawConfig): Set { +function resolveBootstrapRegistries( + cfg: OpenClawConfig, +): Map { const registryGeneration = resolveBootstrapRegistryGeneration(); if (registryGeneration !== bootstrapRegistryGeneration) { bootstrapRegistryGeneration = registryGeneration; - bootstrapAttemptedChannelsByConfig.clear(); + bootstrapRegistriesByConfig.clear(); } const configKey = resolveRuntimeConfigCacheKey(cfg); - const existing = bootstrapAttemptedChannelsByConfig.get(configKey); + const existing = bootstrapRegistriesByConfig.get(configKey); if (existing) { - bootstrapAttemptedChannelsByConfig.delete(configKey); - bootstrapAttemptedChannelsByConfig.set(configKey, existing); + bootstrapRegistriesByConfig.delete(configKey); + bootstrapRegistriesByConfig.set(configKey, existing); return existing; } // Agent-scoped configs may interleave within one registry generation. Keep a // bounded LRU so one caller cannot evict another on every delivery attempt. - if (bootstrapAttemptedChannelsByConfig.size >= MAX_BOOTSTRAP_CONFIG_GENERATIONS) { - const oldestConfigKey = bootstrapAttemptedChannelsByConfig.keys().next().value; + if (bootstrapRegistriesByConfig.size >= MAX_BOOTSTRAP_CONFIG_GENERATIONS) { + const oldestConfigKey = bootstrapRegistriesByConfig.keys().next().value; if (oldestConfigKey !== undefined) { - bootstrapAttemptedChannelsByConfig.delete(oldestConfigKey); + bootstrapRegistriesByConfig.delete(oldestConfigKey); } } - const attemptedChannels = new Set(); - bootstrapAttemptedChannelsByConfig.set(configKey, attemptedChannels); - return attemptedChannels; + const registries = new Map(); + bootstrapRegistriesByConfig.set(configKey, registries); + return registries; } -/** Clears the per-generation channel bootstrap retry guard for isolated tests. */ +/** Clears the per-generation channel bootstrap handle cache for isolated tests. */ export function resetOutboundChannelBootstrapStateForTests(): void { bootstrapRegistryGeneration = undefined; - bootstrapAttemptedChannelsByConfig.clear(); + bootstrapRegistriesByConfig.clear(); } function channelEntryCanSend(entry: PluginChannelRegistration | undefined): boolean { @@ -65,63 +68,66 @@ function findChannelEntry( return registry?.channels?.find((entry) => entry?.plugin?.id === channel); } -function canResolveSendCapableChannel(channel: DeliverableMessageChannel): boolean { - const activeChannelRegistry = getActivePluginChannelRegistry(); - const channelEntry = findChannelEntry(activeChannelRegistry, channel); - if (channelEntryCanSend(channelEntry)) { - return true; - } - - const activeRegistry = getActivePluginRegistry(); - if (activeRegistry && activeRegistry !== activeChannelRegistry) { - return channelEntryCanSend(findChannelEntry(activeRegistry, channel)); - } - return false; +function resolveSendCapableRegistry( + registry: PluginRegistry | null | undefined, + channel: DeliverableMessageChannel, +): PluginRegistry | undefined { + return registry && channelEntryCanSend(findChannelEntry(registry, channel)) + ? registry + : undefined; } /** Loads runtime plugins on demand when a selected outbound channel has only a setup shell. */ export function bootstrapOutboundChannelPlugin(params: { channel: DeliverableMessageChannel; cfg?: OpenClawConfig; -}): void { +}): PluginRegistry | undefined { const cfg = params.cfg; if (!cfg) { - return; + return undefined; } - if (canResolveSendCapableChannel(params.channel)) { - return; + const activeRegistry = getActivePluginRegistry(); + const activeSendRegistry = resolveSendCapableRegistry(activeRegistry, params.channel); + if (activeSendRegistry) { + return activeSendRegistry; } - const attemptedChannels = resolveBootstrapAttemptedChannels(cfg); - if (attemptedChannels.has(params.channel)) { - return; + const registries = resolveBootstrapRegistries(cfg); + if (registries.has(params.channel)) { + return resolveSendCapableRegistry(registries.get(params.channel), params.channel); } - attemptedChannels.add(params.channel); const autoEnabled = applyPluginAutoEnable({ config: cfg }); const defaultAgentId = resolveDefaultAgentId(autoEnabled.config); const workspaceDir = resolveAgentWorkspaceDir(autoEnabled.config, defaultAgentId); + const pluginIds = resolveDiscoverableScopedChannelPluginIds({ + config: autoEnabled.config, + activationSourceConfig: cfg, + channelIds: [params.channel], + workspaceDir, + env: process.env, + }); + const activatedConfig = + withActivatedPluginIds({ config: autoEnabled.config, pluginIds }) ?? autoEnabled.config; + const activatedSourceConfig = withActivatedPluginIds({ config: cfg, pluginIds }) ?? cfg; try { - resolveRuntimePluginRegistry({ - config: autoEnabled.config, - activationSourceConfig: cfg, + const registry = loadPluginRegistryHandle({ + config: activatedConfig, + activationSourceConfig: activatedSourceConfig, autoEnabledReasons: autoEnabled.autoEnabledReasons, + onlyPluginIds: pluginIds, workspaceDir, runtimeOptions: { allowGatewaySubagentBinding: true, }, }); + const sendRegistry = resolveSendCapableRegistry(registry, params.channel); + registries.set(params.channel, sendRegistry ?? null); + return sendRegistry; } catch { // Best-effort bootstrap; the caller reports the unavailable channel. - } - // A bootstrap can replace the registry itself. Adopt that generation without - // forgetting failures for interleaved configs; external replacements observed - // before the next attempt still clear the guard above. - bootstrapRegistryGeneration = resolveBootstrapRegistryGeneration(); - if (!canResolveSendCapableChannel(params.channel)) { - // Loading can replace the active registry without making this channel usable. - // Carry the failure forward so polling callers wait for config or registry reload. - resolveBootstrapAttemptedChannels(cfg).add(params.channel); + registries.set(params.channel, null); + return undefined; } } diff --git a/src/infra/outbound/channel-resolution.test.ts b/src/infra/outbound/channel-resolution.test.ts index 6a2e51f3f535..1bfdabba227e 100644 --- a/src/infra/outbound/channel-resolution.test.ts +++ b/src/infra/outbound/channel-resolution.test.ts @@ -7,11 +7,10 @@ const resolveAgentWorkspaceDirMock = vi.hoisted(() => vi.fn()); const getLoadedChannelPluginMock = vi.hoisted(() => vi.fn()); const getChannelPluginMock = vi.hoisted(() => vi.fn()); const applyPluginAutoEnableMock = vi.hoisted(() => vi.fn()); +const resolveDiscoverableScopedChannelPluginIdsMock = vi.hoisted(() => vi.fn()); const resolveRuntimePluginRegistryMock = vi.hoisted(() => vi.fn()); const getActivePluginRegistryMock = vi.hoisted(() => vi.fn()); const getActivePluginRegistryVersionMock = vi.hoisted(() => vi.fn()); -const getActivePluginChannelRegistryMock = vi.hoisted(() => vi.fn()); -const getActivePluginChannelRegistryVersionMock = vi.hoisted(() => vi.fn()); const normalizeMessageChannelMock = vi.hoisted(() => vi.fn()); const isDeliverableMessageChannelMock = vi.hoisted(() => vi.fn()); @@ -29,18 +28,19 @@ vi.mock("../../config/plugin-auto-enable.js", () => ({ applyPluginAutoEnable: (...args: unknown[]) => applyPluginAutoEnableMock(...args), })); +vi.mock("../../plugins/channel-plugin-ids.js", () => ({ + resolveDiscoverableScopedChannelPluginIds: (...args: unknown[]) => + resolveDiscoverableScopedChannelPluginIdsMock(...args), +})); + vi.mock("../../plugins/loader.js", () => ({ - resolveRuntimePluginRegistry: (...args: unknown[]) => resolveRuntimePluginRegistryMock(...args), + loadPluginRegistryHandle: (...args: unknown[]) => resolveRuntimePluginRegistryMock(...args), })); vi.mock("../../plugins/runtime.js", () => ({ getActivePluginRegistry: (...args: unknown[]) => getActivePluginRegistryMock(...args), getActivePluginRegistryVersion: (...args: unknown[]) => getActivePluginRegistryVersionMock(...args), - getActivePluginChannelRegistry: (...args: unknown[]) => - getActivePluginChannelRegistryMock(...args), - getActivePluginChannelRegistryVersion: (...args: unknown[]) => - getActivePluginChannelRegistryVersionMock(...args), })); vi.mock("../../utils/message-channel.js", () => ({ @@ -78,11 +78,10 @@ describe("outbound channel resolution", () => { getLoadedChannelPluginMock.mockReset(); getChannelPluginMock.mockReset(); applyPluginAutoEnableMock.mockReset(); + resolveDiscoverableScopedChannelPluginIdsMock.mockReset(); resolveRuntimePluginRegistryMock.mockReset(); getActivePluginRegistryMock.mockReset(); getActivePluginRegistryVersionMock.mockReset(); - getActivePluginChannelRegistryMock.mockReset(); - getActivePluginChannelRegistryVersionMock.mockReset(); normalizeMessageChannelMock.mockReset(); isDeliverableMessageChannelMock.mockReset(); @@ -94,12 +93,12 @@ describe("outbound channel resolution", () => { ); getActivePluginRegistryMock.mockReturnValue({ channels: [] }); getActivePluginRegistryVersionMock.mockReturnValue(1); - getActivePluginChannelRegistryMock.mockReturnValue({ channels: [] }); - getActivePluginChannelRegistryVersionMock.mockReturnValue(1); applyPluginAutoEnableMock.mockReturnValue({ config: { autoEnabled: true }, autoEnabledReasons: {}, }); + resolveDiscoverableScopedChannelPluginIdsMock.mockReturnValue(["alpha-plugin"]); + resolveRuntimePluginRegistryMock.mockReturnValue({ channels: [] }); resolveDefaultAgentIdMock.mockReturnValue("main"); resolveAgentWorkspaceDirMock.mockReturnValue("/tmp/workspace"); }); @@ -149,9 +148,6 @@ describe("outbound channel resolution", () => { getActivePluginRegistryMock.mockReturnValue({ channels: [{ plugin }], }); - getActivePluginChannelRegistryMock.mockReturnValue({ - channels: [{ plugin }], - }); const channelResolution = await importChannelResolution("direct-registry"); expect( @@ -162,25 +158,6 @@ describe("outbound channel resolution", () => { ).toBe(plugin); }); - it("resolves message adapters from the pinned channel registry after active registry replacement", async () => { - const message = { send: { text: vi.fn() } }; - const plugin = { id: "alpha", message }; - getLoadedChannelPluginMock.mockReturnValue(undefined); - getChannelPluginMock.mockReturnValue(undefined); - getActivePluginChannelRegistryMock.mockReturnValue({ - channels: [{ plugin }], - }); - getActivePluginRegistryMock.mockReturnValue({ channels: [] }); - const channelResolution = await importChannelResolution("pinned-message-registry"); - - expect( - channelResolution.resolveOutboundChannelMessageAdapter({ - channel: "alpha", - cfg: {} as never, - }), - ).toBe(message); - }); - it("skips metadata-only loaded message shells for active send-capable message adapters", async () => { const setupMessage = { receive: { defaultAckPolicy: "manual" } }; const runtimeMessage = { send: { text: vi.fn() } }; @@ -188,9 +165,6 @@ describe("outbound channel resolution", () => { const runtimePlugin = { id: "alpha", message: runtimeMessage }; getLoadedChannelPluginMock.mockReturnValue(setupPlugin); getChannelPluginMock.mockReturnValue(undefined); - getActivePluginChannelRegistryMock.mockReturnValue({ - channels: [{ plugin: setupPlugin }], - }); getActivePluginRegistryMock.mockReturnValue({ channels: [{ plugin: runtimePlugin }], }); @@ -206,6 +180,24 @@ describe("outbound channel resolution", () => { expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled(); }); + it("resolves message adapters from the current registry handle", async () => { + const message = { send: { text: vi.fn() } }; + const registry = { + channels: [{ plugin: { id: "alpha", message } }], + } as never; + getLoadedChannelPluginMock.mockReturnValue(undefined); + getChannelPluginMock.mockReturnValue(undefined); + const channelResolution = await importChannelResolution("scoped-message-adapter"); + const { withPluginRuntimeRegistryScope } = + await import("../../plugins/runtime/gateway-request-scope.js"); + + expect( + withPluginRuntimeRegistryScope(registry, () => + channelResolution.resolveOutboundChannelMessageAdapter({ channel: "alpha" }), + ), + ).toBe(message); + }); + it("bootstraps configured channel plugins when the active registry is missing the target", async () => { const plugin = { id: "alpha", outbound: { sendText: vi.fn() } }; getLoadedChannelPluginMock.mockReturnValueOnce(undefined).mockReturnValueOnce(plugin); @@ -221,9 +213,22 @@ describe("outbound channel resolution", () => { expect(applyPluginAutoEnableMock).toHaveBeenCalledWith({ config: { channels: {} } }); expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledOnce(); const registryOptions = firstMockArg(resolveRuntimePluginRegistryMock); - expect(registryOptions.config).toEqual({ autoEnabled: true }); - expect(registryOptions.activationSourceConfig).toEqual({ channels: {} }); + expect(registryOptions.config).toEqual({ + autoEnabled: true, + plugins: { + allow: ["alpha-plugin"], + entries: { "alpha-plugin": { enabled: true } }, + }, + }); + expect(registryOptions.activationSourceConfig).toEqual({ + channels: {}, + plugins: { + allow: ["alpha-plugin"], + entries: { "alpha-plugin": { enabled: true } }, + }, + }); expect(registryOptions.autoEnabledReasons).toEqual({}); + expect(registryOptions.onlyPluginIds).toEqual(["alpha-plugin"]); expect(registryOptions.workspaceDir).toBe("/tmp/workspace"); expect(registryOptions.runtimeOptions).toEqual({ allowGatewaySubagentBinding: true, @@ -251,14 +256,11 @@ describe("outbound channel resolution", () => { expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledTimes(1); }); - it("resolves a bootstrapped external channel from the active registry when the pin is stale", async () => { + it("resolves a bootstrapped external channel from the root registry", async () => { const plugin = { id: "external-channel", outbound: { sendText: vi.fn() } }; isDeliverableMessageChannelMock.mockReturnValue(false); getLoadedChannelPluginMock.mockReturnValue(undefined); getChannelPluginMock.mockReturnValue(undefined); - getActivePluginChannelRegistryMock.mockReturnValue({ - channels: [{ plugin: { id: "other-channel" } }], - }); getActivePluginRegistryMock.mockReturnValue({ channels: [{ plugin }] }); const channelResolution = await importChannelResolution("bootstrap-external-active-registry"); @@ -282,7 +284,6 @@ describe("outbound channel resolution", () => { isDeliverableMessageChannelMock.mockReturnValue(false); getLoadedChannelPluginMock.mockReturnValue(undefined); getChannelPluginMock.mockReturnValue(undefined); - getActivePluginChannelRegistryMock.mockReturnValue({ channels: [] }); getActivePluginRegistryMock.mockImplementation(() => resolveRuntimePluginRegistryMock.mock.calls.length > 0 ? { channels: [{ plugin }] } : null, ); @@ -316,9 +317,6 @@ describe("outbound channel resolution", () => { getLoadedChannelPluginMock.mockReturnValueOnce(setupPlugin).mockReturnValueOnce(runtimePlugin); getChannelPluginMock.mockReturnValue(undefined); getActivePluginRegistryMock.mockReturnValue({ channels: [] }); - getActivePluginChannelRegistryMock.mockReturnValue({ - channels: [{ plugin: setupPlugin }], - }); const channelResolution = await importChannelResolution("bootstrap-setup-shell"); expect( @@ -336,9 +334,6 @@ describe("outbound channel resolution", () => { const runtimePlugin = { id: "alpha", outbound: { deliveryMode: "direct", sendText: vi.fn() } }; getLoadedChannelPluginMock.mockReturnValue(setupPlugin); getChannelPluginMock.mockReturnValue(undefined); - getActivePluginChannelRegistryMock.mockReturnValue({ - channels: [{ plugin: setupPlugin }], - }); getActivePluginRegistryMock.mockImplementation(() => resolveRuntimePluginRegistryMock.mock.calls.length > 0 ? { channels: [{ plugin: runtimePlugin }] } @@ -363,9 +358,6 @@ describe("outbound channel resolution", () => { getActivePluginRegistryMock.mockReturnValue({ channels: [{ plugin: setupPlugin }], }); - getActivePluginChannelRegistryMock.mockReturnValue({ - channels: [{ plugin: setupPlugin }], - }); const channelResolution = await importChannelResolution("bootstrap-still-setup-shell"); expect( @@ -385,9 +377,6 @@ describe("outbound channel resolution", () => { getActivePluginRegistryMock.mockReturnValue({ channels: [{ plugin: actionsOnlyPlugin }], }); - getActivePluginChannelRegistryMock.mockReturnValue({ - channels: [{ plugin: actionsOnlyPlugin }], - }); const channelResolution = await importChannelResolution("actions-only-plugin"); expect( @@ -408,9 +397,6 @@ describe("outbound channel resolution", () => { getActivePluginRegistryMock.mockReturnValue({ channels: [{ plugin: runtimePlugin }], }); - getActivePluginChannelRegistryMock.mockReturnValue({ - channels: [{ plugin: setupPlugin }], - }); const channelResolution = await importChannelResolution("active-runtime-over-setup"); expect( @@ -423,29 +409,6 @@ describe("outbound channel resolution", () => { expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled(); }); - it("resolves outbound plugins from the selected runtime channel registry", async () => { - const setupPlugin = { id: "alpha" }; - const runtimePlugin = { id: "alpha", outbound: { sendText: vi.fn() } }; - getLoadedChannelPluginMock.mockReturnValue(undefined); - getChannelPluginMock.mockReturnValue(undefined); - getActivePluginRegistryMock.mockReturnValue({ - channels: [{ plugin: setupPlugin }], - }); - getActivePluginChannelRegistryMock.mockReturnValue({ - channels: [{ plugin: runtimePlugin }], - }); - const channelResolution = await importChannelResolution("selected-runtime-registry"); - - expect( - channelResolution.resolveOutboundChannelPlugin({ - channel: "alpha", - cfg: { channels: {} } as never, - allowBootstrap: true, - }), - ).toBe(runtimePlugin); - expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled(); - }); - it("resolves runtime outbound adapters that do not send text directly", async () => { const setupPlugin = { id: "alpha" }; const runtimePlugin = { id: "alpha", outbound: { deliveryMode: "gateway" } }; @@ -454,9 +417,6 @@ describe("outbound channel resolution", () => { getActivePluginRegistryMock.mockReturnValue({ channels: [{ plugin: runtimePlugin }], }); - getActivePluginChannelRegistryMock.mockReturnValue({ - channels: [{ plugin: setupPlugin }], - }); const channelResolution = await importChannelResolution("runtime-outbound-adapter"); expect( @@ -475,9 +435,6 @@ describe("outbound channel resolution", () => { getActivePluginRegistryMock.mockReturnValue({ channels: [{ plugin: { id: "beta" } }], }); - getActivePluginChannelRegistryMock.mockReturnValue({ - channels: [{ plugin: { id: "beta" } }], - }); const channelResolution = await importChannelResolution("bootstrap-missing-target"); expect( @@ -510,26 +467,6 @@ describe("outbound channel resolution", () => { expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledTimes(1); }); - it("allows another activation attempt when the pinned channel registry version changes", async () => { - getChannelPluginMock.mockReturnValue(undefined); - const channelResolution = await importChannelResolution("channel-version-change"); - - channelResolution.resolveOutboundChannelPlugin({ - channel: "alpha", - cfg: { channels: {} } as never, - allowBootstrap: true, - }); - expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledTimes(1); - - getActivePluginChannelRegistryVersionMock.mockReturnValue(2); - channelResolution.resolveOutboundChannelPlugin({ - channel: "alpha", - cfg: { channels: {} } as never, - allowBootstrap: true, - }); - expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledTimes(2); - }); - it("allows another activation attempt when the active registry version changes", async () => { getChannelPluginMock.mockReturnValue(undefined); const channelResolution = await importChannelResolution("active-version-change"); diff --git a/src/infra/outbound/channel-resolution.ts b/src/infra/outbound/channel-resolution.ts index 728479b150a4..bf9611906004 100644 --- a/src/infra/outbound/channel-resolution.ts +++ b/src/infra/outbound/channel-resolution.ts @@ -5,7 +5,9 @@ import type { ChannelMessageAdapterShape } from "../../channels/message/types.js import { getChannelPlugin, getLoadedChannelPlugin } from "../../channels/plugins/index.js"; import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { getActivePluginChannelRegistry, getActivePluginRegistry } from "../../plugins/runtime.js"; +import type { PluginRegistry } from "../../plugins/registry-types.js"; +import { getActivePluginRegistry } from "../../plugins/runtime.js"; +import { getPluginRuntimeGatewayRequestScope } from "../../plugins/runtime/gateway-request-scope.js"; import { INTERNAL_MESSAGE_CHANNEL, isDeliverableMessageChannel, @@ -28,22 +30,33 @@ export function normalizeDeliverableOutboundChannel( function maybeBootstrapChannelPlugin(params: { channel: DeliverableMessageChannel; cfg?: OpenClawConfig; -}): void { - bootstrapOutboundChannelPlugin(params); +}): PluginRegistry | undefined { + return bootstrapOutboundChannelPlugin(params); +} + +function getOutboundRuntimeRegistry(): PluginRegistry | null { + return getPluginRuntimeGatewayRequestScope()?.pluginRegistry ?? getActivePluginRegistry(); } function normalizeOutboundChannelForResolution(params: { channel: string; cfg?: OpenClawConfig; allowBootstrap?: boolean; -}): { channel?: DeliverableMessageChannel; didBootstrap: boolean } { +}): { + channel?: DeliverableMessageChannel; + didBootstrap: boolean; + bootstrapRegistry?: PluginRegistry; +} { const normalized = normalizeMessageChannel(params.channel); const deliverable = normalizeDeliverableOutboundChannel(normalized); if (deliverable || !normalized || normalized === INTERNAL_MESSAGE_CHANNEL) { return { channel: deliverable, didBootstrap: false }; } - const activeRuntimePlugin = resolveActivatedOutboundPluginFromRuntimeRegistries(normalized); + const activeRuntimePlugin = resolveActivatedOutboundPluginFromRuntimeRegistry( + normalized, + getOutboundRuntimeRegistry() ?? undefined, + ); if (activeRuntimePlugin) { return { channel: activeRuntimePlugin.id as DeliverableMessageChannel, @@ -56,16 +69,18 @@ function normalizeOutboundChannelForResolution(params: { // External channel ids remain normalized before their runtime is registered. // Bootstrap first, then let the runtime candidate lookup confirm sendability. - maybeBootstrapChannelPlugin({ + const bootstrapRegistry = maybeBootstrapChannelPlugin({ channel: normalized as DeliverableMessageChannel, cfg: params.cfg, }); - const bootstrappedRuntimePlugin = resolveActivatedOutboundPluginFromRuntimeRegistries(normalized); + const bootstrappedRuntimePlugin = resolveActivatedOutboundPluginFromRuntimeRegistry( + normalized, + bootstrapRegistry, + ); return { - // The pinned channel registry may intentionally lag the active runtime - // registry, so strict registry validation here would hide a usable plugin. channel: (bootstrappedRuntimePlugin?.id ?? normalized) as DeliverableMessageChannel, didBootstrap: true, + ...(bootstrapRegistry ? { bootstrapRegistry } : {}), }; } @@ -153,42 +168,34 @@ function resolveRuntimeOutboundPluginCandidate(params: { return undefined; } -function resolveValueFromRuntimeRegistries( +function resolveValueFromRuntimeRegistry( channel: string, resolveValue: (plugin: ChannelPlugin) => TValue | undefined, + registry: PluginRegistry | null | undefined = getOutboundRuntimeRegistry(), ): TValue | undefined { - const channelRegistry = getActivePluginChannelRegistry(); - const channelPlugin = resolveDirectFromRegistry(channelRegistry, channel); - if (channelPlugin) { - const value = resolveValue(channelPlugin); - if (value !== undefined) { - return value; - } - } - const activeRegistry = getActivePluginRegistry(); - if (activeRegistry && activeRegistry !== channelRegistry) { - const activePlugin = resolveDirectFromRegistry(activeRegistry, channel); - if (activePlugin) { - return resolveValue(activePlugin); - } - } - return undefined; + const plugin = resolveDirectFromRegistry(registry ?? null, channel); + return plugin ? resolveValue(plugin) : undefined; } -function resolveDirectFromRuntimeRegistries(channel: string): ChannelPlugin | undefined { - return resolveValueFromRuntimeRegistries(channel, (plugin) => plugin); -} - -function resolveRuntimeOutboundPluginFromRuntimeRegistries( +function resolveDirectFromRuntimeRegistry( channel: string, + registry?: PluginRegistry, ): ChannelPlugin | undefined { - return resolveValueFromRuntimeRegistries(channel, resolveRuntimeOutboundPlugin); + return resolveValueFromRuntimeRegistry(channel, (plugin) => plugin, registry); } -function resolveActivatedOutboundPluginFromRuntimeRegistries( +function resolveRuntimeOutboundPluginFromRuntimeRegistry( channel: string, + registry?: PluginRegistry, ): ChannelPlugin | undefined { - return resolveValueFromRuntimeRegistries(channel, resolveActivatedOutboundPlugin); + return resolveValueFromRuntimeRegistry(channel, resolveRuntimeOutboundPlugin, registry); +} + +function resolveActivatedOutboundPluginFromRuntimeRegistry( + channel: string, + registry?: PluginRegistry, +): ChannelPlugin | undefined { + return resolveValueFromRuntimeRegistry(channel, resolveActivatedOutboundPlugin, registry); } /** Resolves a deliverable outbound channel plugin, optionally bootstrapping it. */ @@ -197,7 +204,11 @@ export function resolveOutboundChannelPlugin(params: { cfg?: OpenClawConfig; allowBootstrap?: boolean; }): ChannelPlugin | undefined { - const { channel: normalized, didBootstrap } = normalizeOutboundChannelForResolution(params); + const { + channel: normalized, + didBootstrap, + bootstrapRegistry, + } = normalizeOutboundChannelForResolution(params); if (!normalized) { return undefined; } @@ -207,9 +218,9 @@ export function resolveOutboundChannelPlugin(params: { const current = resolveLoaded(); const requireActivatedRuntime = params.allowBootstrap === true; const runtimeCurrent = requireActivatedRuntime - ? resolveActivatedOutboundPluginFromRuntimeRegistries(normalized) - : resolveRuntimeOutboundPluginFromRuntimeRegistries(normalized); - const setupFallback = resolveDirectFromRuntimeRegistries(normalized); + ? resolveActivatedOutboundPluginFromRuntimeRegistry(normalized, bootstrapRegistry) + : resolveRuntimeOutboundPluginFromRuntimeRegistry(normalized, bootstrapRegistry); + const setupFallback = resolveDirectFromRuntimeRegistry(normalized, bootstrapRegistry); const bundledCurrent = resolve(); const candidate = resolveRuntimeOutboundPluginCandidate({ loaded: current, @@ -227,11 +238,11 @@ export function resolveOutboundChannelPlugin(params: { return undefined; } - maybeBootstrapChannelPlugin({ channel: normalized, cfg: params.cfg }); + const registry = maybeBootstrapChannelPlugin({ channel: normalized, cfg: params.cfg }); return resolveRuntimeOutboundPluginCandidate({ loaded: resolveLoaded(), - runtime: resolveActivatedOutboundPluginFromRuntimeRegistries(normalized), - setupFallback: resolveDirectFromRuntimeRegistries(normalized), + runtime: resolveActivatedOutboundPluginFromRuntimeRegistry(normalized, registry), + setupFallback: resolveDirectFromRuntimeRegistry(normalized, registry), bundled: resolve(), requireActivatedRuntime: true, }); @@ -243,21 +254,29 @@ export function resolveOutboundChannelMessageAdapter(params: { cfg?: OpenClawConfig; allowBootstrap?: boolean; }): ChannelMessageAdapterShape | undefined { - const { channel: normalized, didBootstrap } = normalizeOutboundChannelForResolution(params); + const { + channel: normalized, + didBootstrap, + bootstrapRegistry, + } = normalizeOutboundChannelForResolution(params); if (!normalized) { return undefined; } const current = resolveSendCapableMessageAdapter(getLoadedChannelPlugin(normalized)) ?? - resolveValueFromRuntimeRegistries(normalized, resolveSendCapableMessageAdapter) ?? + resolveValueFromRuntimeRegistry( + normalized, + resolveSendCapableMessageAdapter, + bootstrapRegistry, + ) ?? resolveSendCapableMessageAdapter(getChannelPlugin(normalized)); if (current || params.allowBootstrap !== true || didBootstrap) { return current; } - maybeBootstrapChannelPlugin({ channel: normalized, cfg: params.cfg }); + const registry = maybeBootstrapChannelPlugin({ channel: normalized, cfg: params.cfg }); return ( resolveSendCapableMessageAdapter(getLoadedChannelPlugin(normalized)) ?? - resolveValueFromRuntimeRegistries(normalized, resolveSendCapableMessageAdapter) ?? + resolveValueFromRuntimeRegistry(normalized, resolveSendCapableMessageAdapter, registry) ?? resolveSendCapableMessageAdapter(getChannelPlugin(normalized)) ); } diff --git a/src/infra/outbound/deliver-channel.ts b/src/infra/outbound/deliver-channel.ts index 02c433a1a661..863981fa6b80 100644 --- a/src/infra/outbound/deliver-channel.ts +++ b/src/infra/outbound/deliver-channel.ts @@ -15,6 +15,8 @@ import type { } from "../../channels/plugins/types.adapters.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; +import type { PluginRegistry } from "../../plugins/registry-types.js"; +import { withPluginRuntimeRegistryScope } from "../../plugins/runtime/gateway-request-scope.js"; import { createLazyRuntimeModule } from "../../shared/lazy-runtime.js"; import { formatErrorMessage } from "../errors.js"; import { resolveOutboundChannelMessageAdapter } from "./channel-resolution.js"; @@ -42,36 +44,64 @@ export async function resolveChannelOutboundDirectiveOptions(params: { cfg: OpenClawConfig; channel: Exclude; }): Promise<{ extractMarkdownImages?: boolean }> { - const outbound = await loadBootstrappedOutboundAdapter(params); + const { outbound } = await loadBootstrappedOutboundAdapter(params); return { extractMarkdownImages: outbound?.extractMarkdownImages === true ? true : undefined, }; } export async function createChannelHandler(params: ChannelHandlerParams): Promise { - const outbound = await loadBootstrappedOutboundAdapter(params); - const message = resolveOutboundChannelMessageAdapter(params); - const handler = createPluginHandler({ ...params, outbound, message }); + const { outbound, pluginRegistry } = await loadBootstrappedOutboundAdapter(params); + const handler = withPluginRuntimeRegistryScope(pluginRegistry, () => { + const message = resolveOutboundChannelMessageAdapter(params); + return createPluginHandler({ ...params, outbound, message }); + }); if (!handler) { throw new Error(`Outbound not configured for channel: ${params.channel}`); } - return handler; + return scopeChannelHandler(handler, pluginRegistry); } async function loadBootstrappedOutboundAdapter(params: { cfg: OpenClawConfig; channel: Exclude; -}): Promise { +}): Promise<{ outbound?: ChannelOutboundAdapter; pluginRegistry?: PluginRegistry }> { let outbound = await loadChannelOutboundAdapter(params.channel); - if (!outbound) { - const { bootstrapOutboundChannelPlugin } = await loadChannelBootstrapRuntime(); - bootstrapOutboundChannelPlugin({ - channel: params.channel, - cfg: params.cfg, - }); - outbound = await loadChannelOutboundAdapter(params.channel); + if (outbound) { + return { outbound }; } - return outbound; + const { bootstrapOutboundChannelPlugin } = await loadChannelBootstrapRuntime(); + const pluginRegistry = bootstrapOutboundChannelPlugin({ + channel: params.channel, + cfg: params.cfg, + }); + outbound = pluginRegistry?.channels.find((entry) => entry.plugin.id === params.channel)?.plugin + .outbound; + return { + ...(outbound ? { outbound } : {}), + ...(pluginRegistry ? { pluginRegistry } : {}), + }; +} + +function scopeChannelHandler( + handler: ChannelHandler, + registry: PluginRegistry | undefined, +): ChannelHandler { + if (!registry) { + return handler; + } + return Object.fromEntries( + Object.entries(handler).map(([key, value]) => { + if (typeof value !== "function") { + return [key, value]; + } + const call = value as (...args: unknown[]) => unknown; + return [ + key, + (...args: unknown[]) => withPluginRuntimeRegistryScope(registry, () => call(...args)), + ]; + }), + ) as ChannelHandler; } async function runChannelMessageSendWithLifecycle< @@ -131,8 +161,10 @@ export async function resolveOutboundDurableFinalDeliverySupport(params: { channel: Exclude; requirements?: DurableFinalDeliveryRequirements; }): Promise { - const outbound = await loadBootstrappedOutboundAdapter(params); - const message = resolveOutboundChannelMessageAdapter(params); + const { outbound, pluginRegistry } = await loadBootstrappedOutboundAdapter(params); + const message = withPluginRuntimeRegistryScope(pluginRegistry, () => + resolveOutboundChannelMessageAdapter(params), + ); if (!message?.send?.text && !outbound?.sendText) { return { ok: false, reason: "missing_outbound_handler" }; } diff --git a/src/infra/outbound/deliver.queue-integration.test.ts b/src/infra/outbound/deliver.queue-integration.test.ts index f0f3c48ba194..a282429341d8 100644 --- a/src/infra/outbound/deliver.queue-integration.test.ts +++ b/src/infra/outbound/deliver.queue-integration.test.ts @@ -6,10 +6,7 @@ import type { TrustedMessageAuditEvent } from "../../audit/message-audit-events. import { onTrustedMessageAuditEventForTest as onTrustedMessageAuditEvent } from "../../audit/message-audit-events.test-support.js"; import type { OpenClawConfig } from "../../config/config.js"; import { createEmptyPluginRegistry } from "../../plugins/registry.js"; -import { - releasePinnedPluginChannelRegistry, - setActivePluginRegistry, -} from "../../plugins/runtime.js"; +import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../../plugins/runtime.js"; import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js"; import { getDeliveryQueueEntryStatus } from "../delivery-queue-sqlite.js"; import { PlatformMessageNotDispatchedError } from "./deliver-types.js"; @@ -78,7 +75,7 @@ describe("deliverOutboundPayloads queue integration: mid-batch failure with send }); afterEach(() => { - releasePinnedPluginChannelRegistry(); + resetPluginRuntimeStateForTest(); setActivePluginRegistry(createEmptyPluginRegistry()); }); diff --git a/src/infra/outbound/deliver.test.ts b/src/infra/outbound/deliver.test.ts index dd6dc41b45cf..f7251c0c3af5 100644 --- a/src/infra/outbound/deliver.test.ts +++ b/src/infra/outbound/deliver.test.ts @@ -20,17 +20,9 @@ import * as mediaCapabilityModule from "../../media/read-capability.js"; import { createHookRunner } from "../../plugins/hooks.js"; import { addTestHook } from "../../plugins/hooks.test-fixtures.js"; import { createEmptyPluginRegistry } from "../../plugins/registry.js"; -import { - pinActivePluginChannelRegistry, - releasePinnedPluginChannelRegistry, - setActivePluginRegistry, -} from "../../plugins/runtime.js"; +import { setActivePluginRegistry } from "../../plugins/runtime.js"; import type { PluginHookRegistration } from "../../plugins/types.js"; -import { - createChannelTestPluginBase, - createOutboundTestPlugin, - createTestRegistry, -} from "../../test-utils/channel-plugins.js"; +import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js"; import { createInternalHookEventPayload } from "../../test-utils/internal-hook-event-payload.js"; import { createOpenClawTestState } from "../../test-utils/openclaw-test-state.js"; import { @@ -405,7 +397,6 @@ describe("deliverOutboundPayloads", () => { beforeEach(() => { resetDiagnosticEventsForTest(); - releasePinnedPluginChannelRegistry(); setActivePluginRegistry(defaultRegistry); mocks.appendAssistantMessageToSessionTranscript.mockClear(); hookMocks.runner.hasHooks.mockClear(); @@ -500,45 +491,9 @@ describe("deliverOutboundPayloads", () => { afterEach(() => { resetDiagnosticEventsForTest(); - releasePinnedPluginChannelRegistry(); setActivePluginRegistry(emptyRegistry); }); - it("delivers through full active plugin when pinned setup channel has no sender", async () => { - const sendMatrix = vi.fn().mockResolvedValue({ messageId: "m1", roomId: "!room:example" }); - const setupRegistry = createTestRegistry([ - { - pluginId: "matrix", - source: "setup", - plugin: createChannelTestPluginBase({ id: "matrix" }), - }, - ]); - const runtimeRegistry = createTestRegistry([ - { - pluginId: "matrix", - source: "runtime", - plugin: createOutboundTestPlugin({ id: "matrix", outbound: matrixOutboundForTest }), - }, - ]); - - setActivePluginRegistry(setupRegistry); - pinActivePluginChannelRegistry(setupRegistry); - setActivePluginRegistry(runtimeRegistry); - - const results = await deliverMatrix({ - cfg: matrixChunkConfig, - payloads: [{ text: "hello from queue" }], - deps: { matrix: sendMatrix }, - }); - - expect(sendMatrix).toHaveBeenCalledWith("!room:example", "hello from queue", { - cfg: matrixChunkConfig, - accountId: undefined, - gifPlayback: undefined, - }); - expect(results).toEqual([{ channel: "matrix", messageId: "m1", roomId: "!room:example" }]); - }); - it("reports unsupported durable final delivery when required capabilities are missing", async () => { setTestOutbound({ deliveryMode: "direct", diff --git a/src/infra/outbound/delivery-queue-media-directive-durability.test.ts b/src/infra/outbound/delivery-queue-media-directive-durability.test.ts index 3a0b63958396..9cd4de656546 100644 --- a/src/infra/outbound/delivery-queue-media-directive-durability.test.ts +++ b/src/infra/outbound/delivery-queue-media-directive-durability.test.ts @@ -8,10 +8,7 @@ import type { } from "../../channels/plugins/types.adapters.js"; import type { OpenClawConfig } from "../../config/config.js"; import { createEmptyPluginRegistry } from "../../plugins/registry.js"; -import { - releasePinnedPluginChannelRegistry, - setActivePluginRegistry, -} from "../../plugins/runtime.js"; +import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../../plugins/runtime.js"; import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js"; import { PlatformMessageNotDispatchedError } from "./deliver-types.js"; import { collectEntrySpoolPaths } from "./delivery-queue-media-spool.js"; @@ -101,7 +98,7 @@ describe("delivery-queue MEDIA-directive durability (end-to-end)", () => { }); afterEach(() => { - releasePinnedPluginChannelRegistry(); + resetPluginRuntimeStateForTest(); setActivePluginRegistry(createEmptyPluginRegistry()); }); diff --git a/src/infra/outbound/delivery-queue-migration.test.ts b/src/infra/outbound/delivery-queue-migration.test.ts index d959fe3c343e..89de54979d00 100644 --- a/src/infra/outbound/delivery-queue-migration.test.ts +++ b/src/infra/outbound/delivery-queue-migration.test.ts @@ -4,10 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createMessageReceiptFromOutboundResults } from "../../channels/message/receipt.js"; import type { ChannelOutboundAdapter } from "../../channels/plugins/types.public.js"; import { createEmptyPluginRegistry } from "../../plugins/registry.js"; -import { - releasePinnedPluginChannelRegistry, - setActivePluginRegistry, -} from "../../plugins/runtime.js"; +import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../../plugins/runtime.js"; import { openOpenClawStateDatabase } from "../../state/openclaw-state-db.js"; import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js"; import { @@ -868,7 +865,7 @@ describe("outbound prepared queue migration", () => { }); afterEach(() => { - releasePinnedPluginChannelRegistry(); + resetPluginRuntimeStateForTest(); setActivePluginRegistry(createEmptyPluginRegistry()); }); }); diff --git a/src/infra/outbound/session-binding-service.test.ts b/src/infra/outbound/session-binding-service.test.ts index 813abbc629f3..bcdacd0c9641 100644 --- a/src/infra/outbound/session-binding-service.test.ts +++ b/src/infra/outbound/session-binding-service.test.ts @@ -1,12 +1,7 @@ // Covers session binding adapter registration, generic current-conversation // fallback, capability errors, deduping, and duplicate graph teardown. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { createEmptyPluginRegistry } from "../../plugins/registry-empty.js"; -import { - pinActivePluginChannelRegistry, - releasePinnedPluginChannelRegistry, - setActivePluginRegistry, -} from "../../plugins/runtime.js"; +import { setActivePluginRegistry } from "../../plugins/runtime.js"; import { closeOpenClawStateDatabaseForTest } from "../../state/openclaw-state-db.js"; import { createTestRegistry } from "../../test-utils/channel-plugins.js"; import { createTrackedTempDirs } from "../../test-utils/tracked-temp-dirs.js"; @@ -444,49 +439,6 @@ describe("session binding service", () => { }); }); - it("does not advertise generic plugin bindings from a stale global registry when the active channel registry is empty", async () => { - const activeRegistry = createEmptyPluginRegistry(); - activeRegistry.channels.push({ - plugin: { - id: "external-chat", - meta: { aliases: ["external-chat-alias"] }, - } as never, - } as never); - setActivePluginRegistry(activeRegistry); - const pinnedEmptyChannelRegistry = createEmptyPluginRegistry(); - pinActivePluginChannelRegistry(pinnedEmptyChannelRegistry); - - try { - const service = getSessionBindingService(); - expect( - service.getCapabilities({ - channel: "external-chat-alias", - accountId: "default", - }), - ).toEqual({ - adapterAvailable: false, - bindSupported: false, - unbindSupported: false, - placements: [], - }); - - await expectSessionBindingError( - service.bind({ - targetSessionKey: "agent:codex:acp:external-chat", - targetKind: "session", - conversation: { - channel: "external-chat-alias", - accountId: "default", - conversationId: "room-1", - }, - }), - "BINDING_ADAPTER_UNAVAILABLE", - ); - } finally { - releasePinnedPluginChannelRegistry(pinnedEmptyChannelRegistry); - } - }); - it("keeps the newest live adapter authoritative until it unregisters", () => { const firstBinding = { bindingId: "first-binding", diff --git a/src/media/web-media.test.ts b/src/media/web-media.test.ts index c079f12d2078..3ff81f68f604 100644 --- a/src/media/web-media.test.ts +++ b/src/media/web-media.test.ts @@ -11,12 +11,7 @@ import { createSolidPngBuffer } from "../../test/helpers/image-fixtures.js"; import { resolveStateDir } from "../config/paths.js"; import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; -import { - pinActivePluginHttpRouteRegistry, - releasePinnedPluginHttpRouteRegistry, - resetPluginRuntimeStateForTest, - setActivePluginRegistry, -} from "../plugins/runtime.js"; +import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js"; import { withEnvAsync } from "../test-utils/env.js"; import { resizeToJpeg } from "./media-services.js"; import { encodePngRgba, fillPixel } from "./png-encode.js"; @@ -359,32 +354,6 @@ describe("loadWebMedia", () => { expect(result.buffer.length).toBeGreaterThan(0); }); - it("loads hosted plugin media from the pinned HTTP-route registry", async () => { - const httpRegistry = createEmptyPluginRegistry(); - httpRegistry.hostedMediaResolvers = [ - { - pluginId: "hosted-media", - resolver: (mediaUrl) => - mediaUrl === "/__test__/hosted/pinned-tiny.png" ? canvasPngFile : null, - source: "test", - }, - ]; - - try { - pinActivePluginHttpRouteRegistry(httpRegistry); - setActivePluginRegistry(createEmptyPluginRegistry()); - - const result = await loadWebMedia("/__test__/hosted/pinned-tiny.png", { - maxBytes: 1024 * 1024, - }); - - expect(result.kind).toBe("image"); - expect(result.buffer.length).toBeGreaterThan(0); - } finally { - releasePinnedPluginHttpRouteRegistry(httpRegistry); - } - }); - it("surfaces Rastermill decode failures when image optimization cannot produce a JPEG", async () => { await expect(optimizeImageToJpeg(Buffer.from("not an image"), 8)).rejects.toThrow( /Unable to determine image dimensions/, diff --git a/src/plugin-sdk/channel-test-helpers.ts b/src/plugin-sdk/channel-test-helpers.ts index 5d3b4b4b6335..f7741ce4bf67 100644 --- a/src/plugin-sdk/channel-test-helpers.ts +++ b/src/plugin-sdk/channel-test-helpers.ts @@ -15,7 +15,7 @@ export { createOutboundTestPlugin, createTestRegistry, initializeGlobalHookRunner, - releasePinnedPluginChannelRegistry, + resetPluginRuntimeStateForTest, resetGlobalHookRunner, setActivePluginRegistry, type PluginHookRegistration, diff --git a/src/plugin-sdk/plugin-test-runtime.ts b/src/plugin-sdk/plugin-test-runtime.ts index e5ecb7137d8d..dce50d221068 100644 --- a/src/plugin-sdk/plugin-test-runtime.ts +++ b/src/plugin-sdk/plugin-test-runtime.ts @@ -34,7 +34,6 @@ export { } from "../plugins/web-provider-public-artifacts.explicit.js"; export { getActivePluginRegistry, - releasePinnedPluginChannelRegistry, resetPluginRuntimeStateForTest, setActivePluginRegistry, } from "../plugins/runtime.js"; diff --git a/src/plugin-sdk/test-helpers/outbound-delivery.ts b/src/plugin-sdk/test-helpers/outbound-delivery.ts index cfaf3f8d1989..d46e39f88a28 100644 --- a/src/plugin-sdk/test-helpers/outbound-delivery.ts +++ b/src/plugin-sdk/test-helpers/outbound-delivery.ts @@ -6,8 +6,5 @@ export { export { addTestHook } from "../../plugins/hooks.test-helpers.js"; export type { PluginHookRegistration } from "../../plugins/hook-types.js"; export { createEmptyPluginRegistry } from "../../plugins/registry.js"; -export { - releasePinnedPluginChannelRegistry, - setActivePluginRegistry, -} from "../../plugins/runtime.js"; +export { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../../plugins/runtime.js"; export { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js"; diff --git a/src/plugins/active-runtime-registry.test.ts b/src/plugins/active-runtime-registry.test.ts index a4a3d464aedb..059092ca70a2 100644 --- a/src/plugins/active-runtime-registry.test.ts +++ b/src/plugins/active-runtime-registry.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { getLoadedRuntimePluginRegistry, - listLoadedRuntimePluginIdsAcrossSurfaces, + listLoadedRuntimePluginIds, } from "./active-runtime-registry.js"; import { clearPluginLoaderCache } from "./loader.test-fixtures.js"; import { createEmptyPluginRegistry } from "./registry-empty.js"; @@ -117,7 +117,7 @@ describe("getLoadedRuntimePluginRegistry", () => { requiredPluginIds: ["deferred"], }), ).toBeUndefined(); - expect(listLoadedRuntimePluginIdsAcrossSurfaces()).not.toContain("deferred"); + expect(listLoadedRuntimePluginIds()).not.toContain("deferred"); }); it("accepts metadata-only bundle plugins as loaded runtimes", () => { @@ -136,7 +136,7 @@ describe("getLoadedRuntimePluginRegistry", () => { requiredPluginIds: ["bundle"], }), ).toBe(bundleRegistry); - expect(listLoadedRuntimePluginIdsAcrossSurfaces()).toContain("bundle"); + expect(listLoadedRuntimePluginIds()).toContain("bundle"); }); it("does not reuse workspace-agnostic registries for workspace-specific requests", () => { diff --git a/src/plugins/active-runtime-registry.ts b/src/plugins/active-runtime-registry.ts index f076ffb28380..d89354372bee 100644 --- a/src/plugins/active-runtime-registry.ts +++ b/src/plugins/active-runtime-registry.ts @@ -2,15 +2,7 @@ import { normalizeSortedUniqueStringEntries } from "@openclaw/normalization-core/string-normalization"; import { resolveCompatibleRuntimePluginRegistry, type PluginLoadOptions } from "./loader.js"; import type { PluginRecord, PluginRegistry } from "./registry-types.js"; -import { - collectLivePluginRegistries, - getActivePluginChannelRegistry, - getActivePluginHttpRouteRegistry, - getActivePluginRegistry, - getActivePluginRegistryWorkspaceDir, -} from "./runtime.js"; - -export type ActiveRuntimePluginRegistrySurface = "active" | "channel" | "http-route"; +import { getActivePluginRegistry, getActivePluginRegistryWorkspaceDir } from "./runtime.js"; export function getActiveRuntimePluginRegistry(): PluginRegistry | null { return getActivePluginRegistry(); @@ -20,22 +12,12 @@ function isRuntimePluginRecordLoaded(plugin: PluginRecord): boolean { return plugin.status === "loaded" && (plugin.format === "bundle" || plugin.imported !== false); } -// Plugin ids confirmed loaded across every live runtime registry surface -// (active plus any pinned http-route/channel/session-extension registry), via -// the canonical collectLivePluginRegistries() set. A plugin can stay live via a -// pinned surface that diverged from the active registry, so reading "loaded" -// from the active registry alone would mislabel it. No-op when the surfaces are -// synced to the active registry (the common case). -export function listLoadedRuntimePluginIdsAcrossSurfaces(): string[] { - const loaded: string[] = []; - for (const registry of collectLivePluginRegistries()) { - for (const plugin of registry.plugins ?? []) { - if (isRuntimePluginRecordLoaded(plugin)) { - loaded.push(plugin.id); - } - } - } - return normalizeSortedUniqueStringEntries(loaded); +export function listLoadedRuntimePluginIds(): string[] { + return normalizeSortedUniqueStringEntries( + (getActivePluginRegistry()?.plugins ?? []) + .filter(isRuntimePluginRecordLoaded) + .map((plugin) => plugin.id), + ); } function normalizeRequiredPluginIds(ids?: readonly string[]): string[] | undefined { @@ -93,34 +75,18 @@ export function registryContainsRuntimePluginIds( return pluginIds.every((pluginId) => loaded.has(pluginId)); } -function resolveSurfaceRegistry( - surface: ActiveRuntimePluginRegistrySurface, -): PluginRegistry | null { - switch (surface) { - case "active": - return getActivePluginRegistry(); - case "channel": - return getActivePluginChannelRegistry(); - case "http-route": - return getActivePluginHttpRouteRegistry(); - } - return null; -} - export function getLoadedRuntimePluginRegistry( params: { env?: NodeJS.ProcessEnv; loadOptions?: PluginLoadOptions; workspaceDir?: string; requiredPluginIds?: readonly string[]; - surface?: ActiveRuntimePluginRegistrySurface; } = {}, ): PluginRegistry | undefined { - const surface = params.surface ?? "active"; const requiredPluginIds = normalizeRequiredPluginIds( params.requiredPluginIds ?? params.loadOptions?.onlyPluginIds, ); - if (surface === "active" && params.loadOptions && requiredPluginIds?.length !== 0) { + if (params.loadOptions && requiredPluginIds?.length !== 0) { const compatible = resolveCompatibleRuntimePluginRegistry(params.loadOptions); if (!compatible || !registryContainsRuntimePluginIds(compatible, requiredPluginIds)) { return undefined; @@ -133,7 +99,7 @@ export function getLoadedRuntimePluginRegistry( if (requestedWorkspaceDir !== undefined && activeWorkspaceDir !== requestedWorkspaceDir) { return undefined; } - const registry = resolveSurfaceRegistry(surface); + const registry = getActivePluginRegistry(); if (!registry) { return undefined; } diff --git a/src/plugins/bundled-capability-runtime.test.ts b/src/plugins/bundled-capability-runtime.test.ts index df44fdc4b76f..976f1394a0cc 100644 --- a/src/plugins/bundled-capability-runtime.test.ts +++ b/src/plugins/bundled-capability-runtime.test.ts @@ -13,7 +13,6 @@ import { import { createEmptyPluginRegistry } from "./registry-empty.js"; import { captureActivePluginRegistrySnapshot, - collectLivePluginRegistries, getActivePluginRegistry, getPluginRegistrationContext, listImportedRuntimePluginIds, @@ -152,7 +151,6 @@ describe("loadBundledCapabilityRuntimeRegistry", () => { const active = createEmptyPluginRegistry(); setActivePluginRegistry(active, "existing-registry"); const activeSnapshotBefore = captureActivePluginRegistrySnapshot(); - const liveRegistriesBefore = collectLivePluginRegistries(); const registrationContextBefore = getPluginRegistrationContext(); const registry = loadBundledCapabilityRuntimeRegistry({ @@ -165,7 +163,6 @@ describe("loadBundledCapabilityRuntimeRegistry", () => { expect(registry.providers.map((entry) => entry.provider.id)).toEqual([target.id]); expect(getActivePluginRegistry()).toBe(active); expect(captureActivePluginRegistrySnapshot()).toEqual(activeSnapshotBefore); - expect(collectLivePluginRegistries()).toEqual(liveRegistriesBefore); expect(getPluginRegistrationContext()).toBe(registrationContextBefore); expect(listImportedRuntimePluginIds()).toContain(target.id); }); diff --git a/src/plugins/cli.test.ts b/src/plugins/cli.test.ts index 26f767af5b54..2bf058c1b45e 100644 --- a/src/plugins/cli.test.ts +++ b/src/plugins/cli.test.ts @@ -20,6 +20,7 @@ const mocks = vi.hoisted(() => ({ vi.mock("./loader.js", () => ({ loadOpenClawPluginCliRegistry: (...args: unknown[]) => mocks.loadOpenClawPluginCliRegistry(...args), + loadPluginRegistryHandle: (...args: unknown[]) => mocks.loadOpenClawPlugins(...args), loadOpenClawPlugins: (...args: unknown[]) => mocks.loadOpenClawPlugins(...args), })); @@ -385,7 +386,6 @@ describe("registerPluginCliCommands", () => { expect(loadOptions.autoEnabledReasons).toEqual({ demo: ["demo configured"], }); - expect(loadOptions.activate).toBe(false); expect(loadOptions.cache).toBe(false); expect(loadOptions.forceFullRuntimeForChannelPlugins).toBe(true); expect(mocks.loadOpenClawPluginCliRegistry).not.toHaveBeenCalled(); diff --git a/src/plugins/contracts/host-hooks.contract.test.ts b/src/plugins/contracts/host-hooks.contract.test.ts index ca76ce475517..d8f5af1fd201 100644 --- a/src/plugins/contracts/host-hooks.contract.test.ts +++ b/src/plugins/contracts/host-hooks.contract.test.ts @@ -48,11 +48,7 @@ import { import { buildPluginAgentTurnPrepareContext, isPluginJsonValue } from "../host-hooks.js"; import { createEmptyPluginRegistry } from "../registry-empty.js"; import { createPluginRegistry } from "../registry.js"; -import { - pinActivePluginSessionExtensionRegistry, - releasePinnedPluginSessionExtensionRegistry, - setActivePluginRegistry, -} from "../runtime.js"; +import { setActivePluginRegistry } from "../runtime.js"; import type { PluginRuntime } from "../runtime/types.js"; import { createPluginRecord } from "../status.test-helpers.js"; import { @@ -173,7 +169,6 @@ async function withHostHookState( describe("host-hook fixture plugin contract", () => { afterEach(() => { - releasePinnedPluginSessionExtensionRegistry(); setActivePluginRegistry(createEmptyPluginRegistry()); clearPluginHostRuntimeState(); resetAgentEventsForTest(); @@ -2143,58 +2138,6 @@ 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/scheduled-turns.contract.test.ts b/src/plugins/contracts/scheduled-turns.contract.test.ts index 7956585fb9b0..8b19834a60a7 100644 --- a/src/plugins/contracts/scheduled-turns.contract.test.ts +++ b/src/plugins/contracts/scheduled-turns.contract.test.ts @@ -24,17 +24,7 @@ import { } from "../host-hook-scheduled-turns.js"; import { loadOpenClawPlugins } from "../loader.js"; import { clearPluginLoaderCache, makeTempDir, writePlugin } from "../loader.test-fixtures.js"; -import { createEmptyPluginRegistry } from "../registry-empty.js"; -import { - pinActivePluginChannelRegistry, - pinActivePluginHttpRouteRegistry, - pinActivePluginSessionExtensionRegistry, - releasePinnedPluginChannelRegistry, - releasePinnedPluginHttpRouteRegistry, - releasePinnedPluginSessionExtensionRegistry, - resetPluginRuntimeStateForTest, - setActivePluginRegistry, -} from "../runtime.js"; +import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../runtime.js"; import { createPluginRecord } from "../status.test-helpers.js"; import type { OpenClawPluginApi } from "../types.js"; @@ -219,9 +209,6 @@ describe("plugin scheduled turns", () => { afterEach(() => { vi.useRealTimers(); clearPluginLoaderCache(); - releasePinnedPluginChannelRegistry(); - releasePinnedPluginHttpRouteRegistry(); - releasePinnedPluginSessionExtensionRegistry(); clearPluginHostRuntimeState(); resetPluginRuntimeStateForTest(); }); @@ -1114,76 +1101,6 @@ describe("plugin scheduled turns", () => { expect(workflowMocks.cronRemove).not.toHaveBeenCalled(); }); - it("keeps pinned scheduled-turn APIs live until their registry retires", async () => { - const scheduledIds = ["job-live", "job-pinned"]; - workflowMocks.cronAdd.mockImplementation(async () => - makeCronJob({ id: scheduledIds.shift() ?? "unexpected-job" }), - ); - const { config, registry } = createPluginRegistryFixture({}, { hostServices: { cron } }); - let capturedApi: OpenClawPluginApi | undefined; - registerTestPlugin({ - registry, - config, - record: createPluginRecord({ - id: "scheduler-plugin", - name: "Scheduler Plugin", - origin: "bundled", - }), - register(api) { - capturedApi = api; - }, - }); - setActivePluginRegistry(registry.registry); - - const liveHandle = await capturedApi?.session.workflow.scheduleSessionTurn({ - sessionKey: "agent:main:main", - message: "wake", - delayMs: 10, - }); - expectSessionTurnHandle(liveHandle, "job-live", "scheduler-plugin"); - await expect( - capturedApi?.session.workflow.unscheduleSessionTurnsByTag({ - sessionKey: "agent:main:main", - tag: "nudge", - }), - ).resolves.toEqual({ removed: 0, failed: 0 }); - - pinActivePluginChannelRegistry(registry.registry); - pinActivePluginHttpRouteRegistry(registry.registry); - pinActivePluginSessionExtensionRegistry(registry.registry); - setActivePluginRegistry(createEmptyPluginRegistry()); - const pinnedHandle = await capturedApi?.session.workflow.scheduleSessionTurn({ - sessionKey: "agent:main:main", - message: "wake while pinned", - cron: "* * * * *", - tz: "UTC", - }); - expectSessionTurnHandle(pinnedHandle, "job-pinned", "scheduler-plugin"); - await expect( - capturedApi?.session.workflow.unscheduleSessionTurnsByTag({ - sessionKey: "agent:main:main", - tag: "nudge", - }), - ).resolves.toEqual({ removed: 0, failed: 0 }); - - releasePinnedPluginChannelRegistry(registry.registry); - releasePinnedPluginHttpRouteRegistry(registry.registry); - releasePinnedPluginSessionExtensionRegistry(registry.registry); - await expect( - capturedApi?.session.workflow.scheduleSessionTurn({ - sessionKey: "agent:main:main", - message: "wake", - delayMs: 10, - }), - ).resolves.toBeUndefined(); - await expect( - capturedApi?.session.workflow.unscheduleSessionTurnsByTag({ - sessionKey: "agent:main:main", - tag: "nudge", - }), - ).resolves.toEqual({ removed: 0, failed: 0 }); - }); - it("resolves live cron service for captured plugin scheduled-turn APIs", async () => { const firstCron = createMockCronService(); const secondCron = createMockCronService(); diff --git a/src/plugins/contracts/session-actions.contract.test.ts b/src/plugins/contracts/session-actions.contract.test.ts index 3b52137a0424..f0d4b091c8e4 100644 --- a/src/plugins/contracts/session-actions.contract.test.ts +++ b/src/plugins/contracts/session-actions.contract.test.ts @@ -13,11 +13,7 @@ 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 { - pinActivePluginSessionExtensionRegistry, - releasePinnedPluginSessionExtensionRegistry, - setActivePluginRegistry, -} from "../runtime.js"; +import { setActivePluginRegistry } from "../runtime.js"; import { createPluginRecord } from "../status.test-fixtures.js"; import type { OpenClawPluginApi } from "../types.js"; @@ -157,7 +153,6 @@ function registerActionFixture(params: { describe("plugin session actions", () => { afterEach(() => { - releasePinnedPluginSessionExtensionRegistry(); setActivePluginRegistry(createEmptyPluginRegistry()); resetAgentEventsForTest(); }); @@ -617,58 +612,6 @@ 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/contracts/session-attachments.contract.test.ts b/src/plugins/contracts/session-attachments.contract.test.ts index 74ce95d3e379..67a3395dd38f 100644 --- a/src/plugins/contracts/session-attachments.contract.test.ts +++ b/src/plugins/contracts/session-attachments.contract.test.ts @@ -2,10 +2,7 @@ import * as fs from "node:fs/promises"; import path from "node:path"; import { FILE_TYPE_SNIFF_MAX_BYTES } from "@openclaw/media-core/mime"; -import { - createPluginRegistryFixture, - registerTestPlugin, -} from "openclaw/plugin-sdk/plugin-test-contracts"; +import { registerTestPlugin } from "openclaw/plugin-sdk/plugin-test-contracts"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { SessionEntry } from "../../config/sessions.js"; import { replaceSessionEntry } from "../../config/sessions/session-accessor.js"; @@ -17,16 +14,7 @@ import { sendPluginSessionAttachment } from "../host-hook-attachments.js"; import { clearPluginLoaderCache } from "../loader.test-fixtures.js"; import { createEmptyPluginRegistry } from "../registry-empty.js"; import { createPluginRegistry } from "../registry.js"; -import { - pinActivePluginChannelRegistry, - pinActivePluginHttpRouteRegistry, - pinActivePluginSessionExtensionRegistry, - releasePinnedPluginChannelRegistry, - releasePinnedPluginHttpRouteRegistry, - releasePinnedPluginSessionExtensionRegistry, - resetPluginRuntimeStateForTest, - setActivePluginRegistry, -} from "../runtime.js"; +import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../runtime.js"; import type { PluginRuntime } from "../runtime/types.js"; import { createPluginRecord } from "../status.test-helpers.js"; import type { OpenClawPluginApi } from "../types.js"; @@ -148,9 +136,6 @@ describe("plugin session attachments", () => { afterEach(() => { workflowMocks.getChannelPlugin.mockReset(); workflowMocks.sendMessage.mockReset(); - releasePinnedPluginChannelRegistry(); - releasePinnedPluginHttpRouteRegistry(); - releasePinnedPluginSessionExtensionRegistry(); resetPluginRuntimeStateForTest(); clearPluginLoaderCache(); delete (globalThis as { proofAttachmentApi?: OpenClawPluginApi }).proofAttachmentApi; @@ -490,55 +475,6 @@ describe("plugin session attachments", () => { }); }); - it("keeps pinned attachment APIs live until their registry retires", async () => { - await withSessionStore(async ({ storePath, filePath }) => { - await writeSessionEntry(storePath); - mockSuccessfulAttachmentDelivery(); - - const { config, registry } = createPluginRegistryFixture({ session: { store: storePath } }); - let capturedApi: OpenClawPluginApi | undefined; - registerTestPlugin({ - registry, - config, - record: createPluginRecord({ - id: "attachment-plugin", - name: "Attachment Plugin", - origin: "bundled", - }), - register(api) { - capturedApi = api; - }, - }); - setActivePluginRegistry(registry.registry); - - const firstResult = await capturedApi?.sendSessionAttachment({ - sessionKey: MAIN_SESSION_KEY, - files: [{ path: filePath }], - }); - expectTelegramAttachmentResult(firstResult, 1); - - pinActivePluginChannelRegistry(registry.registry); - pinActivePluginHttpRouteRegistry(registry.registry); - pinActivePluginSessionExtensionRegistry(registry.registry); - setActivePluginRegistry(createEmptyPluginRegistry()); - const pinnedResult = await capturedApi?.sendSessionAttachment({ - sessionKey: MAIN_SESSION_KEY, - files: [{ path: filePath }], - }); - expectTelegramAttachmentResult(pinnedResult, 1); - - releasePinnedPluginChannelRegistry(registry.registry); - releasePinnedPluginHttpRouteRegistry(registry.registry); - releasePinnedPluginSessionExtensionRegistry(registry.registry); - await expect( - capturedApi?.sendSessionAttachment({ - sessionKey: MAIN_SESSION_KEY, - files: [{ path: filePath }], - }), - ).resolves.toEqual({ ok: false, error: "plugin is not loaded" }); - }); - }); - it("uses the live runtime config when a captured API sends an attachment", async () => { await withSessionStore(async ({ stateDir, storePath, filePath }) => { await writeSessionEntry(storePath); diff --git a/src/plugins/hook-runner-global-state.ts b/src/plugins/hook-runner-global-state.ts index f9aabd7317f3..229e0fe07b85 100644 --- a/src/plugins/hook-runner-global-state.ts +++ b/src/plugins/hook-runner-global-state.ts @@ -1,5 +1,4 @@ -import { expectDefined } from "@openclaw/normalization-core"; -// Internal state and composed-registry view for the global hook runner. +// Internal state and live registry view for the global hook runner. import { resolveGlobalSingleton } from "../shared/global-singleton.js"; import type { GlobalHookRunnerRegistry } from "./hook-registry.types.js"; import type { HookRunner } from "./hooks.js"; @@ -8,8 +7,8 @@ import type { PluginRegistry, PluginTrustedToolPolicyRegistryRegistration, } from "./registry-types.js"; -import { getPluginRegistryState } from "./runtime-state.js"; -import { collectLivePluginRegistries } from "./runtime.js"; +import { getActivePluginRegistry } from "./runtime.js"; +import { getPluginRuntimeGatewayRequestScope } from "./runtime/gateway-request-scope.js"; type TrustedPolicyHookRunnerRegistry = GlobalHookRunnerRegistry & { trustedToolPolicies?: PluginTrustedToolPolicyRegistryRegistration[]; @@ -29,232 +28,121 @@ export function getHookRunnerGlobalState(): HookRunnerGlobalState { })); } -function collectHookRegistrySources( - lastInitialized: TrustedPolicyHookRunnerRegistry | null, -): TrustedPolicyHookRunnerRegistry[] { - const ordered: TrustedPolicyHookRunnerRegistry[] = []; - const seen = new Set(); - const add = (registry: TrustedPolicyHookRunnerRegistry | null) => { - if (!registry || seen.has(registry)) { - return; - } - // Retired registries were superseded by a newer activation; dispatching - // their hooks would resurrect stale config closures. Only lastInitialized - // can be retired here (the live registries below are active/pinned, never - // retired); SDK-supplied registries are not PluginRegistry and never match. - if (isPluginRegistryRetired(registry as PluginRegistry)) { - return; - } - seen.add(registry); - ordered.push(registry); - }; - const liveRegistries = collectLivePluginRegistries(); - const initializedLiveRegistry = liveRegistries.some((registry) => registry === lastInitialized); - // SDK callers can initialize an isolated registry and expect it to stay - // authoritative. Runtime activations compose all live registries; owner - // selection below aligns same-plugin hooks with their tool registry. - if (!initializedLiveRegistry) { - add(lastInitialized); +function resolveRootHookRegistry( + state: HookRunnerGlobalState, +): TrustedPolicyHookRunnerRegistry | null { + const activeRegistry = getActivePluginRegistry(); + const initializedRegistry = + state.registry && !isPluginRegistryRetired(state.registry as PluginRegistry) + ? state.registry + : null; + if (!initializedRegistry || initializedRegistry === activeRegistry) { + return activeRegistry ?? initializedRegistry; } - for (const registry of liveRegistries) { - add(registry); - } - return ordered; + // SDK consumers can initialize an isolated hook registry while a process root + // exists. Preserve both sources, with the explicit initialization on top. + return overlayHookRegistries(activeRegistry, initializedRegistry); } -function composeLiveHookRegistry( - lastInitialized: TrustedPolicyHookRunnerRegistry | null, -): TrustedPolicyHookRunnerRegistry { - const sources = collectHookRegistrySources(lastInitialized); - // One source registry owns a plugin's entire contribution (status + hooks), - // so handlers never double-fire across registries and a plugin's hooks stay - // paired with the status the inbound-claim path reads. - const ownerSourceIndexByPluginId = new Map(); - const claimOwner = (pluginId: string, index: number) => { - if (!ownerSourceIndexByPluginId.has(pluginId)) { - ownerSourceIndexByPluginId.set(pluginId, index); - } - }; - // pluginIds each source actually contributes a hook for, so ownership can - // prefer a source that carries the plugin's hooks over a same-plugin record - // that loaded without any (e.g. a setup-runtime channel load registers the - // channel but not the plugin's api.on(...) hooks). - const hookPluginIdsBySource = sources.map((registry) => { - const ids = new Set(); - for (const hook of registry.typedHooks) { - ids.add(hook.pluginId); - } - for (const hook of registry.hooks) { - ids.add(hook.pluginId); - } - return ids; - }); - const liveRegistries = collectLivePluginRegistries(); - if (lastInitialized && !liveRegistries.includes(lastInitialized as PluginRegistry)) { - const isolatedSourceIndex = sources.indexOf(lastInitialized); - if (isolatedSourceIndex >= 0) { - for (const pluginId of expectDefined( - hookPluginIdsBySource[isolatedSourceIndex], - "isolated hook plugin ids", - )) { - claimOwner(pluginId, isolatedSourceIndex); - } - } +function overlayHookRegistries( + baseRegistry: TrustedPolicyHookRunnerRegistry | null, + overlayRegistry: TrustedPolicyHookRunnerRegistry | null, +): TrustedPolicyHookRunnerRegistry | null { + if (!overlayRegistry || overlayRegistry === baseRegistry) { + return baseRegistry; } - const claimToolOwners = (registry: PluginRegistry | null | undefined) => { - if (!registry) { - return; + if (!baseRegistry) { + return overlayRegistry; + } + + // Each higher-precedence source overlays only the contributions it carries. A + // partial or failed source must not hide unrelated fail-closed hooks or policy. + const overlayPluginIds = new Set(overlayRegistry.plugins.map((plugin) => plugin.id)); + const overlayLegacyHookEvents = new Map>(); + for (const hook of overlayRegistry.hooks) { + if (!Array.isArray(hook.events)) { + continue; } - const sourceIndex = sources.indexOf(registry); - if (sourceIndex < 0) { - return; - } - for (const tool of registry.tools) { - claimOwner(tool.pluginId, sourceIndex); - } - }; - const runtimeState = getPluginRegistryState(); - // Match tool resolution: an isolated initialized registry stays authoritative, - // then the pinned Gateway owner wins only for tools it actually registered, - // followed by the active registry for remaining tool owners. - claimToolOwners(runtimeState?.channel.pinned ? runtimeState.channel.registry : null); - claimToolOwners(runtimeState?.activeRegistry); - // Prefer the highest-precedence source where the plugin loaded AND actually - // contributes a hook, so a loaded-but-hookless record (failed/disabled scoped - // reload, or a setup-runtime channel load) cannot shadow a lower-precedence - // registration that still carries a fail-closed tool-call gate. - sources.forEach((registry, index) => { - for (const plugin of registry.plugins) { - if ( - plugin.status === "loaded" && - expectDefined(hookPluginIdsBySource[index], "hook plugin ids by source entry at index").has( - plugin.id, - ) - ) { - claimOwner(plugin.id, index); - } + const events = overlayLegacyHookEvents.get(hook.pluginId) ?? new Set(); + for (const event of hook.events) { + events.add(event); } + overlayLegacyHookEvents.set(hook.pluginId, events); + } + const overlayTypedHooks = new Set( + overlayRegistry.typedHooks.map((hook) => `${hook.pluginId}\0${hook.hookName}`), + ); + const overlayTrustedPolicies = new Set( + (overlayRegistry.trustedToolPolicies ?? []).map( + (entry) => `${entry.pluginId}\0${entry.policy.id}`, + ), + ); + const trustedToolPolicies = [ + ...(baseRegistry.trustedToolPolicies ?? []).filter( + (entry) => !overlayTrustedPolicies.has(`${entry.pluginId}\0${entry.policy.id}`), + ), + ...(overlayRegistry.trustedToolPolicies ?? []), + ].toSorted((left, right) => { + const leftRank = left.origin === "bundled" ? 0 : 1; + const rightRank = right.origin === "bundled" ? 0 : 1; + return leftRank - rightRank; }); - // Then a loaded record owns the plugin's status when no live source - // contributes a hook for it, keeping status paired with a single owner. - sources.forEach((registry, index) => { - for (const plugin of registry.plugins) { - if (plugin.status === "loaded") { - claimOwner(plugin.id, index); - } - } - }); - sources.forEach((registry, index) => { - for (const plugin of registry.plugins) { - claimOwner(plugin.id, index); - } - }); - // Defensive: claim any hook whose plugin record is absent from .plugins so a - // malformed registry never silently drops a registered hook. - sources.forEach((registry, index) => { - for (const hook of registry.typedHooks) { - claimOwner(hook.pluginId, index); - } - for (const hook of registry.hooks) { - claimOwner(hook.pluginId, index); - } - }); - const policyOwnerSourceIndexByPluginId = new Map(); - const claimPolicyOwner = (pluginId: string, index: number) => { - if (!policyOwnerSourceIndexByPluginId.has(pluginId)) { - policyOwnerSourceIndexByPluginId.set(pluginId, index); - } - }; - const trustedPolicyPluginIdsBySource = sources.map((registry) => { - const ids = new Set(); - for (const registration of registry.trustedToolPolicies ?? []) { - ids.add(registration.pluginId); - } - return ids; - }); - sources.forEach((registry, index) => { - for (const plugin of registry.plugins) { - if ( - plugin.status === "loaded" && - expectDefined( - trustedPolicyPluginIdsBySource[index], - "trusted policy plugin ids by source entry at index", - ).has(plugin.id) - ) { - claimPolicyOwner(plugin.id, index); - } - } - }); - sources.forEach((registry, index) => { - for (const plugin of registry.plugins) { - if (plugin.status === "loaded") { - claimPolicyOwner(plugin.id, index); - } - } - }); - sources.forEach((registry, index) => { - for (const plugin of registry.plugins) { - claimPolicyOwner(plugin.id, index); - } - }); - sources.forEach((registry, index) => { - for (const registration of registry.trustedToolPolicies ?? []) { - claimPolicyOwner(registration.pluginId, index); - } - }); - const trustedToolPolicies = sources - .flatMap((registry, index) => - (registry.trustedToolPolicies ?? []).filter( - (registration) => policyOwnerSourceIndexByPluginId.get(registration.pluginId) === index, - ), - ) - // Preserve the trusted-policy tier contract across composed registries: - // bundled policies run before installed policies, and same-tier entries - // keep the source/plugin-load order selected above. - .toSorted((left, right) => { - const leftRank = left.origin === "bundled" ? 0 : 1; - const rightRank = right.origin === "bundled" ? 0 : 1; - return leftRank - rightRank; - }); return { - hooks: sources.flatMap((registry, index) => - registry.hooks.filter((hook) => ownerSourceIndexByPluginId.get(hook.pluginId) === index), - ), - typedHooks: sources.flatMap((registry, index) => - registry.typedHooks.filter((hook) => ownerSourceIndexByPluginId.get(hook.pluginId) === index), - ), - plugins: sources.flatMap((registry, index) => - registry.plugins.filter((plugin) => ownerSourceIndexByPluginId.get(plugin.id) === index), - ), + hooks: [ + ...baseRegistry.hooks.flatMap((hook) => { + const overlayEvents = overlayLegacyHookEvents.get(hook.pluginId); + if (!overlayEvents || !Array.isArray(hook.events)) { + return hook; + } + const events = hook.events.filter((event) => !overlayEvents.has(event)); + return events.length === 0 ? [] : [{ ...hook, events }]; + }), + ...overlayRegistry.hooks, + ], + typedHooks: [ + ...baseRegistry.typedHooks.filter( + (hook) => !overlayTypedHooks.has(`${hook.pluginId}\0${hook.hookName}`), + ), + ...overlayRegistry.typedHooks, + ], + plugins: [ + ...baseRegistry.plugins.filter((plugin) => !overlayPluginIds.has(plugin.id)), + ...overlayRegistry.plugins, + ], trustedToolPolicies, }; } -export function createComposedHookRegistryFacade( +function resolveHookRegistry(state: HookRunnerGlobalState): TrustedPolicyHookRunnerRegistry | null { + return overlayHookRegistries( + resolveRootHookRegistry(state), + getPluginRuntimeGatewayRequestScope()?.pluginRegistry ?? null, + ); +} + +export function createLiveHookRegistryFacade( state: HookRunnerGlobalState, ): TrustedPolicyHookRunnerRegistry { - // Live getters: createHookRunner reads these on every hasHooks/getHooksForName - // call, so the runner always dispatches the current live registry set rather - // than a snapshot captured at initialization. Composition is bounded by the - // small live registry set and runs on hook-paced events, not tight loops. + // The runner object stays stable while these getters select the current request + // handle or process root on every dispatch. return { get hooks() { - return composeLiveHookRegistry(state.registry).hooks; + return resolveHookRegistry(state)?.hooks ?? []; }, get typedHooks() { - return composeLiveHookRegistry(state.registry).typedHooks; + return resolveHookRegistry(state)?.typedHooks ?? []; }, get plugins() { - return composeLiveHookRegistry(state.registry).plugins; + return resolveHookRegistry(state)?.plugins ?? []; }, get trustedToolPolicies() { - return composeLiveHookRegistry(state.registry).trustedToolPolicies; + return resolveHookRegistry(state)?.trustedToolPolicies ?? []; }, }; } -/** Get the composed registry that backs global hook dispatch. */ +/** Get the registry view that backs global hook dispatch. */ export function getGlobalHookRunnerRegistry(): TrustedPolicyHookRunnerRegistry | null { const state = getHookRunnerGlobalState(); - return state.registry ? createComposedHookRegistryFacade(state) : null; + return resolveHookRegistry(state) ? createLiveHookRegistryFacade(state) : null; } diff --git a/src/plugins/hook-runner-global.compose.test.ts b/src/plugins/hook-runner-global.compose.test.ts index 5b3d71773e85..c3c06017b11b 100644 --- a/src/plugins/hook-runner-global.compose.test.ts +++ b/src/plugins/hook-runner-global.compose.test.ts @@ -1,11 +1,3 @@ -/** - * Composition rules for the global hook runner's live registry view (#91918). - * These exercise the ownership/precedence/liveness decisions directly with - * mock registries, complementing the real-load kill-chain coverage in - * loader.hook-runner-live-view.test.ts. - */ - -import { expectDefined } from "@openclaw/normalization-core"; import { afterEach, describe, expect, it, vi } from "vitest"; import { getGlobalHookRunnerRegistry } from "./hook-runner-global-state.js"; import { @@ -15,12 +7,9 @@ import { } from "./hook-runner-global.js"; import { addTestHook, createMockPluginRegistry } from "./hooks.test-fixtures.js"; import type { PluginRegistry } from "./registry.js"; -import { - pinActivePluginChannelRegistry, - resetPluginRuntimeStateForTest, - setActivePluginRegistry, -} from "./runtime.js"; -import { createPluginRecord } from "./status.test-fixtures.js"; +import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "./runtime.js"; +import { withPluginRuntimeRegistryScope } from "./runtime/gateway-request-scope.js"; +import { createPluginRecord } from "./status.test-helpers.js"; function runner() { const value = getGlobalHookRunner(); @@ -30,37 +19,13 @@ function runner() { return value; } -function addToolOwner(registry: PluginRegistry, pluginId: string, toolName: string) { - registry.tools.push({ - pluginId, - factory: () => [], - names: [toolName], - declaredNames: [toolName], - optional: false, - source: "test", - }); -} - -function addChannelOwner(registry: PluginRegistry, pluginId: string) { - registry.channels.push({ - pluginId, - source: "test", - plugin: { - id: pluginId, - meta: { - id: pluginId, - label: pluginId, - selectionLabel: pluginId, - docsPath: `/channels/${pluginId}`, - blurb: "test", - }, - capabilities: { chatTypes: ["direct"] }, - config: { - listAccountIds: () => [], - resolveAccount: () => undefined, - }, - }, - }); +function toolCallContext() { + return { + agentId: "test-agent", + sessionKey: "test-session", + toolCallId: "test-call", + toolName: "read", + }; } afterEach(() => { @@ -68,231 +33,156 @@ afterEach(() => { resetPluginRuntimeStateForTest(); }); -describe("global hook runner composition (#91918, #107933)", () => { - it("uses the pinned gateway owner when the active registry has the same plugin", async () => { - const gatewayHook = vi.fn(); - const activeHook = vi.fn(); - const gateway = createMockPluginRegistry([ - { hookName: "before_tool_call", handler: gatewayHook, pluginId: "stateful" }, +describe("global hook runner registry selection", () => { + it("overlays a partial request registry and then returns to the process root", async () => { + const rootHook = vi.fn(); + const scopedHook = vi.fn(); + const root = createMockPluginRegistry([ + { hookName: "before_tool_call", handler: rootHook, pluginId: "root" }, ]); - const active = createMockPluginRegistry([ - { hookName: "before_tool_call", handler: activeHook, pluginId: "stateful" }, + const scoped = createMockPluginRegistry([ + { hookName: "before_tool_call", handler: scopedHook, pluginId: "scoped" }, ]); - addToolOwner(gateway, "stateful", "stateful_tool"); - addToolOwner(active, "stateful", "stateful_tool"); - - setActivePluginRegistry(gateway); - pinActivePluginChannelRegistry(gateway); - initializeGlobalHookRunner(gateway); - setActivePluginRegistry(active); - initializeGlobalHookRunner(active); - - await runner().runBeforeToolCall( - { toolName: "stateful_tool", params: {} }, + root.trustedToolPolicies = [ { - agentId: "test-agent", - sessionKey: "test-session", - toolCallId: "test-call", - toolName: "stateful_tool", - }, - ); - - // Plugin tools resolve from the pinned channel registry before the active - // registry. Hooks for that plugin must use the same registration closure. - expect(gatewayHook).toHaveBeenCalledOnce(); - expect(activeHook).not.toHaveBeenCalled(); - }); - - it("uses the active hook when the pinned registry has no matching tool owner", async () => { - const gatewayHook = vi.fn(); - const activeHook = vi.fn(); - const gateway = createMockPluginRegistry([ - { hookName: "before_tool_call", handler: gatewayHook, pluginId: "conditional" }, - ]); - const active = createMockPluginRegistry([ - { hookName: "before_tool_call", handler: activeHook, pluginId: "conditional" }, - ]); - addToolOwner(active, "conditional", "conditional_tool"); - - setActivePluginRegistry(gateway); - pinActivePluginChannelRegistry(gateway); - initializeGlobalHookRunner(gateway); - setActivePluginRegistry(active); - initializeGlobalHookRunner(active); - - await runner().runBeforeToolCall( - { toolName: "conditional_tool", params: {} }, - { - agentId: "test-agent", - sessionKey: "test-session", - toolCallId: "test-call", - toolName: "conditional_tool", - }, - ); - - expect(activeHook).toHaveBeenCalledOnce(); - expect(gatewayHook).not.toHaveBeenCalled(); - }); - - it("does not borrow a pinned hook when the active tool owner registered none", () => { - const gateway = createMockPluginRegistry([ - { hookName: "before_tool_call", handler: vi.fn(), pluginId: "conditional" }, - ]); - const active = createMockPluginRegistry([]); - expectDefined(active.plugins[0], "active.plugins[0] test invariant").id = "conditional"; - addToolOwner(active, "conditional", "conditional_tool"); - - setActivePluginRegistry(gateway); - pinActivePluginChannelRegistry(gateway); - initializeGlobalHookRunner(gateway); - setActivePluginRegistry(active); - initializeGlobalHookRunner(active); - - expect(runner().hasHooks("before_tool_call")).toBe(false); - }); - - it("prefers a loaded registration over a failed scoped reload of the same plugin", () => { - const boot = createMockPluginRegistry([ - { hookName: "before_tool_call", handler: vi.fn(), pluginId: "gate" }, - ]); - // Scoped reload where the gate plugin failed to register: record present, - // status not loaded, no hooks. - const scopedFailure = createMockPluginRegistry([]); - expectDefined(scopedFailure.plugins[0], "scopedFailure.plugins[0] test invariant").id = "gate"; - expectDefined(scopedFailure.plugins[0], "scopedFailure.plugins[0] test invariant").status = - "error"; - - setActivePluginRegistry(boot); - pinActivePluginChannelRegistry(boot); - initializeGlobalHookRunner(boot); - expect(runner().hasHooks("before_tool_call")).toBe(true); - - setActivePluginRegistry(scopedFailure); - initializeGlobalHookRunner(scopedFailure); - // The pinned boot registry still owns the loaded gate, so the fail-closed - // tool-call hook is not shadowed by the errored scoped record. - expect(runner().hasHooks("before_tool_call")).toBe(true); - }); - - it("prefers a loaded source that carries the hook over a loaded-but-hookless record", () => { - // Pinned boot registry: plugin C loaded WITH a fail-closed tool-call gate. - const boot = createMockPluginRegistry([ - { hookName: "before_tool_call", handler: vi.fn(), pluginId: "C" }, - ]); - // Scoped reload where C is present and loaded but registered no hooks - // (e.g. a setup-runtime channel load registers the channel, not api.on). - const scopedHookless = createMockPluginRegistry([]); - expectDefined(scopedHookless.plugins[0], "scopedHookless.plugins[0] test invariant").id = "C"; - expectDefined(scopedHookless.plugins[0], "scopedHookless.plugins[0] test invariant").status = - "loaded"; - - pinActivePluginChannelRegistry(boot); - setActivePluginRegistry(scopedHookless); - initializeGlobalHookRunner(scopedHookless); - // The hookless scoped record is highest precedence but must not shadow the - // pinned registration that actually carries C's gate. - expect(runner().hasHooks("before_tool_call")).toBe(true); - }); - - it("keeps a pinned registry with zero channels visible to hook dispatch", () => { - const hookOnlyPinned = createMockPluginRegistry([ - { hookName: "subagent_ended", handler: vi.fn(), pluginId: "hooky" }, - ]); - const channelActive = createMockPluginRegistry([ - { hookName: "message_sent", handler: vi.fn(), pluginId: "chan" }, - ]); - // Give the active registry a channel so the channel-presentation selector - // would prefer it and evict the zero-channel pinned registry — the raw - // live-registry collector must keep the pinned one regardless. - addChannelOwner(channelActive, "chan"); - - setActivePluginRegistry(channelActive); - pinActivePluginChannelRegistry(hookOnlyPinned); - initializeGlobalHookRunner(channelActive); - - expect(runner().hasHooks("subagent_ended")).toBe(true); - expect(runner().hasHooks("message_sent")).toBe(true); - }); - - it("keeps bundled trusted policies before installed policies across live registries", () => { - const pinnedBundled = createMockPluginRegistry([ - { hookName: "before_tool_call", handler: vi.fn(), pluginId: "bundled-policy" }, - ]); - pinnedBundled.plugins = [createPluginRecord({ id: "bundled-policy", origin: "bundled" })]; - pinnedBundled.trustedToolPolicies = [ - { - pluginId: "bundled-policy", - pluginName: "Bundled Policy", - origin: "bundled", + pluginId: "root", + pluginName: "Root", source: "test", - policy: { - id: "bundled-first", - description: "bundled policy", - evaluate: () => undefined, - }, + policy: { id: "root-policy", description: "root", evaluate: () => undefined }, }, ]; - const activeInstalled = createMockPluginRegistry([]); - activeInstalled.plugins = [createPluginRecord({ id: "installed-policy", origin: "workspace" })]; - activeInstalled.trustedToolPolicies = [ + scoped.trustedToolPolicies = [ { - pluginId: "installed-policy", - pluginName: "Installed Policy", - origin: "workspace", + pluginId: "scoped", + pluginName: "Scoped", source: "test", - policy: { - id: "installed-second", - description: "installed policy", - evaluate: () => undefined, - }, + policy: { id: "scoped-policy", description: "scoped", evaluate: () => undefined }, }, ]; - pinActivePluginChannelRegistry(pinnedBundled); - setActivePluginRegistry(activeInstalled); - initializeGlobalHookRunner(activeInstalled); + setActivePluginRegistry(root); + initializeGlobalHookRunner(root); + await withPluginRuntimeRegistryScope(scoped, async () => { + await runner().runBeforeToolCall({ toolName: "read", params: {} }, toolCallContext()); + expect( + getGlobalHookRunnerRegistry()?.trustedToolPolicies?.map((entry) => entry.policy.id), + ).toEqual(["root-policy", "scoped-policy"]); + }); + + expect(scopedHook).toHaveBeenCalledOnce(); + expect(rootHook).toHaveBeenCalledOnce(); + await runner().runBeforeToolCall({ toolName: "read", params: {} }, toolCallContext()); + expect(rootHook).toHaveBeenCalledTimes(2); + expect( + getGlobalHookRunnerRegistry()?.trustedToolPolicies?.map((entry) => entry.policy.id), + ).toEqual(["root-policy"]); + }); + + it("lets the request registry replace the same plugin without double dispatch", async () => { + const rootHook = vi.fn(); + const scopedHook = vi.fn(); + const root = createMockPluginRegistry([ + { hookName: "before_tool_call", handler: rootHook, pluginId: "shared" }, + ]); + const scoped = createMockPluginRegistry([ + { hookName: "before_tool_call", handler: scopedHook, pluginId: "shared" }, + ]); + setActivePluginRegistry(root); + initializeGlobalHookRunner(root); + + await withPluginRuntimeRegistryScope(scoped, () => + runner().runBeforeToolCall({ toolName: "read", params: {} }, toolCallContext()), + ); + + expect(scopedHook).toHaveBeenCalledOnce(); + expect(rootHook).not.toHaveBeenCalled(); + }); + + it("keeps root contributions when a same-plugin request handle has none", async () => { + const rootHook = vi.fn(); + const root = createMockPluginRegistry([ + { hookName: "before_tool_call", handler: rootHook, pluginId: "shared" }, + ]); + root.trustedToolPolicies = [ + { + pluginId: "shared", + pluginName: "Shared", + source: "test", + policy: { id: "shared-policy", description: "shared", evaluate: () => undefined }, + }, + ]; + const scoped = createMockPluginRegistry([]); + scoped.plugins = [ + createPluginRecord({ id: "shared", name: "Shared", status: "error", error: "load failed" }), + ]; + setActivePluginRegistry(root); + initializeGlobalHookRunner(root); + + await withPluginRuntimeRegistryScope(scoped, async () => { + await runner().runBeforeToolCall({ toolName: "read", params: {} }, toolCallContext()); + expect( + getGlobalHookRunnerRegistry()?.trustedToolPolicies?.map((entry) => entry.policy.id), + ).toEqual(["shared-policy"]); + }); + + expect(rootHook).toHaveBeenCalledOnce(); + }); + + it("overlays an explicitly initialized SDK registry on the process root", async () => { + const rootToolHook = vi.fn(); + const rootWriteHook = vi.fn(); + const sdkWriteHook = vi.fn(() => ({ + message: { role: "user", content: "sdk redaction", timestamp: 2 }, + })); + const root = createMockPluginRegistry([ + { hookName: "before_tool_call", handler: rootToolHook, pluginId: "shared" }, + { hookName: "before_message_write", handler: rootWriteHook, pluginId: "shared" }, + ]); + root.trustedToolPolicies = [ + { + pluginId: "shared", + pluginName: "Shared", + source: "test", + policy: { id: "shared-policy", description: "shared", evaluate: () => undefined }, + }, + ]; + const sdk = createMockPluginRegistry([ + { hookName: "before_message_write", handler: sdkWriteHook, pluginId: "shared" }, + ]); + + setActivePluginRegistry(root); + initializeGlobalHookRunner(sdk); expect( - getGlobalHookRunnerRegistry()?.trustedToolPolicies?.map((registration) => [ - registration.origin, - registration.policy.id, - ]), - ).toEqual([ - ["bundled", "bundled-first"], - ["workspace", "installed-second"], - ]); + runner().runBeforeMessageWrite( + { message: { role: "user", content: "private", timestamp: 1 } }, + { agentId: "test-agent", sessionKey: "test-session" }, + ), + ).toEqual({ message: { role: "user", content: "sdk redaction", timestamp: 2 } }); + expect(sdkWriteHook).toHaveBeenCalledOnce(); + expect(rootWriteHook).not.toHaveBeenCalled(); + + await runner().runBeforeToolCall({ toolName: "read", params: {} }, toolCallContext()); + expect(rootToolHook).toHaveBeenCalledOnce(); + expect( + getGlobalHookRunnerRegistry()?.trustedToolPolicies?.map((entry) => entry.policy.id), + ).toEqual(["shared-policy"]); }); - it("lets an explicitly initialized registry win ownership over the active registry", () => { - const activeRegistry = createMockPluginRegistry([ - { hookName: "message_received", handler: vi.fn(), pluginId: "foo" }, - ]); - const sdkRegistry = createMockPluginRegistry([ - { hookName: "message_sent", handler: vi.fn(), pluginId: "foo" }, - ]); - - setActivePluginRegistry(activeRegistry); - initializeGlobalHookRunner(sdkRegistry); - - // Last-initialized highest precedence: the SDK registry owns plugin "foo", - // so its hook dispatches and the active registry's "foo" hook is shadowed. - expect(runner().hasHooks("message_sent")).toBe(true); - expect(runner().hasHooks("message_received")).toBe(false); - }); - - it("dispatches hooks pushed into a registry after initialization", () => { + it("sees hooks added after initialization", () => { const registry: PluginRegistry = createMockPluginRegistry([ - { hookName: "message_received", handler: vi.fn(), pluginId: "p" }, + { hookName: "message_received", handler: vi.fn(), pluginId: "plugin" }, ]); - setActivePluginRegistry(registry); initializeGlobalHookRunner(registry); - // Read once so any internal caching would have settled. - expect(runner().hasHooks("message_received")).toBe(true); - expect(runner().hasHooks("message_sent")).toBe(false); - addTestHook({ registry, pluginId: "p", hookName: "message_sent", handler: vi.fn() }); - // Live composition: the late registration is visible without re-init. + expect(runner().hasHooks("message_sent")).toBe(false); + addTestHook({ + registry, + pluginId: "plugin", + hookName: "message_sent", + handler: vi.fn(), + }); expect(runner().hasHooks("message_sent")).toBe(true); }); }); diff --git a/src/plugins/hook-runner-global.test.ts b/src/plugins/hook-runner-global.test.ts index 210a446a04b8..0d344f5fbd7c 100644 --- a/src/plugins/hook-runner-global.test.ts +++ b/src/plugins/hook-runner-global.test.ts @@ -1,22 +1,12 @@ /** Verifies global hook runner sequencing, mutation, and error behavior. */ import { afterEach, describe, expect, it, vi } from "vitest"; import { createMockPluginRegistry } from "./hooks.test-fixtures.js"; -import { createEmptyPluginRegistry } from "./registry-empty.js"; -import { - pinActivePluginChannelRegistry, - releasePinnedPluginChannelRegistry, - setActivePluginRegistry, -} from "./runtime.js"; -import { createPluginRecord } from "./status.test-fixtures.js"; +import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "./runtime.js"; async function importHookRunnerGlobalModule() { return import("./hook-runner-global.js"); } -async function importHookRunnerGlobalStateModule() { - return import("./hook-runner-global-state.js"); -} - type HookRunnerGlobalModule = Awaited>; type HookRunner = NonNullable>; @@ -43,11 +33,12 @@ afterEach(async () => { vi.useRealTimers(); const mod = await importHookRunnerGlobalModule(); mod.resetGlobalHookRunner(); - setActivePluginRegistry(createEmptyPluginRegistry()); + resetPluginRuntimeStateForTest(); }); describe("hook-runner-global", () => { async function createInitializedModule() { + resetPluginRuntimeStateForTest(); const modA = await importHookRunnerGlobalModule(); const registry = createMockPluginRegistry([{ hookName: "message_received", handler: vi.fn() }]); modA.initializeGlobalHookRunner(registry); @@ -83,56 +74,6 @@ describe("hook-runner-global", () => { await expectGlobalRunnerState({ hasRunner: false }); }); - it("exposes trusted policies from the same live registry set as hooks", async () => { - const mod = await importHookRunnerGlobalModule(); - const gatewayRegistry = createMockPluginRegistry([ - { - hookName: "before_tool_call", - pluginId: "rovoclaw", - handler: vi.fn(), - }, - ]); - gatewayRegistry.plugins = [createPluginRecord({ id: "rovoclaw" })]; - gatewayRegistry.trustedToolPolicies = [ - { - pluginId: "rovoclaw", - pluginName: "RovoClaw", - source: "test", - policy: { - id: "atl-sec-core", - description: "trusted policy", - evaluate: () => undefined, - }, - }, - ]; - - setActivePluginRegistry(gatewayRegistry); - mod.initializeGlobalHookRunner(gatewayRegistry); - pinActivePluginChannelRegistry(gatewayRegistry); - try { - const laterRegistry = createEmptyPluginRegistry(); - laterRegistry.plugins = [createPluginRecord({ id: "openai" })]; - setActivePluginRegistry(laterRegistry); - mod.initializeGlobalHookRunner(laterRegistry); - - expect(expectGlobalHookRunner(mod.getGlobalHookRunner()).hasHooks("before_tool_call")).toBe( - true, - ); - expect(mod.getGlobalPluginRegistry()).toBe(laterRegistry); - const stateMod = await importHookRunnerGlobalStateModule(); - expect( - stateMod - .getGlobalHookRunnerRegistry() - ?.trustedToolPolicies?.map((registration) => [ - registration.pluginId, - registration.policy.id, - ]), - ).toEqual([["rovoclaw", "atl-sec-core"]]); - } finally { - releasePinnedPluginChannelRegistry(gatewayRegistry); - } - }); - it.each([ { hookName: "before_tool_call" as const, diff --git a/src/plugins/hook-runner-global.ts b/src/plugins/hook-runner-global.ts index dedfcf95dedc..9f65ed8f7d59 100644 --- a/src/plugins/hook-runner-global.ts +++ b/src/plugins/hook-runner-global.ts @@ -4,20 +4,15 @@ * Singleton hook runner that's initialized when plugins are loaded * and can be called from anywhere in the codebase. * - * The runner is created once and resolves hooks live on every dispatch from a - * composed view of the registries that are currently live: an explicitly - * initialized SDK registry, the pinned channel registry, the active registry, - * and other pinned surfaces. Freezing one registry caused scoped mid-run activations (harness - * and memory ensures) to rebind the runner to a narrow registry and silently - * drop other plugins' tool-call hooks (#91918). Composing live also preserves - * the older contract that hooks pushed into a registry after initialization - * (e.g. the SDK `addTestHook` helper) dispatch immediately. + * The runner is created once and resolves hooks live on every dispatch from the + * current request-scoped registry or process root. This also preserves the + * contract that hooks pushed after initialization dispatch immediately. */ import { createSubsystemLogger } from "../logging/subsystem.js"; import type { GlobalHookRunnerRegistry } from "./hook-registry.types.js"; import { - createComposedHookRegistryFacade, + createLiveHookRegistryFacade, getHookRunnerGlobalState, } from "./hook-runner-global-state.js"; import type { @@ -33,16 +28,14 @@ const getLog = () => createSubsystemLogger("plugins"); /** * Initialize the global hook runner with a plugin registry. * Called on every plugin registry activation and by SDK consumers. The runner - * instance stays stable so references captured mid-run keep seeing current - * hooks. An isolated SDK registry stays authoritative; runtime registries use - * the gateway surface precedence shared by plugin tool resolution. + * instance stays stable so references captured mid-run keep seeing current hooks. */ export function initializeGlobalHookRunner(registry: GlobalHookRunnerRegistry): void { const state = getHookRunnerGlobalState(); const log = getLog(); state.registry = registry; if (!state.hookRunner) { - state.hookRunner = createHookRunner(createComposedHookRegistryFacade(state), { + state.hookRunner = createHookRunner(createLiveHookRegistryFacade(state), { logger: { debug: (msg) => log.debug(msg), warn: (msg) => log.warn(msg), @@ -73,8 +66,7 @@ export function getGlobalHookRunner(): HookRunner | null { /** * Get the registry from the most recent activation or explicit initialization. - * Returns null if plugins haven't been loaded yet. Hook dispatch does not use - * this single registry; the runner resolves hooks from the live composed view. + * Returns null if plugins haven't been loaded yet. */ export function getGlobalPluginRegistry(): GlobalHookRunnerRegistry | null { return getHookRunnerGlobalState().registry; diff --git a/src/plugins/http-registry.test.ts b/src/plugins/http-registry.test.ts index 1d3420b14e29..338fe4bf9d72 100644 --- a/src/plugins/http-registry.test.ts +++ b/src/plugins/http-registry.test.ts @@ -4,12 +4,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { registerPluginHttpRoute, withPluginHttpRouteRegistry } from "./http-registry.js"; import { createEmptyPluginRegistry } from "./registry-empty.js"; import { createPluginRegistry } from "./registry.js"; -import { - pinActivePluginHttpRouteRegistry, - releasePinnedPluginHttpRouteRegistry, - resetPluginRuntimeStateForTest, - setActivePluginRegistry, -} from "./runtime.js"; +import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "./runtime.js"; import type { PluginRuntime } from "./runtime/types.js"; import { createPluginRecord } from "./status.test-fixtures.js"; @@ -88,7 +83,6 @@ function createLoggedRouteHarness() { describe("registerPluginHttpRoute", () => { afterEach(() => { - releasePinnedPluginHttpRouteRegistry(); resetPluginRuntimeStateForTest(); }); @@ -257,36 +251,11 @@ describe("registerPluginHttpRoute", () => { expect(registry.httpRoutes).toHaveLength(1); }); - it("uses the pinned route registry when the active registry changes later", () => { - const startupRegistry = createEmptyPluginRegistry(); - const laterActiveRegistry = createEmptyPluginRegistry(); - - setActivePluginRegistry(startupRegistry); - pinActivePluginHttpRouteRegistry(startupRegistry); - setActivePluginRegistry(laterActiveRegistry); - - const unregister = registerPluginHttpRoute({ - path: "/imessage-webhook", - auth: "plugin", - handler: vi.fn(), - }); - - expectRegisteredRouteShape(startupRegistry, { - path: "/imessage-webhook", - auth: "plugin", - }); - expect(laterActiveRegistry.httpRoutes).toHaveLength(0); - - unregister(); - expect(startupRegistry.httpRoutes).toHaveLength(0); - }); - - it("prefers the scoped route registry over the process-global pinned registry", () => { + it("prefers the scoped route registry over the process root", () => { const scopedRegistry = createEmptyPluginRegistry(); const pinnedRegistry = createEmptyPluginRegistry(); setActivePluginRegistry(pinnedRegistry); - pinActivePluginHttpRouteRegistry(pinnedRegistry); const unregister = withPluginHttpRouteRegistry(scopedRegistry, () => registerPluginHttpRoute({ diff --git a/src/plugins/interactive.ts b/src/plugins/interactive.ts index c7eb1f108fa1..e45797bea524 100644 --- a/src/plugins/interactive.ts +++ b/src/plugins/interactive.ts @@ -8,7 +8,7 @@ import { commitPluginInteractiveCallbackDedupe, releasePluginInteractiveCallbackDedupe, } from "./interactive-state.js"; -import { collectLivePluginRegistries } from "./runtime.js"; +import { getActivePluginRegistry } from "./runtime.js"; type InteractiveDispatchResult = | { matched: false; handled: false; duplicate: false } @@ -31,20 +31,12 @@ export { registerPluginInteractiveHandler, } from "./interactive-registry.js"; -function resolveLivePluginInteractiveNamespaceMatch(channel: string, data: string) { - // Registry membership is lifecycle-owned. Resolve registry registrations only - // through live owners so a replaced or released registry cannot keep executing. - for (const registry of collectLivePluginRegistries()) { - const match = resolvePluginInteractiveRegistrationsMatch( - registry.interactiveHandlers, - channel, - data, - ); - if (match) { - return match; - } - } - return null; +function resolveActivePluginInteractiveNamespaceMatch(channel: string, data: string) { + return resolvePluginInteractiveRegistrationsMatch( + getActivePluginRegistry()?.interactiveHandlers ?? [], + channel, + data, + ); } /** Dispatches one interactive callback payload to a matching plugin handler. */ @@ -59,7 +51,7 @@ export async function dispatchPluginInteractiveHandler< invoke: (match: PluginInteractiveMatch) => Promise | TResult; afterInvoke?: (result: TResult) => Promise | void; }): Promise> { - const match = resolveLivePluginInteractiveNamespaceMatch(params.channel, params.data); + const match = resolveActivePluginInteractiveNamespaceMatch(params.channel, params.data); if (!match) { return { matched: false, handled: false, duplicate: false }; } diff --git a/src/plugins/legacy-internal-hook-state.ts b/src/plugins/legacy-internal-hook-state.ts index 471c210def9e..a05117dd7ea0 100644 --- a/src/plugins/legacy-internal-hook-state.ts +++ b/src/plugins/legacy-internal-hook-state.ts @@ -1,38 +1,25 @@ import type { InternalHookHandler } from "../hooks/internal-hook-types.js"; import { - collectLivePluginRegistries, + getActivePluginRegistry, getPluginRegistrationContext, requireActivePluginRegistry, } from "./runtime.js"; -function listLiveRegistrations() { - const registrations = [] as ReturnType["legacyInternalHooks"]; - const seenPluginIds = new Set(); - for (const registry of collectLivePluginRegistries()) { - // Ownership is capability-specific: hookless scoped/setup registries must not shadow - // a pinned runtime that actually registered the plugin's legacy hooks. - registrations.push( - ...registry.legacyInternalHooks.filter((entry) => !seenPluginIds.has(entry.pluginId)), - ); - registry.legacyInternalHooks.forEach((entry) => seenPluginIds.add(entry.pluginId)); - } - return registrations; +function listActiveRegistrations() { + return getActivePluginRegistry()?.legacyInternalHooks ?? []; } export function listLegacyPluginInternalHooks(event: string): InternalHookHandler[] { - return listLiveRegistrations() + return listActiveRegistrations() .filter((registration) => registration.event === event) .map((registration) => registration.handler); } export function listLegacyPluginInternalHookEventKeys(): string[] { - return [...new Set(listLiveRegistrations().map((registration) => registration.event))]; + return [...new Set(listActiveRegistrations().map((registration) => registration.event))]; } export function clearLegacyPluginInternalHooks(): void { const context = getPluginRegistrationContext(); - const live = context ? [context.registry] : collectLivePluginRegistries(); - for (const registry of live.length > 0 ? live : [requireActivePluginRegistry()]) { - registry.legacyInternalHooks.length = 0; - } + (context?.registry ?? requireActivePluginRegistry()).legacyInternalHooks.length = 0; } diff --git a/src/plugins/loader-cache-instances.ts b/src/plugins/loader-cache-instances.ts deleted file mode 100644 index 31c5a56f1202..000000000000 --- a/src/plugins/loader-cache-instances.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { PluginLoaderCacheState } from "./loader-cache-state.js"; -import type { PluginRegistry } from "./registry-types.js"; - -export type CachedPluginState = PluginRegistry; - -const MAX_PLUGIN_REGISTRY_CACHE_ENTRIES = 128; - -export const pluginLoaderCacheInstances = { - scoped: new PluginLoaderCacheState(MAX_PLUGIN_REGISTRY_CACHE_ENTRIES), - fullWorkspace: new PluginLoaderCacheState(MAX_PLUGIN_REGISTRY_CACHE_ENTRIES), -}; diff --git a/src/plugins/loader-cache.ts b/src/plugins/loader-cache.ts index 9fcdbbe4cc81..802664a9c1d1 100644 --- a/src/plugins/loader-cache.ts +++ b/src/plugins/loader-cache.ts @@ -1,78 +1,26 @@ -import { pluginLoaderCacheInstances, type CachedPluginState } from "./loader-cache-instances.js"; +import { PluginLoaderCacheState } from "./loader-cache-state.js"; import { resolvePluginLoadCacheContext } from "./loader-load-context.js"; -import type { PluginLoadOptions, PluginRuntimeSubagentMode } from "./loader-types.js"; +import type { PluginLoadOptions } from "./loader-types.js"; import { clearPluginRuntimeArtifactResolutionMemo } from "./plugin-runtime-artifact-resolution.js"; +import type { PluginRegistry } from "./registry-types.js"; -export const pluginLoaderCacheState = pluginLoaderCacheInstances.scoped; -const fullWorkspacePluginLoaderCacheState = pluginLoaderCacheInstances.fullWorkspace; +const MAX_PLUGIN_REGISTRY_CACHE_ENTRIES = 128; -function getPluginRegistryCache(onlyPluginIds?: string[]) { - return onlyPluginIds ? pluginLoaderCacheState : fullWorkspacePluginLoaderCacheState; +export const pluginLoaderCacheState = new PluginLoaderCacheState( + MAX_PLUGIN_REGISTRY_CACHE_ENTRIES, +); + +export function setCachedPluginRegistry(cacheKey: string, registry: PluginRegistry): void { + pluginLoaderCacheState.set(cacheKey, registry); } -function getCachedPluginRegistry( - cacheKey: string, - onlyPluginIds?: string[], -): CachedPluginState | undefined { - return getPluginRegistryCache(onlyPluginIds).get(cacheKey); -} - -export function setCachedPluginRegistry( - cacheKey: string, - state: CachedPluginState, - onlyPluginIds?: string[], -): void { - getPluginRegistryCache(onlyPluginIds).set(cacheKey, state); -} - -export function getReusableCachedPluginRegistry(params: { - cacheKey: string; - onlyPluginIds: string[] | undefined; - runtimeSubagentMode: PluginRuntimeSubagentMode; - options: PluginLoadOptions; -}): - | { - state: CachedPluginState; - cacheKey: string; - runtimeSubagentMode: PluginRuntimeSubagentMode; - } - | undefined { - const exact = getCachedPluginRegistry(params.cacheKey, params.onlyPluginIds); - if (exact) { - return { - state: exact, - cacheKey: params.cacheKey, - runtimeSubagentMode: params.runtimeSubagentMode, - }; - } - if (params.runtimeSubagentMode !== "default") { - return undefined; - } - const gatewayBindableContext = resolvePluginLoadCacheContext({ - ...params.options, - runtimeOptions: { - ...params.options.runtimeOptions, - allowGatewaySubagentBinding: true, - }, - }); - const gatewayBindable = getCachedPluginRegistry( - gatewayBindableContext.cacheKey, - gatewayBindableContext.onlyPluginIds, - ); - if (!gatewayBindable) { - return undefined; - } - return { - state: gatewayBindable, - cacheKey: gatewayBindableContext.cacheKey, - runtimeSubagentMode: gatewayBindableContext.runtimeSubagentMode, - }; +export function getReusableCachedPluginRegistry(cacheKey: string): PluginRegistry | undefined { + return pluginLoaderCacheState.get(cacheKey); } export function clearPluginRegistryLoadCache(): void { clearPluginRuntimeArtifactResolutionMemo(); pluginLoaderCacheState.clearCachedRegistries(); - fullWorkspacePluginLoaderCacheState.clearCachedRegistries(); } export function resolvePluginRegistryLoadCacheKey(options: PluginLoadOptions = {}): string { diff --git a/src/plugins/loader-load-context.ts b/src/plugins/loader-load-context.ts index 93c8ad9eeae6..35bc4f9610dc 100644 --- a/src/plugins/loader-load-context.ts +++ b/src/plugins/loader-load-context.ts @@ -24,11 +24,7 @@ import { fingerprintPluginDiscoveryContext, resolvePluginDiscoveryContext, } from "./plugin-control-plane-context.js"; -import { - hasExplicitPluginIdScope, - normalizePluginIdScope, - serializePluginIdScope, -} from "./plugin-scope.js"; +import { normalizePluginIdScope, serializePluginIdScope } from "./plugin-scope.js"; import type { PluginSdkResolutionPreference } from "./sdk-alias.js"; function safeRealpathOrResolve(value: string): string { @@ -75,6 +71,30 @@ type BundledPackageCacheIdentity = { }; const bundledPackageCacheIdentityByStockRoot = new Map(); +const runtimeBindingCacheIds = new WeakMap(); +let nextRuntimeBindingCacheId = 1; + +function resolveRuntimeBindingCacheId(value: object | undefined): number | undefined { + if (!value) { + return undefined; + } + const existing = runtimeBindingCacheIds.get(value); + if (existing !== undefined) { + return existing; + } + const id = nextRuntimeBindingCacheId++; + runtimeBindingCacheIds.set(value, id); + return id; +} + +function resolveRuntimeBindingCacheIdentity( + runtimeOptions: PluginLoadOptions["runtimeOptions"], +): string { + return JSON.stringify({ + nodes: resolveRuntimeBindingCacheId(runtimeOptions?.nodes), + subagent: resolveRuntimeBindingCacheId(runtimeOptions?.subagent), + }); +} function resolveBundledPackageCacheIdentity( stockRoot?: string, @@ -170,6 +190,7 @@ function buildCacheKey(params: { toolDiscovery?: boolean; loadModules?: boolean; runtimeSubagentMode?: PluginRuntimeSubagentMode; + runtimeBindingIdentity?: string; pluginSdkResolution?: PluginSdkResolutionPreference; coreGatewayMethodNames?: string[]; activate?: boolean; @@ -227,7 +248,7 @@ function buildCacheKey(params: { loadPaths, activationMetadataKey: params.activationMetadataKey ?? "", }, - )}::${serializePluginIdScope(params.onlyPluginIds)}::${setupOnlyKey}::${setupOnlyModeKey}::${setupOnlyRequirementKey}::${startupChannelMode}::${bundledArtifactMode}::${rawConfigEnvMode}::${moduleLoadMode}::${discoveryMode}::${params.runtimeSubagentMode ?? "default"}::${params.pluginSdkResolution ?? "auto"}::${JSON.stringify(params.coreGatewayMethodNames ?? [])}::${activationMode}`; + )}::${serializePluginIdScope(params.onlyPluginIds)}::${setupOnlyKey}::${setupOnlyModeKey}::${setupOnlyRequirementKey}::${startupChannelMode}::${bundledArtifactMode}::${rawConfigEnvMode}::${moduleLoadMode}::${discoveryMode}::${params.runtimeSubagentMode ?? "default"}::${params.runtimeBindingIdentity ?? "{}"}::${params.pluginSdkResolution ?? "auto"}::${JSON.stringify(params.coreGatewayMethodNames ?? [])}::${activationMode}`; return createHash("sha256").update(cacheIdentity).digest("hex"); } @@ -240,27 +261,6 @@ export function resolveRuntimeSubagentMode( return runtimeOptions?.subagent ? "explicit" : "default"; } -export function hasExplicitCompatibilityInputs(options: PluginLoadOptions): boolean { - return ( - options.config !== undefined || - options.activationSourceConfig !== undefined || - options.autoEnabledReasons !== undefined || - options.workspaceDir !== undefined || - options.env !== undefined || - options.resolveRawConfigEnvVars !== undefined || - hasExplicitPluginIdScope(options.onlyPluginIds) || - options.runtimeOptions !== undefined || - options.pluginSdkResolution !== undefined || - options.coreGatewayHandlers !== undefined || - options.includeSetupOnlyChannelPlugins === true || - options.forceSetupOnlyChannelPlugins === true || - options.requireSetupEntryForSetupOnlyChannelPlugins === true || - options.preferSetupRuntimeForChannelPlugins === true || - options.preferBuiltPluginArtifacts === true || - options.loadModules === false - ); -} - function resolveCoreGatewayMethodNames(options: PluginLoadOptions): string[] { const names = new Set(options.coreGatewayMethodNames ?? []); for (const name of Object.keys(options.coreGatewayHandlers ?? {})) { @@ -396,6 +396,7 @@ export function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) { toolDiscovery: options.toolDiscovery, loadModules: options.loadModules, runtimeSubagentMode, + runtimeBindingIdentity: resolveRuntimeBindingCacheIdentity(options.runtimeOptions), pluginSdkResolution: options.pluginSdkResolution, coreGatewayMethodNames, activate: options.activate, diff --git a/src/plugins/loader-runtime-load.ts b/src/plugins/loader-runtime-load.ts index b7d832fc3637..24b6f101726a 100644 --- a/src/plugins/loader-runtime-load.ts +++ b/src/plugins/loader-runtime-load.ts @@ -28,6 +28,7 @@ import { import type { PluginLoadOptions } from "./loader-types.js"; import { createPluginIdScopeSet, normalizePluginIdScope } from "./plugin-scope.js"; import { createEmptyPluginRegistry } from "./registry-empty.js"; +import { getPluginRegistryRuntime } from "./registry-runtime-binding.js"; import { createPluginRegistry, type PluginRegistry } from "./registry.js"; import { getActivePluginRegistry } from "./runtime.js"; import type { PluginRuntime } from "./runtime/types.js"; @@ -41,6 +42,22 @@ type InternalPluginLoadOverrides = { runtime: Pick; }; +function createDeferredGatewaySubagentRuntime(runtime: PluginRuntime): PluginRuntime["subagent"] { + return { + run: (...args) => runtime.subagent.run(...args), + waitForRun: (...args) => runtime.subagent.waitForRun(...args), + getSessionMessages: (...args) => runtime.subagent.getSessionMessages(...args), + deleteSession: (...args) => runtime.subagent.deleteSession(...args), + }; +} + +function createDeferredGatewayNodesRuntime(runtime: PluginRuntime): PluginRuntime["nodes"] { + return { + list: (...args) => runtime.nodes.list(...args), + invoke: (...args) => runtime.nodes.invoke(...args), + }; +} + export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegistry { return loadOpenClawPluginsInternal(options); } @@ -79,22 +96,17 @@ function loadOpenClawPluginsInternal( const onlyPluginIdSet = createPluginIdScopeSet(context.onlyPluginIds); const cacheEnabled = options.cache !== false && options.resolveRawConfigEnvVars !== true; if (cacheEnabled) { - const cached = getReusableCachedPluginRegistry({ - cacheKey: context.cacheKey, - onlyPluginIds: context.onlyPluginIds, - runtimeSubagentMode: context.runtimeSubagentMode, - options, - }); + const cached = getReusableCachedPluginRegistry(context.cacheKey); if (cached) { if (context.shouldActivate) { activatePluginRegistry( - cached.state, - cached.cacheKey, - cached.runtimeSubagentMode, + cached, + context.cacheKey, + context.runtimeSubagentMode, options.workspaceDir, ); } - return cached.state; + return cached; } } @@ -107,13 +119,30 @@ function loadOpenClawPluginsInternal( pluginSdkResolution: options.pluginSdkResolution, ...overrides?.moduleLoader, }); + const activeRuntime = + options.runtimeOptions?.allowGatewaySubagentBinding === true + ? getActivePluginRegistry() + : undefined; + const activeGatewayRuntime = activeRuntime + ? getPluginRegistryRuntime(activeRuntime) + : undefined; + const borrowedSubagent = activeGatewayRuntime + ? createDeferredGatewaySubagentRuntime(activeGatewayRuntime) + : undefined; + const borrowedNodes = activeGatewayRuntime + ? createDeferredGatewayNodesRuntime(activeGatewayRuntime) + : undefined; const runtime = overrides?.runtime ? // The registry wraps this discovery-only base with scoped lazy capabilities. (overrides.runtime as unknown as PluginRuntime) : createLazyPluginRuntime({ devSourceRoot: context.devSourceRoot, pluginSdkResolution: options.pluginSdkResolution, - runtimeOptions: options.runtimeOptions, + runtimeOptions: { + ...options.runtimeOptions, + subagent: options.runtimeOptions?.subagent ?? borrowedSubagent, + nodes: options.runtimeOptions?.nodes ?? borrowedNodes, + }, loadPluginModule, }); registryBuilder = createPluginRegistry({ @@ -238,8 +267,7 @@ function loadOpenClawPluginsInternal( } } if (context.shouldActivate) { - // Install the complete bundle before hook-runner initialization because hook composition - // reads the active/pinned registry set and must never observe contributions from two loads. + // Install the complete bundle before hook-runner initialization. activatePluginRegistry( registry, context.cacheKey, @@ -250,7 +278,7 @@ function loadOpenClawPluginsInternal( // Publish only complete registries: failed activation restores the prior runtime selection, // then the catch below can discard this builder without poisoning a reusable cache value. if (cacheEnabled) { - setCachedPluginRegistry(context.cacheKey, registry, context.onlyPluginIds); + setCachedPluginRegistry(context.cacheKey, registry); } return registry; } catch (error) { diff --git a/src/plugins/loader-runtime-registry.ts b/src/plugins/loader-runtime-registry.ts index 8b20003ff9fe..2393e84052a4 100644 --- a/src/plugins/loader-runtime-registry.ts +++ b/src/plugins/loader-runtime-registry.ts @@ -1,200 +1,39 @@ import { isPluginRegistryLoadInFlight } from "./loader-cache.js"; -import { - hasExplicitCompatibilityInputs, - resolvePluginLoadCacheContext, -} from "./loader-load-context.js"; +import { resolvePluginLoadCacheContext } from "./loader-load-context.js"; import { loadOpenClawPlugins } from "./loader-runtime-load.js"; import type { PluginLoadOptions } from "./loader-types.js"; import type { PluginRegistry } from "./registry.js"; -import { - getActivePluginRegistry, - getActivePluginRegistryKey, - getActivePluginRuntimeSubagentMode, -} from "./runtime.js"; +import { getActivePluginRegistry, getActivePluginRegistryKey } from "./runtime.js"; -function pluginLoadOptionsMatchCacheKey( - options: PluginLoadOptions, - expectedCacheKey: string, -): boolean { - return resolvePluginLoadCacheContext(options).cacheKey === expectedCacheKey; -} - -function pluginToolDiscoveryOptionsMatchActiveCacheKey( - options: PluginLoadOptions, - expectedCacheKey: string, -): boolean { - if (options.toolDiscovery !== true) { - return false; - } - const fullRuntimeOptions = { ...options, toolDiscovery: undefined }; - if (pluginLoadOptionsMatchCacheKey(fullRuntimeOptions, expectedCacheKey)) { - return true; - } - if (options.activate !== false) { - return false; - } - return pluginLoadOptionsMatchCacheKey( - { ...fullRuntimeOptions, activate: true }, - expectedCacheKey, - ); -} - -function registryContainsPluginScope( - registry: PluginRegistry, - onlyPluginIds: readonly string[] | undefined, -): boolean { - if (!onlyPluginIds || onlyPluginIds.length === 0) { - return false; - } - const loadedPluginIds = new Set(registry.plugins.map((plugin) => plugin.id)); - return onlyPluginIds.every((pluginId) => loadedPluginIds.has(pluginId)); -} - -function scopedPluginLoadOptionsMatchWiderActiveCacheKey( - options: PluginLoadOptions, - expectedCacheKey: string, - activeRegistry: PluginRegistry, -): boolean { - const { onlyPluginIds } = resolvePluginLoadCacheContext(options); - if (!registryContainsPluginScope(activeRegistry, onlyPluginIds)) { - return false; - } - return pluginLoadOptionsMatchCacheKey({ ...options, onlyPluginIds: undefined }, expectedCacheKey); -} - -function getCompatibleActivePluginRegistry( - options: PluginLoadOptions = {}, -): PluginRegistry | undefined { - if (options.resolveRawConfigEnvVars === true) { - return undefined; - } +function getExactActivePluginRegistry(options?: PluginLoadOptions): PluginRegistry | undefined { const activeRegistry = getActivePluginRegistry() ?? undefined; - if (!activeRegistry) { - return undefined; - } - if (!hasExplicitCompatibilityInputs(options)) { + if (!activeRegistry || options === undefined) { return activeRegistry; } const activeCacheKey = getActivePluginRegistryKey(); if (!activeCacheKey) { return undefined; } - const loadContext = resolvePluginLoadCacheContext(options); - const matchesActiveCacheKey = (candidate: PluginLoadOptions): boolean => { - if (pluginLoadOptionsMatchCacheKey(candidate, activeCacheKey)) { - return true; - } - if (candidate.coreGatewayMethodNames !== undefined) { - return false; - } - return pluginLoadOptionsMatchCacheKey( - { ...candidate, coreGatewayMethodNames: activeRegistry.coreGatewayMethodNames }, - activeCacheKey, - ); - }; - const matchesCompatibleActiveRegistry = (candidate: PluginLoadOptions): boolean => { - if (matchesActiveCacheKey(candidate)) { - return true; - } - if ( - scopedPluginLoadOptionsMatchWiderActiveCacheKey(candidate, activeCacheKey, activeRegistry) - ) { - return true; - } - return pluginToolDiscoveryOptionsMatchActiveCacheKey(candidate, activeCacheKey); - }; - - if (matchesCompatibleActiveRegistry(options)) { - return activeRegistry; - } - if (!loadContext.shouldActivate) { - const activatingOptions = { ...options, activate: true }; - if (matchesCompatibleActiveRegistry(activatingOptions)) { - return activeRegistry; - } - } - const activeRuntimeSubagentMode = getActivePluginRuntimeSubagentMode(); - if (activeRuntimeSubagentMode === "gateway-bindable") { - const gatewayStartupOptions: PluginLoadOptions = { - ...options, - preferBuiltPluginArtifacts: true, - }; - if (matchesCompatibleActiveRegistry(gatewayStartupOptions)) { - return activeRegistry; - } - if (!loadContext.shouldActivate) { - const activatingGatewayStartupOptions: PluginLoadOptions = { - ...options, - activate: true, - preferBuiltPluginArtifacts: true, - }; - if (matchesCompatibleActiveRegistry(activatingGatewayStartupOptions)) { - return activeRegistry; - } - } - } - if ( - loadContext.runtimeSubagentMode === "default" && - activeRuntimeSubagentMode === "gateway-bindable" - ) { - const gatewayBindableOptions: PluginLoadOptions = { - ...options, - runtimeOptions: { - ...options.runtimeOptions, - allowGatewaySubagentBinding: true, - }, - }; - const gatewayStartupOptions: PluginLoadOptions = { - ...gatewayBindableOptions, - preferBuiltPluginArtifacts: true, - }; - if (!loadContext.shouldActivate) { - const activatingGatewayBindableOptions: PluginLoadOptions = { - ...options, - activate: true, - runtimeOptions: { - ...options.runtimeOptions, - allowGatewaySubagentBinding: true, - }, - }; - const activatingGatewayStartupOptions: PluginLoadOptions = { - ...activatingGatewayBindableOptions, - preferBuiltPluginArtifacts: true, - }; - if ( - matchesCompatibleActiveRegistry(gatewayBindableOptions) || - matchesCompatibleActiveRegistry(gatewayStartupOptions) || - matchesCompatibleActiveRegistry(activatingGatewayBindableOptions) || - matchesCompatibleActiveRegistry(activatingGatewayStartupOptions) - ) { - return activeRegistry; - } - } else if ( - matchesCompatibleActiveRegistry(gatewayBindableOptions) || - matchesCompatibleActiveRegistry(gatewayStartupOptions) - ) { - return activeRegistry; - } - } - return undefined; + return resolvePluginLoadCacheContext(options).cacheKey === activeCacheKey + ? activeRegistry + : undefined; } export function resolveRuntimePluginRegistry( options?: PluginLoadOptions, ): PluginRegistry | undefined { - if (!options || !hasExplicitCompatibilityInputs(options)) { - return getCompatibleActivePluginRegistry(); - } - const compatible = getCompatibleActivePluginRegistry(options); - if (compatible) { - return compatible; + const activeRegistry = getExactActivePluginRegistry(options); + if (activeRegistry) { + return activeRegistry; } // Runtime helpers must not recurse while this exact snapshot is registering. // Direct loadOpenClawPlugins callers still surface the hard error. if (isPluginRegistryLoadInFlight(options)) { return undefined; } - return loadOpenClawPlugins(options); + // Runtime consumers own handles. Process-root installation is reserved for + // loadAndActivateRootPluginRegistry at the composition boundary. + return loadOpenClawPlugins({ ...options, activate: false }); } export function getRuntimePluginRegistryForLoadOptions( @@ -203,9 +42,9 @@ export function getRuntimePluginRegistryForLoadOptions( return resolveRuntimePluginRegistry(options); } -/** Return a compatible active registry without triggering a fresh load on cache miss. */ +/** Return the exact active registry without triggering a fresh load on cache miss. */ export function resolveCompatibleRuntimePluginRegistry( options?: PluginLoadOptions, ): PluginRegistry | undefined { - return getCompatibleActivePluginRegistry(options); + return getExactActivePluginRegistry(options); } diff --git a/src/plugins/loader.hook-runner-live-view.test.ts b/src/plugins/loader.hook-runner-live-view.test.ts deleted file mode 100644 index e3c08cb45655..000000000000 --- a/src/plugins/loader.hook-runner-live-view.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -/** - * Regression coverage for #91918: local-extension before_tool_call / - * after_tool_call hooks must stay dispatchable across the gateway run - * lifecycle. - * - * Mirrors the production sequence that killed them on v2026.6.5: - * 1. gateway boot: full gateway-bindable load (coreGatewayMethodNames set), - * boot registry pinned to the channel/http surfaces - * 2. harness ensure: scoped default-mode activating load (consumed the old - * one-shot preserve gate and flipped active mode to "default") - * 3. memory ensure: second scoped default-mode activating load (re-initialized - * the runner from a memory-only registry, silently dropping tool hooks) - * - * With the live composed view, the pinned boot registry keeps the extension's - * hooks dispatchable no matter how many scoped activations follow. - */ -import { afterEach, describe, expect, it } from "vitest"; -import { getGlobalHookRunner, resetGlobalHookRunner } from "./hook-runner-global.js"; -import { loadOpenClawPlugins } from "./loader.js"; -import { - resetPluginLoaderTestStateForTest, - useNoBundledPlugins, - writePlugin, -} from "./loader.test-fixtures.js"; -import { - getActivePluginRegistry, - pinActivePluginChannelRegistry, - pinActivePluginHttpRouteRegistry, -} from "./runtime.js"; - -describe("global hook runner live view (#91918)", () => { - afterEach(() => { - resetGlobalHookRunner(); - resetPluginLoaderTestStateForTest(); - }); - - it("keeps local-extension tool-call hooks dispatchable across scoped default-mode activations", async () => { - useNoBundledPlugins(); - const gate = writePlugin({ - id: "local-gate", - filename: "local-gate.cjs", - body: `module.exports = { id: "local-gate", register(api) { - api.on("before_tool_call", (event) => { - if (String(event.params?.command ?? "").includes("curl")) { - return { block: true, blockReason: "blocked by gate" }; - } - }); - api.on("after_tool_call", () => undefined); - } };`, - }); - const harnessStandIn = writePlugin({ - id: "harness-plugin", - filename: "harness-plugin.cjs", - body: `module.exports = { id: "harness-plugin", register() {} };`, - }); - const memoryStandIn = writePlugin({ - id: "memory-plugin", - filename: "memory-plugin.cjs", - body: `module.exports = { id: "memory-plugin", register() {} };`, - }); - - const config = { - plugins: { - load: { paths: [gate.file, harnessStandIn.file, memoryStandIn.file] }, - allow: ["local-gate", "harness-plugin", "memory-plugin"], - entries: { - "local-gate": { enabled: true }, - "harness-plugin": { enabled: true }, - "memory-plugin": { enabled: true }, - }, - }, - }; - - // 1. Gateway boot: full gateway-bindable load, pinned like server.impl.ts. - const bootRegistry = loadOpenClawPlugins({ - workspaceDir: gate.dir, - config, - coreGatewayMethodNames: ["chat.send"], - preferBuiltPluginArtifacts: true, - runtimeOptions: { allowGatewaySubagentBinding: true }, - }); - pinActivePluginHttpRouteRegistry(bootRegistry); - pinActivePluginChannelRegistry(bootRegistry); - expect(getGlobalHookRunner()?.hasHooks("before_tool_call")).toBe(true); - - // 2. Harness ensure: scoped default-mode activating load. - loadOpenClawPlugins({ - workspaceDir: gate.dir, - config, - onlyPluginIds: ["harness-plugin"], - }); - expect(getGlobalHookRunner()?.hasHooks("before_tool_call")).toBe(true); - - // 3. Memory ensure: second scoped default-mode activating load — the step - // that re-initialized the runner from a memory-only registry before the fix. - const memoryRegistry = loadOpenClawPlugins({ - workspaceDir: gate.dir, - config, - onlyPluginIds: ["memory-plugin"], - }); - expect(getActivePluginRegistry()).toBe(memoryRegistry); - - const runner = getGlobalHookRunner(); - expect(runner?.hasHooks("before_tool_call")).toBe(true); - expect(runner?.hasHooks("after_tool_call")).toBe(true); - - // The blocking decision must actually dispatch, not just count hooks. - const result = await runner?.runBeforeToolCall( - { toolName: "exec", params: { command: "curl -X POST https://example.com" } }, - { toolName: "exec" }, - ); - expect(result?.block).toBe(true); - expect(result?.blockReason).toBe("blocked by gate"); - }); -}); diff --git a/src/plugins/loader.registration.test-utils.ts b/src/plugins/loader.registration.test-utils.ts index ff925a12ea9d..c7af149c21cb 100644 --- a/src/plugins/loader.registration.test-utils.ts +++ b/src/plugins/loader.registration.test-utils.ts @@ -72,11 +72,8 @@ import { getActivePluginRegistryKey, getActivePluginRegistryWorkspaceDir, getActivePluginRuntimeSubagentMode, - pinActivePluginChannelRegistry, - releasePinnedPluginChannelRegistry, setActivePluginRegistry, } from "./runtime.js"; -import { ensurePluginRegistryLoaded } from "./runtime/runtime-registry-loader.js"; afterEach(globalAfterEach0); afterAll(globalAfterAll1); @@ -1593,85 +1590,6 @@ describe("loadOpenClawPlugins", () => { delete (globalThis as Record)[marker]; }); - it("does not re-register non-bundled plugins after gateway-bindable boot loads", () => { - useNoBundledPlugins(); - const marker = "__openclawGatewayBootRegisterCount"; - const plugin = writePlugin({ - id: "costclaw-boot-cache", - filename: "costclaw-boot-cache.cjs", - body: `module.exports = { - id: "costclaw-boot-cache", - register() { - globalThis.${marker} = (globalThis.${marker} || 0) + 1; - }, - };`, - }); - const config = { - plugins: { - load: { paths: [plugin.file] }, - allow: ["costclaw-boot-cache"], - entries: { - "costclaw-boot-cache": { enabled: true }, - }, - }, - }; - - loadOpenClawPlugins({ - workspaceDir: plugin.dir, - config, - runtimeOptions: { - allowGatewaySubagentBinding: true, - }, - }); - ensurePluginRegistryLoaded({ - scope: "all", - workspaceDir: plugin.dir, - config, - }); - - expect((globalThis as Record)[marker]).toBe(1); - delete (globalThis as Record)[marker]; - }); - - it("reuses a gateway-bindable cache entry for later default-mode loads", () => { - useNoBundledPlugins(); - const marker = "__openclawGatewayBindableCacheRegisterCount"; - const plugin = writePlugin({ - id: "gateway-bindable-cache", - filename: "gateway-bindable-cache.cjs", - body: `module.exports = { - id: "gateway-bindable-cache", - register() { - globalThis.${marker} = (globalThis.${marker} || 0) + 1; - }, - };`, - }); - const options = { - workspaceDir: plugin.dir, - config: { - plugins: { - load: { paths: [plugin.file] }, - allow: ["gateway-bindable-cache"], - entries: { - "gateway-bindable-cache": { enabled: true }, - }, - }, - }, - }; - - const gatewayBindable = loadOpenClawPlugins({ - ...options, - runtimeOptions: { - allowGatewaySubagentBinding: true, - }, - }); - const defaultMode = loadOpenClawPlugins(options); - - expect(defaultMode).toBe(gatewayBindable); - expect((globalThis as Record)[marker]).toBe(1); - delete (globalThis as Record)[marker]; - }); - it("re-initializes global hook runner when serving registry from cache", () => { useNoBundledPlugins(); const plugin = writePlugin({ @@ -1703,76 +1621,6 @@ describe("loadOpenClawPlugins", () => { resetGlobalHookRunner(); }); - it("keeps pinned gateway hooks and later default-mode hooks dispatchable together", () => { - useNoBundledPlugins(); - const gatewayPlugin = writePlugin({ - id: "gateway-hook-surface", - filename: "gateway-hook-surface.cjs", - body: `module.exports = { id: "gateway-hook-surface", register(api) { - api.on("subagent_ended", () => undefined); - } };`, - }); - const defaultPlugin = writePlugin({ - id: "default-hook-surface", - filename: "default-hook-surface.cjs", - body: `module.exports = { id: "default-hook-surface", register(api) { - api.on("message_sent", () => undefined); - } };`, - }); - - const gatewayRegistry = loadOpenClawPlugins({ - workspaceDir: gatewayPlugin.dir, - config: { - plugins: { - load: { paths: [gatewayPlugin.file] }, - allow: ["gateway-hook-surface"], - entries: { - "gateway-hook-surface": { - enabled: true, - hooks: { allowConversationAccess: true }, - }, - }, - }, - }, - runtimeOptions: { - allowGatewaySubagentBinding: true, - }, - }); - // The gateway pins its boot registry to the channel/http surfaces; the - // pin is what keeps gateway lifecycle hooks live across later swaps. - pinActivePluginChannelRegistry(gatewayRegistry); - try { - expect(getGlobalPluginRegistry()).toBe(gatewayRegistry); - expect(expectGlobalHookRunner(getGlobalHookRunner()).hasHooks("subagent_ended")).toBe(true); - - const defaultRegistry = loadOpenClawPlugins({ - workspaceDir: defaultPlugin.dir, - config: { - plugins: { - load: { paths: [defaultPlugin.file] }, - allow: ["default-hook-surface"], - entries: { - "default-hook-surface": { - enabled: true, - hooks: { allowConversationAccess: true }, - }, - }, - }, - }, - }); - - expect(getActivePluginRegistry()).toBe(defaultRegistry); - expect(getGlobalPluginRegistry()).toBe(defaultRegistry); - // Regression guard for #91918: the runner must see the union of live - // registries, not just whichever registry initialized it last. - const globalHookRunner = expectGlobalHookRunner(getGlobalHookRunner()); - expect(globalHookRunner.hasHooks("subagent_ended")).toBe(true); - expect(globalHookRunner.hasHooks("message_sent")).toBe(true); - } finally { - releasePinnedPluginChannelRegistry(gatewayRegistry); - } - }); - it("drops hooks of replaced unpinned registries from the global runner", () => { useNoBundledPlugins(); const firstPlugin = writePlugin({ diff --git a/src/plugins/loader.runtime-registry.test.ts b/src/plugins/loader.runtime-registry.test.ts index 1018cc8439cc..0bc65b008ea6 100644 --- a/src/plugins/loader.runtime-registry.test.ts +++ b/src/plugins/loader.runtime-registry.test.ts @@ -25,6 +25,7 @@ import { buildMemoryPromptSection, registerMemoryCapability } from "./memory-sta import { clearPluginMetadataLifecycleCaches } from "./plugin-metadata-lifecycle.js"; import { createEmptyPluginRegistry } from "./registry.js"; import { getActivePluginRegistry, setActivePluginRegistry } from "./runtime.js"; +import type { PluginRuntime } from "./runtime/types.js"; afterEach(() => { resetPluginLoaderTestStateForTest(); @@ -80,6 +81,32 @@ function setLoaderMetadataSnapshot(params: { pluginIds?: readonly string[] } = { } describe("resolvePluginLoadCacheContext", () => { + it("keys concrete runtime bindings by identity", () => { + const firstNodes = {} as PluginRuntime["nodes"]; + const firstSubagent = {} as PluginRuntime["subagent"]; + const firstOptions = { + config: {}, + runtimeOptions: { + allowGatewaySubagentBinding: true, + nodes: firstNodes, + subagent: firstSubagent, + }, + }; + const firstKey = resolvePluginLoadCacheContext(firstOptions).cacheKey; + + expect(resolvePluginLoadCacheContext(firstOptions).cacheKey).toBe(firstKey); + expect( + resolvePluginLoadCacheContext({ + ...firstOptions, + runtimeOptions: { + ...firstOptions.runtimeOptions, + nodes: {} as PluginRuntime["nodes"], + subagent: {} as PluginRuntime["subagent"], + }, + }).cacheKey, + ).not.toBe(firstKey); + }); + it("reuses prepared install records from the compatible metadata generation", () => { const { config, env, installRecords, snapshot, workspaceDir } = setLoaderMetadataSnapshot(); diff --git a/src/plugins/loader.test-fixtures.ts b/src/plugins/loader.test-fixtures.ts index 15dda2cf78e1..ab146825e91f 100644 --- a/src/plugins/loader.test-fixtures.ts +++ b/src/plugins/loader.test-fixtures.ts @@ -4,7 +4,7 @@ import os from "node:os"; import path from "node:path"; import { resetDiagnosticEventsForTest } from "../infra/diagnostic-events.js"; import { withEnv } from "../test-utils/env.js"; -import { pluginLoaderCacheInstances } from "./loader-cache-instances.js"; +import { pluginLoaderCacheState } from "./loader-cache.js"; import { loadOpenClawPlugins } from "./loader.js"; import { resetPluginRuntimeStateForTest } from "./runtime.js"; @@ -159,8 +159,7 @@ export function resetPluginLoaderTestStateForTest() { /** Clears loader state for test isolation without exposing a production-only reset export. */ export function clearPluginLoaderCache(): void { - pluginLoaderCacheInstances.scoped.clear(); - pluginLoaderCacheInstances.fullWorkspace.clear(); + pluginLoaderCacheState.clear(); resetPluginRuntimeStateForTest(); } diff --git a/src/plugins/loader.test-harness.ts b/src/plugins/loader.test-harness.ts index 51c7ba8a7036..044e7520094e 100644 --- a/src/plugins/loader.test-harness.ts +++ b/src/plugins/loader.test-harness.ts @@ -25,7 +25,6 @@ import { useNoBundledPlugins, writePlugin, } from "./loader.test-fixtures.js"; -import { testing as runtimeRegistryLoaderTesting } from "./runtime/runtime-registry-loader.js"; export const getEmbeddingProvider = (id: string) => getRegisteredEmbeddingProvider(id)?.adapter; @@ -993,7 +992,6 @@ export function collectStartupTraceMetrics( export const globalAfterEach0 = () => { resetDiagnosticEventsForTest(); clearRuntimeConfigSnapshot(); - runtimeRegistryLoaderTesting.resetPluginRegistryLoadedForTests(); resetPluginLoaderTestStateForTest(); }; diff --git a/src/plugins/memory-runtime.test.ts b/src/plugins/memory-runtime.test.ts index edaf08eea8b9..44dd7d305a34 100644 --- a/src/plugins/memory-runtime.test.ts +++ b/src/plugins/memory-runtime.test.ts @@ -5,7 +5,8 @@ import { getPluginRuntimeGatewayRequestScope } from "./runtime/gateway-request-s const mocks = vi.hoisted(() => ({ getMemoryRuntime: vi.fn(), - loadRuntimePluginRegistryHandle: vi.fn(), + loadPluginRegistryHandle: vi.fn(), + resolvePluginRegistryLoadCacheKey: vi.fn((options: unknown) => JSON.stringify(options)), resolveAgentWorkspaceDir: vi.fn(), })); @@ -13,8 +14,9 @@ vi.mock("../agents/agent-scope.js", () => ({ resolveAgentWorkspaceDir: mocks.resolveAgentWorkspaceDir, })); -vi.mock("./runtime/standalone-runtime-registry-loader.js", () => ({ - loadRuntimePluginRegistryHandle: mocks.loadRuntimePluginRegistryHandle, +vi.mock("./loader.js", () => ({ + loadPluginRegistryHandle: mocks.loadPluginRegistryHandle, + resolvePluginRegistryLoadCacheKey: mocks.resolvePluginRegistryLoadCacheKey, })); vi.mock("./memory-state.js", async (importOriginal) => { @@ -53,7 +55,8 @@ describe("memory runtime handles", () => { beforeEach(() => { resetStandaloneMemoryRegistrySlot(); mocks.getMemoryRuntime.mockReset().mockReturnValue(undefined); - mocks.loadRuntimePluginRegistryHandle.mockReset(); + mocks.loadPluginRegistryHandle.mockReset(); + mocks.resolvePluginRegistryLoadCacheKey.mockClear(); mocks.resolveAgentWorkspaceDir .mockReset() .mockImplementation((_cfg, agentId: string) => @@ -71,20 +74,17 @@ describe("memory runtime handles", () => { expect(getPluginRuntimeGatewayRequestScope()?.pluginRegistry).toBe(registry); return { backend: "builtin" }; }); - mocks.loadRuntimePluginRegistryHandle.mockReturnValue(registry); + mocks.loadPluginRegistryHandle.mockReturnValue(registry); await expect( getActiveMemorySearchManager({ cfg: memoryConfig, agentId: "main" }), ).resolves.toEqual({ manager: null, error: "no index" }); - expect(mocks.loadRuntimePluginRegistryHandle).toHaveBeenCalledWith({ - requiredPluginIds: ["memory-core"], - loadOptions: { - activate: false, - config: memoryConfig, - onlyPluginIds: ["memory-core"], - workspaceDir: "/workspace/main", - }, + expect(mocks.loadPluginRegistryHandle).toHaveBeenCalledWith({ + activate: false, + config: memoryConfig, + onlyPluginIds: ["memory-core"], + workspaceDir: "/workspace/main", }); expect(runtime.getMemorySearchManager).toHaveBeenCalledWith({ cfg: memoryConfig, @@ -98,7 +98,7 @@ describe("memory runtime handles", () => { it("keys the single slot by the requesting agent workspace", () => { const main = createRegistry(); const research = createRegistry(); - mocks.loadRuntimePluginRegistryHandle + mocks.loadPluginRegistryHandle .mockReturnValueOnce(main.registry) .mockReturnValueOnce(research.registry); @@ -114,7 +114,7 @@ describe("memory runtime handles", () => { expect(mocks.resolveAgentWorkspaceDir).toHaveBeenNthCalledWith(1, memoryConfig, "main"); expect(mocks.resolveAgentWorkspaceDir).toHaveBeenLastCalledWith(memoryConfig, "research"); - expect(mocks.loadRuntimePluginRegistryHandle).toHaveBeenCalledTimes(2); + expect(mocks.loadPluginRegistryHandle).toHaveBeenCalledTimes(2); }); it.each([ @@ -131,7 +131,7 @@ describe("memory runtime handles", () => { await expect( getActiveMemorySearchManager({ cfg: cfg as never, agentId: "main" }), ).resolves.toEqual({ manager: null, error: "memory plugin unavailable" }); - expect(mocks.loadRuntimePluginRegistryHandle).not.toHaveBeenCalled(); + expect(mocks.loadPluginRegistryHandle).not.toHaveBeenCalled(); }); it("prefers an already-registered runtime", () => { @@ -141,7 +141,7 @@ describe("memory runtime handles", () => { expect(resolveActiveMemoryBackendConfig({ cfg: memoryConfig, agentId: "main" })).toEqual({ backend: "builtin", }); - expect(mocks.loadRuntimePluginRegistryHandle).not.toHaveBeenCalled(); + expect(mocks.loadPluginRegistryHandle).not.toHaveBeenCalled(); }); it("closes managers through current and retired workspace handles without reloading", async () => { @@ -155,12 +155,12 @@ describe("memory runtime handles", () => { expect(getPluginRuntimeGatewayRequestScope()?.pluginRegistry).toBe(owner.registry); }); } - mocks.loadRuntimePluginRegistryHandle + mocks.loadPluginRegistryHandle .mockReturnValueOnce(main.registry) .mockReturnValueOnce(research.registry); resolveActiveMemoryBackendConfig({ cfg: memoryConfig, agentId: "main" }); resolveActiveMemoryBackendConfig({ cfg: memoryConfig, agentId: "research" }); - mocks.loadRuntimePluginRegistryHandle.mockClear(); + mocks.loadPluginRegistryHandle.mockClear(); await closeActiveMemorySearchManager({ cfg: memoryConfig, agentId: "main" }); await closeActiveMemorySearchManagers(memoryConfig); @@ -172,6 +172,6 @@ describe("memory runtime handles", () => { }); expect(runtime.closeAllMemorySearchManagers).toHaveBeenCalledTimes(1); } - expect(mocks.loadRuntimePluginRegistryHandle).not.toHaveBeenCalled(); + expect(mocks.loadPluginRegistryHandle).not.toHaveBeenCalled(); }); }); diff --git a/src/plugins/memory-runtime.ts b/src/plugins/memory-runtime.ts index 95c0c8d08877..23dbf39d630d 100644 --- a/src/plugins/memory-runtime.ts +++ b/src/plugins/memory-runtime.ts @@ -3,11 +3,10 @@ import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveUserPath } from "../utils.js"; import { normalizePluginsConfig } from "./config-state.js"; -import { resolvePluginRegistryLoadCacheKey } from "./loader.js"; +import { loadPluginRegistryHandle, resolvePluginRegistryLoadCacheKey } from "./loader.js"; import { getMemoryRuntime, resolveMemoryCapabilityRegistration } from "./memory-state.js"; import type { PluginRegistry } from "./registry-types.js"; import { withPluginRuntimeRegistryScope } from "./runtime/gateway-request-scope.js"; -import { loadRuntimePluginRegistryHandle } from "./runtime/standalone-runtime-registry-loader.js"; type MemoryRuntime = NonNullable< PluginRegistry["memoryCapabilities"][number]["capability"]["runtime"] @@ -95,10 +94,7 @@ function ensureMemoryRuntime(params?: { const runtime = resolveMemoryRuntimeFromRegistry(standaloneMemoryRegistrySlot.registry); return runtime ? { runtime, registry: standaloneMemoryRegistrySlot.registry } : undefined; } - const registry = loadRuntimePluginRegistryHandle({ - requiredPluginIds: onlyPluginIds, - loadOptions, - }); + const registry = loadPluginRegistryHandle(loadOptions); if (!registry) { return undefined; } diff --git a/src/plugins/migration-provider-runtime.test.ts b/src/plugins/migration-provider-runtime.test.ts index 88d14e25e227..0fb094fcd8d9 100644 --- a/src/plugins/migration-provider-runtime.test.ts +++ b/src/plugins/migration-provider-runtime.test.ts @@ -43,13 +43,14 @@ const mocks = vi.hoisted(() => ({ snapshot: params?.index ?? createMockPluginIndex([]), diagnostics: [], })), - loadRuntimePluginRegistryHandle: vi.fn(), + loadPluginRegistryHandle: vi.fn(), listBundledPluginMetadata: vi.fn(() => []), })); vi.mock("./loader.js", async (importOriginal) => ({ ...(await importOriginal()), resolveRuntimePluginRegistry: mocks.resolveRuntimePluginRegistry, + loadPluginRegistryHandle: mocks.loadPluginRegistryHandle, })); vi.mock("./active-runtime-registry.js", () => ({ @@ -73,10 +74,6 @@ vi.mock("./manifest-registry-installed.js", () => ({ resolveInstalledManifestRegistryIndexFingerprint: () => "test-installed-index", })); -vi.mock("./runtime/standalone-runtime-registry-loader.js", () => ({ - loadRuntimePluginRegistryHandle: mocks.loadRuntimePluginRegistryHandle, -})); - vi.mock("./bundled-plugin-metadata.js", () => ({ listBundledPluginMetadata: mocks.listBundledPluginMetadata, })); @@ -113,7 +110,7 @@ describe("migration provider runtime", () => { mocks.resolveRuntimePluginRegistry.mockReturnValue(createEmptyPluginRegistry()); mocks.loadPluginManifestRegistry.mockReturnValue(createEmptyMockManifestRegistry()); mocks.loadPluginRegistrySnapshot.mockReturnValue(createMockPluginIndex([])); - mocks.loadRuntimePluginRegistryHandle.mockReturnValue(createEmptyPluginRegistry()); + mocks.loadPluginRegistryHandle.mockReturnValue(createEmptyPluginRegistry()); mocks.listBundledPluginMetadata.mockReturnValue([]); mocks.loadPluginRegistrySnapshotWithMetadata.mockImplementation( (params?: { index?: MockPluginIndex }) => ({ @@ -155,22 +152,17 @@ describe("migration provider runtime", () => { }); const standaloneParams = requireMockCallArg( - mocks.loadRuntimePluginRegistryHandle, - "loadRuntimePluginRegistryHandle", + mocks.loadPluginRegistryHandle, + "loadPluginRegistryHandle", ) as { - surface?: unknown; - requiredPluginIds?: unknown; - loadOptions?: { - activate?: unknown; - onlyPluginIds?: unknown; - config?: OpenClawConfig; - }; + activate?: unknown; + onlyPluginIds?: unknown; + config?: OpenClawConfig; }; - expect(standaloneParams.surface).toBe("active"); - expect(standaloneParams.requiredPluginIds).toEqual(["migrate-hermes"]); - expect(standaloneParams.loadOptions?.onlyPluginIds).toEqual(["migrate-hermes"]); - expect(standaloneParams.loadOptions?.config?.plugins?.enabled).toBe(true); - expect(standaloneParams.loadOptions?.config?.plugins?.entries).toEqual({ + expect(standaloneParams.activate).toBe(false); + expect(standaloneParams.onlyPluginIds).toEqual(["migrate-hermes"]); + expect(standaloneParams.config?.plugins?.enabled).toBe(true); + expect(standaloneParams.config?.plugins?.entries).toEqual({ "migrate-hermes": { enabled: true }, }); }); @@ -188,13 +180,10 @@ describe("migration provider runtime", () => { ensureStandaloneMigrationProviderRegistryLoaded({ providerId: "hermes" }); const standaloneParams = requireMockCallArg( - mocks.loadRuntimePluginRegistryHandle, - "loadRuntimePluginRegistryHandle", + mocks.loadPluginRegistryHandle, + "loadPluginRegistryHandle", ); - expect(standaloneParams.requiredPluginIds).toEqual(["migrate-hermes"]); - expect( - (standaloneParams.loadOptions as { onlyPluginIds?: unknown } | undefined)?.onlyPluginIds, - ).toEqual(["migrate-hermes"]); + expect(standaloneParams.onlyPluginIds).toEqual(["migrate-hermes"]); }); it("loads configured external migration-provider plugins from manifest contracts", async () => { @@ -329,7 +318,7 @@ describe("migration provider runtime", () => { source: "test", provider, } as never); - mocks.loadRuntimePluginRegistryHandle.mockReturnValue(loadedA); + mocks.loadPluginRegistryHandle.mockReturnValue(loadedA); mocks.listBundledPluginMetadata.mockReturnValue([ { manifest: { diff --git a/src/plugins/migration-provider-runtime.ts b/src/plugins/migration-provider-runtime.ts index c328031d1188..7b3dc04670f9 100644 --- a/src/plugins/migration-provider-runtime.ts +++ b/src/plugins/migration-provider-runtime.ts @@ -6,10 +6,10 @@ import { withBundledPluginVitestCompat, } from "./bundled-compat.js"; import { listBundledPluginMetadata } from "./bundled-plugin-metadata.js"; +import { loadPluginRegistryHandle } from "./loader.js"; import { resolveManifestContractRuntimePluginResolution } from "./manifest-contract-runtime.js"; import type { PluginRegistry } from "./registry-types.js"; import { withPluginRuntimeRegistryScope } from "./runtime/gateway-request-scope.js"; -import { loadRuntimePluginRegistryHandle } from "./runtime/standalone-runtime-registry-loader.js"; import type { MigrationProviderPlugin } from "./types.js"; type MigrationProviderPluginResolution = { @@ -147,13 +147,10 @@ export function ensureStandaloneMigrationProviderRegistryLoaded( cfg: params.cfg, bundledCompatPluginIds: resolution.bundledCompatPluginIds, }); - const registry = loadRuntimePluginRegistryHandle({ - surface: "active", - requiredPluginIds: resolution.pluginIds, - loadOptions: { - ...(compatConfig === undefined ? {} : { config: compatConfig }), - onlyPluginIds: resolution.pluginIds, - }, + const registry = loadPluginRegistryHandle({ + ...(compatConfig === undefined ? {} : { config: compatConfig }), + onlyPluginIds: resolution.pluginIds, + activate: false, }); standaloneMigrationRegistrySlot = registry ? { diff --git a/src/plugins/plugin-runtime-artifact-resolution.test.ts b/src/plugins/plugin-runtime-artifact-resolution.test.ts index c528f7b96db0..d7abbad9753c 100644 --- a/src/plugins/plugin-runtime-artifact-resolution.test.ts +++ b/src/plugins/plugin-runtime-artifact-resolution.test.ts @@ -9,7 +9,7 @@ import { clearPluginRuntimeArtifactResolutionMemo, resolvePluginRuntimeArtifact, } from "./plugin-runtime-artifact-resolution.js"; -import { getActivePluginChannelRegistry, pinActivePluginChannelRegistry } from "./runtime.js"; +import { getActivePluginChannelRegistry } from "./runtime.js"; const tempDirs: string[] = []; @@ -182,7 +182,6 @@ describe("resolvePluginRuntimeArtifact", () => { onlyPluginIds: ["fixture"], preferBuiltPluginArtifacts: false, }); - pinActivePluginChannelRegistry(sourceRegistry); const builtPreferredRegistry = loadOpenClawPlugins({ cache: false, config, @@ -199,7 +198,7 @@ describe("resolvePluginRuntimeArtifact", () => { expect([...second.pluginRuntimeArtifacts.values()].map((entry) => entry.source)).toEqual([ fixture.builtSource, ]); - expect(getActivePluginChannelRegistry()).toBe(first); + expect(getActivePluginChannelRegistry()).toBe(second); }); it("leaves dist-only installs unchanged because both preferences resolve the built entry", () => { diff --git a/src/plugins/plugin-runtime-artifact-resolution.ts b/src/plugins/plugin-runtime-artifact-resolution.ts index 709f41a86ab5..664f105bcd8f 100644 --- a/src/plugins/plugin-runtime-artifact-resolution.ts +++ b/src/plugins/plugin-runtime-artifact-resolution.ts @@ -4,12 +4,11 @@ import path from "node:path"; import type { OpenClawPackageManifest } from "./manifest.js"; import type { PluginOrigin } from "./plugin-origin.types.js"; import type { PluginRegistry } from "./registry-types.js"; -import { collectLivePluginRegistries, requireActivePluginRegistry } from "./runtime.js"; +import { getActivePluginRegistry, requireActivePluginRegistry } from "./runtime.js"; type PluginRuntimeArtifactEntryKind = "runtime" | "setup"; // Pin one physical path per plugin id and logical entry within one installed registry. -// Pinned surfaces retain their registry while replacement builders resolve independently. function safeRealpathOrResolve(value: string): string { try { return fs.realpathSync(value); @@ -19,9 +18,7 @@ function safeRealpathOrResolve(value: string): string { } export function clearPluginRuntimeArtifactResolutionMemo(): void { - for (const registry of collectLivePluginRegistries()) { - registry.pluginRuntimeArtifacts.clear(); - } + getActivePluginRegistry()?.pluginRuntimeArtifacts.clear(); } /** Canonical packaged runtime replaces staging-only dist-runtime artifacts. */ diff --git a/src/plugins/prepared-message-tool-catalog.test.ts b/src/plugins/prepared-message-tool-catalog.test.ts index bc5417984d80..3c773571173c 100644 --- a/src/plugins/prepared-message-tool-catalog.test.ts +++ b/src/plugins/prepared-message-tool-catalog.test.ts @@ -11,11 +11,7 @@ import { getPreparedMessageToolCatalogForRegistry, settlePreparedMessageToolCatalog, } from "./prepared-message-tool-catalog.js"; -import { - pinActivePluginChannelRegistry, - resetPluginRuntimeStateForTest, - setActivePluginRegistry, -} from "./runtime.js"; +import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "./runtime.js"; function channel(id: string, reconcilesUnknownSend = false): ChannelPlugin { return { @@ -88,23 +84,22 @@ describe("prepared message-tool catalog", () => { ); }); - it("settles the exact runtime registry while another channel registry is pinned", () => { - const pinned = createTestRegistry([ + it("retains catalogs by registry across a root swap", () => { + const first = createTestRegistry([ { pluginId: "alpha", source: "test", plugin: channel("alpha") }, ]); const runtime = createTestRegistry([ { pluginId: "beta", source: "test", plugin: channel("beta") }, ]); - setActivePluginRegistry(pinned); - pinActivePluginChannelRegistry(pinned); + setActivePluginRegistry(first); + const firstCatalog = getPreparedMessageToolCatalog(); setActivePluginRegistry(runtime); - const pinnedCatalog = getPreparedMessageToolCatalog(); const runtimeCatalog = getPreparedMessageToolCatalogForRegistry(runtime); - expect(pinnedCatalog?.channels.map((entry) => entry.id)).toEqual(["alpha"]); + expect(firstCatalog?.channels.map((entry) => entry.id)).toEqual(["alpha"]); expect(runtimeCatalog?.channels.map((entry) => entry.id)).toEqual(["beta"]); - expect(listMessageActionDiscoveryChannels().map((entry) => entry.id)).toEqual(["alpha"]); + expect(listMessageActionDiscoveryChannels().map((entry) => entry.id)).toEqual(["beta"]); expect(listMessageActionDiscoveryChannels(runtimeCatalog).map((entry) => entry.id)).toEqual([ "beta", ]); diff --git a/src/plugins/prepared-message-tool-catalog.ts b/src/plugins/prepared-message-tool-catalog.ts index 1f6b775ca01e..23425c99283d 100644 --- a/src/plugins/prepared-message-tool-catalog.ts +++ b/src/plugins/prepared-message-tool-catalog.ts @@ -28,7 +28,7 @@ function selectedRegistry( return (snapshot.registry as PluginRegistry | null | undefined) ?? undefined; } -/** Settles the catalog after the channel registry surface changes. */ +/** Settles the catalog after the process-root registry changes. */ export function settlePreparedMessageToolCatalog( preparedRegistry?: PluginRegistry, preparedVersion?: number, diff --git a/src/plugins/provider-hook-runtime.ts b/src/plugins/provider-hook-runtime.ts index 0f32757bbbcc..36613531217e 100644 --- a/src/plugins/provider-hook-runtime.ts +++ b/src/plugins/provider-hook-runtime.ts @@ -35,7 +35,6 @@ import type { let providerRuntimePluginCache: ConfigScopedRuntimeCache = new WeakMap(); const defaultProviderRuntimePluginCache = new PluginLruCache(128); -const PREPARED_PROVIDER_RUNTIME_SURFACES = ["channel"] as const; type ProviderRuntimePluginLookupParams = { provider: string; @@ -152,23 +151,6 @@ function findProviderRuntimePluginInLoadedRegistries(params: { if (activePlugin) { return activePlugin; } - for (const surface of PREPARED_PROVIDER_RUNTIME_SURFACES) { - const registry = getLoadedRuntimePluginRegistry({ - env: params.lookup.env, - workspaceDir: params.lookup.workspaceDir, - surface, - }); - const plugin = registry - ? findProviderRuntimePluginInRegistry({ - registry, - provider: params.lookup.provider, - apiOwnerHint: params.apiOwnerHint, - }) - : undefined; - if (plugin) { - return plugin; - } - } return undefined; } diff --git a/src/plugins/registry-runtime-binding.ts b/src/plugins/registry-runtime-binding.ts new file mode 100644 index 000000000000..927dbca8c3e2 --- /dev/null +++ b/src/plugins/registry-runtime-binding.ts @@ -0,0 +1,21 @@ +import type { PluginRegistry } from "./registry-types.js"; +import type { PluginRuntime } from "./runtime/types.js"; + +const PLUGIN_REGISTRY_RUNTIME = Symbol.for("openclaw.pluginRegistryRuntime"); + +type RuntimeBoundPluginRegistry = PluginRegistry & { + [PLUGIN_REGISTRY_RUNTIME]?: PluginRuntime; +}; + +export function bindPluginRegistryRuntime(registry: PluginRegistry, runtime: PluginRuntime): void { + Object.defineProperty(registry, PLUGIN_REGISTRY_RUNTIME, { + configurable: false, + enumerable: false, + value: runtime, + writable: false, + }); +} + +export function getPluginRegistryRuntime(registry: PluginRegistry): PluginRuntime | undefined { + return (registry as RuntimeBoundPluginRegistry)[PLUGIN_REGISTRY_RUNTIME]; +} diff --git a/src/plugins/registry-state.ts b/src/plugins/registry-state.ts index 572a81712e81..d689666c44d8 100644 --- a/src/plugins/registry-state.ts +++ b/src/plugins/registry-state.ts @@ -1,6 +1,7 @@ import type { PluginDiagnostic } from "./manifest-types.js"; import { createModelCatalogRegistrationHandlers } from "./model-catalog-registration.js"; import { createEmptyPluginRegistry } from "./registry-empty.js"; +import { bindPluginRegistryRuntime } from "./registry-runtime-binding.js"; import type { PluginRegistryParams } from "./registry-types.js"; import type { PluginHookName } from "./types.js"; @@ -57,6 +58,7 @@ export function resolveTypedHookTimeoutMs(params: { export function createPluginRegistryState(registryParams: PluginRegistryParams) { const registry = createEmptyPluginRegistry(); + bindPluginRegistryRuntime(registry, registryParams.runtime); const coreGatewayMethodNames = Array.from( new Set([ ...(registryParams.coreGatewayMethodNames ?? []), diff --git a/src/plugins/runtime-channel-state.ts b/src/plugins/runtime-channel-state.ts index 4a6b3aebfe76..8f5971302cb0 100644 --- a/src/plugins/runtime-channel-state.ts +++ b/src/plugins/runtime-channel-state.ts @@ -6,10 +6,6 @@ type GlobalChannelRegistryState = typeof globalThis & { [PLUGIN_REGISTRY_STATE]?: { activeVersion?: number; activeRegistry?: ActivePluginChannelRegistry | null; - channel?: { - registry: ActivePluginChannelRegistry | null; - version?: number; - }; }; }; @@ -20,55 +16,21 @@ export type ActivePluginChannelRegistrySnapshot = { version: number; }; -let activePluginChannelRegistrySnapshot: - | { - state: GlobalChannelRegistryRuntimeState; - pinnedRegistry: ActivePluginChannelRegistry | null; - activeRegistry: ActivePluginChannelRegistry | null; - pinnedChannelCount: number; - activeChannelCount: number; - snapshot: ActivePluginChannelRegistrySnapshot; - } - | undefined; +let activePluginChannelRegistrySnapshot: ActivePluginChannelRegistrySnapshot | undefined; -function countChannels(registry: ActivePluginChannelRegistry | null | undefined): number { - return registry?.channels?.length ?? 0; -} - -/** Returns a cached channel registry snapshot, preferring pinned channel state when populated. */ +/** Returns a snapshot of the process-root plugin registry. */ export function getActivePluginChannelRegistrySnapshotFromState(): ActivePluginChannelRegistrySnapshot { - const state = (globalThis as GlobalChannelRegistryState)[PLUGIN_REGISTRY_STATE]; - const pinnedRegistry = state?.channel?.registry ?? null; - const activeRegistry = state?.activeRegistry ?? null; - const pinnedChannelCount = countChannels(pinnedRegistry); - const activeChannelCount = countChannels(activeRegistry); - const selectedPinnedRegistry = - pinnedChannelCount > 0 || (pinnedRegistry !== null && activeChannelCount === 0); - const version = selectedPinnedRegistry - ? (state?.channel?.version ?? 0) - : (state?.activeVersion ?? 0); + const state: GlobalChannelRegistryRuntimeState = (globalThis as GlobalChannelRegistryState)[ + PLUGIN_REGISTRY_STATE + ]; + const registry = state?.activeRegistry ?? null; + const version = state?.activeVersion ?? 0; const cached = activePluginChannelRegistrySnapshot; - if ( - cached && - cached.state === state && - cached.pinnedRegistry === pinnedRegistry && - cached.activeRegistry === activeRegistry && - cached.pinnedChannelCount === pinnedChannelCount && - cached.activeChannelCount === activeChannelCount && - cached.snapshot.version === version - ) { - return cached.snapshot; + if (cached && cached.registry === registry && cached.version === version) { + return cached; } - const registry = selectedPinnedRegistry ? pinnedRegistry : activeRegistry; const snapshot = { registry, version }; - activePluginChannelRegistrySnapshot = { - state, - pinnedRegistry, - activeRegistry, - pinnedChannelCount, - activeChannelCount, - snapshot, - }; + activePluginChannelRegistrySnapshot = snapshot; return snapshot; } diff --git a/src/plugins/runtime-state.ts b/src/plugins/runtime-state.ts index 188ec0d3f8b3..f244e0e9e19e 100644 --- a/src/plugins/runtime-state.ts +++ b/src/plugins/runtime-state.ts @@ -1,25 +1,14 @@ import { PLUGIN_REGISTRY_STATE } from "./runtime-state-key.js"; // Stores plugin runtime registry state for the current process lifecycle. -import { getActivePluginRegistryWorkspaceDirFromState as getPinnedWorkspaceDirFromState } from "./runtime-workspace-state.js"; +import { getActivePluginRegistryWorkspaceDirFromState as getWorkspaceDirFromState } from "./runtime-workspace-state.js"; export { PLUGIN_REGISTRY_STATE }; type PluginRegistry = import("./registry-types.js").PluginRegistry; -type RuntimeTrackedPluginRegistry = PluginRegistry; - -export type RegistrySurfaceState = { - registry: RuntimeTrackedPluginRegistry | null; - pinned: boolean; - version: number; -}; - export type RegistryState = { - activeRegistry: RuntimeTrackedPluginRegistry | null; + activeRegistry: PluginRegistry | null; activeVersion: number; - httpRoute: RegistrySurfaceState; - channel: RegistrySurfaceState; - sessionExtension: RegistrySurfaceState; agentEventBridgeUnsubscribe?: (() => void) | undefined; key: string | null; workspaceDir: string | null; @@ -36,5 +25,5 @@ export function getPluginRegistryState(): RegistryState | undefined { return (globalThis as GlobalRegistryState)[PLUGIN_REGISTRY_STATE]; } export function getActivePluginRegistryWorkspaceDirFromState(): string | undefined { - return getPinnedWorkspaceDirFromState(); + return getWorkspaceDirFromState(); } diff --git a/src/plugins/runtime.channel-pin.test.ts b/src/plugins/runtime.channel-pin.test.ts deleted file mode 100644 index 5befd2a25c13..000000000000 --- a/src/plugins/runtime.channel-pin.test.ts +++ /dev/null @@ -1,349 +0,0 @@ -// Verifies runtime channel pinning for plugin activation. -import { afterEach, describe, expect, it } from "vitest"; -import { loadChannelOutboundAdapter } from "../channels/plugins/outbound/load.js"; -import { getChannelPlugin } from "../channels/plugins/registry.js"; -import { emitAgentEvent, resetAgentEventsForTest } from "../infra/agent-events.js"; -import { createEmptyPluginRegistry } from "./registry-empty.js"; -import { isPluginRegistryRetired } from "./registry-lifecycle.js"; -import { - getActivePluginChannelRegistryVersion, - getActivePluginRegistryVersion, - getActivePluginChannelRegistry, - pinActivePluginChannelRegistry, - releasePinnedPluginChannelRegistry, - requireActivePluginChannelRegistry, - resetPluginRuntimeStateForTest, - setActivePluginRegistry, -} from "./runtime.js"; - -function createRegistryWithChannel(pluginId = "demo-channel") { - const registry = createEmptyPluginRegistry(); - const plugin = { id: pluginId, meta: {} } as never; - registry.channels = [{ plugin }] as never; - return { registry, plugin }; -} - -function createChannelRegistryPair(pluginId = "demo-channel") { - return { - first: createRegistryWithChannel(pluginId), - second: createRegistryWithChannel(pluginId), - }; -} - -function createRegistrySet() { - return { - startup: createEmptyPluginRegistry(), - replacement: createEmptyPluginRegistry(), - unrelated: createEmptyPluginRegistry(), - }; -} - -function expectActiveChannelRegistry(registry: ReturnType) { - expect(getActivePluginChannelRegistry()).toBe(registry); -} - -function expectPinnedChannelRegistry( - startupRegistry: ReturnType, - replacementRegistry: ReturnType, -) { - setActivePluginRegistry(startupRegistry); - pinActivePluginChannelRegistry(startupRegistry); - setActivePluginRegistry(replacementRegistry); - expectActiveChannelRegistry(startupRegistry); -} - -function expectResetClearsPinnedChannelRegistry(params: { - startupRegistry: ReturnType; - freshRegistry: ReturnType; -}) { - setActivePluginRegistry(params.startupRegistry); - pinActivePluginChannelRegistry(params.startupRegistry); - - resetPluginRuntimeStateForTest(); - - setActivePluginRegistry(params.freshRegistry); - expectActiveChannelRegistry(params.freshRegistry); -} - -function expectChannelRegistrySwap(params: { - startupRegistry: ReturnType; - replacementRegistry: ReturnType; - pin?: boolean; - releaseRegistry?: ReturnType; - expectedDuringSwap: ReturnType; - expectedAfterRelease: ReturnType; -}) { - setActivePluginRegistry(params.startupRegistry); - if (params.pin) { - pinActivePluginChannelRegistry(params.startupRegistry); - } - - setActivePluginRegistry(params.replacementRegistry); - expectActiveChannelRegistry(params.expectedDuringSwap); - - if (params.pin && params.releaseRegistry) { - releasePinnedPluginChannelRegistry(params.releaseRegistry); - } - - expectActiveChannelRegistry(params.expectedAfterRelease); -} - -describe("channel registry pinning", () => { - afterEach(() => { - resetAgentEventsForTest(); - resetPluginRuntimeStateForTest(); - }); - - it("returns the active registry when not pinned", () => { - const registry = createEmptyPluginRegistry(); - setActivePluginRegistry(registry); - expectActiveChannelRegistry(registry); - }); - - it("preserves pinned channel registry across setActivePluginRegistry calls", () => { - const { registry: startup } = createRegistryWithChannel(); - // A subsequent registry swap (e.g., config-schema load) must not evict channels. - const replacement = createEmptyPluginRegistry(); - expectPinnedChannelRegistry(startup, replacement); - expect(getActivePluginChannelRegistry()!.channels).toHaveLength(1); - }); - - it("keeps pinned channel registries live until they are released", () => { - const { registry: startup } = createRegistryWithChannel(); - const replacement = createEmptyPluginRegistry(); - - setActivePluginRegistry(startup); - pinActivePluginChannelRegistry(startup); - setActivePluginRegistry(replacement); - - expect(getActivePluginChannelRegistry()).toBe(startup); - expect(isPluginRegistryRetired(startup)).toBe(false); - - releasePinnedPluginChannelRegistry(startup); - - expect(getActivePluginChannelRegistry()).toBe(replacement); - expect(isPluginRegistryRetired(startup)).toBe(true); - }); - - it("falls back to the active channel registry when the pinned registry is empty", () => { - const startup = createEmptyPluginRegistry(); - const { registry: replacement } = createRegistryWithChannel("replacement-channel"); - setActivePluginRegistry(startup); - pinActivePluginChannelRegistry(startup); - - const channelVersionBeforeSwap = getActivePluginChannelRegistryVersion(); - setActivePluginRegistry(replacement); - - expectActiveChannelRegistry(replacement); - expect(getActivePluginChannelRegistryVersion()).not.toBe(channelVersionBeforeSwap); - }); - - it("re-pin invalidates cached channel lookups", () => { - const { first, second } = createChannelRegistryPair(); - const { registry: setup, plugin: setupPlugin } = first; - setActivePluginRegistry(setup); - pinActivePluginChannelRegistry(setup); - - expect(getChannelPlugin("demo-channel")).toBe(setupPlugin); - - const { registry: full, plugin: fullPlugin } = second; - setActivePluginRegistry(full); - - expect(getChannelPlugin("demo-channel")).toBe(setupPlugin); - - const activeVersionBeforeRepin = getActivePluginRegistryVersion(); - const channelVersionBeforeRepin = getActivePluginChannelRegistryVersion(); - pinActivePluginChannelRegistry(full); - - expect(getActivePluginRegistryVersion()).toBe(activeVersionBeforeRepin); - expect(getActivePluginChannelRegistryVersion()).toBe(channelVersionBeforeRepin + 1); - expect(getChannelPlugin("demo-channel")).toBe(fullPlugin); - }); - - it.each([ - { - name: "updates channel registry on swap when not pinned", - pin: false, - releasePinnedRegistry: false, - expectDuringPin: false, - expectAfterSwap: "second", - }, - { - name: "release restores live-tracking behavior", - pin: true, - releasePinnedRegistry: true, - expectDuringPin: true, - expectAfterSwap: "second", - }, - { - name: "release is a no-op when the pinned registry does not match", - pin: true, - releasePinnedRegistry: false, - expectDuringPin: true, - expectAfterSwap: "first", - }, - ] as const)("$name", ({ pin, releasePinnedRegistry, expectDuringPin, expectAfterSwap }) => { - const { startup, replacement, unrelated } = createRegistrySet(); - expectChannelRegistrySwap({ - startupRegistry: startup, - replacementRegistry: replacement, - ...(pin ? { pin: true } : {}), - ...(pin ? { releaseRegistry: releasePinnedRegistry ? startup : unrelated } : {}), - expectedDuringSwap: expectDuringPin ? startup : replacement, - expectedAfterRelease: expectAfterSwap === "second" ? replacement : startup, - }); - }); - - it("requireActivePluginChannelRegistry creates a registry when none exists", () => { - resetPluginRuntimeStateForTest(); - const registry = requireActivePluginChannelRegistry(); - expect(registry).toEqual(createEmptyPluginRegistry()); - }); - - it("resetPluginRuntimeStateForTest clears channel pin", () => { - const { startup, replacement: fresh } = createRegistrySet(); - expectResetClearsPinnedChannelRegistry({ - startupRegistry: startup, - freshRegistry: fresh, - }); - }); - - it("loadChannelOutboundAdapter resolves from pinned registry after active registry replacement", async () => { - const outboundAdapter = { send: async () => ({ messageId: "1" }) }; - const startup = createEmptyPluginRegistry(); - startup.channels = [ - { - pluginId: "telegram", - plugin: { id: "telegram", meta: {}, outbound: outboundAdapter }, - source: "test", - }, - ] as never; - setActivePluginRegistry(startup); - pinActivePluginChannelRegistry(startup); - - // Simulate a post-boot registry replacement (e.g. config-schema load, plugin status query). - const replacement = createEmptyPluginRegistry(); - setActivePluginRegistry(replacement); - - // The outbound loader must still find the telegram adapter from the pinned registry. - const adapter = await loadChannelOutboundAdapter("telegram"); - expect(adapter).toBe(outboundAdapter); - }); - - it("loadChannelOutboundAdapter falls back to active registry when pinned setup entry cannot send", async () => { - const outboundAdapter = { sendText: async () => ({ messageId: "1" }) }; - const startup = createEmptyPluginRegistry(); - startup.channels = [ - { - pluginId: "discord", - plugin: { id: "discord", meta: {} }, - source: "setup", - }, - ] as never; - const replacement = createEmptyPluginRegistry(); - replacement.channels = [ - { - pluginId: "discord", - plugin: { id: "discord", meta: {}, outbound: outboundAdapter }, - source: "runtime", - }, - ] as never; - - setActivePluginRegistry(startup); - pinActivePluginChannelRegistry(startup); - setActivePluginRegistry(replacement); - - const adapter = await loadChannelOutboundAdapter("discord"); - expect(adapter).toBe(outboundAdapter); - }); - - it("keeps pinned channel registry agent-event subscriptions live after active registry replacement", () => { - const observed: string[] = []; - const startup = createEmptyPluginRegistry(); - startup.agentEventSubscriptions = [ - { - pluginId: "startup-plugin", - pluginName: "Startup Plugin", - source: "test", - subscription: { - id: "startup-subscription", - handle: (event) => { - observed.push(`startup:${event.stream}`); - }, - }, - }, - ]; - const replacement = createEmptyPluginRegistry(); - replacement.agentEventSubscriptions = [ - { - pluginId: "replacement-plugin", - pluginName: "Replacement Plugin", - source: "test", - subscription: { - id: "replacement-subscription", - handle: (event) => { - observed.push(`replacement:${event.stream}`); - }, - }, - }, - ]; - - setActivePluginRegistry(startup); - pinActivePluginChannelRegistry(startup); - setActivePluginRegistry(replacement); - - emitAgentEvent({ - runId: "run-pinned-agent-events", - stream: "approval", - data: { state: "queued" }, - }); - - expect(observed.toSorted()).toEqual(["replacement:approval", "startup:approval"]); - }); - - it("dedupes the agent-event bridge across multiple runtime module instances", async () => { - const observed: string[] = []; - const runtimeA = await import(new URL("./runtime.ts?runtimeA", import.meta.url).href); - const runtimeB = await import(new URL("./runtime.ts?runtimeB", import.meta.url).href); - const startup = createEmptyPluginRegistry(); - startup.agentEventSubscriptions = [ - { - pluginId: "startup-plugin", - pluginName: "Startup Plugin", - source: "test", - subscription: { - id: "startup-subscription", - handle: (event) => { - observed.push(`startup:${event.stream}`); - }, - }, - }, - ]; - const replacement = createEmptyPluginRegistry(); - replacement.agentEventSubscriptions = [ - { - pluginId: "replacement-plugin", - pluginName: "Replacement Plugin", - source: "test", - subscription: { - id: "replacement-subscription", - handle: (event) => { - observed.push(`replacement:${event.stream}`); - }, - }, - }, - ]; - - runtimeA.setActivePluginRegistry(startup); - runtimeA.pinActivePluginChannelRegistry(startup); - runtimeB.setActivePluginRegistry(replacement); - - emitAgentEvent({ - runId: "run-cross-module-pinned-agent-events", - stream: "approval", - data: { state: "queued" }, - }); - - expect(observed.toSorted()).toEqual(["replacement:approval", "startup:approval"]); - }); -}); diff --git a/src/plugins/runtime.test.ts b/src/plugins/runtime.test.ts index b11668660610..a6af33f61d84 100644 --- a/src/plugins/runtime.test.ts +++ b/src/plugins/runtime.test.ts @@ -1,94 +1,17 @@ /** Covers plugin runtime registration API behavior and registry mutation guards. */ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { isPluginRegistryRetired } from "./registry-lifecycle.js"; +import { beforeEach, describe, expect, it } from "vitest"; import { createEmptyPluginRegistry } from "./registry.js"; import type { PluginHttpRouteRegistration } from "./registry.js"; import { - getActivePluginGatewayCommandRegistry, - getActivePluginHttpRouteRegistryVersion, - getActivePluginRegistryVersion, + clearActivePluginRegistry, getActivePluginRegistry, - getActivePluginSessionExtensionRegistry, listImportedRuntimePluginIds, - pinActivePluginChannelRegistry, - pinActivePluginHttpRouteRegistry, - pinActivePluginSessionExtensionRegistry, recordImportedPluginId, - releasePinnedPluginChannelRegistry, - releasePinnedPluginHttpRouteRegistry, - releasePinnedPluginSessionExtensionRegistry, resetPluginRuntimeStateForTest, - resolveActivePluginHttpRouteRegistry, setActivePluginRegistry, } from "./runtime.js"; import { createPluginRecord } from "./status.test-fixtures.js"; -function createRegistryWithRoute(path: string) { - const registry = createEmptyPluginRegistry(); - registry.httpRoutes.push({ - path, - auth: "plugin", - match: path === "/plugins/diffs" ? "prefix" : "exact", - handler: () => true, - pluginId: path === "/plugins/diffs" ? "diffs" : "demo", - source: "test", - }); - return registry; -} - -function createRegistryWithSessionExtension(pluginId = "demo-plugin", namespace = "demo") { - const registry = createEmptyPluginRegistry(); - registry.sessionExtensions.push({ - pluginId, - extension: { - namespace, - description: "Demo session extension", - }, - source: "test", - }); - return registry; -} - -function createRuntimeRegistryPair() { - return { - startupRegistry: createEmptyPluginRegistry(), - laterRegistry: createEmptyPluginRegistry(), - }; -} - -function expectRegistryVersions(params: { active: number; routes: number }) { - expect(getActivePluginRegistryVersion()).toBe(params.active); - expect(getActivePluginHttpRouteRegistryVersion()).toBe(params.routes); -} - -function expectActiveRouteRegistryResolution(params: { - pinnedRegistry: ReturnType; - explicitRegistry: ReturnType; - expectedRegistry: "pinned" | "explicit"; -}) { - setActivePluginRegistry(params.pinnedRegistry); - pinActivePluginHttpRouteRegistry(params.pinnedRegistry); - - expect(resolveActivePluginHttpRouteRegistry(params.explicitRegistry)).toBe( - params.expectedRegistry === "pinned" ? params.pinnedRegistry : params.explicitRegistry, - ); -} - -function expectPinnedRouteRegistry( - startupRegistry: ReturnType, - laterRegistry: ReturnType, -) { - setActivePluginRegistry(startupRegistry); - pinActivePluginHttpRouteRegistry(startupRegistry); - setActivePluginRegistry(laterRegistry); - expect(resolveActivePluginHttpRouteRegistry(laterRegistry)).toBe(startupRegistry); -} - -function expectRouteRegistryState(params: { setup: () => void; assert: () => void }) { - params.setup(); - params.assert(); -} - async function waitForCleanupSignal(signal: Promise, label: string): Promise { let timer: NodeJS.Timeout | undefined; try { @@ -105,192 +28,6 @@ async function waitForCleanupSignal(signal: Promise, label: string): Promi } } -describe("plugin runtime route registry", () => { - afterEach(() => { - releasePinnedPluginChannelRegistry(); - releasePinnedPluginHttpRouteRegistry(); - releasePinnedPluginSessionExtensionRegistry(); - resetPluginRuntimeStateForTest(); - }); - - it("stays empty until a caller explicitly installs or requires a registry", () => { - resetPluginRuntimeStateForTest(); - - expect(getActivePluginRegistry()).toBeNull(); - }); - - it.each([ - { - name: "keeps the pinned route registry when the active plugin registry changes", - run: () => { - const { startupRegistry, laterRegistry } = createRuntimeRegistryPair(); - expectPinnedRouteRegistry(startupRegistry, laterRegistry); - }, - }, - { - name: "tracks route registry repins separately from the active registry version", - run: () => { - const { startupRegistry, laterRegistry } = createRuntimeRegistryPair(); - const repinnedRegistry = createEmptyPluginRegistry(); - - setActivePluginRegistry(startupRegistry); - pinActivePluginHttpRouteRegistry(laterRegistry); - - const activeVersionBeforeRepin = getActivePluginRegistryVersion(); - const routeVersionBeforeRepin = getActivePluginHttpRouteRegistryVersion(); - - pinActivePluginHttpRouteRegistry(repinnedRegistry); - - expectRegistryVersions({ - active: activeVersionBeforeRepin, - routes: routeVersionBeforeRepin + 1, - }); - }, - }, - ] as const)("$name", ({ run }) => { - expectRouteRegistryState({ - setup: () => {}, - assert: run, - }); - }); - - it("keeps pinned route registries live until they are released", () => { - const { startupRegistry, laterRegistry } = createRuntimeRegistryPair(); - - setActivePluginRegistry(startupRegistry); - pinActivePluginHttpRouteRegistry(startupRegistry); - setActivePluginRegistry(laterRegistry); - - expect(resolveActivePluginHttpRouteRegistry(laterRegistry)).toBe(startupRegistry); - expect(isPluginRegistryRetired(startupRegistry)).toBe(false); - - releasePinnedPluginHttpRouteRegistry(startupRegistry); - - expect(resolveActivePluginHttpRouteRegistry(laterRegistry)).toBe(laterRegistry); - expect(isPluginRegistryRetired(startupRegistry)).toBe(true); - }); - - it("keeps pinned session extension registries across active registry churn", () => { - const startupRegistry = createRegistryWithSessionExtension("startup", "presence"); - const laterRegistry = createRegistryWithSessionExtension("later", "presence"); - - setActivePluginRegistry(startupRegistry); - pinActivePluginSessionExtensionRegistry(startupRegistry); - setActivePluginRegistry(laterRegistry); - - expect(getActivePluginSessionExtensionRegistry()).toBe(startupRegistry); - expect( - getActivePluginSessionExtensionRegistry()?.sessionExtensions.map((entry) => entry.pluginId), - ).toEqual(["startup"]); - expect(isPluginRegistryRetired(startupRegistry)).toBe(false); - }); - - it("releases pinned session extension registries back to the active registry", () => { - const startupRegistry = createRegistryWithSessionExtension("startup", "presence"); - const laterRegistry = createRegistryWithSessionExtension("later", "presence"); - - setActivePluginRegistry(startupRegistry); - pinActivePluginSessionExtensionRegistry(startupRegistry); - setActivePluginRegistry(laterRegistry); - - releasePinnedPluginSessionExtensionRegistry(startupRegistry); - - expect(getActivePluginSessionExtensionRegistry()).toBe(laterRegistry); - expect(isPluginRegistryRetired(startupRegistry)).toBe(true); - }); - - it("keeps empty pinned session extension registries authoritative over active extensions", () => { - const startupRegistry = createEmptyPluginRegistry(); - const laterRegistry = createRegistryWithSessionExtension("later", "presence"); - - setActivePluginRegistry(startupRegistry); - pinActivePluginSessionExtensionRegistry(startupRegistry); - setActivePluginRegistry(laterRegistry); - - expect(getActivePluginSessionExtensionRegistry()).toBe(startupRegistry); - expect(getActivePluginSessionExtensionRegistry()?.sessionExtensions).toHaveLength(0); - }); - - it("resolves the gateway command registry from pinned startup surfaces before active churn", () => { - const { startupRegistry, laterRegistry } = createRuntimeRegistryPair(); - startupRegistry.commands.push({ - pluginId: "startup", - command: { - name: "startup", - description: "Startup command", - handler: () => ({}), - }, - source: "test", - }); - - setActivePluginRegistry(startupRegistry); - pinActivePluginChannelRegistry(startupRegistry); - setActivePluginRegistry(laterRegistry); - - expect(getActivePluginGatewayCommandRegistry()).toBe(startupRegistry); - }); - - it("falls through from an empty pinned startup registry to the active command surface", () => { - const { startupRegistry, laterRegistry } = createRuntimeRegistryPair(); - laterRegistry.commands.push({ - pluginId: "later", - command: { - name: "later", - description: "Later command", - handler: () => ({}), - }, - source: "test", - }); - - setActivePluginRegistry(startupRegistry); - pinActivePluginChannelRegistry(startupRegistry); - setActivePluginRegistry(laterRegistry); - - expect(getActivePluginGatewayCommandRegistry()).toBe(laterRegistry); - }); - - it("prefers channel-pinned command registries over route-only pins", () => { - const routeRegistry = createRegistryWithRoute("/demo"); - const channelRegistry = createEmptyPluginRegistry(); - channelRegistry.commands.push({ - pluginId: "channel", - command: { - name: "channel", - description: "Channel command", - handler: () => ({}), - }, - source: "test", - }); - - setActivePluginRegistry(routeRegistry); - pinActivePluginHttpRouteRegistry(routeRegistry); - pinActivePluginChannelRegistry(channelRegistry); - - expect(getActivePluginGatewayCommandRegistry()).toBe(channelRegistry); - }); - - it.each([ - { - name: "keeps an explicitly pinned empty route registry authoritative", - pinnedRegistry: createEmptyPluginRegistry(), - explicitRegistry: createRegistryWithRoute("/demo"), - expected: "pinned", - }, - { - name: "prefers the pinned route registry when it already owns routes", - pinnedRegistry: createRegistryWithRoute("/imessage-webhook"), - explicitRegistry: createRegistryWithRoute("/plugins/diffs"), - expected: "pinned", - }, - ] as const)("$name", ({ pinnedRegistry, explicitRegistry, expected }) => { - expectActiveRouteRegistryResolution({ - pinnedRegistry, - explicitRegistry, - expectedRegistry: expected, - }); - }); -}); - const makeRoute = (path: string): PluginHttpRouteRegistration => ({ path, handler: () => {}, @@ -448,4 +185,32 @@ describe("setActivePluginRegistry", () => { expect(listImportedRuntimePluginIds()).toEqual(["broken-plugin"]); }); + + it("clears the root only after its host cleanup completes", async () => { + let cleanupCount = 0; + const registry = createEmptyPluginRegistry(); + registry.plugins.push( + createPluginRecord({ id: "cleanup-on-close", name: "Cleanup on close", status: "loaded" }), + ); + registry.runtimeLifecycles = [ + { + pluginId: "cleanup-on-close", + pluginName: "Cleanup on close", + lifecycle: { + id: "cleanup-on-close", + cleanup() { + cleanupCount += 1; + }, + }, + source: "/virtual/cleanup-on-close/index.ts", + rootDir: "/virtual/cleanup-on-close", + }, + ]; + setActivePluginRegistry(registry); + + await clearActivePluginRegistry(); + + expect(getActivePluginRegistry()).toBeNull(); + expect(cleanupCount).toBe(1); + }); }); diff --git a/src/plugins/runtime.ts b/src/plugins/runtime.ts index 61719d68a1fd..d41a64ef22a4 100644 --- a/src/plugins/runtime.ts +++ b/src/plugins/runtime.ts @@ -11,11 +11,7 @@ import { createEmptyPluginRegistry } from "./registry-empty.js"; import { markPluginRegistryActive, markPluginRegistryRetired } from "./registry-lifecycle.js"; import type { PluginRegistry } from "./registry-types.js"; import { getActivePluginChannelRegistrySnapshotFromState } from "./runtime-channel-state.js"; -import { - PLUGIN_REGISTRY_STATE, - type RegistryState, - type RegistrySurfaceState, -} from "./runtime-state.js"; +import { PLUGIN_REGISTRY_STATE, type RegistryState } from "./runtime-state.js"; import { getPluginRuntimeGatewayRequestScope } from "./runtime/gateway-request-scope.js"; const log = createSubsystemLogger("plugins/runtime"); @@ -33,21 +29,6 @@ const state: RegistryState = (() => { registryState = { activeRegistry: null, activeVersion: 0, - httpRoute: { - registry: null, - pinned: false, - version: 0, - }, - channel: { - registry: null, - pinned: false, - version: 0, - }, - sessionExtension: { - registry: null, - pinned: false, - version: 0, - }, agentEventBridgeUnsubscribe: undefined, key: null, workspaceDir: null, @@ -72,16 +53,8 @@ function registryHasPluginHostCleanupWork(registry: PluginRegistry | null): bool ); } -function isRegistryPinned(registry: PluginRegistry): boolean { - return ( - (state.httpRoute.pinned && state.httpRoute.registry === registry) || - (state.channel.pinned && state.channel.registry === registry) || - (state.sessionExtension.pinned && state.sessionExtension.registry === registry) - ); -} - function isRegistryLive(registry: PluginRegistry): boolean { - return state.activeRegistry === registry || isRegistryPinned(registry); + return state.activeRegistry === registry; } async function cleanupPreviousPluginHostRegistry(params: { @@ -92,7 +65,7 @@ async function cleanupPreviousPluginHostRegistry(params: { import("./host-hook-cleanup.js"), ]); const nextRegistry = asPluginRegistry(state.activeRegistry); - if (!nextRegistry || nextRegistry === params.previousRegistry) { + if (nextRegistry === params.previousRegistry) { return; } // Async cleanup must not clear state for a registry that has been restored @@ -125,39 +98,15 @@ function retirePluginRegistryIfUnused(registry: PluginRegistry | null): boolean return true; } -/** - * Returns the distinct live plugin registries in precedence order: the active - * registry first, then the pinned http-route and channel surfaces. Uses the - * raw pinned registries (not channel-presentation selection) so a pinned - * registry stays visible to runtime dispatch even with zero channels. Shared - * by the agent-event bridge and the global hook runner so both dispatch - * surfaces agree on what "live" means. - */ -export function collectLivePluginRegistries(): PluginRegistry[] { - const registries: PluginRegistry[] = []; - const seen = new Set(); - const addRegistry = (registry: PluginRegistry | null) => { - if (!registry || seen.has(registry)) { - return; - } - seen.add(registry); - registries.push(registry); - }; - addRegistry(asPluginRegistry(state.activeRegistry)); - addRegistry(asPluginRegistry(state.httpRoute.registry)); - addRegistry(asPluginRegistry(state.channel.registry)); - addRegistry(asPluginRegistry(state.sessionExtension.registry)); - return registries; -} - function syncPluginAgentEventBridge(): void { state.agentEventBridgeUnsubscribe?.(); state.agentEventBridgeUnsubscribe = undefined; - if (collectLivePluginRegistries().length === 0) { + if (!state.activeRegistry) { return; } state.agentEventBridgeUnsubscribe = onAgentEvent((event) => { - for (const registry of collectLivePluginRegistries()) { + const registry = asPluginRegistry(state.activeRegistry); + if (registry) { dispatchPluginAgentEventSubscriptions({ registry, event }); } }); @@ -167,36 +116,6 @@ export function recordImportedPluginId(pluginId: string): void { state.importedPluginIds.add(pluginId); } -function installSurfaceRegistry( - surface: RegistrySurfaceState, - registry: RegistryState["activeRegistry"], - pinned: boolean, -) { - if (surface.registry === registry && surface.pinned === pinned) { - return; - } - surface.registry = registry; - surface.pinned = pinned; - surface.version += 1; -} - -function syncTrackedSurface( - surface: RegistrySurfaceState, - registry: RegistryState["activeRegistry"], - refreshVersion = false, -) { - if (surface.pinned) { - return; - } - if (surface.registry === registry && !surface.pinned) { - if (refreshVersion) { - surface.version += 1; - } - return; - } - installSurfaceRegistry(surface, registry, false); -} - export function setActivePluginRegistry( registry: PluginRegistry, cacheKey?: string, @@ -267,17 +186,11 @@ function installActivePluginRegistry(params: { state.activeRegistry = params.registry; markPluginRegistryActive(params.registry); state.activeVersion += 1; - syncTrackedSurface(state.httpRoute, params.registry, true); - syncTrackedSurface(state.channel, params.registry, true); if (params.registry) { - settlePreparedMessageToolCatalog( - params.registry, - state.channel.pinned ? state.activeVersion : state.channel.version, - ); + settlePreparedMessageToolCatalog(params.registry, state.activeVersion); } else { settlePreparedMessageToolCatalog(); } - syncTrackedSurface(state.sessionExtension, params.registry, true); state.key = params.key; state.workspaceDir = params.workspaceDir; state.runtimeSubagentMode = params.runtimeSubagentMode; @@ -315,10 +228,8 @@ export function requireActivePluginRegistry(): PluginRegistry { state.activeRegistry = createEmptyPluginRegistry(); markPluginRegistryActive(state.activeRegistry); state.activeVersion += 1; - syncTrackedSurface(state.httpRoute, state.activeRegistry); - syncTrackedSurface(state.channel, state.activeRegistry); - settlePreparedMessageToolCatalog(state.activeRegistry, state.channel.version); - syncTrackedSurface(state.sessionExtension, state.activeRegistry); + settlePreparedMessageToolCatalog(state.activeRegistry, state.activeVersion); + syncPluginAgentEventBridge(); } return asPluginRegistry(state.activeRegistry)!; } @@ -358,34 +269,12 @@ export function assertDirectPluginRegistrationReplacement( } } -export function pinActivePluginHttpRouteRegistry(registry: PluginRegistry) { - const previousRegistry = asPluginRegistry(state.httpRoute.registry); - installSurfaceRegistry(state.httpRoute, registry, true); - markPluginRegistryActive(registry); - syncPluginAgentEventBridge(); - if (retirePluginRegistryIfUnused(previousRegistry)) { - cleanupRetiredPluginHostRegistry(previousRegistry!); - } -} - -export function releasePinnedPluginHttpRouteRegistry(registry?: PluginRegistry) { - if (registry && state.httpRoute.registry !== registry) { - return; - } - const previousRegistry = asPluginRegistry(state.httpRoute.registry); - installSurfaceRegistry(state.httpRoute, state.activeRegistry, false); - syncPluginAgentEventBridge(); - if (retirePluginRegistryIfUnused(previousRegistry)) { - cleanupRetiredPluginHostRegistry(previousRegistry!); - } -} - export function getActivePluginHttpRouteRegistry(): PluginRegistry | null { - return asPluginRegistry(state.httpRoute.registry ?? state.activeRegistry); + return asPluginRegistry(state.activeRegistry); } export function getActivePluginHttpRouteRegistryVersion(): number { - return state.httpRoute.registry ? state.httpRoute.version : state.activeVersion; + return state.activeVersion; } export function requireActivePluginHttpRouteRegistry(): PluginRegistry { @@ -393,49 +282,7 @@ export function requireActivePluginHttpRouteRegistry(): PluginRegistry { if (existing) { return existing; } - const created = requireActivePluginRegistry(); - installSurfaceRegistry(state.httpRoute, created, false); - return created; -} - -export function resolveActivePluginHttpRouteRegistry(fallback: PluginRegistry): PluginRegistry { - const routeRegistry = getActivePluginHttpRouteRegistry(); - if (!routeRegistry) { - return fallback; - } - if (state.httpRoute.pinned) { - return routeRegistry; - } - const routeCount = routeRegistry.httpRoutes?.length ?? 0; - const fallbackRouteCount = fallback.httpRoutes?.length ?? 0; - if (routeCount === 0 && fallbackRouteCount > 0) { - return fallback; - } - return routeRegistry; -} - -export function pinActivePluginChannelRegistry(registry: PluginRegistry) { - const previousRegistry = asPluginRegistry(state.channel.registry); - installSurfaceRegistry(state.channel, registry, true); - settlePreparedMessageToolCatalog(); - markPluginRegistryActive(registry); - syncPluginAgentEventBridge(); - if (retirePluginRegistryIfUnused(previousRegistry)) { - cleanupRetiredPluginHostRegistry(previousRegistry!); - } -} - -export function releasePinnedPluginChannelRegistry(registry?: PluginRegistry) { - if (registry && state.channel.registry !== registry) { - return; - } - const previousRegistry = asPluginRegistry(state.channel.registry); - installSurfaceRegistry(state.channel, state.activeRegistry, false); - settlePreparedMessageToolCatalog(); - syncPluginAgentEventBridge(); - if (retirePluginRegistryIfUnused(previousRegistry)) { - cleanupRetiredPluginHostRegistry(previousRegistry!); - } + return requireActivePluginRegistry(); } export function getActivePluginChannelRegistry(): PluginRegistry | null { @@ -446,39 +293,12 @@ export function getActivePluginChannelRegistryVersion(): number { return getActivePluginChannelRegistrySnapshotFromState().version; } -function countCommandChannelSurface(registry: PluginRegistry | null): number { - return (registry?.commands.length ?? 0) + (registry?.channels.length ?? 0); -} - export function getActivePluginGatewayCommandRegistry(): PluginRegistry | null { - const pinnedChannelRegistry = state.channel.pinned - ? asPluginRegistry(state.channel.registry) - : null; - const pinnedHttpRouteRegistry = state.httpRoute.pinned - ? asPluginRegistry(state.httpRoute.registry) - : null; - const activeRegistry = asPluginRegistry(state.activeRegistry); - const pinnedRegistry = - (countCommandChannelSurface(pinnedChannelRegistry) > 0 ? pinnedChannelRegistry : null) ?? - (countCommandChannelSurface(pinnedHttpRouteRegistry) > 0 ? pinnedHttpRouteRegistry : null); - if (pinnedRegistry) { - return pinnedRegistry; - } - if (activeRegistry && countCommandChannelSurface(activeRegistry) > 0) { - return activeRegistry; - } - return pinnedChannelRegistry ?? pinnedHttpRouteRegistry ?? activeRegistry; + return asPluginRegistry(state.activeRegistry); } export function getActivePluginGatewayNodePolicyRegistry(): PluginRegistry | null { - // Node allowlists and invoke guards are Gateway security policy. Agent-scoped - // registry swaps must not add commands or shadow the pinned startup policy. - return ( - (state.channel.pinned ? asPluginRegistry(state.channel.registry) : null) ?? - (state.httpRoute.pinned ? asPluginRegistry(state.httpRoute.registry) : null) ?? - (state.sessionExtension.pinned ? asPluginRegistry(state.sessionExtension.registry) : null) ?? - asPluginRegistry(state.activeRegistry) - ); + return asPluginRegistry(state.activeRegistry); } export function requireActivePluginChannelRegistry(): PluginRegistry { @@ -486,35 +306,11 @@ export function requireActivePluginChannelRegistry(): PluginRegistry { if (existing) { return existing; } - const created = requireActivePluginRegistry(); - installSurfaceRegistry(state.channel, created, false); - return created; -} - -export function pinActivePluginSessionExtensionRegistry(registry: PluginRegistry) { - const previousRegistry = asPluginRegistry(state.sessionExtension.registry); - installSurfaceRegistry(state.sessionExtension, registry, true); - markPluginRegistryActive(registry); - syncPluginAgentEventBridge(); - if (retirePluginRegistryIfUnused(previousRegistry)) { - cleanupRetiredPluginHostRegistry(previousRegistry!); - } -} - -export function releasePinnedPluginSessionExtensionRegistry(registry?: PluginRegistry) { - if (registry && state.sessionExtension.registry !== registry) { - return; - } - const previousRegistry = asPluginRegistry(state.sessionExtension.registry); - installSurfaceRegistry(state.sessionExtension, state.activeRegistry, false); - syncPluginAgentEventBridge(); - if (retirePluginRegistryIfUnused(previousRegistry)) { - cleanupRetiredPluginHostRegistry(previousRegistry!); - } + return requireActivePluginRegistry(); } export function getActivePluginSessionExtensionRegistry(): PluginRegistry | null { - return asPluginRegistry(state.sessionExtension.registry ?? state.activeRegistry); + return asPluginRegistry(state.activeRegistry); } export function getActivePluginRegistryKey(): string | null { @@ -557,25 +353,35 @@ function collectLoadedPluginIds( export function listImportedRuntimePluginIds(): string[] { const imported = new Set(state.importedPluginIds); collectLoadedPluginIds(asPluginRegistry(state.activeRegistry), imported); - collectLoadedPluginIds(asPluginRegistry(state.channel.registry), imported); - collectLoadedPluginIds(asPluginRegistry(state.httpRoute.registry), imported); - collectLoadedPluginIds(asPluginRegistry(state.sessionExtension.registry), imported); return [...imported].toSorted((left, right) => left.localeCompare(right)); } +function clearActivePluginRegistryState(): PluginRegistry | null { + const previousRegistry = asPluginRegistry(state.activeRegistry); + state.activeRegistry = null; + state.activeVersion += 1; + state.key = null; + state.workspaceDir = null; + state.runtimeSubagentMode = "default"; + settlePreparedMessageToolCatalog(); + syncPluginAgentEventBridge(); + if (previousRegistry) { + markPluginRegistryRetired(previousRegistry); + } + return previousRegistry; +} + +export async function clearActivePluginRegistry(): Promise { + const previousRegistry = clearActivePluginRegistryState(); + if (registryHasPluginHostCleanupWork(previousRegistry)) { + await cleanupPreviousPluginHostRegistry({ previousRegistry: previousRegistry! }); + } +} + export function resetPluginRuntimeStateForTest(): void { state.registrationContext = undefined; - state.activeRegistry = null; - state.activeVersion += 1; - installSurfaceRegistry(state.httpRoute, null, false); - installSurfaceRegistry(state.channel, null, false); - settlePreparedMessageToolCatalog(); - installSurfaceRegistry(state.sessionExtension, null, false); - state.key = null; - state.workspaceDir = null; - state.runtimeSubagentMode = "default"; + clearActivePluginRegistryState(); state.importedPluginIds.clear(); - syncPluginAgentEventBridge(); // Also clear the plugin host-hook runtime singleton (run context map, // scheduler-job records, pending agent-event handlers, closedRunIds set). // Otherwise per-test bleed-over of those globals can cause flaky behavior diff --git a/src/plugins/runtime/gateway-bindings.test-fixtures.ts b/src/plugins/runtime/gateway-bindings.test-fixtures.ts deleted file mode 100644 index 69ec2baab266..000000000000 --- a/src/plugins/runtime/gateway-bindings.test-fixtures.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** Clears gateway plugin runtime bindings between tests. */ -import { gatewaySubagentState } from "./gateway-bindings.js"; - -export function clearGatewaySubagentRuntime(): void { - gatewaySubagentState.subagent = undefined; - gatewaySubagentState.nodes = undefined; -} diff --git a/src/plugins/runtime/gateway-bindings.ts b/src/plugins/runtime/gateway-bindings.ts deleted file mode 100644 index 7548cac3d930..000000000000 --- a/src/plugins/runtime/gateway-bindings.ts +++ /dev/null @@ -1,37 +0,0 @@ -// Gateway binding helpers expose plugin runtime bindings through gateway-safe singletons. -import { resolveGlobalSingleton } from "../../shared/global-singleton.js"; -import type { PluginRuntime } from "./types.js"; - -const GATEWAY_SUBAGENT_SYMBOL: unique symbol = Symbol.for( - "openclaw.plugin.gatewaySubagentRuntime", -) as unknown as typeof GATEWAY_SUBAGENT_SYMBOL; - -type GatewaySubagentState = { - subagent: PluginRuntime["subagent"] | undefined; - nodes: PluginRuntime["nodes"] | undefined; -}; - -export const gatewaySubagentState = resolveGlobalSingleton( - GATEWAY_SUBAGENT_SYMBOL, - () => ({ - subagent: undefined, - nodes: undefined, - }), -); - -// PHASE2C: Remove this singleton after session-catalog owns its nodes runtime and prepared/request -// registry handles carry concrete gateway bindings across reload instead of using late proxies. - -/** - * Set the process-global gateway subagent runtime. - * Called during gateway startup so that gateway-bindable plugin runtimes can - * resolve subagent methods dynamically even when their registry was cached - * before the gateway finished loading plugins. - */ -export function setGatewaySubagentRuntime(subagent: PluginRuntime["subagent"]): void { - gatewaySubagentState.subagent = subagent; -} - -export function setGatewayNodesRuntime(nodes: PluginRuntime["nodes"]): void { - gatewaySubagentState.nodes = nodes; -} diff --git a/src/plugins/runtime/index.test.ts b/src/plugins/runtime/index.test.ts index 713627fd7a71..60c3ee81171b 100644 --- a/src/plugins/runtime/index.test.ts +++ b/src/plugins/runtime/index.test.ts @@ -24,8 +24,6 @@ const sandboxContextMocks = vi.hoisted(() => ({ vi.mock("./runtime-model-auth.runtime.js", () => runtimeModelAuthMocks); vi.mock("../../agents/sandbox/context.js", () => sandboxContextMocks); -import { setGatewayNodesRuntime, setGatewaySubagentRuntime } from "./gateway-bindings.js"; -import { clearGatewaySubagentRuntime } from "./gateway-bindings.test-fixtures.js"; import { createPluginRuntime } from "./index.js"; function createCommandResult() { @@ -80,20 +78,6 @@ function expectRuntimeSubagentRun( return runtime.subagent.run(params); } -function createGatewaySubagentRunFixture(params?: { allowGatewaySubagentBinding?: boolean }) { - const run = vi.fn().mockResolvedValue({ runId: "run-1" }); - const runtime = params?.allowGatewaySubagentBinding - ? createPluginRuntime({ allowGatewaySubagentBinding: true }) - : createPluginRuntime(); - - setGatewaySubagentRuntime({ - ...createGatewaySubagentRuntime(), - run, - }); - - return { run, runtime }; -} - function expectFunctionKeys(value: Record, keys: readonly string[]) { for (const key of keys) { expect(typeof value[key]).toBe("function"); @@ -122,7 +106,6 @@ describe("plugin runtime command execution", () => { runtimeModelAuthMocks.resolveApiKeyForProvider.mockReset(); sandboxContextMocks.resolveSandboxContext.mockReset(); resetConfigRuntimeState(); - clearGatewaySubagentRuntime(); }); it.each([ @@ -478,15 +461,15 @@ describe("plugin runtime command execution", () => { }); }); - it("keeps subagent unavailable by default even after gateway initialization", () => { - const { runtime } = createGatewaySubagentRunFixture(); - + it("keeps subagent unavailable by default", () => { + const runtime = createPluginRuntime(); expectGatewaySubagentRunFailure(runtime, { sessionKey: "s-1", message: "hello" }); }); - it("late-binds to the gateway subagent when explicitly enabled", async () => { - const { run, runtime } = createGatewaySubagentRunFixture({ - allowGatewaySubagentBinding: true, + it("uses an explicit subagent runtime", async () => { + const run = vi.fn().mockResolvedValue({ runId: "run-1" }); + const runtime = createPluginRuntime({ + subagent: { ...createGatewaySubagentRuntime(), run }, }); await expect( @@ -511,18 +494,4 @@ describe("plugin runtime command execution", () => { expect(nodes.list).toHaveBeenCalledWith({ connected: true }); expect(nodes.invoke).toHaveBeenCalledWith({ nodeId: "node-1", command: "browser.proxy" }); }); - - it("late-binds to gateway nodes when explicitly enabled", async () => { - const nodes = { - list: vi.fn().mockResolvedValue({ nodes: [{ nodeId: "node-1" }] }), - invoke: vi.fn().mockResolvedValue({ ok: true }), - }; - const runtime = createPluginRuntime({ allowGatewaySubagentBinding: true }); - setGatewayNodesRuntime(nodes); - - await expect(runtime.nodes.list({ connected: true })).resolves.toEqual({ - nodes: [{ nodeId: "node-1" }], - }); - expect(nodes.list).toHaveBeenCalledWith({ connected: true }); - }); }); diff --git a/src/plugins/runtime/index.ts b/src/plugins/runtime/index.ts index 6a53942d4a5e..5534c7d96fa2 100644 --- a/src/plugins/runtime/index.ts +++ b/src/plugins/runtime/index.ts @@ -23,7 +23,6 @@ import { listRuntimeVideoGenerationProviders, } from "../../video-generation/runtime.js"; import { listWebSearchProviders, runWebSearch } from "../../web-search/runtime.js"; -import { gatewaySubagentState } from "./gateway-bindings.js"; import { createRuntimeAgent } from "./runtime-agent.js"; import { defineCachedValue } from "./runtime-cache.js"; import { createRuntimeChannel } from "./runtime-channel.js"; @@ -183,40 +182,6 @@ function createUnavailableSubagentRuntime(): PluginRuntime["subagent"] { }; } -// ── Process-global gateway subagent runtime ───────────────────────── -// The gateway creates a real subagent runtime during startup, but gateway-owned -// plugin registries may be loaded (and cached) before the gateway path runs. -// A process-global holder lets explicitly gateway-bindable runtimes resolve the -// active gateway subagent dynamically without changing the default behavior for -// ordinary plugin runtimes. - -/** - * Create a late-binding subagent that resolves to: - * 1. An explicitly provided subagent (from runtimeOptions), OR - * 2. The process-global gateway subagent when the caller explicitly opts in, OR - * 3. The unavailable fallback (throws with a clear error message). - */ -function createLateBindingSubagent( - explicit?: PluginRuntime["subagent"], - allowGatewaySubagentBinding = false, -): PluginRuntime["subagent"] { - if (explicit) { - return explicit; - } - - const unavailable = createUnavailableSubagentRuntime(); - if (!allowGatewaySubagentBinding) { - return unavailable; - } - - return new Proxy(unavailable, { - get(_target, prop, _receiver) { - const resolved = gatewaySubagentState.subagent ?? unavailable; - return Reflect.get(resolved, prop, resolved); - }, - }); -} - function createUnavailableNodesRuntime(): PluginRuntime["nodes"] { const unavailable = () => { throw new Error("Plugin node runtime is only available inside the Gateway."); @@ -227,19 +192,6 @@ function createUnavailableNodesRuntime(): PluginRuntime["nodes"] { }; } -function createLateBindingNodes(allowGatewayBinding = false): PluginRuntime["nodes"] { - const unavailable = createUnavailableNodesRuntime(); - if (!allowGatewayBinding) { - return unavailable; - } - return new Proxy(unavailable, { - get(_target, prop, _receiver) { - const resolved = gatewaySubagentState.nodes ?? unavailable; - return Reflect.get(resolved, prop, resolved); - }, - }); -} - function createRuntimeWorktrees(): PluginRuntime["worktrees"] { const loadService = () => import("../../agents/worktrees/service.js"); return { @@ -317,11 +269,8 @@ export function createPluginRuntime(_options: CreatePluginRuntimeOptions = {}): gateway: createRuntimeGateway(), config: createRuntimeConfig(), agent, - subagent: createLateBindingSubagent( - _options.subagent, - _options.allowGatewaySubagentBinding === true, - ), - nodes: _options.nodes ?? createLateBindingNodes(_options.allowGatewaySubagentBinding === true), + subagent: _options.subagent ?? createUnavailableSubagentRuntime(), + nodes: _options.nodes ?? createUnavailableNodesRuntime(), sandbox: createRuntimeSandbox(agent), worktrees: createRuntimeWorktrees(), system: createRuntimeSystem(), diff --git a/src/plugins/runtime/runtime-registry-loader.test.ts b/src/plugins/runtime/runtime-registry-loader.test.ts index eac61f9dda64..6e85b7e52d32 100644 --- a/src/plugins/runtime/runtime-registry-loader.test.ts +++ b/src/plugins/runtime/runtime-registry-loader.test.ts @@ -1,19 +1,10 @@ -// Runtime registry loader tests cover plugin runtime assembly and activation boundaries. -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { createEmptyPluginRegistry } from "../registry.js"; +// Runtime registry loader tests cover the surviving process-root load scopes. +import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ loadOpenClawPlugins: vi.fn(), - resolveCompatibleRuntimePluginRegistry: - vi.fn(), - resolveRuntimePluginRegistry: vi.fn(), - getActivePluginRegistry: vi.fn(), - getActivePluginRegistryWorkspaceDir: - vi.fn(), resolveConfiguredChannelPluginIds: vi.fn(), - resolveDiscoverableScopedChannelPluginIds: - vi.fn(), resolveChannelPluginIds: vi.fn(), resolveEffectivePluginIds: @@ -30,69 +21,15 @@ const mocks = vi.hoisted(() => ({ ), })); -let ensurePluginRegistryLoaded: typeof import("./runtime-registry-loader.js").ensurePluginRegistryLoaded; -let resetPluginRegistryLoadedForTests: typeof import("./runtime-registry-loader.js").testing.resetPluginRegistryLoadedForTests; - -function requireRecord(value: unknown, label: string): Record { - if (!value || typeof value !== "object") { - throw new Error(`expected ${label}`); - } - return value as Record; -} - -function loadOptions(index = 0) { - return requireRecord(mocks.loadOpenClawPlugins.mock.calls[index]?.[0], `load options ${index}`); -} - -function configuredChannelOptions(index = 0) { - return requireRecord( - mocks.resolveConfiguredChannelPluginIds.mock.calls[index]?.[0], - `configured channel options ${index}`, - ); -} - -function scopedChannelOptions(index = 0) { - return requireRecord( - mocks.resolveDiscoverableScopedChannelPluginIds.mock.calls[index]?.[0], - `scoped channel options ${index}`, - ); -} - -function pluginsConfig(config: Record) { - return requireRecord(config.plugins, "plugins config"); -} - -function pluginEntries(config: Record) { - return requireRecord(pluginsConfig(config).entries, "plugin entries"); -} - vi.mock("../loader.js", () => ({ loadOpenClawPlugins: (...args: Parameters) => mocks.loadOpenClawPlugins(...args), - resolveCompatibleRuntimePluginRegistry: ( - ...args: Parameters - ) => mocks.resolveCompatibleRuntimePluginRegistry(...args), - resolveRuntimePluginRegistry: (...args: Parameters) => - mocks.resolveRuntimePluginRegistry(...args), -})); - -vi.mock("../runtime.js", () => ({ - getActivePluginChannelRegistry: () => null, - getActivePluginHttpRouteRegistry: () => null, - getActivePluginRegistry: (...args: Parameters) => - mocks.getActivePluginRegistry(...args), - getActivePluginRegistryWorkspaceDir: ( - ...args: Parameters - ) => mocks.getActivePluginRegistryWorkspaceDir(...args), })); vi.mock("../channel-plugin-ids.js", () => ({ resolveConfiguredChannelPluginIds: ( ...args: Parameters ) => mocks.resolveConfiguredChannelPluginIds(...args), - resolveDiscoverableScopedChannelPluginIds: ( - ...args: Parameters - ) => mocks.resolveDiscoverableScopedChannelPluginIds(...args), resolveChannelPluginIds: (...args: Parameters) => mocks.resolveChannelPluginIds(...args), })); @@ -120,434 +57,68 @@ vi.mock("../../agents/agent-scope.js", () => ({ mocks.resolveDefaultAgentId(...args), })); +import { ensurePluginRegistryLoaded } from "./runtime-registry-loader.js"; + +function requireLoadOptions(): Record { + const options = mocks.loadOpenClawPlugins.mock.calls[0]?.[0]; + if (!options) { + throw new Error("expected plugin load options"); + } + return options as Record; +} + describe("ensurePluginRegistryLoaded", () => { - beforeAll(async () => { - const mod = await import("./runtime-registry-loader.js"); - ensurePluginRegistryLoaded = mod.ensurePluginRegistryLoaded; - resetPluginRegistryLoadedForTests = () => mod.testing.resetPluginRegistryLoadedForTests(); - }); - beforeEach(() => { - mocks.loadOpenClawPlugins.mockReset(); - mocks.resolveCompatibleRuntimePluginRegistry.mockReset(); - mocks.resolveRuntimePluginRegistry.mockReset(); - mocks.getActivePluginRegistry.mockReset(); - mocks.getActivePluginRegistryWorkspaceDir.mockReset(); - mocks.resolveConfiguredChannelPluginIds.mockReset(); - mocks.resolveDiscoverableScopedChannelPluginIds.mockReset(); - mocks.resolveChannelPluginIds.mockReset(); - mocks.resolveEffectivePluginIds.mockReset(); - mocks.applyPluginAutoEnable.mockReset(); - mocks.resolvePluginMetadataSnapshot.mockReset(); - mocks.resolveAgentWorkspaceDir.mockClear(); - mocks.resolveDefaultAgentId.mockClear(); - resetPluginRegistryLoadedForTests(); - - mocks.getActivePluginRegistry.mockReturnValue(null); - mocks.getActivePluginRegistryWorkspaceDir.mockReturnValue(undefined); - mocks.resolveCompatibleRuntimePluginRegistry.mockReturnValue(undefined); - mocks.loadOpenClawPlugins.mockReturnValue(createEmptyPluginRegistry()); - mocks.resolveRuntimePluginRegistry.mockImplementation( - (...args: Parameters) => mocks.loadOpenClawPlugins(...args), - ); + vi.clearAllMocks(); mocks.applyPluginAutoEnable.mockImplementation((params) => ({ - config: - params.config && typeof params.config === "object" - ? { - ...params.config, - plugins: { - entries: { - demo: { enabled: true }, - }, - }, - } - : {}, + config: params.config ?? {}, changes: [], - autoEnabledReasons: { - demo: ["demo configured"], - }, + autoEnabledReasons: {}, })); - mocks.resolveDiscoverableScopedChannelPluginIds.mockReturnValue([]); - mocks.resolveEffectivePluginIds.mockReturnValue(["demo"]); }); - it("uses the shared runtime load context for configured-channel loads", () => { - const rawConfig = { channels: { demo: { enabled: true } } }; - const resolvedConfig = { - ...rawConfig, - plugins: { - entries: { - demo: { enabled: true }, - }, - }, - }; - const env = { HOME: "/tmp/openclaw-home" } as NodeJS.ProcessEnv; - + it("loads configured channel owners through the canonical root loader", () => { + const config = { channels: { demo: { enabled: true } } }; mocks.resolveConfiguredChannelPluginIds.mockReturnValue(["demo-channel"]); - ensurePluginRegistryLoaded({ - scope: "configured-channels", - config: rawConfig as never, - env, - activationSourceConfig: { plugins: { allow: ["demo-channel"] } } as never, - }); - const channelOptions = configuredChannelOptions(); - expect(channelOptions.config).toEqual(resolvedConfig); - expect(channelOptions.activationSourceConfig).toEqual({ plugins: { allow: ["demo-channel"] } }); - expect(channelOptions.env).toBe(env); - expect(channelOptions.workspaceDir).toBe("/resolved-workspace"); - expect(mocks.applyPluginAutoEnable).toHaveBeenCalledWith( + ensurePluginRegistryLoaded({ scope: "configured-channels", config: config as never }); + + expect(mocks.resolveConfiguredChannelPluginIds).toHaveBeenCalledWith( + expect.objectContaining({ config, workspaceDir: "/resolved-workspace" }), + ); + expect(requireLoadOptions()).toEqual( expect.objectContaining({ - config: rawConfig, - env, + onlyPluginIds: ["demo-channel"], + throwOnLoadError: true, + workspaceDir: "/resolved-workspace", }), ); - const load = loadOptions(); - const loadConfig = requireRecord(load.config, "load config"); - expect(loadConfig.channels).toEqual(rawConfig.channels); - expect(pluginEntries(loadConfig)).toEqual({ - demo: { enabled: true }, - "demo-channel": { enabled: true }, - }); - expect(pluginsConfig(loadConfig).allow).toEqual(["demo-channel"]); - expect(load.activationSourceConfig).toEqual({ - plugins: { - allow: ["demo-channel"], - entries: { - "demo-channel": { enabled: true }, - }, - }, - }); - expect(load.autoEnabledReasons).toEqual({ - demo: ["demo configured"], - }); - expect(load.workspaceDir).toBe("/resolved-workspace"); - expect(load.onlyPluginIds).toEqual(["demo-channel"]); - expect(load.throwOnLoadError).toBe(true); }); - it("temporarily activates configured-channel owners before loading them", () => { - const rawConfig = { channels: { demo: { enabled: true } } }; - - mocks.resolveConfiguredChannelPluginIds.mockReturnValue(["activation-only-channel"]); - - ensurePluginRegistryLoaded({ - scope: "configured-channels", - config: rawConfig as never, - }); - - const load = loadOptions(); - const loadConfig = requireRecord(load.config, "load config"); - expect(pluginEntries(loadConfig)["activation-only-channel"]).toEqual({ enabled: true }); - expect(pluginsConfig(loadConfig).allow).toEqual(["activation-only-channel"]); - const activation = requireRecord(load.activationSourceConfig, "activation config"); - expect(pluginEntries(activation)["activation-only-channel"]).toEqual({ enabled: true }); - expect(pluginsConfig(activation).allow).toEqual(["activation-only-channel"]); - expect(load.onlyPluginIds).toEqual(["activation-only-channel"]); - }); - - it("does not cache scoped loads by explicit plugin ids", () => { - ensurePluginRegistryLoaded({ - scope: "configured-channels", - config: {} as never, - onlyPluginIds: ["demo-a"], - }); - ensurePluginRegistryLoaded({ - scope: "configured-channels", - config: {} as never, - onlyPluginIds: ["demo-b"], - }); - - expect(mocks.loadOpenClawPlugins).toHaveBeenCalledTimes(2); - expect(loadOptions(0).onlyPluginIds).toEqual(["demo-a"]); - expect(loadOptions(1).onlyPluginIds).toEqual(["demo-b"]); - }); - - it("maps explicit channel scopes to owner plugin ids before loading", () => { - const rawConfig = { channels: { "external-chat": { token: "configured" } } }; - mocks.resolveDiscoverableScopedChannelPluginIds.mockReturnValue(["external-chat-plugin"]); - - ensurePluginRegistryLoaded({ - scope: "configured-channels", - config: rawConfig as never, - onlyChannelIds: ["external-chat"], - }); - - const channelOptions = scopedChannelOptions(); - const channelConfig = requireRecord(channelOptions.config, "scoped channel config"); - expect(channelConfig.channels).toEqual(rawConfig.channels); - expect(pluginEntries(channelConfig).demo).toEqual({ enabled: true }); - expect(channelOptions.activationSourceConfig).toBe(rawConfig); - expect(channelOptions.channelIds).toEqual(["external-chat"]); - expect(channelOptions.workspaceDir).toBe("/resolved-workspace"); - const load = loadOptions(); - const loadConfig = requireRecord(load.config, "load config"); - expect(pluginsConfig(loadConfig).allow).toEqual(["external-chat-plugin"]); - expect(pluginEntries(loadConfig)["external-chat-plugin"]).toEqual({ enabled: true }); - const activation = requireRecord(load.activationSourceConfig, "activation config"); - expect(pluginsConfig(activation).allow).toEqual(["external-chat-plugin"]); - expect(pluginEntries(activation)["external-chat-plugin"]).toEqual({ enabled: true }); - expect(load.onlyPluginIds).toEqual(["external-chat-plugin"]); - }); - - it("forwards explicit empty scopes without widening to channel resolution", () => { - ensurePluginRegistryLoaded({ - scope: "configured-channels", - config: {} as never, - onlyPluginIds: [], - }); - - expect(mocks.resolveConfiguredChannelPluginIds).not.toHaveBeenCalled(); - expect(mocks.resolveChannelPluginIds).not.toHaveBeenCalled(); - expect(loadOptions().onlyPluginIds).toEqual([]); - }); - - it("preserves empty configured-channel scopes when no owners are activatable", () => { + it("keeps an empty configured-channel scope empty", () => { mocks.resolveConfiguredChannelPluginIds.mockReturnValue([]); - ensurePluginRegistryLoaded({ - scope: "configured-channels", - config: { channels: { demo: { enabled: true } } } as never, - }); + ensurePluginRegistryLoaded({ scope: "configured-channels", config: {} }); - expect(loadOptions().onlyPluginIds).toEqual([]); + expect(requireLoadOptions().onlyPluginIds).toEqual([]); }); - it("does not forward empty channel scopes for broad channel loads", () => { - mocks.resolveChannelPluginIds.mockReturnValue([]); + it("loads effective plugin ids for the all scope", () => { + const config = { plugins: { enabled: true } }; + mocks.resolveEffectivePluginIds.mockReturnValue(["demo", "memory-core"]); - ensurePluginRegistryLoaded({ - scope: "channels", - config: {} as never, - }); - - expect(loadOptions().onlyPluginIds).toBeUndefined(); - }); - - it("derives all-scope runtime loads from effective plugin ids", () => { - const config = { - plugins: { enabled: true }, - channels: { "demo-channel-a": { enabled: true } }, - }; - const env = { HOME: "/tmp/openclaw-home" } as NodeJS.ProcessEnv; - - mocks.resolveEffectivePluginIds.mockReturnValue(["demo-effective", "demo-hook"]); - - ensurePluginRegistryLoaded({ scope: "all", config: config as never, env }); + ensurePluginRegistryLoaded({ scope: "all", config }); expect(mocks.resolveEffectivePluginIds).toHaveBeenCalledWith({ config, - env, + env: process.env, workspaceDir: "/resolved-workspace", }); - const load = loadOptions(); - const loadConfig = requireRecord(load.config, "load config"); - expect(loadConfig.channels).toEqual(config.channels); - expect(pluginEntries(loadConfig).demo).toEqual({ enabled: true }); - expect(load.onlyPluginIds).toEqual(["demo-effective", "demo-hook"]); - expect(load.throwOnLoadError).toBe(true); - expect(load.workspaceDir).toBe("/resolved-workspace"); - }); - - it("does not reuse non-empty all-scope registries without loader compatibility", () => { - mocks.resolveEffectivePluginIds.mockReturnValue(["demo"]); - - ensurePluginRegistryLoaded({ - scope: "all", - config: { plugins: { allow: ["demo"] } } as never, - }); - const activeRegistry = createEmptyPluginRegistry(); - activeRegistry.plugins.push({ - id: "demo", - source: "/tmp/demo.js", - origin: "workspace", - enabled: true, - status: "loaded", - } as never); - mocks.getActivePluginRegistry.mockReturnValue(activeRegistry); - mocks.getActivePluginRegistryWorkspaceDir.mockReturnValue("/resolved-workspace"); - mocks.loadOpenClawPlugins.mockClear(); - - ensurePluginRegistryLoaded({ - scope: "all", - config: { plugins: { allow: ["demo"], entries: { demo: { value: "changed" } } } } as never, - }); - - expect(loadOptions().onlyPluginIds).toEqual(["demo"]); - }); - - it("preserves empty all-scope loads instead of widening to all discovered plugins", () => { - mocks.resolveEffectivePluginIds.mockReturnValue([]); - - ensurePluginRegistryLoaded({ - scope: "all", - config: { plugins: { enabled: true } } as never, - }); - - expect(loadOptions().onlyPluginIds).toEqual([]); - }); - - it("reuses an active empty registry for repeated empty all-scope loads", () => { - mocks.resolveEffectivePluginIds.mockReturnValue([]); - - ensurePluginRegistryLoaded({ - scope: "all", - config: { plugins: { enabled: true } } as never, - }); - const emptyRegistry = createEmptyPluginRegistry(); - mocks.getActivePluginRegistry.mockReturnValue(emptyRegistry); - mocks.getActivePluginRegistryWorkspaceDir.mockReturnValue("/resolved-workspace"); - mocks.loadOpenClawPlugins.mockClear(); - - ensurePluginRegistryLoaded({ - scope: "all", - config: { plugins: { enabled: true } } as never, - }); - - expect(mocks.loadOpenClawPlugins).not.toHaveBeenCalled(); - }); - - it("does not reuse an empty active registry from another workspace", () => { - mocks.resolveEffectivePluginIds.mockReturnValue([]); - - ensurePluginRegistryLoaded({ - scope: "all", - config: { plugins: { enabled: true } } as never, - }); - const emptyRegistry = createEmptyPluginRegistry(); - mocks.getActivePluginRegistry.mockReturnValue(emptyRegistry); - mocks.getActivePluginRegistryWorkspaceDir.mockReturnValue("/other-workspace"); - mocks.loadOpenClawPlugins.mockClear(); - - ensurePluginRegistryLoaded({ - scope: "all", - config: { plugins: { enabled: true } } as never, - }); - - expect(loadOptions().onlyPluginIds).toEqual([]); - }); - - it("does not reuse a non-empty active registry for empty all-scope loads", () => { - mocks.resolveEffectivePluginIds.mockReturnValue([]); - - ensurePluginRegistryLoaded({ - scope: "all", - config: { plugins: { enabled: true } } as never, - }); - const staleRegistry = createEmptyPluginRegistry(); - staleRegistry.plugins.push({ - id: "stale", - source: "/tmp/stale.js", - origin: "workspace", - enabled: true, - status: "loaded", - } as never); - mocks.getActivePluginRegistry.mockReturnValue(staleRegistry); - mocks.loadOpenClawPlugins.mockClear(); - - ensurePluginRegistryLoaded({ - scope: "all", - config: { plugins: { enabled: true } } as never, - }); - - expect(loadOptions().onlyPluginIds).toEqual([]); - }); - - it("does not reuse a disabled-record registry for empty all-scope loads", () => { - mocks.resolveEffectivePluginIds.mockReturnValue([]); - - ensurePluginRegistryLoaded({ - scope: "all", - config: { plugins: { enabled: true } } as never, - }); - const disabledRegistry = createEmptyPluginRegistry(); - disabledRegistry.plugins.push({ - id: "disabled", - source: "/tmp/disabled.js", - origin: "workspace", - enabled: false, - status: "disabled", - } as never); - mocks.getActivePluginRegistry.mockReturnValue(disabledRegistry); - mocks.getActivePluginRegistryWorkspaceDir.mockReturnValue("/resolved-workspace"); - mocks.loadOpenClawPlugins.mockClear(); - - ensurePluginRegistryLoaded({ - scope: "all", - config: { plugins: { enabled: true } } as never, - }); - - expect(loadOptions().onlyPluginIds).toEqual([]); - }); - - it("does not reuse a failed diagnostic registry for explicit plugin scopes", () => { - const failedRegistry = createEmptyPluginRegistry(); - failedRegistry.plugins.push({ - id: "failed", - source: "/tmp/failed.js", - origin: "workspace", - enabled: true, - status: "error", - } as never); - failedRegistry.diagnostics.push({ - level: "error", - pluginId: "failed", - message: "failed to load", - } as never); - mocks.getActivePluginRegistry.mockReturnValue(failedRegistry); - mocks.getActivePluginRegistryWorkspaceDir.mockReturnValue("/resolved-workspace"); - - ensurePluginRegistryLoaded({ - scope: "all", - config: { plugins: { enabled: true } } as never, - onlyPluginIds: ["failed"], - }); - - expect(loadOptions().onlyPluginIds).toEqual(["failed"]); - }); - - it("does not reuse a setup-only registry for explicit plugin scopes", () => { - const setupRegistry = createEmptyPluginRegistry(); - setupRegistry.plugins.push({ - id: "setup-only", - source: "/tmp/setup-only.js", - origin: "workspace", - enabled: false, - status: "disabled", - } as never); - setupRegistry.channelSetups.push({ - pluginId: "setup-only", - } as never); - mocks.getActivePluginRegistry.mockReturnValue(setupRegistry); - mocks.getActivePluginRegistryWorkspaceDir.mockReturnValue("/resolved-workspace"); - - ensurePluginRegistryLoaded({ - scope: "all", - config: { plugins: { enabled: true } } as never, - onlyPluginIds: ["setup-only"], - }); - - expect(loadOptions().onlyPluginIds).toEqual(["setup-only"]); - }); - - it("reuses a compatible active registry instead of forcing a broad reload", () => { - const activeRegistry = createEmptyPluginRegistry(); - activeRegistry.plugins.push({ - id: "demo", - source: "/tmp/demo.js", - origin: "workspace", - enabled: true, - status: "loaded", - } as never); - mocks.getActivePluginRegistry.mockReturnValue(activeRegistry); - mocks.resolveCompatibleRuntimePluginRegistry.mockReturnValue(activeRegistry); - - ensurePluginRegistryLoaded({ - scope: "all", - config: { plugins: { allow: ["demo"] } } as never, - }); - - expect(mocks.resolveRuntimePluginRegistry).not.toHaveBeenCalled(); - expect(mocks.loadOpenClawPlugins).not.toHaveBeenCalled(); + expect(requireLoadOptions()).toEqual( + expect.objectContaining({ + onlyPluginIds: ["demo", "memory-core"], + throwOnLoadError: true, + }), + ); }); }); diff --git a/src/plugins/runtime/runtime-registry-loader.ts b/src/plugins/runtime/runtime-registry-loader.ts index 185518546f4b..0c573143e48e 100644 --- a/src/plugins/runtime/runtime-registry-loader.ts +++ b/src/plugins/runtime/runtime-registry-loader.ts @@ -1,126 +1,44 @@ -// Runtime registry loader assembles activated plugin runtimes from config and registry metadata. +// Runtime registry loader assembles process-root plugin runtimes from config metadata. import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { withActivatedPluginIds } from "../activation-context.js"; -import { - getLoadedRuntimePluginRegistry, - registryContainsRuntimePluginIds, -} from "../active-runtime-registry.js"; import { resolveChannelPluginIds, resolveConfiguredChannelPluginIds, - resolveDiscoverableScopedChannelPluginIds, } from "../channel-plugin-ids.js"; import { resolveEffectivePluginIds } from "../effective-plugin-ids.js"; import { loadOpenClawPlugins } from "../loader.js"; -import { - hasExplicitPluginIdScope, - hasNonEmptyPluginIdScope, - normalizePluginIdScope, -} from "../plugin-scope.js"; -import { getActivePluginRegistry, getActivePluginRegistryWorkspaceDir } from "../runtime.js"; +import { hasNonEmptyPluginIdScope } from "../plugin-scope.js"; import { buildPluginRuntimeLoadOptionsFromValues, resolvePluginRuntimeLoadContext, } from "./load-context.js"; -let pluginRegistryLoaded: "none" | "configured-channels" | "channels" | "all" = "none"; - export type PluginRegistryScope = "configured-channels" | "channels" | "all"; -function scopeRank(scope: typeof pluginRegistryLoaded): number { - switch (scope) { - case "none": - return 0; - case "configured-channels": - return 1; - case "channels": - return 2; - case "all": - return 3; - } - throw new Error("Unsupported plugin registry scope"); -} - -function activeRegistrySatisfiesScope( - scope: PluginRegistryScope, - active: ReturnType, - expectedChannelPluginIds: readonly string[], - requestedPluginIds: readonly string[] | undefined, - requestedWorkspaceDir: string | undefined, -): boolean { - if (!active) { - return false; - } - if (requestedPluginIds !== undefined) { - const activeWorkspaceDir = getActivePluginRegistryWorkspaceDir(); - if (requestedWorkspaceDir !== undefined && activeWorkspaceDir !== requestedWorkspaceDir) { - return false; - } - return registryContainsRuntimePluginIds(active, requestedPluginIds); - } - const activeChannelPluginIds = new Set(active.channels.map((entry) => entry.plugin.id)); - switch (scope) { - case "configured-channels": - case "channels": - return ( - active.channels.length > 0 && - expectedChannelPluginIds.every((pluginId) => activeChannelPluginIds.has(pluginId)) - ); - case "all": - return false; - } - throw new Error("Unsupported plugin registry scope"); -} - -function shouldForwardChannelScope(params: { - scope: PluginRegistryScope; - scopedLoad: boolean; -}): boolean { - return !params.scopedLoad && params.scope === "configured-channels"; -} - function resolveScopePluginIds(params: { scope: PluginRegistryScope; context: ReturnType; }): string[] { - switch (params.scope) { - case "configured-channels": - return resolveConfiguredChannelPluginIds({ - config: params.context.config, - activationSourceConfig: params.context.activationSourceConfig, - workspaceDir: params.context.workspaceDir, - env: params.context.env, - }); - case "channels": - return resolveChannelPluginIds({ - config: params.context.config, - workspaceDir: params.context.workspaceDir, - env: params.context.env, - }); - case "all": - return resolveEffectivePluginIds({ - config: params.context.rawConfig, - workspaceDir: params.context.workspaceDir, - env: params.context.env, - }); + if (params.scope === "configured-channels") { + return resolveConfiguredChannelPluginIds({ + config: params.context.config, + activationSourceConfig: params.context.activationSourceConfig, + workspaceDir: params.context.workspaceDir, + env: params.context.env, + }); } - const unreachableScope: never = params.scope; - return unreachableScope; -} - -function resolveOrLoadRuntimePluginRegistry( - loadOptions: NonNullable[0]>, -): void { - if ( - !getLoadedRuntimePluginRegistry({ - env: loadOptions.env, - loadOptions, - workspaceDir: loadOptions.workspaceDir, - requiredPluginIds: loadOptions.onlyPluginIds, - }) - ) { - loadOpenClawPlugins(loadOptions); + if (params.scope === "channels") { + return resolveChannelPluginIds({ + config: params.context.config, + workspaceDir: params.context.workspaceDir, + env: params.context.env, + }); } + return resolveEffectivePluginIds({ + config: params.context.rawConfig, + workspaceDir: params.context.workspaceDir, + env: params.context.env, + }); } export function ensurePluginRegistryLoaded(options?: { @@ -129,108 +47,29 @@ export function ensurePluginRegistryLoaded(options?: { activationSourceConfig?: OpenClawConfig; env?: NodeJS.ProcessEnv; workspaceDir?: string; - onlyPluginIds?: string[]; - onlyChannelIds?: string[]; }): void { const scope = options?.scope ?? "all"; - const requestedPluginIdsFromOptions = normalizePluginIdScope(options?.onlyPluginIds); - const requestedChannelIds = normalizePluginIdScope(options?.onlyChannelIds); const context = resolvePluginRuntimeLoadContext(options); - const requestedChannelOwnerPluginIds = - requestedChannelIds === undefined - ? undefined - : resolveDiscoverableScopedChannelPluginIds({ - config: context.config, - activationSourceConfig: context.activationSourceConfig, - channelIds: requestedChannelIds, - workspaceDir: context.workspaceDir, - env: context.env, - }); - const requestedPluginIds = - requestedChannelOwnerPluginIds === undefined - ? requestedPluginIdsFromOptions - : normalizePluginIdScope([ - ...(requestedPluginIdsFromOptions ?? []), - ...requestedChannelOwnerPluginIds, - ]); - const scopedLoad = hasExplicitPluginIdScope(requestedPluginIds); - const expectedPluginIds = scopedLoad - ? (requestedPluginIds ?? []) - : resolveScopePluginIds({ scope, context }); - const active = getActivePluginRegistry(); - const requestedPluginIdsForScope = - scope === "all" && expectedPluginIds.length === 0 ? expectedPluginIds : undefined; - if ( - !scopedLoad && - scopeRank(pluginRegistryLoaded) >= scopeRank(scope) && - activeRegistrySatisfiesScope( - scope, - active, - expectedPluginIds, - requestedPluginIdsForScope, - context.workspaceDir, - ) - ) { - return; - } - if ( - (pluginRegistryLoaded === "none" || scopedLoad) && - activeRegistrySatisfiesScope( - scope, - active, - expectedPluginIds, - requestedPluginIds, - context.workspaceDir, - ) - ) { - if (!scopedLoad) { - pluginRegistryLoaded = scope; - } - return; - } - const scopedConfig = - scope === "configured-channels" && - expectedPluginIds.length > 0 && - (!scopedLoad || requestedChannelOwnerPluginIds !== undefined) - ? (withActivatedPluginIds({ - config: context.config, - pluginIds: expectedPluginIds, - }) ?? context.config) - : context.config; - const scopedActivationSourceConfig = - scope === "configured-channels" && - expectedPluginIds.length > 0 && - (!scopedLoad || requestedChannelOwnerPluginIds !== undefined) - ? (withActivatedPluginIds({ - config: context.activationSourceConfig, - pluginIds: expectedPluginIds, - }) ?? context.activationSourceConfig) - : context.activationSourceConfig; - const loadOptions = buildPluginRuntimeLoadOptionsFromValues( - { - ...context, - config: scopedConfig, - activationSourceConfig: scopedActivationSourceConfig, - }, - { - throwOnLoadError: true, - ...(hasExplicitPluginIdScope(requestedPluginIds) || - shouldForwardChannelScope({ scope, scopedLoad }) || - hasNonEmptyPluginIdScope(expectedPluginIds) || - scope === "all" - ? { onlyPluginIds: expectedPluginIds } - : {}), - }, + const pluginIds = resolveScopePluginIds({ scope, context }); + const activateConfigured = scope === "configured-channels" && pluginIds.length > 0; + const config = activateConfigured + ? (withActivatedPluginIds({ config: context.config, pluginIds }) ?? context.config) + : context.config; + const activationSourceConfig = activateConfigured + ? (withActivatedPluginIds({ config: context.activationSourceConfig, pluginIds }) ?? + context.activationSourceConfig) + : context.activationSourceConfig; + loadOpenClawPlugins( + buildPluginRuntimeLoadOptionsFromValues( + { ...context, config, activationSourceConfig }, + { + throwOnLoadError: true, + ...(scope === "configured-channels" || + scope === "all" || + hasNonEmptyPluginIdScope(pluginIds) + ? { onlyPluginIds: pluginIds } + : {}), + }, + ), ); - resolveOrLoadRuntimePluginRegistry(loadOptions); - if (!scopedLoad) { - pluginRegistryLoaded = scope; - } } - -export const testing = { - resetPluginRegistryLoadedForTests(): void { - pluginRegistryLoaded = "none"; - }, -}; -export { testing as __testing }; diff --git a/src/plugins/runtime/standalone-runtime-registry-loader.test.ts b/src/plugins/runtime/standalone-runtime-registry-loader.test.ts deleted file mode 100644 index b650d70203af..000000000000 --- a/src/plugins/runtime/standalone-runtime-registry-loader.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -// Verifies scoped registry handles cannot install process-wide runtime state. -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { createEmptyPluginRegistry } from "../registry-empty.js"; -import { - getActivePluginChannelRegistry, - getActivePluginRegistry, - pinActivePluginChannelRegistry, - resetPluginRuntimeStateForTest, - setActivePluginRegistry, -} from "../runtime.js"; - -const loaderMocks = vi.hoisted(() => ({ - loadAndActivateRootPluginRegistry: vi.fn(), - loadPluginRegistryHandle: vi.fn(), -})); - -vi.mock("../loader.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - loadAndActivateRootPluginRegistry: loaderMocks.loadAndActivateRootPluginRegistry, - loadPluginRegistryHandle: loaderMocks.loadPluginRegistryHandle, - }; -}); - -import { - installRuntimePluginRegistryAtProcessRoot, - loadRuntimePluginRegistryHandle, -} from "./standalone-runtime-registry-loader.js"; - -beforeEach(() => { - loaderMocks.loadAndActivateRootPluginRegistry.mockReset(); - loaderMocks.loadPluginRegistryHandle.mockReset(); -}); - -afterEach(() => { - resetPluginRuntimeStateForTest(); -}); - -describe("standalone runtime registry ownership", () => { - it("returns a scoped handle without replacing active or pinned registries", () => { - const activeRegistry = createEmptyPluginRegistry(); - const channelRegistry = createEmptyPluginRegistry(); - const scopedRegistry = createEmptyPluginRegistry(); - setActivePluginRegistry(activeRegistry, "active-key", "default", "/tmp/ws"); - pinActivePluginChannelRegistry(channelRegistry); - loaderMocks.loadPluginRegistryHandle.mockReturnValue(scopedRegistry); - - expect( - loadRuntimePluginRegistryHandle({ - forceLoad: true, - surface: "channel", - loadOptions: { onlyPluginIds: ["tool-plugin"], workspaceDir: "/tmp/ws" }, - }), - ).toBe(scopedRegistry); - - expect(loaderMocks.loadPluginRegistryHandle).toHaveBeenCalledWith({ - activate: false, - cache: false, - onlyPluginIds: ["tool-plugin"], - workspaceDir: "/tmp/ws", - }); - expect(getActivePluginRegistry()).toBe(activeRegistry); - expect(getActivePluginChannelRegistry()).toBe(channelRegistry); - }); - - it("builds an explicit empty scope instead of reusing the active registry", () => { - const activeRegistry = createEmptyPluginRegistry(); - const emptyScopedRegistry = createEmptyPluginRegistry(); - setActivePluginRegistry(activeRegistry, "active-key", "default", "/tmp/ws"); - loaderMocks.loadPluginRegistryHandle.mockReturnValue(emptyScopedRegistry); - - expect( - loadRuntimePluginRegistryHandle({ - requiredPluginIds: [], - loadOptions: { onlyPluginIds: [], workspaceDir: "/tmp/ws" }, - }), - ).toBe(emptyScopedRegistry); - - expect(loaderMocks.loadPluginRegistryHandle).toHaveBeenCalledWith({ - activate: false, - onlyPluginIds: [], - workspaceDir: "/tmp/ws", - }); - expect(getActivePluginRegistry()).toBe(activeRegistry); - }); - - it("uses the activating loader only at the process-root entry point", () => { - const rootRegistry = createEmptyPluginRegistry(); - loaderMocks.loadAndActivateRootPluginRegistry.mockReturnValue(rootRegistry); - - expect( - installRuntimePluginRegistryAtProcessRoot({ - forceLoad: true, - loadOptions: { - onlyPluginIds: ["gateway-plugin"], - workspaceDir: "/tmp/ws", - runtimeOptions: { allowGatewaySubagentBinding: true }, - }, - }), - ).toBe(rootRegistry); - - expect(loaderMocks.loadAndActivateRootPluginRegistry).toHaveBeenCalledWith({ - activate: true, - cache: false, - onlyPluginIds: ["gateway-plugin"], - workspaceDir: "/tmp/ws", - runtimeOptions: { allowGatewaySubagentBinding: true }, - }); - expect(loaderMocks.loadPluginRegistryHandle).not.toHaveBeenCalled(); - }); - - it("pins an explicitly installed channel surface", () => { - const rootRegistry = createEmptyPluginRegistry(); - loaderMocks.loadAndActivateRootPluginRegistry.mockReturnValue(rootRegistry); - - installRuntimePluginRegistryAtProcessRoot({ - forceLoad: true, - surface: "channel", - loadOptions: { workspaceDir: "/tmp/ws" }, - }); - - expect(getActivePluginRegistry()).toBe(rootRegistry); - expect(getActivePluginChannelRegistry()).toBe(rootRegistry); - }); -}); diff --git a/src/plugins/runtime/standalone-runtime-registry-loader.ts b/src/plugins/runtime/standalone-runtime-registry-loader.ts deleted file mode 100644 index 71a4e1483c82..000000000000 --- a/src/plugins/runtime/standalone-runtime-registry-loader.ts +++ /dev/null @@ -1,114 +0,0 @@ -// Runtime registry loader entry points distinguish process-root installation from scoped handles. -import { - type ActiveRuntimePluginRegistrySurface, - getLoadedRuntimePluginRegistry, -} from "../active-runtime-registry.js"; -import { - loadAndActivateRootPluginRegistry, - loadPluginRegistryHandle, - resolvePluginRegistryLoadCacheKey, - type PluginLoadOptions, -} from "../loader.js"; -import type { PluginRegistry } from "../registry-types.js"; -import { - pinActivePluginChannelRegistry, - pinActivePluginHttpRouteRegistry, - setActivePluginRegistry, -} from "../runtime.js"; - -function resolveRuntimeSubagentMode( - loadOptions: PluginLoadOptions, -): "default" | "explicit" | "gateway-bindable" { - if (loadOptions.runtimeOptions?.allowGatewaySubagentBinding === true) { - return "gateway-bindable"; - } - if (loadOptions.runtimeOptions?.subagent) { - return "explicit"; - } - return "default"; -} - -function installProcessRootRuntimePluginRegistry( - registry: PluginRegistry, - params: { - loadOptions: PluginLoadOptions; - surface: ActiveRuntimePluginRegistrySurface; - }, -): void { - const cacheKey = resolvePluginRegistryLoadCacheKey(params.loadOptions); - const mode = resolveRuntimeSubagentMode(params.loadOptions); - setActivePluginRegistry(registry, cacheKey, mode, params.loadOptions.workspaceDir); - switch (params.surface) { - case "active": - break; - case "channel": - pinActivePluginChannelRegistry(registry); - break; - case "http-route": - pinActivePluginHttpRouteRegistry(registry); - break; - } -} - -type RuntimePluginRegistryLoadParams = { - loadOptions: PluginLoadOptions; - forceLoad?: boolean; - requiredPluginIds?: readonly string[]; - surface?: ActiveRuntimePluginRegistrySurface; -}; - -function findLoadedRuntimePluginRegistry( - params: RuntimePluginRegistryLoadParams, -): PluginRegistry | undefined { - if (params.loadOptions.onlyPluginIds?.length === 0) { - return undefined; - } - const requiredPluginIds = params.requiredPluginIds ?? params.loadOptions.onlyPluginIds; - const surface = params.surface ?? "active"; - if (!params.forceLoad) { - const existing = getLoadedRuntimePluginRegistry({ - env: params.loadOptions.env, - loadOptions: params.loadOptions, - workspaceDir: params.loadOptions.workspaceDir, - requiredPluginIds, - surface, - }); - if (existing) { - return existing; - } - } - - return undefined; -} - -/** Builds or reuses a registry value without changing any process-wide active surface. */ -export function loadRuntimePluginRegistryHandle( - params: RuntimePluginRegistryLoadParams, -): PluginRegistry | undefined { - const loadOptions = { ...params.loadOptions, activate: false }; - return ( - findLoadedRuntimePluginRegistry({ ...params, loadOptions }) ?? - loadPluginRegistryHandle(params.forceLoad ? { ...loadOptions, cache: false } : loadOptions) - ); -} - -/** Installs a registry from a process composition root. Never call from request/run scope. */ -export function installRuntimePluginRegistryAtProcessRoot( - params: RuntimePluginRegistryLoadParams, -): PluginRegistry | undefined { - const loadOptions = { ...params.loadOptions, activate: true }; - const registry = - findLoadedRuntimePluginRegistry({ ...params, loadOptions }) ?? - loadAndActivateRootPluginRegistry( - params.forceLoad ? { ...loadOptions, cache: false } : loadOptions, - ); - const surface = params.surface ?? "active"; - if (surface === "active") { - return registry; - } - installProcessRootRuntimePluginRegistry(registry, { - loadOptions, - surface, - }); - return registry; -} diff --git a/src/plugins/services.test.ts b/src/plugins/services.test.ts index 4e40b5e0ad7c..afa05af63c90 100644 --- a/src/plugins/services.test.ts +++ b/src/plugins/services.test.ts @@ -19,11 +19,7 @@ vi.mock("../logging/subsystem.js", () => ({ import { STATE_DIR } from "../config/paths.js"; import { queuePluginSessionsChanged, subscribePluginSessionsChanged } from "./gateway-events.js"; import { registerPluginHttpRoute } from "./http-registry.js"; -import { - pinActivePluginHttpRouteRegistry, - resetPluginRuntimeStateForTest, - setActivePluginRegistry, -} from "./runtime.js"; +import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "./runtime.js"; import { startPluginServices } from "./services.js"; function createRegistry( @@ -466,7 +462,6 @@ describe("startPluginServices", () => { const pinnedRegistry = createEmptyPluginRegistry(); setActivePluginRegistry(pinnedRegistry); - pinActivePluginHttpRouteRegistry(pinnedRegistry); const handle = await startPluginServices({ registry: serviceRegistry, diff --git a/src/plugins/tools.optional.test.ts b/src/plugins/tools.optional.test.ts index f9690c677f6e..b9a0a019216c 100644 --- a/src/plugins/tools.optional.test.ts +++ b/src/plugins/tools.optional.test.ts @@ -19,16 +19,17 @@ type MockRegistryToolEntry = { }; const loadOpenClawPluginsMock = vi.fn(); -const resolveRuntimePluginRegistryMock = vi.fn(); +const resolveCompatibleRuntimePluginRegistryMock = vi.fn(); const applyPluginAutoEnableMock = vi.fn(); vi.mock("./loader.js", () => ({ loadOpenClawPlugins: (params: unknown) => loadOpenClawPluginsMock(params), loadPluginRegistryHandle: (params: unknown) => loadOpenClawPluginsMock(params), resolveCompatibleRuntimePluginRegistry: (params: unknown) => - resolveRuntimePluginRegistryMock(params), + resolveCompatibleRuntimePluginRegistryMock(params), resolvePluginRegistryLoadCacheKey: (params: unknown) => JSON.stringify(params), - resolveRuntimePluginRegistry: (params: unknown) => resolveRuntimePluginRegistryMock(params), + resolveRuntimePluginRegistry: (params: unknown) => + resolveCompatibleRuntimePluginRegistryMock(params), })); vi.mock("../config/plugin-auto-enable.js", () => ({ @@ -41,7 +42,6 @@ let buildPluginToolMetadataKey: typeof import("./tools.js").buildPluginToolMetad let getPluginToolMeta: typeof import("./tools.js").getPluginToolMeta; let resetPluginToolDescriptorCacheForTest: typeof import("./tools.test-fixtures.js").resetPluginToolDescriptorCacheForTest; let getActivePluginRegistry: typeof import("./runtime.js").getActivePluginRegistry; -let pinActivePluginChannelRegistry: typeof import("./runtime.js").pinActivePluginChannelRegistry; let resetPluginRuntimeStateForTest: typeof import("./runtime.js").resetPluginRuntimeStateForTest; let setActivePluginRegistry: typeof import("./runtime.js").setActivePluginRegistry; let clearCurrentPluginMetadataSnapshot: typeof import("./current-plugin-metadata-state.js").clearCurrentPluginMetadataSnapshot; @@ -494,12 +494,8 @@ describe("resolvePluginTools optional tools", () => { getPluginToolMeta, resolvePluginTools, } = await import("./tools.js")); - ({ - getActivePluginRegistry, - pinActivePluginChannelRegistry, - resetPluginRuntimeStateForTest, - setActivePluginRegistry, - } = await import("./runtime.js")); + ({ getActivePluginRegistry, resetPluginRuntimeStateForTest, setActivePluginRegistry } = + await import("./runtime.js")); ({ getPluginRuntimeGatewayRequestScope, withPluginRuntimeGatewayRequestScope } = await import("./runtime/gateway-request-scope.js")); ({ clearCurrentPluginMetadataSnapshot } = await import("./current-plugin-metadata-state.js")); @@ -509,9 +505,9 @@ describe("resolvePluginTools optional tools", () => { beforeEach(() => { loadOpenClawPluginsMock.mockReset(); - resolveRuntimePluginRegistryMock.mockReset(); - resolveRuntimePluginRegistryMock.mockImplementation((params) => - loadOpenClawPluginsMock(params), + resolveCompatibleRuntimePluginRegistryMock.mockReset(); + resolveCompatibleRuntimePluginRegistryMock.mockImplementation(() => + getActivePluginRegistry?.(), ); applyPluginAutoEnableMock.mockReset(); applyPluginAutoEnableMock.mockImplementation(({ config }: { config: unknown }) => ({ @@ -1014,7 +1010,7 @@ describe("resolvePluginTools optional tools", () => { expectLoaderSelectedOnlyPluginIds(["optional-demo"]); }); - it("combines a partial active registry with missing wildcard-selected plugin tools", () => { + it("rebuilds the exact requested tool scope when the active root is partial", () => { const context = createContext(); const config = context.config; const optionalEntry = createOptionalDemoEntry(); @@ -1043,7 +1039,7 @@ describe("resolvePluginTools optional tools", () => { "gateway-bindable", "/tmp", ); - resolveRuntimePluginRegistryMock.mockReturnValue(partialRegistry); + resolveCompatibleRuntimePluginRegistryMock.mockReturnValue(partialRegistry); loadOpenClawPluginsMock.mockReturnValue(fullRegistry); const tools = resolvePluginTools( @@ -1061,8 +1057,8 @@ describe("resolvePluginTools optional tools", () => { toolDiscovery?: unknown; }; expect(loaderParams.activate).toBe(false); - expect(loaderParams.cache).toBe(false); - expect(loaderParams.onlyPluginIds).toEqual(["optional-demo"]); + expect(loaderParams.cache).toBeUndefined(); + expect(loaderParams.onlyPluginIds).toEqual(["multi", "optional-demo"]); expect(loaderParams.toolDiscovery).toBe(true); }); @@ -1104,7 +1100,7 @@ describe("resolvePluginTools optional tools", () => { ); }); - it("combines partial active and cold-loaded registries without false diagnostics", () => { + it("uses one rebuilt handle instead of composing it with a partial root", () => { const context = createContext(); const config = context.config; const multiEntry = createNamedToolEntry("multi", "other_tool", { @@ -1124,19 +1120,14 @@ describe("resolvePluginTools optional tools", () => { origin: "bundled", status: "loaded", }); - const freshRegistry = createToolRegistry([optionalEntry]); - freshRegistry.plugins.push({ - id: "multi", - origin: "bundled", - status: "loaded", - }); + const freshRegistry = createToolRegistry([multiEntry, optionalEntry]); setActivePluginRegistry?.( staleRegistry as never, "partial-test-tool-registry", "gateway-bindable", "/tmp", ); - resolveRuntimePluginRegistryMock.mockReturnValue(staleRegistry); + resolveCompatibleRuntimePluginRegistryMock.mockReturnValue(staleRegistry); loadOpenClawPluginsMock.mockReturnValue(freshRegistry); const tools = resolvePluginTools( @@ -1148,89 +1139,11 @@ describe("resolvePluginTools optional tools", () => { expectResolvedToolNames(tools, ["other_tool", "optional_tool"]); expect(getActivePluginRegistry?.()).toBe(staleRegistry); - expectLoaderSelectedOnlyPluginIds(["optional-demo"]); + expectLoaderSelectedOnlyPluginIds(["multi", "optional-demo"]); expect(freshRegistry.diagnostics).toStrictEqual([]); expect(staleRegistry.diagnostics).toStrictEqual([]); }); - it("keeps missing-owner diagnostics on the request-local cold registry", () => { - const context = createContext(); - const config = context.config; - const multiEntry = createNamedToolEntry("multi", "other_tool", { - declaredNames: ["other_tool"], - }); - installToolManifestSnapshots({ - config, - plugins: [ - createToolManifest("multi", ["other_tool"]), - createToolManifest("optional-demo", ["optional_tool"]), - ], - }); - const activeRegistry = createToolRegistry([multiEntry]); - const coldRegistry = createToolRegistry([]); - coldRegistry.plugins.push({ id: "optional-demo", origin: "bundled", status: "loaded" }); - setActivePluginRegistry?.( - activeRegistry as never, - "partial-test-tool-registry", - "gateway-bindable", - "/tmp", - ); - resolveRuntimePluginRegistryMock.mockReturnValue(activeRegistry); - loadOpenClawPluginsMock.mockReturnValue(coldRegistry); - - const tools = resolvePluginTools( - createResolveToolsParams({ - context, - toolAllowlist: ["*", "optional-demo"], - }), - ); - - expectResolvedToolNames(tools, ["other_tool"]); - expectLoaderSelectedOnlyPluginIds(["optional-demo"]); - expectSingleDiagnosticMessage( - coldRegistry.diagnostics, - "plugin tool registry did not include selected plugin tools after cold load (optional-demo)", - ); - expect(activeRegistry.diagnostics).toStrictEqual([]); - }); - - it("does not reuse a pinned gateway registry for manifest-unavailable tools", () => { - const config = createContext().config; - installToolManifestSnapshot({ - config, - env: {}, - plugin: createXaiToolManifest(), - }); - const factory = vi.fn(() => makeTool("x_search")); - pinActivePluginChannelRegistry({ - plugins: [{ id: "xai", status: "loaded" }], - tools: [ - { - pluginId: "xai", - optional: false, - source: "/tmp/xai.js", - names: ["x_search"], - factory, - }, - ], - diagnostics: [], - } as never); - loadOpenClawPluginsMock.mockReturnValue({ tools: [], diagnostics: [] }); - - const tools = resolvePluginTools({ - context: { - ...createContext(), - config, - } as never, - env: {}, - allowGatewaySubagentBinding: true, - }); - - expect(tools).toStrictEqual([]); - expect(factory).not.toHaveBeenCalled(); - expect(loadOpenClawPluginsMock).not.toHaveBeenCalled(); - }); - it("loads plugin-owned tools when manifest tool metadata has env auth evidence", () => { const config = createContext().config; installToolManifestSnapshot({ @@ -2776,7 +2689,7 @@ describe("resolvePluginTools optional tools", () => { status: "loaded", }); setActivePluginRegistry?.(replacementRegistry as never, "provider-runtime", "default", "/tmp"); - resolveRuntimePluginRegistryMock.mockReturnValue(undefined); + resolveCompatibleRuntimePluginRegistryMock.mockReturnValue(undefined); loadOpenClawPluginsMock.mockReset(); loadOpenClawPluginsMock .mockReturnValueOnce(gatewayRegistry) @@ -3007,7 +2920,7 @@ describe("resolvePluginTools optional tools", () => { it("reuses a compatible active registry instead of loading again", () => { const activeRegistry = createOptionalDemoActiveRegistry(); - resolveRuntimePluginRegistryMock.mockReturnValue(activeRegistry); + resolveCompatibleRuntimePluginRegistryMock.mockReturnValue(activeRegistry); const tools = resolvePluginTools( createResolveToolsParams({ @@ -3022,7 +2935,7 @@ describe("resolvePluginTools optional tools", () => { it("reuses the gateway-bindable registry when it covers the tool runtime scope", () => { const activeRegistry = createOptionalDemoActiveRegistry(); setActivePluginRegistry(activeRegistry as never, "gateway-startup", "gateway-bindable", "/tmp"); - resolveRuntimePluginRegistryMock.mockReturnValue(activeRegistry); + resolveCompatibleRuntimePluginRegistryMock.mockReturnValue(activeRegistry); const tools = resolvePluginTools( createResolveToolsParams({ @@ -3032,7 +2945,7 @@ describe("resolvePluginTools optional tools", () => { ); expectResolvedToolNames(tools, ["optional_tool"]); - expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled(); + expect(resolveCompatibleRuntimePluginRegistryMock).toHaveBeenCalledOnce(); expect(loadOpenClawPluginsMock).not.toHaveBeenCalled(); }); @@ -3060,7 +2973,8 @@ describe("resolvePluginTools optional tools", () => { diagnostics: [], }; setActivePluginRegistry(activeRegistry as never, "gateway-startup", "gateway-bindable", "/tmp"); - resolveRuntimePluginRegistryMock.mockReturnValue(undefined); + resolveCompatibleRuntimePluginRegistryMock.mockReturnValue(undefined); + loadOpenClawPluginsMock.mockReturnValue(activeRegistry); const tools = resolvePluginTools( createResolveToolsParams({ @@ -3071,8 +2985,8 @@ describe("resolvePluginTools optional tools", () => { expectResolvedToolNames(tools, ["optional_tool"]); expect(heavyFactory).not.toHaveBeenCalled(); - expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled(); - expect(loadOpenClawPluginsMock).not.toHaveBeenCalled(); + expect(resolveCompatibleRuntimePluginRegistryMock).toHaveBeenCalledOnce(); + expectLoaderSelectedOnlyPluginIds(["optional-demo"]); }); it("does not let disabled bundled tool owners poison explicit runtime allowlists", () => { @@ -3118,7 +3032,8 @@ describe("resolvePluginTools optional tools", () => { diagnostics: [], }; setActivePluginRegistry(activeRegistry as never, "gateway-startup", "gateway-bindable", "/tmp"); - resolveRuntimePluginRegistryMock.mockReturnValue(undefined); + resolveCompatibleRuntimePluginRegistryMock.mockReturnValue(undefined); + loadOpenClawPluginsMock.mockReturnValue(activeRegistry); const tools = resolvePluginTools( createResolveToolsParams({ @@ -3130,71 +3045,8 @@ describe("resolvePluginTools optional tools", () => { expectResolvedToolNames(tools, ["memory_search", "memory_get"]); expect(memorySearchFactory).toHaveBeenCalledTimes(1); - expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled(); - expect(loadOpenClawPluginsMock).not.toHaveBeenCalled(); - }); - - it("falls back from a loaded channel registry without matching tool entries", () => { - const config = { - plugins: { - enabled: true, - allow: ["memory-core"], - load: { paths: [] }, - entries: { - "memory-core": { enabled: true }, - }, - slots: { memory: "memory-core" }, - }, - }; - installToolManifestSnapshot({ - config, - plugin: createToolManifest("memory-core", ["memory_get", "memory_search"], { - enabledByDefault: false, - }), - }); - const memorySearchFactory = vi.fn(() => [makeTool("memory_search"), makeTool("memory_get")]); - const activeRegistry = { - plugins: [{ id: "memory-core", status: "loaded" }], - tools: [ - { - pluginId: "memory-core", - optional: false, - source: "/tmp/memory-core.js", - names: ["memory_search", "memory_get"], - declaredNames: ["memory_search", "memory_get"], - factory: memorySearchFactory, - }, - ], - diagnostics: [], - }; - setActivePluginRegistry(activeRegistry as never, "gateway-startup", "gateway-bindable", "/tmp"); - const channelRegistry = { - plugins: [{ id: "memory-core", status: "loaded" }], - tools: [], - diagnostics: [], - } as never; - pinActivePluginChannelRegistry(channelRegistry); - resolveRuntimePluginRegistryMock.mockReturnValue(undefined); - - const runtimeRegistry = ensureStandalonePluginToolRegistryLoaded({ - context: { ...createContext(), config }, - toolAllowlist: ["memory_search", "memory_get"], - allowGatewaySubagentBinding: true, - }); - expect(runtimeRegistry).toBe(activeRegistry); - - const tools = resolvePluginTools({ - ...createResolveToolsParams({ - context: { ...createContext(), config }, - toolAllowlist: ["memory_search", "memory_get"], - allowGatewaySubagentBinding: true, - }), - runtimeRegistry, - }); - - expectResolvedToolNames(tools, ["memory_search", "memory_get"]); - expect(memorySearchFactory).toHaveBeenCalledTimes(1); - expect(loadOpenClawPluginsMock).not.toHaveBeenCalled(); + expect(resolveCompatibleRuntimePluginRegistryMock).toHaveBeenCalledOnce(); + expectLoaderSelectedOnlyPluginIds(["memory-core"]); }); it("keeps a cold-loaded standalone registry scoped through tool callbacks", async () => { @@ -3250,12 +3102,7 @@ describe("resolvePluginTools optional tools", () => { "gateway-bindable", "/tmp", ); - pinActivePluginChannelRegistry({ - plugins: [{ id: "memory-core", status: "loaded" }], - tools: [], - diagnostics: [], - } as never); - resolveRuntimePluginRegistryMock.mockReturnValue(undefined); + resolveCompatibleRuntimePluginRegistryMock.mockReturnValue(undefined); loadOpenClawPluginsMock.mockReturnValue(loadedRegistry); const tools = resolvePluginTools( @@ -3303,7 +3150,7 @@ describe("resolvePluginTools optional tools", () => { ], }); setActivePluginRegistry(activeRegistry as never, "gateway-startup", "gateway-bindable", "/tmp"); - resolveRuntimePluginRegistryMock.mockReturnValue(activeRegistry); + resolveCompatibleRuntimePluginRegistryMock.mockReturnValue(activeRegistry); loadOpenClawPluginsMock.mockReturnValue(createToolRegistry([])); resolvePluginTools({ @@ -3314,229 +3161,15 @@ describe("resolvePluginTools optional tools", () => { toolAllowlist: ["*", "tavily"], allowGatewaySubagentBinding: true, }); - expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled(); + expect(resolveCompatibleRuntimePluginRegistryMock).toHaveBeenCalledOnce(); const loaderParams = mockCallParams(loadOpenClawPluginsMock) as { onlyPluginIds?: string[]; toolDiscovery?: unknown; }; - expect(loaderParams.onlyPluginIds).toEqual(["tavily"]); + expect(loaderParams.onlyPluginIds).toEqual(["optional-demo", "tavily"]); expect(loaderParams.toolDiscovery).toBe(true); }); - it("reuses the pinned gateway registry across workspace-scoped provider runtime loads", () => { - const gatewayRegistry = createOptionalDemoActiveRegistry(); - setActivePluginRegistry( - gatewayRegistry as never, - "gateway-startup", - "gateway-bindable", - "/tmp", - ); - pinActivePluginChannelRegistry(gatewayRegistry as never); - setActivePluginRegistry( - { - plugins: [], - tools: [], - diagnostics: [], - sessionExtensions: [], - runtimeLifecycles: [], - agentEventSubscriptions: [], - sessionSchedulerJobs: [], - } as never, - "provider-runtime", - "default", - "/tmp/provider-workspace", - ); - resolveRuntimePluginRegistryMock.mockReturnValue(undefined); - - const tools = resolvePluginTools( - createResolveToolsParams({ - toolAllowlist: ["optional_tool"], - allowGatewaySubagentBinding: true, - }), - ); - - expectResolvedToolNames(tools, ["optional_tool"]); - expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled(); - expect(loadOpenClawPluginsMock).not.toHaveBeenCalled(); - }); - - it("loads only tool plugins missing from the pinned gateway registry", () => { - const gatewayFactory = vi.fn(() => makeTool("optional_tool")); - const standaloneFactory = vi.fn(() => makeTool("other_tool")); - const gatewayRegistry = createToolRegistry([ - createNamedToolEntry("optional-demo", "optional_tool", { - optional: true, - factory: gatewayFactory, - }), - ]); - const standaloneRegistry = createToolRegistry([ - createNamedToolEntry("multi", "other_tool", { factory: standaloneFactory }), - ]); - const config = createContext().config; - installToolManifestSnapshots({ - config, - plugins: [ - createToolManifest("optional-demo", ["optional_tool"]), - createToolManifest("multi", ["other_tool"]), - ], - }); - setActivePluginRegistry( - gatewayRegistry as never, - "gateway-startup", - "gateway-bindable", - "/tmp", - ); - pinActivePluginChannelRegistry(gatewayRegistry as never); - setActivePluginRegistry( - createEmptyPluginRegistry(), - "provider-runtime", - "default", - "/tmp/provider-workspace", - ); - resolveRuntimePluginRegistryMock.mockReturnValue(undefined); - loadOpenClawPluginsMock.mockReturnValue(standaloneRegistry); - - const tools = resolvePluginTools( - createResolveToolsParams({ - context: { ...createContext(), config }, - toolAllowlist: ["optional_tool", "other_tool"], - allowGatewaySubagentBinding: true, - }), - ); - - expectResolvedToolNames(tools, ["optional_tool", "other_tool"]); - expect(gatewayFactory).toHaveBeenCalledOnce(); - expect(standaloneFactory).toHaveBeenCalledOnce(); - expectLoaderSelectedOnlyPluginIds(["multi"]); - }); - - it("combines pinned gateway and compatible active tool owners", () => { - const gatewayFactory = vi.fn(() => makeTool("optional_tool")); - const activeFactory = vi.fn(() => makeTool("other_tool")); - const gatewayRegistry = createToolRegistry([ - createNamedToolEntry("optional-demo", "optional_tool", { - optional: true, - factory: gatewayFactory, - }), - ]); - const activeRegistry = createToolRegistry([ - createNamedToolEntry("multi", "other_tool", { factory: activeFactory }), - ]); - const config = createContext().config; - installToolManifestSnapshots({ - config, - plugins: [ - createToolManifest("optional-demo", ["optional_tool"]), - createToolManifest("multi", ["other_tool"]), - ], - }); - setActivePluginRegistry( - gatewayRegistry as never, - "gateway-startup", - "gateway-bindable", - "/tmp", - ); - pinActivePluginChannelRegistry(gatewayRegistry as never); - setActivePluginRegistry(activeRegistry as never, "provider-runtime", "default", "/tmp"); - resolveRuntimePluginRegistryMock.mockReturnValue(activeRegistry); - - const tools = resolvePluginTools( - createResolveToolsParams({ - context: { ...createContext(), config }, - toolAllowlist: ["optional_tool", "other_tool"], - allowGatewaySubagentBinding: true, - }), - ); - - expectResolvedToolNames(tools, ["optional_tool", "other_tool"]); - expect(gatewayFactory).toHaveBeenCalledOnce(); - expect(activeFactory).toHaveBeenCalledOnce(); - expect(loadOpenClawPluginsMock).not.toHaveBeenCalled(); - }); - - it("combines partial active owners before loading only the still-missing plugin", () => { - const gatewayFactory = vi.fn(() => makeTool("optional_tool")); - const activeFactory = vi.fn(() => makeTool("other_tool")); - const standaloneFactory = vi.fn(() => makeTool("third_tool")); - const gatewayRegistry = createToolRegistry([ - createNamedToolEntry("optional-demo", "optional_tool", { - optional: true, - factory: gatewayFactory, - }), - ]); - const activeRegistry = createToolRegistry([ - createNamedToolEntry("multi", "other_tool", { factory: activeFactory }), - ]); - const standaloneRegistry = createToolRegistry([ - createNamedToolEntry("third", "third_tool", { factory: standaloneFactory }), - ]); - const config = createContext().config; - installToolManifestSnapshots({ - config, - plugins: [ - createToolManifest("optional-demo", ["optional_tool"]), - createToolManifest("multi", ["other_tool"]), - createToolManifest("third", ["third_tool"]), - ], - }); - setActivePluginRegistry( - gatewayRegistry as never, - "gateway-startup", - "gateway-bindable", - "/tmp", - ); - pinActivePluginChannelRegistry(gatewayRegistry as never); - setActivePluginRegistry(activeRegistry as never, "provider-runtime", "default", "/tmp"); - resolveRuntimePluginRegistryMock.mockReturnValue(activeRegistry); - loadOpenClawPluginsMock.mockReturnValue(standaloneRegistry); - - const tools = resolvePluginTools( - createResolveToolsParams({ - context: { ...createContext(), config }, - toolAllowlist: ["optional_tool", "other_tool", "third_tool"], - allowGatewaySubagentBinding: true, - }), - ); - - expectResolvedToolNames(tools, ["optional_tool", "other_tool", "third_tool"]); - expect(gatewayFactory).toHaveBeenCalledOnce(); - expect(activeFactory).toHaveBeenCalledOnce(); - expect(standaloneFactory).toHaveBeenCalledOnce(); - expectLoaderSelectedOnlyPluginIds(["third"]); - }); - - it("reuses the pinned gateway channel registry even when the caller omits gateway binding", () => { - const gatewayRegistry = createOptionalDemoActiveRegistry(); - setActivePluginRegistry( - gatewayRegistry as never, - "gateway-startup", - "gateway-bindable", - "/tmp", - ); - pinActivePluginChannelRegistry(gatewayRegistry as never); - setActivePluginRegistry( - { - plugins: [], - tools: [], - diagnostics: [], - } as never, - "provider-runtime", - "default", - "/tmp", - ); - resolveRuntimePluginRegistryMock.mockReturnValue(undefined); - - const tools = resolvePluginTools( - createResolveToolsParams({ - toolAllowlist: ["optional_tool"], - }), - ); - - expectResolvedToolNames(tools, ["optional_tool"]); - expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled(); - expect(loadOpenClawPluginsMock).not.toHaveBeenCalled(); - }); - it("loads plugin tools when gateway-bindable tool loads have no active registry", () => { setOptionalDemoRegistry(); diff --git a/src/plugins/tools.ts b/src/plugins/tools.ts index e27bcf213216..a3cea10c6436 100644 --- a/src/plugins/tools.ts +++ b/src/plugins/tools.ts @@ -16,7 +16,7 @@ import { registrationIncludesHostRestrictedConversationReadTool, } from "./compat/conversation-read-tools.js"; import { applyTestPluginDefaults, normalizePluginsConfig } from "./config-state.js"; -import type { PluginLoadOptions } from "./loader.js"; +import { loadPluginRegistryHandle, type PluginLoadOptions } from "./loader.js"; import { isManifestPluginAvailableForControlPlane, loadManifestContractSnapshot, @@ -25,7 +25,6 @@ import type { PluginManifestRecord } from "./manifest-registry.js"; import { hasManifestToolAvailability } from "./manifest-tool-availability.js"; import type { PluginMetadataManifestView } from "./plugin-metadata-snapshot.types.js"; import type { PluginRegistry, PluginToolRegistration } from "./registry-types.js"; -import { getPluginRegistryState } from "./runtime-state.js"; import { withPluginRuntimePluginScope, withPluginRuntimeRegistryScope, @@ -34,7 +33,6 @@ import { buildPluginRuntimeLoadOptions, resolvePluginRuntimeLoadContext, } from "./runtime/load-context.js"; -import { loadRuntimePluginRegistryHandle } from "./runtime/standalone-runtime-registry-loader.js"; import { findUndeclaredPluginToolNames } from "./tool-contracts.js"; import { buildPluginToolDescriptorCacheKey, @@ -1071,161 +1069,26 @@ function resolvePluginToolRegistry(params: { onRetainRegistry?: (registry: PluginRegistry) => void; }) { const requestedPluginIds = params.onlyPluginIds; - // Retained registries belong to one cached descriptor execution. Reusing one - // across a multi-owner request would keep its tools but lose its hook state - // when another owner triggers a fresh scoped load. - const retainedRegistry = - requestedPluginIds === undefined || requestedPluginIds.length === 1 - ? params.retainedRegistry - : undefined; - const registries: PluginRegistry[] = []; - const seenRegistries = new Set(); - const ownerRegistryByPluginId = new Map(); - const addRegistry = (registry: PluginRegistry | undefined) => { - if (!registry || seenRegistries.has(registry)) { - return; - } - seenRegistries.add(registry); - registries.push(registry); - if (requestedPluginIds === undefined) { - return; - } - const toolPluginIds = new Set(registry.tools.map((entry) => entry.pluginId)); - for (const pluginId of requestedPluginIds) { - if (toolPluginIds.has(pluginId) && !ownerRegistryByPluginId.has(pluginId)) { - ownerRegistryByPluginId.set(pluginId, registry); - } - } - }; - const missingPluginIds = () => - requestedPluginIds?.filter((pluginId) => !ownerRegistryByPluginId.has(pluginId)); - const composeSelectedRegistries = () => - composePluginToolRegistries({ - registries, - ownerRegistryByPluginId, - requestedPluginIds: requestedPluginIds ?? [], - }); - - // Use the established pinned-Gateway owner; its factories receive request - // context directly. Reapplying active-scope metadata would duplicate the - // registration and split hook/tool closure state. - const runtimeState = getPluginRegistryState(); - const gatewayRegistry = runtimeState?.channel.pinned - ? (runtimeState.channel.registry ?? undefined) - : undefined; - if ( - requestedPluginIds === undefined && - registryHasScopedPluginTools(gatewayRegistry, undefined) - ) { - return gatewayRegistry; + if (registryHasScopedPluginTools(params.retainedRegistry, requestedPluginIds)) { + return params.retainedRegistry; } - addRegistry(gatewayRegistry); - let requiredPluginIds = missingPluginIds(); - if (requiredPluginIds?.length === 0) { - return composeSelectedRegistries(); - } - - let activeRegistry: PluginRegistry | undefined; - if (requiredPluginIds === undefined) { - activeRegistry = getLoadedRuntimePluginRegistry({ - env: params.loadOptions.env, - workspaceDir: params.loadOptions.workspaceDir, - surface: "active", - }); - } else { - for (const pluginId of requiredPluginIds) { - activeRegistry = getLoadedRuntimePluginRegistry({ - env: params.loadOptions.env, - workspaceDir: params.loadOptions.workspaceDir, - requiredPluginIds: [pluginId], - surface: "active", - }); - if (activeRegistry) { - break; - } - } - } - if (requestedPluginIds === undefined && registryHasScopedPluginTools(activeRegistry, undefined)) { + const activeRegistry = getLoadedRuntimePluginRegistry({ + loadOptions: params.loadOptions, + workspaceDir: params.loadOptions.workspaceDir, + requiredPluginIds: requestedPluginIds, + }); + if (registryHasScopedPluginTools(activeRegistry, requestedPluginIds)) { return activeRegistry; } - addRegistry(activeRegistry); - requiredPluginIds = missingPluginIds(); - if (requiredPluginIds?.length === 0) { - return composeSelectedRegistries(); - } - - if ( - requestedPluginIds === undefined && - registryHasScopedPluginTools(retainedRegistry, undefined) - ) { - return retainedRegistry; - } - addRegistry(retainedRegistry); - requiredPluginIds = missingPluginIds(); - if (requiredPluginIds?.length === 0) { - return composeSelectedRegistries(); - } - // Partial active/retained registries contribute their matching owners, but - // missing requested owners still force a fresh load. Plugin records alone - // do not prove that the executable tool registrations are available. - const forceStandaloneLoad = Boolean(gatewayRegistry || activeRegistry || retainedRegistry); - const shouldRetainColdLoadedToolRegistry = - forceStandaloneLoad && - params.loadOptions.activate === false && - params.loadOptions.toolDiscovery === true && - params.onRetainRegistry !== undefined; - const standaloneRegistry = loadRuntimePluginRegistryHandle({ - surface: "active", - forceLoad: forceStandaloneLoad, - requiredPluginIds, - loadOptions: - requestedPluginIds === undefined - ? params.loadOptions - : { ...params.loadOptions, onlyPluginIds: requiredPluginIds }, + const registry = loadPluginRegistryHandle({ + ...params.loadOptions, + activate: false, + ...(requestedPluginIds === undefined ? {} : { onlyPluginIds: [...requestedPluginIds] }), }); - if (standaloneRegistry && shouldRetainColdLoadedToolRegistry) { - params.onRetainRegistry?.(standaloneRegistry); + if (registryHasScopedPluginTools(registry, requestedPluginIds)) { + params.onRetainRegistry?.(registry); } - addRegistry(standaloneRegistry); - if (requestedPluginIds === undefined) { - return standaloneRegistry ?? gatewayRegistry ?? activeRegistry; - } - return composeSelectedRegistries(); -} - -function composePluginToolRegistries(params: { - registries: PluginRegistry[]; - ownerRegistryByPluginId: ReadonlyMap; - requestedPluginIds: readonly string[]; -}): PluginRegistry | undefined { - if (params.registries.length === 0) { - return undefined; - } - const contributingRegistries = params.registries.filter((registry) => - params.requestedPluginIds.some( - (pluginId) => params.ownerRegistryByPluginId.get(pluginId) === registry, - ), - ); - const baseRegistry = params.registries.at(-1)!; - if (contributingRegistries.length === 1 && contributingRegistries[0] === baseRegistry) { - return baseRegistry; - } - const selectedPluginIds = new Set(params.requestedPluginIds); - return { - ...baseRegistry, - plugins: contributingRegistries.flatMap((registry) => - registry.plugins.filter( - (plugin) => - selectedPluginIds.has(plugin.id) && - params.ownerRegistryByPluginId.get(plugin.id) === registry, - ), - ), - tools: contributingRegistries.flatMap((registry) => - registry.tools.filter( - (entry) => params.ownerRegistryByPluginId.get(entry.pluginId) === registry, - ), - ), - }; + return registry; } function registryHasScopedPluginTools( @@ -1314,11 +1177,7 @@ export function ensureStandalonePluginToolRegistryLoaded(params: { if (!loadState) { return undefined; } - const registry = loadRuntimePluginRegistryHandle({ - surface: "channel", - requiredPluginIds: loadState.onlyPluginIds, - loadOptions: loadState.loadOptions, - }); + const registry = loadPluginRegistryHandle(loadState.loadOptions); if (registryHasScopedPluginTools(registry, loadState.onlyPluginIds)) { return registry; } @@ -1409,32 +1268,10 @@ export function resolvePluginTools(params: { }); } if (!registry) { - // Cold registry: path-based plugins (origin "config") registered via plugins.load.paths - // are not pinned to any active channel/surface registry until explicitly loaded. - // Trigger a standalone load so their tool factories become available, then retry. - try { - registry = loadRuntimePluginRegistryHandle({ - surface: "channel", - requiredPluginIds: runtimePluginIds, - loadOptions, - }); - } catch (error) { - context.logger.error( - `failed to cold-load plugin tool registry for plugin ids [${runtimePluginIds.join(", ")}]: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - throw error; - } - registry ??= resolvePluginToolRegistry({ loadOptions, onlyPluginIds: runtimePluginIds }); - if (!registry) { - context.logger.warn( - `plugin tool registry still unavailable after cold load for plugin ids [${runtimePluginIds.join( - ", ", - )}]`, - ); - return tools; - } + context.logger.warn( + `plugin tool registry unavailable for plugin ids [${runtimePluginIds.join(", ")}]`, + ); + return tools; } const scopedPluginIds = new Set(runtimePluginIds); diff --git a/src/skills/workshop/experience-review.live.test.ts b/src/skills/workshop/experience-review.live.test.ts index 10a04906e667..f2b4a28a2f88 100644 --- a/src/skills/workshop/experience-review.live.test.ts +++ b/src/skills/workshop/experience-review.live.test.ts @@ -94,9 +94,9 @@ describeLive("skill experience review live OpenAI eval", () => { // Warm the plugin runtime outside the review lane: the first load compiles // extensions synchronously and can exceed the lane's no-progress watchdog // on a loaded machine. - const { installAgentRuntimePluginRegistryAtProcessRoot } = + const { loadAgentRuntimePluginRegistryHandle } = await import("../../agents/runtime-plugins.js"); - installAgentRuntimePluginRegistryAtProcessRoot({ + loadAgentRuntimePluginRegistryHandle({ config: candidate("warmup", []).config ?? {}, workspaceDir, }); diff --git a/src/status/status-plugin-health.runtime.test.ts b/src/status/status-plugin-health.runtime.test.ts index 60c9969aa204..3add1e64a21d 100644 --- a/src/status/status-plugin-health.runtime.test.ts +++ b/src/status/status-plugin-health.runtime.test.ts @@ -11,13 +11,7 @@ import { } from "../plugin-state/plugin-state-store.js"; import { createRuntimeHealthRecordEnvelope } from "../plugin-state/runtime-health-store.js"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; -import { - pinActivePluginChannelRegistry, - pinActivePluginHttpRouteRegistry, - pinActivePluginSessionExtensionRegistry, - resetPluginRuntimeStateForTest, - setActivePluginRegistry, -} from "../plugins/runtime.js"; +import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js"; import { withStateDirEnv } from "../test-helpers/state-dir-env.js"; import { collectRuntimePluginHealthSnapshot } from "./status-plugin-health.runtime.js"; @@ -263,71 +257,4 @@ describe("runtime plugin health snapshot", () => { expect(collectRuntimePluginHealthSnapshot().runtimeLoadedPluginIds).toEqual(["runtime-ok"]); }); - - it("includes loaded plugins from a pinned channel registry diverged from active", () => { - const active = createEmptyPluginRegistry(); - active.plugins.push({ id: "active-ok", status: "loaded", enabled: true } as never); - setActivePluginRegistry(active, "active", "default", "/tmp/ws"); - - const channel = createEmptyPluginRegistry(); - channel.plugins.push({ id: "channel-only", status: "loaded", enabled: true } as never); - pinActivePluginChannelRegistry(channel); - - expect(collectRuntimePluginHealthSnapshot().runtimeLoadedPluginIds).toEqual([ - "active-ok", - "channel-only", - ]); - }); - - it("includes loaded plugins from a pinned http-route registry diverged from active", () => { - const active = createEmptyPluginRegistry(); - active.plugins.push({ id: "active-ok", status: "loaded", enabled: true } as never); - setActivePluginRegistry(active, "active", "default", "/tmp/ws"); - - const httpRoute = createEmptyPluginRegistry(); - httpRoute.plugins.push({ id: "route-only", status: "loaded", enabled: true } as never); - pinActivePluginHttpRouteRegistry(httpRoute); - - expect(collectRuntimePluginHealthSnapshot().runtimeLoadedPluginIds).toEqual([ - "active-ok", - "route-only", - ]); - }); - - it("includes loaded plugins from a pinned session-extension registry diverged from active", () => { - const active = createEmptyPluginRegistry(); - active.plugins.push({ id: "active-ok", status: "loaded", enabled: true } as never); - setActivePluginRegistry(active, "active", "default", "/tmp/ws"); - - const sessionExtension = createEmptyPluginRegistry(); - sessionExtension.plugins.push({ - id: "session-only", - status: "loaded", - enabled: true, - } as never); - pinActivePluginSessionExtensionRegistry(sessionExtension); - - expect(collectRuntimePluginHealthSnapshot().runtimeLoadedPluginIds).toEqual([ - "active-ok", - "session-only", - ]); - }); - - it("dedupes runtime-loaded ids shared across registry surfaces", () => { - const active = createEmptyPluginRegistry(); - active.plugins.push({ id: "shared", status: "loaded", enabled: true } as never); - setActivePluginRegistry(active, "active", "default", "/tmp/ws"); - - const channel = createEmptyPluginRegistry(); - channel.plugins.push( - { id: "shared", status: "loaded", enabled: true } as never, - { id: "channel-only", status: "loaded", enabled: true } as never, - ); - pinActivePluginChannelRegistry(channel); - - expect(collectRuntimePluginHealthSnapshot().runtimeLoadedPluginIds).toEqual([ - "channel-only", - "shared", - ]); - }); }); diff --git a/src/status/status-plugin-health.runtime.ts b/src/status/status-plugin-health.runtime.ts index d6e5ea33f08d..9b6e665b1586 100644 --- a/src/status/status-plugin-health.runtime.ts +++ b/src/status/status-plugin-health.runtime.ts @@ -6,7 +6,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { listContextEngineQuarantines } from "../context-engine/registry.js"; import { getActiveRuntimePluginRegistry, - listLoadedRuntimePluginIdsAcrossSurfaces, + listLoadedRuntimePluginIds, } from "../plugins/active-runtime-registry.js"; import { dedupeChannelPluginFailures, @@ -160,11 +160,7 @@ export function collectRuntimePluginHealthSnapshot(): StatusPluginHealthSnapshot const registry = getActiveRuntimePluginRegistry(); const diagnostics = (registry?.diagnostics ?? []).map(normalizeDiagnostic); const plugins = (registry?.plugins ?? []).map(normalizeSnapshotPlugin); - // Confirmed runtime-loaded ids across all live registry surfaces (so a plugin - // still live via a pinned channel/http-route registry counts) let detailed - // status separate actually-loaded plugins from disk-scan inventory the merged - // snapshot also marks "loaded". - const runtimeLoadedPluginIds = listLoadedRuntimePluginIdsAcrossSurfaces(); + const runtimeLoadedPluginIds = listLoadedRuntimePluginIds(); return { plugins, diagnostics, diff --git a/src/status/status-plugin-health.ts b/src/status/status-plugin-health.ts index 412da30d6dfb..8998ba437740 100644 --- a/src/status/status-plugin-health.ts +++ b/src/status/status-plugin-health.ts @@ -263,11 +263,9 @@ function byLocale(left: string, right: string): number { export function formatDetailedPluginHealth(snapshot: StatusPluginHealthSnapshot): string { const statusLoaded = snapshot.plugins.filter((plugin) => plugin.status === "loaded"); // "Loaded" must mean runtime-confirmed loaded. When the snapshot carries runtime - // provenance, render that authoritative id set directly (it spans all live - // registry surfaces, so a plugin live only via a pinned surface still lists even - // when it is absent from the merged records); installed-but-not-active is then - // the status-loaded records the runtime did not load. Fall back to the raw - // status when provenance is absent (hand-built/compact snapshots). + // provenance, render that authoritative root-registry id set directly; + // installed-but-not-active is then the status-loaded records the runtime did + // not load. Fall back to the raw status when provenance is absent. const runtimeLoadedIds = snapshot.runtimeLoadedPluginIds; const runtimeLoaded = runtimeLoadedIds ? new Set(runtimeLoadedIds) : undefined; const loaded = (runtimeLoadedIds ?? statusLoaded.map((plugin) => plugin.id)).toSorted(byLocale);