diff --git a/src/agents/tools/nodes-tool.test.ts b/src/agents/tools/nodes-tool.test.ts index 274e6c4e05d..33f306e2676 100644 --- a/src/agents/tools/nodes-tool.test.ts +++ b/src/agents/tools/nodes-tool.test.ts @@ -320,4 +320,21 @@ describe("createNodesTool screen_record duration guardrails", () => { }), ).rejects.toThrow('invokeCommand "system.run" is reserved for shell execution'); }); + + it("keeps invoke pairing guidance for scope upgrade rejections", async () => { + gatewayMocks.callGatewayTool.mockRejectedValueOnce( + new Error("scope upgrade pending approval (requestId: req-123)"), + ); + const tool = createNodesTool(); + + await expect( + tool.execute("call-1", { + action: "invoke", + node: "macbook", + invokeCommand: "device.status", + }), + ).rejects.toThrow( + "pairing required before node invoke. Approve pairing request req-123 and retry.", + ); + }); }); diff --git a/src/agents/tools/nodes-tool.ts b/src/agents/tools/nodes-tool.ts index d020d233d3b..154ae6bd006 100644 --- a/src/agents/tools/nodes-tool.ts +++ b/src/agents/tools/nodes-tool.ts @@ -2,9 +2,9 @@ import crypto from "node:crypto"; import { Type } from "@sinclair/typebox"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { OperatorScope } from "../../gateway/method-scopes.js"; +import { readConnectPairingRequiredMessage } from "../../gateway/protocol/connect-error-details.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { resolveNodePairApprovalScopes } from "../../infra/node-pairing-authz.js"; -import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; import type { GatewayMessageChannel } from "../../utils/message-channel.js"; import { resolveSessionAgentId } from "../agent-scope.js"; import { resolveImageSanitizationLimits } from "../image-sanitization.js"; @@ -74,20 +74,6 @@ async function resolveNodePairApproveScopes( return resolveApproveScopes(match?.commands); } -function isPairingRequiredMessage(message: string): boolean { - const lower = normalizeLowercaseStringOrEmpty(message); - return lower.includes("pairing required") || lower.includes("not_paired"); -} - -function extractPairingRequestId(message: string): string | null { - const match = message.match(/\(requestId:\s*([^)]+)\)/i); - if (!match) { - return null; - } - const value = (match[1] ?? "").trim(); - return value.length > 0 ? value : null; -} - // Flattened schema: runtime validates per-action requirements. const NodesToolSchema = Type.Object({ action: stringEnum(NODES_TOOL_ACTIONS), @@ -307,8 +293,9 @@ export function createNodesTool(options?: { : "default"; const agentLabel = agentId ?? "unknown"; let message = formatErrorMessage(err); - if (action === "invoke" && isPairingRequiredMessage(message)) { - const requestId = extractPairingRequestId(message); + const pairing = action === "invoke" ? readConnectPairingRequiredMessage(message) : null; + if (pairing) { + const requestId = pairing.requestId ?? null; const approveHint = requestId ? `Approve pairing request ${requestId} and retry.` : "Approve the pending pairing request and retry.";