mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-03 02:21:33 +00:00
fix(gateway): gate plugin node routes on approved capabilities (#115980)
This commit is contained in:
committed by
GitHub
parent
e92e02ad7d
commit
18b733577f
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}`)),
|
||||
|
||||
Reference in New Issue
Block a user