From 01a24c20bfb7feea1e562a84bae52564e76d2007 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 4 Apr 2026 19:22:51 +0900 Subject: [PATCH] refactor: expose node pairing approval scopes --- src/agents/tools/nodes-tool.test.ts | 45 ++++++++++++++++++++++++++-- src/agents/tools/nodes-tool.ts | 17 +++++++++-- src/infra/node-pairing-authz.ts | 22 ++++++++++++++ src/infra/node-pairing.test.ts | 24 +++++++++++++++ src/infra/node-pairing.ts | 46 ++++++++++++----------------- src/shared/node-list-parse.test.ts | 18 +++++++++-- src/shared/node-list-types.ts | 2 ++ 7 files changed, 141 insertions(+), 33 deletions(-) create mode 100644 src/infra/node-pairing-authz.ts diff --git a/src/agents/tools/nodes-tool.test.ts b/src/agents/tools/nodes-tool.test.ts index e8d5868157f..1e0b4473444 100644 --- a/src/agents/tools/nodes-tool.test.ts +++ b/src/agents/tools/nodes-tool.test.ts @@ -218,7 +218,7 @@ describe("createNodesTool screen_record duration guardrails", () => { pending: [ { requestId: "req-1", - commands: ["system.run"], + requiredApproveScopes: ["operator.pairing", "operator.admin"], }, ], }; @@ -258,7 +258,7 @@ describe("createNodesTool screen_record duration guardrails", () => { pending: [ { requestId: "req-1", - commands: ["canvas.snapshot"], + requiredApproveScopes: ["operator.pairing", "operator.write"], }, ], }; @@ -298,6 +298,7 @@ describe("createNodesTool screen_record duration guardrails", () => { pending: [ { requestId: "req-1", + requiredApproveScopes: ["operator.pairing"], }, ], }; @@ -330,6 +331,46 @@ describe("createNodesTool screen_record duration guardrails", () => { ); }); + it("falls back to command inspection when the gateway does not advertise required scopes", 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"] }, + ); + expect(gatewayMocks.callGatewayTool).toHaveBeenNthCalledWith( + 2, + "node.pair.approve", + {}, + { requestId: "req-1" }, + { scopes: ["operator.pairing", "operator.write"] }, + ); + }); + it("blocks invokeCommand system.run so exec stays the only shell path", async () => { const tool = createNodesTool(); diff --git a/src/agents/tools/nodes-tool.ts b/src/agents/tools/nodes-tool.ts index 326cc12d02d..66233c692f6 100644 --- a/src/agents/tools/nodes-tool.ts +++ b/src/agents/tools/nodes-tool.ts @@ -2,7 +2,7 @@ import crypto from "node:crypto"; import { Type } from "@sinclair/typebox"; import type { OpenClawConfig } from "../../config/config.js"; import type { OperatorScope } from "../../gateway/method-scopes.js"; -import { resolveNodePairApprovalScopes } from "../../infra/node-pairing.js"; +import { resolveNodePairApprovalScopes } from "../../infra/node-pairing-authz.js"; import type { GatewayMessageChannel } from "../../utils/message-channel.js"; import { resolveSessionAgentId } from "../agent-scope.js"; import { resolveImageSanitizationLimits } from "../image-sanitization.js"; @@ -51,10 +51,23 @@ async function resolveNodePairApproveScopes( requestId: string, ): Promise { const pairing = await callGatewayTool<{ - pending?: Array<{ requestId?: string; commands?: unknown }>; + pending?: Array<{ + requestId?: string; + commands?: unknown; + requiredApproveScopes?: unknown; + }>; }>("node.pair.list", gatewayOpts, {}, { scopes: ["operator.pairing"] }); const pending = Array.isArray(pairing?.pending) ? pairing.pending : []; const match = pending.find((entry) => entry?.requestId === requestId); + if (Array.isArray(match?.requiredApproveScopes)) { + const scopes = match.requiredApproveScopes.filter( + (scope): scope is OperatorScope => + scope === "operator.pairing" || scope === "operator.write" || scope === "operator.admin", + ); + if (scopes.length > 0) { + return scopes; + } + } return resolveApproveScopes(match?.commands); } diff --git a/src/infra/node-pairing-authz.ts b/src/infra/node-pairing-authz.ts new file mode 100644 index 00000000000..92e1fbe2381 --- /dev/null +++ b/src/infra/node-pairing-authz.ts @@ -0,0 +1,22 @@ +import { NODE_SYSTEM_RUN_COMMANDS } from "./node-commands.js"; + +export type NodeApprovalScope = "operator.pairing" | "operator.write" | "operator.admin"; + +export const OPERATOR_PAIRING_SCOPE: NodeApprovalScope = "operator.pairing"; +export const OPERATOR_WRITE_SCOPE: NodeApprovalScope = "operator.write"; +export const OPERATOR_ADMIN_SCOPE: NodeApprovalScope = "operator.admin"; + +export function resolveNodePairApprovalScopes(commands: unknown): NodeApprovalScope[] { + const normalized = Array.isArray(commands) + ? commands.filter((command): command is string => typeof command === "string") + : []; + if ( + normalized.some((command) => NODE_SYSTEM_RUN_COMMANDS.some((allowed) => allowed === command)) + ) { + return [OPERATOR_PAIRING_SCOPE, OPERATOR_ADMIN_SCOPE]; + } + if (normalized.length > 0) { + return [OPERATOR_PAIRING_SCOPE, OPERATOR_WRITE_SCOPE]; + } + return [OPERATOR_PAIRING_SCOPE]; +} diff --git a/src/infra/node-pairing.test.ts b/src/infra/node-pairing.test.ts index 603f0edbe67..8ecb7c11271 100644 --- a/src/infra/node-pairing.test.ts +++ b/src/infra/node-pairing.test.ts @@ -5,6 +5,7 @@ import { describe, expect, test } from "vitest"; import { approveNodePairing, getPairedNode, + listNodePairing, requestNodePairing, verifyNodeToken, } from "./node-pairing.js"; @@ -218,4 +219,27 @@ describe("node pairing tokens", () => { }), }); }); + + test("lists pending requests with precomputed approval scopes", async () => { + const baseDir = await mkdtemp(join(tmpdir(), "openclaw-node-pairing-")); + await requestNodePairing( + { + nodeId: "node-1", + platform: "darwin", + commands: ["canvas.present"], + }, + baseDir, + ); + + await expect(listNodePairing(baseDir)).resolves.toEqual({ + pending: [ + expect.objectContaining({ + nodeId: "node-1", + commands: ["canvas.present"], + requiredApproveScopes: ["operator.pairing", "operator.write"], + }), + ], + paired: [], + }); + }); }); diff --git a/src/infra/node-pairing.ts b/src/infra/node-pairing.ts index 4b538fff275..b4fac6a11a6 100644 --- a/src/infra/node-pairing.ts +++ b/src/infra/node-pairing.ts @@ -1,6 +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 { type NodeApprovalScope, resolveNodePairApprovalScopes } from "./node-pairing-authz.js"; import { createAsyncLock, pruneExpiredPending, @@ -39,6 +39,10 @@ export type NodePairingPendingRequest = NodePairingRequestInput & { ts: number; }; +export type NodePairingPendingEntry = NodePairingPendingRequest & { + requiredApproveScopes: NodeApprovalScope[]; +}; + export type NodePairingPairedNode = NodeApprovedSurface & { token: string; bins?: string[]; @@ -48,7 +52,7 @@ export type NodePairingPairedNode = NodeApprovedSurface & { }; export type NodePairingList = { - pending: NodePairingPendingRequest[]; + pending: NodePairingPendingEntry[]; paired: NodePairingPairedNode[]; }; @@ -59,9 +63,6 @@ type NodePairingStateFile = { const PENDING_TTL_MS = 5 * 60 * 1000; const OPERATOR_ROLE = "operator"; -const OPERATOR_PAIRING_SCOPE = "operator.pairing"; -const OPERATOR_WRITE_SCOPE = "operator.write"; -const OPERATOR_ADMIN_SCOPE = "operator.admin"; const withLock = createAsyncLock(); @@ -119,26 +120,20 @@ function refreshPendingNodePairingRequest( }; } -export function resolveNodePairApprovalScopes(commands: unknown): string[] { - const normalized = Array.isArray(commands) - ? commands.filter((command): command is string => typeof command === "string") - : []; - if ( - normalized.some((command) => NODE_SYSTEM_RUN_COMMANDS.some((allowed) => allowed === command)) - ) { - return [OPERATOR_PAIRING_SCOPE, OPERATOR_ADMIN_SCOPE]; - } - if (normalized.length > 0) { - return [OPERATOR_PAIRING_SCOPE, OPERATOR_WRITE_SCOPE]; - } - return [OPERATOR_PAIRING_SCOPE]; -} - -function resolveNodeApprovalRequiredScopes(pending: NodePairingPendingRequest): string[] { +function resolveNodeApprovalRequiredScopes( + pending: NodePairingPendingRequest, +): NodeApprovalScope[] { const commands = Array.isArray(pending.commands) ? pending.commands : []; return resolveNodePairApprovalScopes(commands); } +function toPendingNodePairingEntry(pending: NodePairingPendingRequest): NodePairingPendingEntry { + return { + ...pending, + requiredApproveScopes: resolveNodeApprovalRequiredScopes(pending), + }; +} + type ApprovedNodePairingResult = { requestId: string; node: NodePairingPairedNode }; type ForbiddenNodePairingResult = { status: "forbidden"; missingScope: string }; type ApproveNodePairingResult = ApprovedNodePairingResult | ForbiddenNodePairingResult | null; @@ -175,7 +170,9 @@ function newToken() { export async function listNodePairing(baseDir?: string): Promise { const state = await loadState(baseDir); - const pending = Object.values(state.pendingById).toSorted((a, b) => b.ts - a.ts); + const pending = Object.values(state.pendingById) + .toSorted((a, b) => b.ts - a.ts) + .map(toPendingNodePairingEntry); const paired = Object.values(state.pairedByNodeId).toSorted( (a, b) => b.approvedAtMs - a.approvedAtMs, ); @@ -230,11 +227,6 @@ export async function requestNodePairing( }); } -export async function approveNodePairing( - requestId: string, - options: { callerScopes?: readonly string[] }, - baseDir?: string, -): Promise; export async function approveNodePairing( requestId: string, options: { callerScopes?: readonly string[] }, diff --git a/src/shared/node-list-parse.test.ts b/src/shared/node-list-parse.test.ts index 29bc910fcc9..933c9e97b04 100644 --- a/src/shared/node-list-parse.test.ts +++ b/src/shared/node-list-parse.test.ts @@ -12,11 +12,25 @@ describe("shared/node-list-parse", () => { it("parses node.pair.list payloads", () => { expect( parsePairingList({ - pending: [{ requestId: "r1", nodeId: "n1", ts: 1 }], + pending: [ + { + requestId: "r1", + nodeId: "n1", + ts: 1, + requiredApproveScopes: ["operator.pairing"], + }, + ], paired: [{ nodeId: "n1" }], }), ).toEqual({ - pending: [{ requestId: "r1", nodeId: "n1", ts: 1 }], + pending: [ + { + requestId: "r1", + nodeId: "n1", + ts: 1, + requiredApproveScopes: ["operator.pairing"], + }, + ], paired: [{ nodeId: "n1" }], }); expect(parsePairingList({ pending: 1, paired: "x" })).toEqual({ pending: [], paired: [] }); diff --git a/src/shared/node-list-types.ts b/src/shared/node-list-types.ts index 09f33cab238..21216b0fb10 100644 --- a/src/shared/node-list-types.ts +++ b/src/shared/node-list-types.ts @@ -30,6 +30,8 @@ export type PendingRequest = { uiVersion?: string; remoteIp?: string; ts: number; + commands?: string[]; + requiredApproveScopes?: Array<"operator.pairing" | "operator.write" | "operator.admin">; }; export type PairedNode = {