refactor(plugins): delete registry compat scaffolding (#117749)

* refactor(plugins): delete registry compat scaffolding

* test(plugins): update CLI registry handle mock

* fix(plugins): preserve explicitly initialized hook registries

* test(plugins): update registry ownership fixtures

* fix(channels): restore registry snapshot memo
This commit is contained in:
Peter Steinberger
2026-08-01 21:18:47 -07:00
committed by GitHub
parent a084763814
commit ccee629359
129 changed files with 1677 additions and 6720 deletions
@@ -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 () => {
+3 -3
View File
@@ -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();
}
});
@@ -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([
@@ -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 () => {
@@ -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<void>((resolve, reject) => {
@@ -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());
});
@@ -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<typeof vi.fn>).mockReturnValue(false);
@@ -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<symbol, unknown>;
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<string, unknown>) {
@@ -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"),
+25 -212
View File
@@ -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,
+13 -12
View File
@@ -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<string, McpServerConnectionResolverEntry>();
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)));
}
+22 -63
View File
@@ -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,
}),
);
});
+3 -20
View File
@@ -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 });
}
+1 -60
View File
@@ -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(
+1 -30
View File
@@ -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,
+1 -1
View File
@@ -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;
+3 -16
View File
@@ -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<TValue> = (
@@ -25,19 +25,6 @@ export function createChannelRegistryLoader<TValue>(
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());
};
}
+2 -37
View File
@@ -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);
+1 -142
View File
@@ -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<typeof import("../plugins/loader.js").loadOpenClawPlugins>(),
resolveCompatibleRuntimePluginRegistry:
vi.fn<typeof import("../plugins/loader.js").resolveCompatibleRuntimePluginRegistry>(),
resolveRuntimePluginRegistry:
vi.fn<typeof import("../plugins/loader.js").resolveRuntimePluginRegistry>(),
getActivePluginRegistry: vi.fn<typeof import("../plugins/runtime.js").getActivePluginRegistry>(),
resolveConfiguredChannelPluginIds:
vi.fn<typeof import("../plugins/channel-plugin-ids.js").resolveConfiguredChannelPluginIds>(),
resolveDiscoverableScopedChannelPluginIds:
vi.fn<
typeof import("../plugins/channel-plugin-ids.js").resolveDiscoverableScopedChannelPluginIds
>(),
resolveChannelPluginIds:
vi.fn<typeof import("../plugins/channel-plugin-ids.js").resolveChannelPluginIds>(),
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<typeof mocks.loadOpenClawPlugins>) =>
mocks.loadOpenClawPlugins(...args),
resolveCompatibleRuntimePluginRegistry: (
...args: Parameters<typeof mocks.resolveCompatibleRuntimePluginRegistry>
) => mocks.resolveCompatibleRuntimePluginRegistry(...args),
resolveRuntimePluginRegistry: (...args: Parameters<typeof mocks.resolveRuntimePluginRegistry>) =>
mocks.resolveRuntimePluginRegistry(...args),
}));
vi.mock("../plugins/runtime.js", () => ({
getActivePluginRegistry: (...args: Parameters<typeof mocks.getActivePluginRegistry>) =>
mocks.getActivePluginRegistry(...args),
}));
vi.mock("../plugins/channel-plugin-ids.js", () => ({
resolveConfiguredChannelPluginIds: (
...args: Parameters<typeof mocks.resolveConfiguredChannelPluginIds>
) => mocks.resolveConfiguredChannelPluginIds(...args),
resolveDiscoverableScopedChannelPluginIds: (
...args: Parameters<typeof mocks.resolveDiscoverableScopedChannelPluginIds>
) => mocks.resolveDiscoverableScopedChannelPluginIds(...args),
resolveChannelPluginIds: (...args: Parameters<typeof mocks.resolveChannelPluginIds>) =>
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<string, unknown>;
@@ -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",
});
});
});
-1
View File
@@ -1,6 +1,5 @@
// CLI-facing plugin registry loader re-export.
export {
testing,
ensurePluginRegistryLoaded,
type PluginRegistryScope,
} from "../plugins/runtime/runtime-registry-loader.js";
+4 -12
View File
@@ -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<string, unknown>): Record<string, unknown> {
const { account, ...rest } = opts;
@@ -136,9 +132,6 @@ function resolveMessagePluginPreloadPlan(
opts: Record<string, unknown>,
): 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({
+3 -12
View File
@@ -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<typeof getActivePluginHttpRouteRegistry> | 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;
+4 -21
View File
@@ -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();
}
});
+1 -32
View File
@@ -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(
[
+1 -4
View File
@@ -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";
+6 -14
View File
@@ -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<ReturnType<typeof startGatewayServer>> | undefined;
let client: GatewayClient | undefined;
let pinnedChannelRegistry:
| ReturnType<typeof createSlackCurrentConversationBindingRegistry>
| 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(() => {});
+6 -13
View File
@@ -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<ReturnType<typeof startGatewayServer>> | undefined;
let client: Awaited<ReturnType<typeof connectTestGatewayClient>> | undefined;
let pinnedChannelRegistry:
| ReturnType<typeof createSlackCurrentConversationBindingRegistry>
| 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 {
@@ -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(() => {
-2
View File
@@ -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", () => {
+1 -87
View File
@@ -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");
+2 -2
View File
@@ -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;
+1 -33
View File
@@ -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(
+1 -29
View File
@@ -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<PluginApprovalRequestPayload>();
const visibleConnIds = new Set(["conn-owner-approval"]);
+19
View File
@@ -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<void>;
@@ -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(() => {
+2 -6
View File
@@ -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<void>) | null;
tailscaleCleanup: (() => Promise<void>) | null;
releasePluginRouteRegistry?: (() => void) | null;
clearSecretsRuntimeSnapshot?: (() => void) | null;
channelIds?: readonly ChannelId[];
stopChannel: (name: ChannelId, accountId?: string) => Promise<void>;
@@ -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 {
-8
View File
@@ -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 (
-2
View File
@@ -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,
@@ -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<GatewayRequestHandler>(({ respond }) => {
respond(true, { ok: true, source: "gateway" });
});
const scopedHandler = vi.fn<GatewayRequestHandler>(({ respond }) => {
respond(true, { ok: true, source: "agent" });
});
const gatewayRegistry = createEmptyPluginRegistry();
gatewayRegistry.gatewayHandlers["demo.gateway"] = gatewayHandler;
gatewayRegistry.gatewayMethodDescriptors.push(
createPluginGatewayMethodDescriptor({
pluginId: "demo",
name: "demo.gateway",
handler: gatewayHandler,
scope: WRITE_SCOPE,
}),
);
const scopedRegistry = createEmptyPluginRegistry();
scopedRegistry.gatewayHandlers["demo.agent"] = scopedHandler;
scopedRegistry.gatewayMethodDescriptors.push(
createPluginGatewayMethodDescriptor({
pluginId: "demo",
name: "demo.agent",
handler: scopedHandler,
scope: WRITE_SCOPE,
}),
);
setActivePluginRegistry(gatewayRegistry);
pinActivePluginHttpRouteRegistry(gatewayRegistry);
setActivePluginRegistry(scopedRegistry);
const staleStartupRegistry = createGatewayMethodRegistry([]);
const invoke = async (method: string) => {
const respond = vi.fn();
await handleGatewayRequest({
req: { type: "req", id: `pinned-${method}`, method, params: {} },
respond,
client: {
connId: "conn-proof",
connect: {
role: "operator",
scopes: [WRITE_SCOPE],
client: { id: "cli", version: "test", platform: "linux", mode: "cli" },
minProtocol: 1,
maxProtocol: 1,
},
},
isWebchatConnect: () => false,
context: {
logGateway: { warn: vi.fn() },
} as unknown as Parameters<typeof handleGatewayRequest>[0]["context"],
methodRegistry: staleStartupRegistry,
});
return respond;
};
const rejected = await invoke("demo.agent");
expect(rejected).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({ code: "FORBIDDEN" }),
);
expect(scopedHandler).not.toHaveBeenCalled();
const dispatched = await invoke("demo.gateway");
expect(dispatched).toHaveBeenCalledWith(true, { ok: true, source: "gateway" });
expect(gatewayHandler).toHaveBeenCalledOnce();
});
it("fails closed when neither the attached snapshot nor the live registry owns the method", async () => {
const handler = vi.fn<GatewayRequestHandler>();
setActivePluginRegistry(createEmptyPluginRegistry());
+2 -2
View File
@@ -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
@@ -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<GatewayRequestHandlers[string]>(
async ({ params, respond }) => {
respond(true, { owner: "gateway", items: [params.filter ?? "all"] });
},
);
const gatewayActionHandler = vi.fn<GatewayRequestHandlers[string]>(
async ({ params, respond }) => {
respond(true, { owner: "gateway", refreshed: params.force });
},
);
const scopedReadHandler = vi.fn<GatewayRequestHandlers[string]>(async ({ respond }) => {
respond(true, { owner: "agent" });
});
const scopedActionHandler = vi.fn<GatewayRequestHandlers[string]>(async ({ respond }) => {
respond(true, { owner: "agent" });
});
const gatewayRegistry = createWorkboardCapabilityRegistry({
readHandler: gatewayReadHandler,
actionHandler: gatewayActionHandler,
});
const scopedRegistry = createWorkboardCapabilityRegistry({
readHandler: scopedReadHandler,
actionHandler: scopedActionHandler,
});
setActivePluginRegistry(gatewayRegistry);
pinActivePluginSessionExtensionRegistry(gatewayRegistry);
setActivePluginRegistry(scopedRegistry);
try {
const { invoke, store } = createBoardHarness();
await invoke("board.widget.put", {
sessionKey: "session",
name: "plugin-widget",
content: { kind: "html", html: "plugin" },
declared: { tools: ["workboard.cards.list", "workboard.dispatch"] },
});
await invoke("board.widget.grant", {
sessionKey: "session",
name: "plugin-widget",
decision: "granted",
revision: 1,
instanceId: store.getSnapshot("session").widgets[0]?.instanceId,
});
const board = await invoke("board.get", { sessionKey: "session" });
const snapshot = board.mock.calls[0]?.[1] as BoardSnapshot;
const ticket = snapshot.widgets[0]?.viewTicket;
const read = await invoke("board.data.read", {
ticket,
bindingId: "workboard.cards.list",
params: { filter: "ready" },
});
expect(read.mock.calls[0]?.[1]).toEqual({ owner: "gateway", items: ["ready"] });
const action = await invoke("board.action", {
ticket,
action: "workboard.dispatch",
params: { force: true },
});
expect(action.mock.calls[0]?.[1]).toEqual({ owner: "gateway", refreshed: true });
expect(gatewayReadHandler).toHaveBeenCalledOnce();
expect(gatewayActionHandler).toHaveBeenCalledOnce();
expect(scopedReadHandler).not.toHaveBeenCalled();
expect(scopedActionHandler).not.toHaveBeenCalled();
} finally {
releasePinnedPluginSessionExtensionRegistry(gatewayRegistry);
if (previousRegistry) {
setActivePluginRegistry(previousRegistry);
} else {
resetPluginRuntimeStateForTest();
}
}
});
});
@@ -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) => ({
@@ -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<PluginRegistry, "sessionCatalogs"> & {
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<typeof createPluginRuntime>) =>
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 () => {
+11 -4
View File
@@ -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<NonNullable<SessionCatalogListProviderParams["listNodes"]>> | 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 &&
@@ -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 () => {
+9 -43
View File
@@ -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<GatewayPluginBootstrapParams, "beforePrimeRegistry">,
) {
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<GatewayPluginBootstrapParams, "preferSetupRuntimeForChannelPlugins">,
) {
return prepareGatewayPluginLoad({
...params,
beforePrimeRegistry: pinGatewayPluginRuntimeRegistries,
});
return prepareGatewayPluginLoad(params);
}
+21 -68
View File
@@ -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<string, unknown> = {},
): Promise<PluginRuntime["subagent"]> {
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<PluginRuntimeModule["createPluginRuntime"]>[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<Parameters<ServerPluginsModule["loadGatewayPlugins"]>[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<Parameters<ServerPluginBootstrapModule["loadGatewayStartupPlugins"]>[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);
+15
View File
@@ -542,6 +542,19 @@ export function createGatewayNodesRuntime(): PluginRuntime["nodes"] {
};
}
const GATEWAY_PLUGIN_RUNTIME_BINDINGS_KEY: unique symbol = Symbol.for(
"openclaw.gatewayPluginRuntimeBindings",
);
function getGatewayPluginRuntimeBindings(): Pick<PluginRuntime, "nodes" | "subagent"> {
// 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,
+7 -10
View File
@@ -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();
}
});
@@ -346,7 +346,6 @@ export async function prepareGatewayRuntimeState(params: {
current?: (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
} = {};
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,
+2 -54
View File
@@ -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<string> {
return await new Promise<string>((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",
+379 -416
View File
@@ -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<typeof createSubsystemLogger>;
@@ -127,7 +118,6 @@ export async function createGatewayRuntimeState(params: {
handleWatchNodeRequest?: (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
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<number>;
}> {
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<GatewayWsClient>();
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<GatewayWsClient>();
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<number> | null = null;
let startListeningPromise: Promise<void> | null = null;
let startListeningComplete = false;
const startSandboxHost = async (): Promise<number> => {
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<number> | null = null;
let startListeningPromise: Promise<void> | null = null;
let startListeningComplete = false;
const startSandboxHost = async (): Promise<number> => {
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<void>((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<void>((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<number> => {
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<void> => {
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<string>();
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<number> => {
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<string, number>();
const dedupe = new Map<string, DedupeEntry>();
const chatRunState = createChatRunState();
const chatRunRegistry = chatRunState.registry;
const addChatRun = chatRunRegistry.add;
const removeChatRun = chatRunRegistry.remove;
const chatAbortControllers = new Map<string, ChatAbortControllerEntry>();
const chatQueuedTurns = new Map<string, import("./chat-queued-turns.js").QueuedChatTurnEntry>();
const toolEventRecipients = chatRunState.toolEventRecipients;
}
return await startSandboxHost();
};
const startListening = async (): Promise<void> => {
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<string>();
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<string, number>();
const dedupe = new Map<string, DedupeEntry>();
const chatRunState = createChatRunState();
const chatRunRegistry = chatRunState.registry;
const addChatRun = chatRunRegistry.add;
const removeChatRun = chatRunRegistry.remove;
const chatAbortControllers = new Map<string, ChatAbortControllerEntry>();
const chatQueuedTurns = new Map<string, import("./chat-queued-turns.js").QueuedChatTurnEntry>();
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,
};
}
@@ -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<void>((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 }),
);
});
+2 -2
View File
@@ -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,
@@ -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,
@@ -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();
}
});
@@ -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,
+1 -88
View File
@@ -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<typeof createGatewayPluginRequestHandler>[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<typeof createTestRegistry>;
getRouteRegistry?: () => ReturnType<typeof createTestRegistry>;
}) {
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());
});
-4
View File
@@ -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;
+2 -16
View File
@@ -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([]);
});
});
@@ -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);
});
});
+60 -54
View File
@@ -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<string, Set<DeliverableMessageChannel>>();
const bootstrapRegistriesByConfig = new Map<
string,
Map<DeliverableMessageChannel, PluginRegistry | null>
>();
function resolveBootstrapRegistryGeneration(): string {
return `${getActivePluginChannelRegistryVersion()}:${getActivePluginRegistryVersion()}`;
return String(getActivePluginRegistryVersion());
}
function resolveBootstrapAttemptedChannels(cfg: OpenClawConfig): Set<DeliverableMessageChannel> {
function resolveBootstrapRegistries(
cfg: OpenClawConfig,
): Map<DeliverableMessageChannel, PluginRegistry | null> {
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<DeliverableMessageChannel>();
bootstrapAttemptedChannelsByConfig.set(configKey, attemptedChannels);
return attemptedChannels;
const registries = new Map<DeliverableMessageChannel, PluginRegistry | null>();
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;
}
}
+44 -107
View File
@@ -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");
+64 -45
View File
@@ -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<TValue>(
function resolveValueFromRuntimeRegistry<TValue>(
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))
);
}
+48 -16
View File
@@ -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<OutboundChannel, "none">;
}): 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<ChannelHandler> {
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<OutboundChannel, "none">;
}): Promise<ChannelOutboundAdapter | undefined> {
}): 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<OutboundChannel, "none">;
requirements?: DurableFinalDeliveryRequirements;
}): Promise<OutboundDurableDeliverySupport> {
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" };
}
@@ -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());
});
+2 -47
View File
@@ -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",
@@ -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());
});
@@ -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());
});
});
@@ -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",
+1 -32
View File
@@ -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/,
+1 -1
View File
@@ -15,7 +15,7 @@ export {
createOutboundTestPlugin,
createTestRegistry,
initializeGlobalHookRunner,
releasePinnedPluginChannelRegistry,
resetPluginRuntimeStateForTest,
resetGlobalHookRunner,
setActivePluginRegistry,
type PluginHookRegistration,
-1
View File
@@ -34,7 +34,6 @@ export {
} from "../plugins/web-provider-public-artifacts.explicit.js";
export {
getActivePluginRegistry,
releasePinnedPluginChannelRegistry,
resetPluginRuntimeStateForTest,
setActivePluginRegistry,
} from "../plugins/runtime.js";
@@ -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";
+3 -3
View File
@@ -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", () => {
+9 -43
View File
@@ -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;
}
@@ -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);
});
+1 -1
View File
@@ -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();
@@ -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();
@@ -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();
@@ -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;
@@ -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);
+92 -204
View File
@@ -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<TrustedPolicyHookRunnerRegistry>();
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<string, number>();
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<string>();
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<string, Set<string>>();
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<string>();
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<string, number>();
const claimPolicyOwner = (pluginId: string, index: number) => {
if (!policyOwnerSourceIndexByPluginId.has(pluginId)) {
policyOwnerSourceIndexByPluginId.set(pluginId, index);
}
};
const trustedPolicyPluginIdsBySource = sources.map((registry) => {
const ids = new Set<string>();
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;
}
+140 -250
View File
@@ -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);
});
});
+3 -62
View File
@@ -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<ReturnType<typeof importHookRunnerGlobalModule>>;
type HookRunner = NonNullable<ReturnType<HookRunnerGlobalModule["getGlobalHookRunner"]>>;
@@ -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,
+7 -15
View File
@@ -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;
+2 -33
View File
@@ -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({
+8 -16
View File
@@ -8,7 +8,7 @@ import {
commitPluginInteractiveCallbackDedupe,
releasePluginInteractiveCallbackDedupe,
} from "./interactive-state.js";
import { collectLivePluginRegistries } from "./runtime.js";
import { getActivePluginRegistry } from "./runtime.js";
type InteractiveDispatchResult<TResult = unknown> =
| { 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<TRegistration>) => Promise<TResult> | TResult;
afterInvoke?: (result: TResult) => Promise<void> | void;
}): Promise<InteractiveDispatchResult<TResult>> {
const match = resolveLivePluginInteractiveNamespaceMatch(params.channel, params.data);
const match = resolveActivePluginInteractiveNamespaceMatch(params.channel, params.data);
if (!match) {
return { matched: false, handled: false, duplicate: false };
}
+6 -19
View File
@@ -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<typeof requireActivePluginRegistry>["legacyInternalHooks"];
const seenPluginIds = new Set<string>();
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;
}
-11
View File
@@ -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<CachedPluginState>(MAX_PLUGIN_REGISTRY_CACHE_ENTRIES),
fullWorkspace: new PluginLoaderCacheState<CachedPluginState>(MAX_PLUGIN_REGISTRY_CACHE_ENTRIES),
};
+12 -64
View File
@@ -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<PluginRegistry>(
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 {
+28 -27
View File
@@ -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<string, BundledPackageCacheIdentity>();
const runtimeBindingCacheIds = new WeakMap<object, number>();
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,
+42 -14
View File
@@ -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<PluginRuntime, "config">;
};
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) {
+15 -176
View File
@@ -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);
}
@@ -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");
});
});
@@ -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<string, unknown>)[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<string, unknown>)[marker]).toBe(1);
delete (globalThis as Record<string, unknown>)[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<string, unknown>)[marker]).toBe(1);
delete (globalThis as Record<string, unknown>)[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({
@@ -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();
+2 -3
View File
@@ -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();
}
-2
View File
@@ -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();
};
+20 -20
View File
@@ -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();
});
});
+2 -6
View File
@@ -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;
}

Some files were not shown because too many files have changed in this diff Show More