From 18b733577f28fba6c5ad29edd4db54dfe647ec4e Mon Sep 17 00:00:00 2001 From: Pavan Kumar Gondhi Date: Fri, 31 Jul 2026 00:17:15 +0530 Subject: [PATCH] fix(gateway): gate plugin node routes on approved capabilities (#115980) --- src/gateway/server.node-pairing-authz.test.ts | 67 +++++++++++++++++++ .../server/ws-connection/connect-session.ts | 6 ++ src/gateway/test-helpers.e2e.ts | 8 ++- 3 files changed, 79 insertions(+), 2 deletions(-) diff --git a/src/gateway/server.node-pairing-authz.test.ts b/src/gateway/server.node-pairing-authz.test.ts index b1e14203dc12..3b4f1721941d 100644 --- a/src/gateway/server.node-pairing-authz.test.ts +++ b/src/gateway/server.node-pairing-authz.test.ts @@ -2,6 +2,7 @@ // command scopes, and gateway enforcement around node client identity. import { afterAll, beforeAll, describe, expect, test, vi } from "vitest"; import { WebSocket } from "ws"; +import type { HelloOk } from "../../packages/gateway-protocol/src/index.js"; import { getPairedDevice, listDevicePairing } from "../infra/device-pairing.js"; import { NODE_MCP_TOOLS_CALL_COMMAND } from "../infra/node-commands.js"; import { approveNodePairing, listNodePairing, requestNodePairing } from "../infra/node-pairing.js"; @@ -61,6 +62,8 @@ async function connectNodeClient(params: { displayName?: string; platform?: string; deviceFamily?: string; + caps?: string[]; + onHelloOk?: (hello: HelloOk) => void; }) { return await connectGatewayClient({ url: `ws://127.0.0.1:${params.port}`, @@ -73,8 +76,10 @@ async function connectNodeClient(params: { deviceFamily: params.deviceFamily ?? "Mac", mode: GATEWAY_CLIENT_MODES.NODE, scopes: [], + caps: params.caps, commands: params.commands, deviceIdentity: params.deviceIdentity, + onHelloOk: params.onHelloOk, timeoutMessage: "timeout waiting for paired node to connect", }); } @@ -921,6 +926,68 @@ describe("gateway node pairing authorization", () => { }); describeWithGatewayServer("paired node reconnects", (getStarted) => { + test("withholds plugin surface URLs until the node capability is approved", async () => { + // The shared Gateway harness disables Canvas startup; expose its descriptor + // so this handshake test exercises production capability issuance. + const previousSkipCanvasHost = process.env.OPENCLAW_SKIP_CANVAS_HOST; + delete process.env.OPENCLAW_SKIP_CANVAS_HOST; + try { + const pairedNode = await pairDeviceIdentity({ + name: "node-plugin-surface-approval", + role: "node", + scopes: [], + clientId: GATEWAY_CLIENT_NAMES.NODE_HOST, + clientMode: GATEWAY_CLIENT_MODES.NODE, + }); + let pendingHello: HelloOk | undefined; + const pendingClient = await connectNodeClient({ + port: getStarted().port, + deviceIdentity: pairedNode.identity, + caps: ["canvas"], + commands: [], + onHelloOk: (hello) => { + pendingHello = hello; + }, + }); + await pendingClient.stopAndWait(); + + expect(pendingHello?.pluginSurfaceUrls).toBeUndefined(); + const pending = (await listNodePairing()).pending.find( + (entry) => entry.nodeId === pairedNode.identity.deviceId, + ); + expect(pending?.caps).toEqual(["canvas"]); + requireApprovedPairing( + await approveNodePairing(pending?.requestId ?? "", { + callerScopes: ["operator.pairing"], + }), + ); + + let approvedHello: HelloOk | undefined; + const approvedClient = await connectNodeClient({ + port: getStarted().port, + deviceIdentity: pairedNode.identity, + caps: ["canvas"], + commands: [], + onHelloOk: (hello) => { + approvedHello = hello; + }, + }); + try { + expect(approvedHello?.pluginSurfaceUrls?.canvas).toMatch( + /^http:\/\/127\.0\.0\.1:\d+\/__openclaw__\/cap\/[^/]+$/, + ); + } finally { + await approvedClient.stopAndWait(); + } + } finally { + if (previousSkipCanvasHost === undefined) { + delete process.env.OPENCLAW_SKIP_CANVAS_HOST; + } else { + process.env.OPENCLAW_SKIP_CANVAS_HOST = previousSkipCanvasHost; + } + } + }); + test("keeps iOS approval when a transient permission becomes unavailable", async () => { const pairedNode = await pairDeviceIdentity({ name: "ios-transient-permission", diff --git a/src/gateway/server/ws-connection/connect-session.ts b/src/gateway/server/ws-connection/connect-session.ts index ac654fa54664..6b6ae51410fb 100644 --- a/src/gateway/server/ws-connection/connect-session.ts +++ b/src/gateway/server/ws-connection/connect-session.ts @@ -210,8 +210,14 @@ export async function attachAuthenticatedGatewayConnect( capability: string; expiresAtMs: number; }> = []; + const effectiveNodeCaps = role === "node" ? new Set(connectParams.caps ?? []) : undefined; if (pluginSurfaceBaseUrl && !usesLegacyNodeProtocol) { for (const pluginCapabilitySurface of Object.values(pluginNodeCapabilitySurfaces)) { + // Node reconciliation replaces declared caps with the approved surface. + // Issuing a route capability for a withheld cap would bypass node.pair.approve. + if (effectiveNodeCaps && !effectiveNodeCaps.has(pluginCapabilitySurface.surface)) { + continue; + } const capability = mintPluginNodeCapabilityToken(); const expiresAtMs = resolvePluginNodeCapabilityExpiresAtMs(pluginCapabilitySurface); if (expiresAtMs === undefined) { diff --git a/src/gateway/test-helpers.e2e.ts b/src/gateway/test-helpers.e2e.ts index f1e1e626bd95..c7fd9a91ef13 100644 --- a/src/gateway/test-helpers.e2e.ts +++ b/src/gateway/test-helpers.e2e.ts @@ -5,7 +5,7 @@ import os from "node:os"; import path from "node:path"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import { WebSocket } from "ws"; -import { PROTOCOL_VERSION } from "../../packages/gateway-protocol/src/index.js"; +import { type HelloOk, PROTOCOL_VERSION } from "../../packages/gateway-protocol/src/index.js"; import { clearConfigCache, clearRuntimeConfigSnapshot } from "../config/config.js"; import { clearSessionStoreCacheForTest } from "../config/sessions/store-writer-state.js"; import { @@ -50,6 +50,7 @@ export async function connectGatewayClient(params: { instanceId?: string; deviceIdentity?: DeviceIdentity; onEvent?: (evt: { event?: string; payload?: unknown }) => void; + onHelloOk?: (hello: HelloOk) => void; connectChallengeTimeoutMs?: number; requestTimeoutMs?: number; timeoutMs?: number; @@ -113,7 +114,10 @@ export async function connectGatewayClient(params: { instanceId: params.instanceId, deviceIdentity, onEvent: params.onEvent, - onHelloOk: () => stop(undefined, client), + onHelloOk: (hello) => { + params.onHelloOk?.(hello); + stop(undefined, client); + }, onConnectError: (err) => stop(err), onClose: (code, reason) => stop(new Error(`gateway closed during connect (${code}): ${reason}`)),