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:
Jacob Tomlinson
2026-03-27 12:14:16 -07:00
committed by GitHub
parent 68ceaf7a5f
commit 4d7cc6bb4f
11 changed files with 581 additions and 13 deletions

View File

@@ -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", {}, {});

View File

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

View File

@@ -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"] },
);
});
});

View File

@@ -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": {

View File

@@ -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", () => {

View File

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

View File

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

View 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();
}
});
});

View File

@@ -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;
}

View File

@@ -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"],
}),
});
});
});

View File

@@ -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];