fix(gateway): gate plugin node routes on approved capabilities (#115980)

This commit is contained in:
Pavan Kumar Gondhi
2026-07-31 00:17:15 +05:30
committed by GitHub
parent e92e02ad7d
commit 18b733577f
3 changed files with 79 additions and 2 deletions

View File

@@ -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",

View File

@@ -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) {

View File

@@ -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}`)),