Files
openclaw/src/gateway/server.node-pairing-authz.test.ts
2026-06-19 11:14:22 +08:00

565 lines
18 KiB
TypeScript

// Node pairing authorization tests cover approved node reconnects, visible
// command scopes, and gateway enforcement around node client identity.
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
import { WebSocket } from "ws";
import {
approveNodePairing,
listNodePairing,
requestNodePairing,
} from "../infra/node-pairing.js";
import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { callGateway } from "./call.js";
import {
loadDeviceIdentity,
openTrackedWs,
pairDeviceIdentity,
} from "./device-authz.test-helpers.js";
import { connectGatewayClient } from "./test-helpers.e2e.js";
import {
connectOk,
installGatewayTestHooks,
rpcReq,
startServerWithClient,
} from "./test-helpers.js";
installGatewayTestHooks({ scope: "suite" });
const tempDirs = createSuiteTempRootTracker({ prefix: "openclaw-node-pair-authz-" });
async function makeNodePairingStateDir(): Promise<string> {
return await tempDirs.make("case");
}
async function findPairedNode(nodeId: string, baseDir?: string) {
const pairing = await listNodePairing(baseDir);
return pairing.paired.find((node) => node.nodeId === nodeId) ?? null;
}
function requireApprovedPairing(
result: Awaited<ReturnType<typeof approveNodePairing>>,
): Exclude<typeof result, null | { status: "forbidden"; missingScope: string }> {
if (!result || "status" in result) {
throw new Error(`Expected approved node pairing, got ${JSON.stringify(result)}`);
}
return result;
}
async function connectNodeClient(params: {
port: number;
deviceIdentity: ReturnType<typeof loadDeviceIdentity>["identity"];
commands: string[];
}) {
return await connectGatewayClient({
url: `ws://127.0.0.1:${params.port}`,
token: "secret",
role: "node",
clientName: GATEWAY_CLIENT_NAMES.NODE_HOST,
clientDisplayName: "node-command-pin",
clientVersion: "1.0.0",
platform: "macos",
deviceFamily: "Mac",
mode: GATEWAY_CLIENT_MODES.NODE,
scopes: [],
commands: params.commands,
deviceIdentity: params.deviceIdentity,
timeoutMessage: "timeout waiting for paired node to connect",
});
}
async function expectRePairingRequest(params: {
started: Awaited<ReturnType<typeof startServerWithClient>>;
pairedName: string;
initialCommands?: string[];
reconnectCommands: string[];
approvalScopes: string[];
expectedVisibleCommands: string[];
}) {
const pairedNode = await pairDeviceIdentity({
name: params.pairedName,
role: "node",
scopes: [],
clientId: GATEWAY_CLIENT_NAMES.NODE_HOST,
clientMode: GATEWAY_CLIENT_MODES.NODE,
});
let controlWs: WebSocket | undefined;
let firstClient: Awaited<ReturnType<typeof connectGatewayClient>> | undefined;
let nodeClient: Awaited<ReturnType<typeof connectGatewayClient>> | undefined;
try {
controlWs = await openTrackedWs(params.started.port);
await connectOk(controlWs, { token: "secret" });
if (params.initialCommands) {
firstClient = await connectNodeClient({
port: params.started.port,
deviceIdentity: pairedNode.identity,
commands: params.initialCommands,
});
await firstClient.stopAndWait();
}
const request = await requestNodePairing({
nodeId: pairedNode.identity.deviceId,
platform: "macos",
deviceFamily: "Mac",
...(params.initialCommands ? { commands: params.initialCommands } : {}),
});
await approveNodePairing(request.request.requestId, {
callerScopes: params.approvalScopes,
});
nodeClient = await connectNodeClient({
port: params.started.port,
deviceIdentity: pairedNode.identity,
commands: params.reconnectCommands,
});
const connectedControlWs = controlWs;
type NodeDiagnostics = {
nodeId: string;
connected?: boolean;
commands?: string[];
approvalState?: string;
pendingRequestId?: string;
pendingDeclaredCommands?: string[];
};
let lastNodes: NodeDiagnostics[] = [];
await vi.waitFor(async () => {
const list = await rpcReq<{
nodes?: NodeDiagnostics[];
}>(connectedControlWs, "node.list", {});
lastNodes = list.payload?.nodes ?? [];
const node = lastNodes.find(
(entry) => entry.nodeId === pairedNode.identity.deviceId && entry.connected,
);
if (
JSON.stringify(node?.commands?.toSorted() ?? []) ===
JSON.stringify(params.expectedVisibleCommands)
) {
return;
}
throw new Error(`node commands not visible yet: ${JSON.stringify(lastNodes)}`);
});
expect(
lastNodes
.find((entry) => entry.nodeId === pairedNode.identity.deviceId && entry.connected)
?.commands?.toSorted(),
JSON.stringify(lastNodes),
).toEqual(params.expectedVisibleCommands);
const pairing = await listNodePairing();
const pending = pairing.pending?.find((entry) => entry.nodeId === pairedNode.identity.deviceId);
expect(pending?.nodeId).toBe(pairedNode.identity.deviceId);
expect(pending?.commands).toEqual(params.reconnectCommands);
const listedNode = lastNodes.find((entry) => entry.nodeId === pairedNode.identity.deviceId);
expect(listedNode).toMatchObject({
approvalState: "pending-reapproval",
pendingRequestId: pending?.requestId,
pendingDeclaredCommands: params.reconnectCommands,
commands: params.expectedVisibleCommands,
});
const described = await rpcReq<NodeDiagnostics>(connectedControlWs, "node.describe", {
nodeId: pairedNode.identity.deviceId,
});
expect(described.payload).toMatchObject({
approvalState: "pending-reapproval",
pendingRequestId: pending?.requestId,
pendingDeclaredCommands: params.reconnectCommands,
commands: params.expectedVisibleCommands,
});
} finally {
controlWs?.close();
await firstClient?.stopAndWait();
await nodeClient?.stopAndWait();
}
}
async function expectRpcNodePairingApprovalRejected(params: {
started: Awaited<ReturnType<typeof startServerWithClient>>;
operatorScopes: string[];
operatorName: string;
nodeId: string;
expectedMessage: string;
}): Promise<void> {
const ws = await openTrackedWs(params.started.port);
try {
await connectOk(ws, {
token: "secret",
scopes: params.operatorScopes,
deviceIdentityPath: `${await makeNodePairingStateDir()}/${params.operatorName}.json`,
});
const request = await requestNodePairing({
nodeId: params.nodeId,
platform: "macos",
deviceFamily: "Mac",
commands: ["system.run"],
});
const approve = await rpcReq(ws, "node.pair.approve", {
requestId: request.request.requestId,
});
expect(approve.ok).toBe(false);
expect(approve.error?.message).toContain(params.expectedMessage);
await expect(findPairedNode(params.nodeId)).resolves.toBeNull();
} finally {
ws.close();
}
}
function describeWithGatewayServer(
name: string,
defineTests: (getStarted: () => Awaited<ReturnType<typeof startServerWithClient>>) => void,
): void {
describe(name, () => {
let started: Awaited<ReturnType<typeof startServerWithClient>> | undefined;
beforeAll(async () => {
started = await startServerWithClient("secret");
});
afterAll(async () => {
started?.ws.close();
await started?.server.close();
started?.envSnapshot.restore();
});
defineTests(() => {
if (!started) {
throw new Error("gateway test server was not started");
}
return started;
});
});
}
describe("gateway node pairing authorization", () => {
beforeAll(async () => {
await tempDirs.setup();
});
afterAll(async () => {
await tempDirs.cleanup();
});
describe("approval scopes", () => {
test("rejects node pairing approval without admin scope", async () => {
const baseDir = await makeNodePairingStateDir();
const request = await requestNodePairing(
{
nodeId: "node-approve-reject-admin",
platform: "macos",
deviceFamily: "Mac",
commands: ["system.run"],
},
baseDir,
);
await expect(
approveNodePairing(
request.request.requestId,
{ callerScopes: ["operator.pairing"] },
baseDir,
),
).resolves.toEqual({
status: "forbidden",
missingScope: "operator.admin",
});
await expect(findPairedNode("node-approve-reject-admin", baseDir)).resolves.toBeNull();
});
test("rejects node pairing approval without pairing scope", async () => {
const baseDir = await makeNodePairingStateDir();
const request = await requestNodePairing(
{
nodeId: "node-approve-reject-pairing",
platform: "macos",
deviceFamily: "Mac",
commands: ["system.run"],
},
baseDir,
);
await expect(
approveNodePairing(
request.request.requestId,
{ callerScopes: ["operator.write"] },
baseDir,
),
).resolves.toEqual({
status: "forbidden",
missingScope: "operator.pairing",
});
await expect(findPairedNode("node-approve-reject-pairing", baseDir)).resolves.toBeNull();
});
test("approves commandless node pairing with pairing scope", async () => {
const baseDir = await makeNodePairingStateDir();
const request = await requestNodePairing(
{
nodeId: "node-approve-target",
platform: "macos",
deviceFamily: "Mac",
},
baseDir,
);
const approved = requireApprovedPairing(
await approveNodePairing(
request.request.requestId,
{ callerScopes: ["operator.pairing"] },
baseDir,
),
);
expect(approved.requestId).toBe(request.request.requestId);
expect(approved.node.nodeId).toBe("node-approve-target");
const pairedNode = await findPairedNode("node-approve-target", baseDir);
expect(pairedNode?.nodeId).toBe("node-approve-target");
});
});
describeWithGatewayServer("rpc approval scopes", (getStarted) => {
test("rejects system.run node pairing approval without admin scope through rpc", async () => {
await expectRpcNodePairingApprovalRejected({
started: getStarted(),
operatorScopes: ["operator.pairing"],
operatorName: "operator-pairing",
nodeId: "node-rpc-approve-reject-admin",
expectedMessage: "missing scope: operator.admin",
});
});
test("rejects node pairing approval without pairing scope through rpc", async () => {
await expectRpcNodePairingApprovalRejected({
started: getStarted(),
operatorScopes: ["operator.write"],
operatorName: "operator-write",
nodeId: "node-rpc-approve-reject-pairing",
expectedMessage: "operator.pairing",
});
});
});
describeWithGatewayServer("pending diagnostics scopes", (getStarted) => {
test("shows pending pairing records to direct-local backend shared-auth callers", async () => {
const pendingOnlyNodeId = "node-local-backend-pending";
const pending = await requestNodePairing({
nodeId: pendingOnlyNodeId,
platform: "macos",
commands: ["system.run"],
});
const listed = await callGateway<{
nodes?: Array<{
nodeId: string;
approvalState?: string;
pendingRequestId?: string;
}>;
}>({
config: {
gateway: {
mode: "local",
bind: "loopback",
port: getStarted().port,
auth: { mode: "token", token: "secret" },
},
},
method: "node.list",
scopes: ["operator.read", "operator.pairing"],
requireLocalBackendSharedAuth: true,
timeoutMs: 2_000,
});
expect(listed.nodes).toContainEqual(
expect.objectContaining({
nodeId: pendingOnlyNodeId,
approvalState: "pending-approval",
pendingRequestId: pending.request.requestId,
}),
);
});
test("hides pending pairing records from read-only callers", async () => {
const pairedNodeId = "node-read-only-paired";
const pendingOnlyNodeId = "node-read-only-pending";
const visiblePendingNode = await pairDeviceIdentity({
name: "node-read-only-visible-pending",
role: "node",
scopes: [],
clientId: GATEWAY_CLIENT_NAMES.NODE_HOST,
clientMode: GATEWAY_CLIENT_MODES.NODE,
});
const initial = await requestNodePairing({
nodeId: pairedNodeId,
platform: "macos",
commands: ["screen.snapshot"],
});
await approveNodePairing(initial.request.requestId, {
callerScopes: ["operator.pairing", "operator.write"],
});
await requestNodePairing({
nodeId: pairedNodeId,
platform: "macos",
commands: ["screen.snapshot", "system.run"],
});
await requestNodePairing({
nodeId: pendingOnlyNodeId,
platform: "macos",
commands: ["system.run"],
});
await requestNodePairing({
nodeId: visiblePendingNode.identity.deviceId,
platform: "android",
commands: ["device.status"],
});
const ws = await openTrackedWs(getStarted().port);
try {
await connectOk(ws, {
token: "secret",
scopes: ["operator.read"],
deviceIdentityPath: `${await makeNodePairingStateDir()}/read-only.json`,
});
type NodeDiagnostics = {
nodeId: string;
approvalState?: string;
pendingRequestId?: string;
pendingDeclaredCommands?: string[];
};
const listed = await rpcReq<{ nodes?: NodeDiagnostics[] }>(ws, "node.list", {});
expect(listed.ok).toBe(true);
const nodes = listed.payload?.nodes ?? [];
expect(nodes.some((node) => node.nodeId === pendingOnlyNodeId)).toBe(false);
expect(nodes.find((node) => node.nodeId === pairedNodeId)).toEqual(
expect.objectContaining({
nodeId: pairedNodeId,
approvalState: "pending-reapproval",
}),
);
expect(nodes.find((node) => node.nodeId === pairedNodeId)).not.toHaveProperty(
"pendingRequestId",
);
expect(nodes.find((node) => node.nodeId === pairedNodeId)).not.toHaveProperty(
"pendingDeclaredCommands",
);
expect(nodes.find((node) => node.nodeId === visiblePendingNode.identity.deviceId)).toEqual(
expect.objectContaining({
nodeId: visiblePendingNode.identity.deviceId,
approvalState: "pending-approval",
}),
);
expect(
nodes.find((node) => node.nodeId === visiblePendingNode.identity.deviceId),
).not.toHaveProperty("pendingRequestId");
expect(
nodes.find((node) => node.nodeId === visiblePendingNode.identity.deviceId),
).not.toHaveProperty("pendingDeclaredCommands");
const described = await rpcReq<NodeDiagnostics>(ws, "node.describe", {
nodeId: pairedNodeId,
});
expect(described.payload).toEqual(
expect.objectContaining({
nodeId: pairedNodeId,
approvalState: "pending-reapproval",
}),
);
expect(described.payload).not.toHaveProperty("pendingRequestId");
expect(described.payload).not.toHaveProperty("pendingDeclaredCommands");
const describedVisiblePending = await rpcReq<NodeDiagnostics>(ws, "node.describe", {
nodeId: visiblePendingNode.identity.deviceId,
});
expect(describedVisiblePending.payload).toEqual(
expect.objectContaining({
nodeId: visiblePendingNode.identity.deviceId,
approvalState: "pending-approval",
}),
);
expect(describedVisiblePending.payload).not.toHaveProperty("pendingRequestId");
expect(describedVisiblePending.payload).not.toHaveProperty("pendingDeclaredCommands");
const pendingOnly = await rpcReq(ws, "node.describe", { nodeId: pendingOnlyNodeId });
expect(pendingOnly.ok).toBe(false);
expect(pendingOnly.error?.message).toContain("unknown nodeId");
} finally {
ws.close();
}
});
});
describeWithGatewayServer("paired node reconnects", (getStarted) => {
test("clears stale reapproval when a node returns to its approved surface", async () => {
const pairedNode = await pairDeviceIdentity({
name: "node-reverted-reapproval",
role: "node",
scopes: [],
clientId: GATEWAY_CLIENT_NAMES.NODE_HOST,
clientMode: GATEWAY_CLIENT_MODES.NODE,
});
const initial = await requestNodePairing({
nodeId: pairedNode.identity.deviceId,
platform: "macos",
deviceFamily: "Mac",
commands: ["screen.snapshot"],
});
await approveNodePairing(initial.request.requestId, {
callerScopes: ["operator.pairing", "operator.write"],
});
const upgraded = await connectNodeClient({
port: getStarted().port,
deviceIdentity: pairedNode.identity,
commands: ["screen.snapshot", "system.run"],
});
await upgraded.stopAndWait();
expect(
(await listNodePairing()).pending.some(
(entry) => entry.nodeId === pairedNode.identity.deviceId,
),
).toBe(true);
const reverted = await connectNodeClient({
port: getStarted().port,
deviceIdentity: pairedNode.identity,
commands: ["screen.snapshot"],
});
await reverted.stopAndWait();
await vi.waitFor(async () => {
expect(
(await listNodePairing()).pending.some(
(entry) => entry.nodeId === pairedNode.identity.deviceId,
),
).toBe(false);
});
});
test("requests re-pairing when a paired node reconnects with upgraded commands", async () => {
await expectRePairingRequest({
started: getStarted(),
pairedName: "node-command-pin",
initialCommands: ["screen.snapshot"],
reconnectCommands: ["screen.snapshot", "system.run"],
approvalScopes: ["operator.pairing", "operator.write"],
expectedVisibleCommands: ["screen.snapshot"],
});
});
test("requests re-pairing when a commandless paired node reconnects with system.run", async () => {
await expectRePairingRequest({
started: getStarted(),
pairedName: "node-command-empty",
reconnectCommands: ["screen.snapshot", "system.run"],
approvalScopes: ["operator.pairing"],
expectedVisibleCommands: [],
});
});
});
});