mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 11:25:50 -06:00
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:
committed by
GitHub
parent
a084763814
commit
ccee629359
@@ -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 () => {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,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,6 +1,5 @@
|
||||
// CLI-facing plugin registry loader re-export.
|
||||
export {
|
||||
testing,
|
||||
ensurePluginRegistryLoaded,
|
||||
type PluginRegistryScope,
|
||||
} from "../plugins/runtime/runtime-registry-loader.js";
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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,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";
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,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 (
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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/,
|
||||
|
||||
@@ -15,7 +15,7 @@ export {
|
||||
createOutboundTestPlugin,
|
||||
createTestRegistry,
|
||||
initializeGlobalHookRunner,
|
||||
releasePinnedPluginChannelRegistry,
|
||||
resetPluginRuntimeStateForTest,
|
||||
resetGlobalHookRunner,
|
||||
setActivePluginRegistry,
|
||||
type PluginHookRegistration,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user