diff --git a/src/gateway/server/plugins-http.test.ts b/src/gateway/server/plugins-http.test.ts index 6bee6eb654e..6c15b576cff 100644 --- a/src/gateway/server/plugins-http.test.ts +++ b/src/gateway/server/plugins-http.test.ts @@ -192,6 +192,55 @@ describe("createGatewayPluginRequestHandler", () => { expect(log.warn).toHaveBeenCalledWith(expect.stringContaining("missing scope: operator.admin")); }); + it("keeps gateway-authenticated plugin routes on least-privilege runtime scopes", async () => { + loadOpenClawPlugins.mockReset(); + handleGatewayRequest.mockReset(); + handleGatewayRequest.mockImplementation(async (opts: HandleGatewayRequestOptions) => { + const scopes = opts.client?.connect.scopes ?? []; + if (opts.req.method === "sessions.delete" && !scopes.includes("operator.admin")) { + opts.respond(false, undefined, { + code: "invalid_request", + message: "missing scope: operator.admin", + }); + return; + } + opts.respond(true, {}); + }); + + const subagent = await createSubagentRuntime(); + const log = createPluginLog(); + const handler = createGatewayPluginRequestHandler({ + registry: createTestRegistry({ + httpRoutes: [ + createRoute({ + path: "/secure-hook", + auth: "gateway", + handler: async (_req, _res) => { + await subagent.deleteSession({ sessionKey: "agent:main:subagent:child" }); + return true; + }, + }), + ], + }), + log, + }); + + const { res, setHeader, end } = makeMockHttpResponse(); + const handled = await handler({ url: "/secure-hook" } as IncomingMessage, res, undefined, { + gatewayAuthSatisfied: true, + }); + + expect(handled).toBe(true); + expect(handleGatewayRequest).toHaveBeenCalledTimes(1); + expect(handleGatewayRequest.mock.calls[0]?.[0]?.client?.connect.scopes).toEqual([ + "operator.write", + ]); + expect(res.statusCode).toBe(500); + expect(setHeader).toHaveBeenCalledWith("Content-Type", "text/plain; charset=utf-8"); + expect(end).toHaveBeenCalledWith("Internal Server Error"); + expect(log.warn).toHaveBeenCalledWith(expect.stringContaining("missing scope: operator.admin")); + }); + it("returns false when no routes are registered", async () => { const log = createPluginLog(); const handler = createGatewayPluginRequestHandler({ diff --git a/src/gateway/server/plugins-http.ts b/src/gateway/server/plugins-http.ts index b2b3f3a17ec..eb2a8ab850c 100644 --- a/src/gateway/server/plugins-http.ts +++ b/src/gateway/server/plugins-http.ts @@ -3,7 +3,7 @@ import type { createSubsystemLogger } from "../../logging/subsystem.js"; import type { PluginRegistry } from "../../plugins/registry.js"; import { resolveActivePluginHttpRouteRegistry } from "../../plugins/runtime.js"; import { withPluginRuntimeGatewayRequestScope } from "../../plugins/runtime/gateway-request-scope.js"; -import { ADMIN_SCOPE, APPROVALS_SCOPE, PAIRING_SCOPE, WRITE_SCOPE } from "../method-scopes.js"; +import { WRITE_SCOPE } from "../method-scopes.js"; import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../protocol/client-info.js"; import { PROTOCOL_VERSION } from "../protocol/index.js"; import type { GatewayRequestOptions } from "../server-methods/types.js"; @@ -27,16 +27,10 @@ export { shouldEnforceGatewayAuthForPluginPath } from "./plugins-http/route-auth type SubsystemLogger = ReturnType; -function createPluginRouteRuntimeClient(params: { - requiresGatewayAuth: boolean; - gatewayAuthSatisfied?: boolean; -}): GatewayRequestOptions["client"] { - // Plugin-authenticated webhooks can still use non-admin subagent helpers, - // but they must not inherit admin-only gateway methods by default. - const scopes = - params.requiresGatewayAuth && params.gatewayAuthSatisfied !== false - ? [ADMIN_SCOPE, APPROVALS_SCOPE, PAIRING_SCOPE] - : [WRITE_SCOPE]; +function createPluginRouteRuntimeClient(): GatewayRequestOptions["client"] { + // Plugin HTTP handlers only need the least-privilege runtime scope. + // Gateway route auth controls request admission, not runtime admin elevation. + const scopes = [WRITE_SCOPE]; return { connect: { minProtocol: PROTOCOL_VERSION, @@ -87,10 +81,7 @@ export function createGatewayPluginRequestHandler(params: { log.warn(`plugin http route blocked without gateway auth (${pathContext.canonicalPath})`); return false; } - const runtimeClient = createPluginRouteRuntimeClient({ - requiresGatewayAuth, - gatewayAuthSatisfied: dispatchContext?.gatewayAuthSatisfied, - }); + const runtimeClient = createPluginRouteRuntimeClient(); return await withPluginRuntimeGatewayRequestScope( {