diff --git a/src/gateway/server-methods.plugin-gateway-dispatch.test.ts b/src/gateway/server-methods.plugin-gateway-dispatch.test.ts new file mode 100644 index 000000000000..642da652640a --- /dev/null +++ b/src/gateway/server-methods.plugin-gateway-dispatch.test.ts @@ -0,0 +1,140 @@ +/** + * Regression tests for plugin-registered gateway RPC dispatch (#94127). + */ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; +import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js"; +import { + createGatewayMethodRegistry, + createPluginGatewayMethodDescriptor, +} from "./methods/registry.js"; +import { WRITE_SCOPE } from "./operator-scopes.js"; +import { handleGatewayRequest } from "./server-methods.js"; +import type { GatewayRequestHandler } from "./server-methods/types.js"; + +describe("handleGatewayRequest plugin gateway dispatch", () => { + afterEach(() => { + resetPluginRuntimeStateForTest(); + }); + + it("dispatches plugin methods registered after the startup method registry snapshot", async () => { + const handler = vi.fn(({ respond }) => { + respond(true, { ok: true, ts: 42 }); + }); + const activeRegistry = createEmptyPluginRegistry(); + activeRegistry.gatewayHandlers["demo.ping"] = handler; + activeRegistry.gatewayMethodDescriptors.push( + createPluginGatewayMethodDescriptor({ + pluginId: "demo", + name: "demo.ping", + handler, + scope: WRITE_SCOPE, + }), + ); + setActivePluginRegistry(activeRegistry); + + const staleStartupRegistry = createGatewayMethodRegistry([]); + const respond = vi.fn(); + await handleGatewayRequest({ + req: { + type: "req", + id: "proof-94127", + method: "demo.ping", + params: { hello: "world" }, + }, + respond, + client: { + connId: "conn-proof", + connect: { + role: "operator", + scopes: [WRITE_SCOPE], + client: { + id: "cli", + version: "test", + platform: "linux", + mode: "cli", + }, + minProtocol: 1, + maxProtocol: 1, + }, + }, + isWebchatConnect: () => false, + context: { + logGateway: { warn: vi.fn() }, + } as unknown as Parameters[0]["context"], + methodRegistry: staleStartupRegistry, + }); + + expect(handler).toHaveBeenCalledTimes(1); + expect(respond).toHaveBeenCalledWith(true, { ok: true, ts: 42 }); + }); + + it("dispatches a method owned by the caller-attached registry even when global state lacks it (#94343)", async () => { + const handler = vi.fn(({ respond }) => { + respond(true, { ok: true, source: "attached" }); + }); + // Active plugin registry does NOT carry the method; only the caller-attached + // snapshot owns it, so dispatch must prefer the attached registry. + setActivePluginRegistry(createEmptyPluginRegistry()); + const attachedRegistry = createGatewayMethodRegistry([ + createPluginGatewayMethodDescriptor({ + pluginId: "demo", + name: "demo.attached", + handler, + scope: WRITE_SCOPE, + }), + ]); + const respond = vi.fn(); + await handleGatewayRequest({ + req: { type: "req", id: "proof-94343", method: "demo.attached", params: {} }, + respond, + client: { + connId: "conn-proof", + connect: { + role: "operator", + scopes: [WRITE_SCOPE], + client: { id: "cli", version: "test", platform: "linux", mode: "cli" }, + minProtocol: 1, + maxProtocol: 1, + }, + }, + isWebchatConnect: () => false, + context: { + logGateway: { warn: vi.fn() }, + } as unknown as Parameters[0]["context"], + methodRegistry: attachedRegistry, + }); + + expect(handler).toHaveBeenCalledTimes(1); + expect(respond).toHaveBeenCalledWith(true, { ok: true, source: "attached" }); + }); + + it("fails closed when neither the attached snapshot nor the live registry owns the method", async () => { + const handler = vi.fn(); + setActivePluginRegistry(createEmptyPluginRegistry()); + const respond = vi.fn(); + await handleGatewayRequest({ + req: { type: "req", id: "proof-unknown", method: "demo.does-not-exist", params: {} }, + respond, + client: { + connId: "conn-proof", + connect: { + role: "operator", + scopes: [WRITE_SCOPE], + client: { id: "cli", version: "test", platform: "linux", mode: "cli" }, + minProtocol: 1, + maxProtocol: 1, + }, + }, + isWebchatConnect: () => false, + context: { + logGateway: { warn: vi.fn() }, + } as unknown as Parameters[0]["context"], + methodRegistry: createGatewayMethodRegistry([]), + }); + + expect(handler).not.toHaveBeenCalled(); + const [ok] = respond.mock.calls.at(-1) ?? []; + expect(ok).toBe(false); + }); +}); diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index 86f980a02ead..57ae4e38c74a 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -639,8 +639,14 @@ export async function handleGatewayRequest( opts: GatewayRequestOptions & { extraHandlers?: GatewayRequestHandlers }, ): Promise { const { req, respond, client, isWebchatConnect, context } = opts; + // Prefer the caller-attached registry when it owns the requested method so plugin dispatch + // metadata newer than global runtime state still authorizes and dispatches correctly. When the + // attached snapshot does not own the method, rebuild from the live plugin registry so plugin RPC + // methods registered after the startup snapshot stay reachable (#94127). const methodRegistry = - opts.methodRegistry ?? createRequestGatewayMethodRegistry(opts.extraHandlers); + opts.methodRegistry?.getHandler(req.method) !== undefined + ? opts.methodRegistry + : createRequestGatewayMethodRegistry(opts.extraHandlers); const authError = authorizeGatewayMethod(req.method, client, req.params, methodRegistry); if (authError) { respond(false, undefined, authError);