mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-30 02:22:25 +00:00
gateway: restrict node pairing approvals (#55951)
* gateway: restrict node pairing approvals * gateway: tighten node pairing scope checks * gateway: harden node pairing reconnects * agents: request elevated node pairing scopes * agents: fix node pairing approval preflight scopes
This commit is contained in:
@@ -174,6 +174,22 @@ describe("gateway tool defaults", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("allows explicit scope overrides for dynamic callers", async () => {
|
||||
callGatewayMock.mockResolvedValueOnce({ ok: true });
|
||||
await callGatewayTool(
|
||||
"node.pair.approve",
|
||||
{},
|
||||
{ requestId: "req-1" },
|
||||
{ scopes: ["operator.admin"] },
|
||||
);
|
||||
expect(callGatewayMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "node.pair.approve",
|
||||
scopes: ["operator.admin"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("default-denies unknown methods by sending no scopes", async () => {
|
||||
callGatewayMock.mockResolvedValueOnce({ ok: true });
|
||||
await callGatewayTool("nonexistent.method", {}, {});
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { loadConfig, resolveGatewayPort } from "../../config/config.js";
|
||||
import { callGateway } from "../../gateway/call.js";
|
||||
import { resolveGatewayCredentialsFromConfig, trimToUndefined } from "../../gateway/credentials.js";
|
||||
import { resolveLeastPrivilegeOperatorScopesForMethod } from "../../gateway/method-scopes.js";
|
||||
import {
|
||||
resolveLeastPrivilegeOperatorScopesForMethod,
|
||||
type OperatorScope,
|
||||
} from "../../gateway/method-scopes.js";
|
||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js";
|
||||
import { readStringParam } from "./common.js";
|
||||
|
||||
@@ -141,10 +144,12 @@ export async function callGatewayTool<T = Record<string, unknown>>(
|
||||
method: string,
|
||||
opts: GatewayCallOptions,
|
||||
params?: unknown,
|
||||
extra?: { expectFinal?: boolean },
|
||||
extra?: { expectFinal?: boolean; scopes?: OperatorScope[] },
|
||||
) {
|
||||
const gateway = resolveGatewayOptions(opts);
|
||||
const scopes = resolveLeastPrivilegeOperatorScopesForMethod(method);
|
||||
const scopes = Array.isArray(extra?.scopes)
|
||||
? extra.scopes
|
||||
: resolveLeastPrivilegeOperatorScopesForMethod(method);
|
||||
return await callGateway<T>({
|
||||
url: gateway.url,
|
||||
token: gateway.token,
|
||||
|
||||
@@ -273,4 +273,116 @@ describe("createNodesTool screen_record duration guardrails", () => {
|
||||
});
|
||||
expect(JSON.stringify(result?.content ?? [])).not.toContain("MEDIA:");
|
||||
});
|
||||
|
||||
it("uses operator.admin to approve exec-capable node pair requests", async () => {
|
||||
gatewayMocks.callGatewayTool.mockImplementation(async (method, _opts, params, extra) => {
|
||||
if (method === "node.pair.list") {
|
||||
return {
|
||||
pending: [
|
||||
{
|
||||
requestId: "req-1",
|
||||
commands: ["system.run"],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (method === "node.pair.approve") {
|
||||
return { ok: true, method, params, extra };
|
||||
}
|
||||
throw new Error(`unexpected method: ${String(method)}`);
|
||||
});
|
||||
const tool = createNodesTool();
|
||||
|
||||
await tool.execute("call-1", {
|
||||
action: "approve",
|
||||
requestId: "req-1",
|
||||
});
|
||||
|
||||
expect(gatewayMocks.callGatewayTool).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"node.pair.list",
|
||||
{},
|
||||
{},
|
||||
{ scopes: ["operator.pairing", "operator.write"] },
|
||||
);
|
||||
expect(gatewayMocks.callGatewayTool).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"node.pair.approve",
|
||||
{},
|
||||
{ requestId: "req-1" },
|
||||
{ scopes: ["operator.admin"] },
|
||||
);
|
||||
});
|
||||
|
||||
it("uses operator.write to approve non-exec node pair requests", async () => {
|
||||
gatewayMocks.callGatewayTool.mockImplementation(async (method, _opts, params, extra) => {
|
||||
if (method === "node.pair.list") {
|
||||
return {
|
||||
pending: [
|
||||
{
|
||||
requestId: "req-1",
|
||||
commands: ["canvas.snapshot"],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (method === "node.pair.approve") {
|
||||
return { ok: true, method, params, extra };
|
||||
}
|
||||
throw new Error(`unexpected method: ${String(method)}`);
|
||||
});
|
||||
const tool = createNodesTool();
|
||||
|
||||
await tool.execute("call-1", {
|
||||
action: "approve",
|
||||
requestId: "req-1",
|
||||
});
|
||||
|
||||
expect(gatewayMocks.callGatewayTool).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"node.pair.list",
|
||||
{},
|
||||
{},
|
||||
{ scopes: ["operator.pairing", "operator.write"] },
|
||||
);
|
||||
expect(gatewayMocks.callGatewayTool).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"node.pair.approve",
|
||||
{},
|
||||
{ requestId: "req-1" },
|
||||
{ scopes: ["operator.write"] },
|
||||
);
|
||||
});
|
||||
|
||||
it("uses operator.write for commandless node pair requests", async () => {
|
||||
gatewayMocks.callGatewayTool.mockImplementation(async (method, _opts, params, extra) => {
|
||||
if (method === "node.pair.list") {
|
||||
return {
|
||||
pending: [
|
||||
{
|
||||
requestId: "req-1",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (method === "node.pair.approve") {
|
||||
return { ok: true, method, params, extra };
|
||||
}
|
||||
throw new Error(`unexpected method: ${String(method)}`);
|
||||
});
|
||||
const tool = createNodesTool();
|
||||
|
||||
await tool.execute("call-1", {
|
||||
action: "approve",
|
||||
requestId: "req-1",
|
||||
});
|
||||
|
||||
expect(gatewayMocks.callGatewayTool).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"node.pair.approve",
|
||||
{},
|
||||
{ requestId: "req-1" },
|
||||
{ scopes: ["operator.write"] },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
} from "../../cli/nodes-screen.js";
|
||||
import { parseDurationMs } from "../../cli/parse-duration.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { OperatorScope } from "../../gateway/method-scopes.js";
|
||||
import { NODE_SYSTEM_RUN_COMMANDS } from "../../infra/node-commands.js";
|
||||
import { parsePreparedSystemRunPayload } from "../../infra/system-run-approval-context.js";
|
||||
import { imageMimeFromFormat } from "../../media/mime.js";
|
||||
import type { GatewayMessageChannel } from "../../utils/message-channel.js";
|
||||
@@ -72,6 +74,33 @@ const NODE_READ_ACTION_COMMANDS = {
|
||||
} as const;
|
||||
type GatewayCallOptions = ReturnType<typeof readGatewayCallOptions>;
|
||||
|
||||
function resolveApproveScopes(commands: unknown): OperatorScope[] {
|
||||
const normalized = Array.isArray(commands)
|
||||
? commands.filter((value): value is string => typeof value === "string")
|
||||
: [];
|
||||
if (
|
||||
normalized.some((command) => NODE_SYSTEM_RUN_COMMANDS.some((allowed) => allowed === command))
|
||||
) {
|
||||
return ["operator.admin"];
|
||||
}
|
||||
if (normalized.length > 0) {
|
||||
return ["operator.write"];
|
||||
}
|
||||
return ["operator.write"];
|
||||
}
|
||||
|
||||
async function resolveNodePairApproveScopes(
|
||||
gatewayOpts: GatewayCallOptions,
|
||||
requestId: string,
|
||||
): Promise<OperatorScope[]> {
|
||||
const pairing = await callGatewayTool<{
|
||||
pending?: Array<{ requestId?: string; commands?: unknown }>;
|
||||
}>("node.pair.list", gatewayOpts, {}, { scopes: ["operator.pairing", "operator.write"] });
|
||||
const pending = Array.isArray(pairing?.pending) ? pairing.pending : [];
|
||||
const match = pending.find((entry) => entry?.requestId === requestId);
|
||||
return resolveApproveScopes(match?.commands);
|
||||
}
|
||||
|
||||
async function invokeNodeCommandPayload(params: {
|
||||
gatewayOpts: GatewayCallOptions;
|
||||
node: string;
|
||||
@@ -199,10 +228,16 @@ export function createNodesTool(options?: {
|
||||
const requestId = readStringParam(params, "requestId", {
|
||||
required: true,
|
||||
});
|
||||
const scopes = await resolveNodePairApproveScopes(gatewayOpts, requestId);
|
||||
return jsonResult(
|
||||
await callGatewayTool("node.pair.approve", gatewayOpts, {
|
||||
requestId,
|
||||
}),
|
||||
await callGatewayTool(
|
||||
"node.pair.approve",
|
||||
gatewayOpts,
|
||||
{
|
||||
requestId,
|
||||
},
|
||||
{ scopes },
|
||||
),
|
||||
);
|
||||
}
|
||||
case "reject": {
|
||||
|
||||
@@ -22,6 +22,7 @@ describe("method scope resolution", () => {
|
||||
["sessions.abort", ["operator.write"]],
|
||||
["sessions.messages.subscribe", ["operator.read"]],
|
||||
["sessions.messages.unsubscribe", ["operator.read"]],
|
||||
["node.pair.approve", ["operator.write"]],
|
||||
["poll", ["operator.write"]],
|
||||
["config.patch", ["operator.admin"]],
|
||||
["wizard.start", ["operator.admin"]],
|
||||
@@ -66,6 +67,10 @@ describe("operator scope authorization", () => {
|
||||
allowed: false,
|
||||
missingScope: "operator.write",
|
||||
});
|
||||
expect(authorizeOperatorScopesForMethod("node.pair.approve", ["operator.pairing"])).toEqual({
|
||||
allowed: false,
|
||||
missingScope: "operator.write",
|
||||
});
|
||||
});
|
||||
|
||||
it("requires approvals scope for approval methods", () => {
|
||||
|
||||
@@ -43,7 +43,6 @@ const METHOD_SCOPE_GROUPS: Record<OperatorScope, readonly string[]> = {
|
||||
[PAIRING_SCOPE]: [
|
||||
"node.pair.request",
|
||||
"node.pair.list",
|
||||
"node.pair.approve",
|
||||
"node.pair.reject",
|
||||
"node.pair.verify",
|
||||
"device.pair.list",
|
||||
@@ -111,6 +110,7 @@ const METHOD_SCOPE_GROUPS: Record<OperatorScope, readonly string[]> = {
|
||||
"tts.setProvider",
|
||||
"voicewake.set",
|
||||
"node.invoke",
|
||||
"node.pair.approve",
|
||||
"chat.send",
|
||||
"chat.abort",
|
||||
"sessions.create",
|
||||
|
||||
@@ -539,7 +539,7 @@ export const nodeHandlers: GatewayRequestHandlers = {
|
||||
respond(true, list, undefined);
|
||||
});
|
||||
},
|
||||
"node.pair.approve": async ({ params, respond, context }) => {
|
||||
"node.pair.approve": async ({ params, respond, context, client }) => {
|
||||
if (!validateNodePairApproveParams(params)) {
|
||||
respondInvalidParams({
|
||||
respond,
|
||||
@@ -549,17 +549,32 @@ export const nodeHandlers: GatewayRequestHandlers = {
|
||||
return;
|
||||
}
|
||||
const { requestId } = params as { requestId: string };
|
||||
// Intentionally fail closed for RPC callers without an explicit scoped session.
|
||||
const callerScopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : [];
|
||||
await respondUnavailableOnThrow(respond, async () => {
|
||||
const approved = await approveNodePairing(requestId);
|
||||
const approved = await approveNodePairing(requestId, { callerScopes });
|
||||
if (!approved) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId"));
|
||||
return;
|
||||
}
|
||||
if ("status" in approved && approved.status === "forbidden") {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, `missing scope: ${approved.missingScope}`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!("node" in approved)) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId"));
|
||||
return;
|
||||
}
|
||||
const approvedNode = approved.node;
|
||||
context.broadcast(
|
||||
"node.pair.resolved",
|
||||
{
|
||||
requestId,
|
||||
nodeId: approved.node.nodeId,
|
||||
nodeId: approvedNode.nodeId,
|
||||
decision: "approved",
|
||||
ts: Date.now(),
|
||||
},
|
||||
|
||||
270
src/gateway/server.node-pairing-authz.test.ts
Normal file
270
src/gateway/server.node-pairing-authz.test.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
import { approveDevicePairing, listDevicePairing } from "../infra/device-pairing.js";
|
||||
import { approveNodePairing, getPairedNode, requestNodePairing } from "../infra/node-pairing.js";
|
||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||
import {
|
||||
issueOperatorToken,
|
||||
loadDeviceIdentity,
|
||||
openTrackedWs,
|
||||
} 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" });
|
||||
|
||||
async function connectNodeClientWithPairing(params: {
|
||||
port: number;
|
||||
deviceIdentity: ReturnType<typeof loadDeviceIdentity>["identity"];
|
||||
commands: string[];
|
||||
}) {
|
||||
const connect = async () =>
|
||||
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: "darwin",
|
||||
mode: GATEWAY_CLIENT_MODES.NODE,
|
||||
commands: params.commands,
|
||||
deviceIdentity: params.deviceIdentity,
|
||||
timeoutMessage: "timeout waiting for paired node to connect",
|
||||
});
|
||||
|
||||
try {
|
||||
return await connect();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (!message.includes("pairing required")) {
|
||||
throw error;
|
||||
}
|
||||
const pairing = await listDevicePairing();
|
||||
for (const pending of pairing.pending) {
|
||||
await approveDevicePairing(pending.requestId);
|
||||
}
|
||||
return await connect();
|
||||
}
|
||||
}
|
||||
|
||||
describe("gateway node pairing authorization", () => {
|
||||
test("requires operator.write before node pairing approvals", async () => {
|
||||
const started = await startServerWithClient("secret");
|
||||
const approver = await issueOperatorToken({
|
||||
name: "node-pair-approve-pairing-only",
|
||||
approvedScopes: ["operator.admin"],
|
||||
tokenScopes: ["operator.pairing"],
|
||||
clientId: GATEWAY_CLIENT_NAMES.TEST,
|
||||
clientMode: GATEWAY_CLIENT_MODES.TEST,
|
||||
});
|
||||
|
||||
let pairingWs: WebSocket | undefined;
|
||||
try {
|
||||
const request = await requestNodePairing({
|
||||
nodeId: "node-approve-target",
|
||||
platform: "darwin",
|
||||
commands: ["system.run"],
|
||||
});
|
||||
|
||||
pairingWs = await openTrackedWs(started.port);
|
||||
await connectOk(pairingWs, {
|
||||
skipDefaultAuth: true,
|
||||
deviceToken: approver.token,
|
||||
deviceIdentityPath: approver.identityPath,
|
||||
scopes: ["operator.pairing"],
|
||||
});
|
||||
|
||||
const approve = await rpcReq(pairingWs, "node.pair.approve", {
|
||||
requestId: request.request.requestId,
|
||||
});
|
||||
expect(approve.ok).toBe(false);
|
||||
expect(approve.error?.message).toBe("missing scope: operator.write");
|
||||
|
||||
await expect(getPairedNode("node-approve-target")).resolves.toBeNull();
|
||||
} finally {
|
||||
pairingWs?.close();
|
||||
started.ws.close();
|
||||
await started.server.close();
|
||||
started.envSnapshot.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects approving exec-capable node commands above the caller session scopes", async () => {
|
||||
const started = await startServerWithClient("secret");
|
||||
const approver = await issueOperatorToken({
|
||||
name: "node-pair-approve-attacker",
|
||||
approvedScopes: ["operator.admin"],
|
||||
tokenScopes: ["operator.write"],
|
||||
clientId: GATEWAY_CLIENT_NAMES.TEST,
|
||||
clientMode: GATEWAY_CLIENT_MODES.TEST,
|
||||
});
|
||||
|
||||
let pairingWs: WebSocket | undefined;
|
||||
try {
|
||||
const request = await requestNodePairing({
|
||||
nodeId: "node-approve-target",
|
||||
platform: "darwin",
|
||||
commands: ["system.run"],
|
||||
});
|
||||
|
||||
pairingWs = await openTrackedWs(started.port);
|
||||
await connectOk(pairingWs, {
|
||||
skipDefaultAuth: true,
|
||||
deviceToken: approver.token,
|
||||
deviceIdentityPath: approver.identityPath,
|
||||
scopes: ["operator.write"],
|
||||
});
|
||||
|
||||
const approve = await rpcReq(pairingWs, "node.pair.approve", {
|
||||
requestId: request.request.requestId,
|
||||
});
|
||||
expect(approve.ok).toBe(false);
|
||||
expect(approve.error?.message).toBe("missing scope: operator.admin");
|
||||
|
||||
await expect(getPairedNode("node-approve-target")).resolves.toBeNull();
|
||||
} finally {
|
||||
pairingWs?.close();
|
||||
started.ws.close();
|
||||
await started.server.close();
|
||||
started.envSnapshot.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("pins connected node commands to the approved pairing record", async () => {
|
||||
const started = await startServerWithClient("secret");
|
||||
const pairedNode = loadDeviceIdentity("node-command-pin");
|
||||
|
||||
let controlWs: WebSocket | undefined;
|
||||
let firstClient: Awaited<ReturnType<typeof connectGatewayClient>> | undefined;
|
||||
let nodeClient: Awaited<ReturnType<typeof connectGatewayClient>> | undefined;
|
||||
try {
|
||||
controlWs = await openTrackedWs(started.port);
|
||||
await connectOk(controlWs, { token: "secret" });
|
||||
|
||||
firstClient = await connectNodeClientWithPairing({
|
||||
port: started.port,
|
||||
deviceIdentity: pairedNode.identity,
|
||||
commands: ["canvas.snapshot"],
|
||||
});
|
||||
await firstClient.stopAndWait();
|
||||
|
||||
const request = await requestNodePairing({
|
||||
nodeId: pairedNode.identity.deviceId,
|
||||
platform: "darwin",
|
||||
commands: ["canvas.snapshot"],
|
||||
});
|
||||
await approveNodePairing(request.request.requestId);
|
||||
|
||||
nodeClient = await connectNodeClientWithPairing({
|
||||
port: started.port,
|
||||
deviceIdentity: pairedNode.identity,
|
||||
commands: ["canvas.snapshot", "system.run"],
|
||||
});
|
||||
|
||||
const deadline = Date.now() + 2_000;
|
||||
let lastNodes: Array<{ nodeId: string; connected?: boolean; commands?: string[] }> = [];
|
||||
while (Date.now() < deadline) {
|
||||
const list = await rpcReq<{
|
||||
nodes?: Array<{ nodeId: string; connected?: boolean; commands?: string[] }>;
|
||||
}>(controlWs, "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(["canvas.snapshot"])
|
||||
) {
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
const connectedNode = lastNodes.find(
|
||||
(entry) => entry.nodeId === pairedNode.identity.deviceId && entry.connected,
|
||||
);
|
||||
expect(connectedNode?.commands?.toSorted(), JSON.stringify(lastNodes)).toEqual([
|
||||
"canvas.snapshot",
|
||||
]);
|
||||
|
||||
const invoke = await rpcReq(controlWs, "node.invoke", {
|
||||
nodeId: pairedNode.identity.deviceId,
|
||||
command: "system.run",
|
||||
params: { command: "echo blocked" },
|
||||
idempotencyKey: "node-command-pin",
|
||||
});
|
||||
expect(invoke.ok).toBe(false);
|
||||
expect(invoke.error?.message ?? "").toContain("node command not allowed");
|
||||
} finally {
|
||||
controlWs?.close();
|
||||
await firstClient?.stopAndWait();
|
||||
await nodeClient?.stopAndWait();
|
||||
started.ws.close();
|
||||
await started.server.close();
|
||||
started.envSnapshot.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("treats paired nodes without stored commands as having no approved commands", async () => {
|
||||
const started = await startServerWithClient("secret");
|
||||
const pairedNode = loadDeviceIdentity("node-command-empty");
|
||||
|
||||
let controlWs: WebSocket | undefined;
|
||||
let firstClient: Awaited<ReturnType<typeof connectGatewayClient>> | undefined;
|
||||
let nodeClient: Awaited<ReturnType<typeof connectGatewayClient>> | undefined;
|
||||
try {
|
||||
controlWs = await openTrackedWs(started.port);
|
||||
await connectOk(controlWs, { token: "secret" });
|
||||
|
||||
firstClient = await connectNodeClientWithPairing({
|
||||
port: started.port,
|
||||
deviceIdentity: pairedNode.identity,
|
||||
commands: ["canvas.snapshot"],
|
||||
});
|
||||
await firstClient.stopAndWait();
|
||||
|
||||
const request = await requestNodePairing({
|
||||
nodeId: pairedNode.identity.deviceId,
|
||||
platform: "darwin",
|
||||
});
|
||||
await approveNodePairing(request.request.requestId);
|
||||
|
||||
nodeClient = await connectNodeClientWithPairing({
|
||||
port: started.port,
|
||||
deviceIdentity: pairedNode.identity,
|
||||
commands: ["canvas.snapshot", "system.run"],
|
||||
});
|
||||
|
||||
const deadline = Date.now() + 2_000;
|
||||
let lastNodes: Array<{ nodeId: string; connected?: boolean; commands?: string[] }> = [];
|
||||
while (Date.now() < deadline) {
|
||||
const list = await rpcReq<{
|
||||
nodes?: Array<{ nodeId: string; connected?: boolean; commands?: string[] }>;
|
||||
}>(controlWs, "node.list", {});
|
||||
lastNodes = list.payload?.nodes ?? [];
|
||||
const node = lastNodes.find(
|
||||
(entry) => entry.nodeId === pairedNode.identity.deviceId && entry.connected,
|
||||
);
|
||||
if ((node?.commands?.length ?? 0) === 0) {
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
const connectedNode = lastNodes.find(
|
||||
(entry) => entry.nodeId === pairedNode.identity.deviceId && entry.connected,
|
||||
);
|
||||
expect(connectedNode?.commands ?? [], JSON.stringify(lastNodes)).toEqual([]);
|
||||
} finally {
|
||||
controlWs?.close();
|
||||
await firstClient?.stopAndWait();
|
||||
await nodeClient?.stopAndWait();
|
||||
started.ws.close();
|
||||
await started.server.close();
|
||||
started.envSnapshot.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
updatePairedDeviceMetadata,
|
||||
verifyDeviceToken,
|
||||
} from "../../../infra/device-pairing.js";
|
||||
import { updatePairedNodeMetadata } from "../../../infra/node-pairing.js";
|
||||
import { getPairedNode, updatePairedNodeMetadata } from "../../../infra/node-pairing.js";
|
||||
import { recordRemoteNodeInfo, refreshRemoteNodeBins } from "../../../infra/skills-remote.js";
|
||||
import { upsertPresence } from "../../../infra/system-presence.js";
|
||||
import { loadVoiceWakeConfig } from "../../../infra/voicewake.js";
|
||||
@@ -966,14 +966,22 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
|
||||
if (role === "node") {
|
||||
const cfg = loadConfig();
|
||||
const nodeId = connectParams.device?.id ?? connectParams.client.id;
|
||||
const pairedNode = await getPairedNode(nodeId);
|
||||
const allowlist = resolveNodeCommandAllowlist(cfg, {
|
||||
platform: connectParams.client.platform,
|
||||
deviceFamily: connectParams.client.deviceFamily,
|
||||
});
|
||||
const declared = Array.isArray(connectParams.commands) ? connectParams.commands : [];
|
||||
const pairedCommands = pairedNode ? new Set(pairedNode.commands ?? []) : null;
|
||||
const filtered = declared
|
||||
.map((cmd) => cmd.trim())
|
||||
.filter((cmd) => cmd.length > 0 && allowlist.has(cmd));
|
||||
.filter(
|
||||
(cmd) =>
|
||||
cmd.length > 0 &&
|
||||
allowlist.has(cmd) &&
|
||||
(pairedCommands === null || pairedCommands.has(cmd)),
|
||||
);
|
||||
connectParams.commands = filtered;
|
||||
}
|
||||
|
||||
|
||||
@@ -79,4 +79,60 @@ describe("node pairing tokens", () => {
|
||||
ok: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("requires operator.admin to approve system.run node commands", async () => {
|
||||
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-node-pairing-"));
|
||||
const request = await requestNodePairing(
|
||||
{
|
||||
nodeId: "node-1",
|
||||
platform: "darwin",
|
||||
commands: ["system.run"],
|
||||
},
|
||||
baseDir,
|
||||
);
|
||||
|
||||
await expect(
|
||||
approveNodePairing(
|
||||
request.request.requestId,
|
||||
{ callerScopes: ["operator.pairing"] },
|
||||
baseDir,
|
||||
),
|
||||
).resolves.toEqual({
|
||||
status: "forbidden",
|
||||
missingScope: "operator.admin",
|
||||
});
|
||||
await expect(getPairedNode("node-1", baseDir)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
test("requires operator.write to approve non-exec node commands", async () => {
|
||||
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-node-pairing-"));
|
||||
const request = await requestNodePairing(
|
||||
{
|
||||
nodeId: "node-1",
|
||||
platform: "darwin",
|
||||
commands: ["canvas.present"],
|
||||
},
|
||||
baseDir,
|
||||
);
|
||||
|
||||
await expect(
|
||||
approveNodePairing(
|
||||
request.request.requestId,
|
||||
{ callerScopes: ["operator.pairing"] },
|
||||
baseDir,
|
||||
),
|
||||
).resolves.toEqual({
|
||||
status: "forbidden",
|
||||
missingScope: "operator.write",
|
||||
});
|
||||
await expect(
|
||||
approveNodePairing(request.request.requestId, { callerScopes: ["operator.write"] }, baseDir),
|
||||
).resolves.toEqual({
|
||||
requestId: request.request.requestId,
|
||||
node: expect.objectContaining({
|
||||
nodeId: "node-1",
|
||||
commands: ["canvas.present"],
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { resolveMissingRequestedScope } from "../shared/operator-scope-compat.js";
|
||||
import { NODE_SYSTEM_RUN_COMMANDS } from "./node-commands.js";
|
||||
import {
|
||||
createAsyncLock,
|
||||
pruneExpiredPending,
|
||||
@@ -51,9 +53,27 @@ type NodePairingStateFile = {
|
||||
};
|
||||
|
||||
const PENDING_TTL_MS = 5 * 60 * 1000;
|
||||
const OPERATOR_ROLE = "operator";
|
||||
const OPERATOR_WRITE_SCOPE = "operator.write";
|
||||
const OPERATOR_ADMIN_SCOPE = "operator.admin";
|
||||
|
||||
const withLock = createAsyncLock();
|
||||
|
||||
function resolveNodeApprovalRequiredScope(pending: NodePairingPendingRequest): string | null {
|
||||
const commands = Array.isArray(pending.commands) ? pending.commands : [];
|
||||
if (commands.some((command) => NODE_SYSTEM_RUN_COMMANDS.some((allowed) => allowed === command))) {
|
||||
return OPERATOR_ADMIN_SCOPE;
|
||||
}
|
||||
if (commands.length > 0) {
|
||||
return OPERATOR_WRITE_SCOPE;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type ApprovedNodePairingResult = { requestId: string; node: NodePairingPairedNode };
|
||||
type ForbiddenNodePairingResult = { status: "forbidden"; missingScope: string };
|
||||
type ApproveNodePairingResult = ApprovedNodePairingResult | ForbiddenNodePairingResult | null;
|
||||
|
||||
async function loadState(baseDir?: string): Promise<NodePairingStateFile> {
|
||||
const { pendingPath, pairedPath } = resolvePairingPaths(baseDir, "nodes");
|
||||
const [pending, paired] = await Promise.all([
|
||||
@@ -146,13 +166,39 @@ export async function requestNodePairing(
|
||||
export async function approveNodePairing(
|
||||
requestId: string,
|
||||
baseDir?: string,
|
||||
): Promise<{ requestId: string; node: NodePairingPairedNode } | null> {
|
||||
): Promise<ApprovedNodePairingResult | null>;
|
||||
export async function approveNodePairing(
|
||||
requestId: string,
|
||||
options: { callerScopes?: readonly string[] },
|
||||
baseDir?: string,
|
||||
): Promise<ApproveNodePairingResult>;
|
||||
export async function approveNodePairing(
|
||||
requestId: string,
|
||||
optionsOrBaseDir?: { callerScopes?: readonly string[] } | string,
|
||||
maybeBaseDir?: string,
|
||||
): Promise<ApproveNodePairingResult> {
|
||||
const options =
|
||||
typeof optionsOrBaseDir === "string" || optionsOrBaseDir === undefined
|
||||
? undefined
|
||||
: optionsOrBaseDir;
|
||||
const baseDir = typeof optionsOrBaseDir === "string" ? optionsOrBaseDir : maybeBaseDir;
|
||||
return await withLock(async () => {
|
||||
const state = await loadState(baseDir);
|
||||
const pending = state.pendingById[requestId];
|
||||
if (!pending) {
|
||||
return null;
|
||||
}
|
||||
const requiredScope = resolveNodeApprovalRequiredScope(pending);
|
||||
if (requiredScope && options !== undefined) {
|
||||
const missingScope = resolveMissingRequestedScope({
|
||||
role: OPERATOR_ROLE,
|
||||
requestedScopes: [requiredScope],
|
||||
allowedScopes: options.callerScopes ?? [],
|
||||
});
|
||||
if (missingScope) {
|
||||
return { status: "forbidden", missingScope };
|
||||
}
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const existing = state.pairedByNodeId[pending.nodeId];
|
||||
|
||||
Reference in New Issue
Block a user