Files
openclaw/src/agents/tools/nodes-tool.ts

312 lines
12 KiB
TypeScript

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 type { GatewayMessageChannel } from "../../utils/message-channel.js";
import { resolveSessionAgentId } from "../agent-scope.js";
import { resolveImageSanitizationLimits } from "../image-sanitization.js";
import { optionalStringEnum, stringEnum } from "../schema/typebox.js";
import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js";
import { callGatewayTool, readGatewayCallOptions } from "./gateway.js";
import { executeNodeCommandAction, type NodeCommandAction } from "./nodes-tool-commands.js";
import { executeNodeMediaAction, MEDIA_INVOKE_ACTIONS } from "./nodes-tool-media.js";
import { resolveNodeId } from "./nodes-utils.js";
import { isOpenClawOwnerOnlyCoreToolName } from "./owner-only-tools.js";
const NODES_TOOL_ACTIONS = [
"status",
"describe",
"pending",
"approve",
"reject",
"notify",
"camera_snap",
"camera_list",
"camera_clip",
"photos_latest",
"screen_record",
"location_get",
"notifications_list",
"notifications_action",
"device_status",
"device_info",
"device_permissions",
"device_health",
"invoke",
] as const;
const NOTIFY_PRIORITIES = ["passive", "active", "timeSensitive"] as const;
const NOTIFY_DELIVERIES = ["system", "overlay", "auto"] as const;
const NOTIFICATIONS_ACTIONS = ["open", "dismiss", "reply"] as const;
const CAMERA_FACING = ["front", "back", "both"] as const;
const LOCATION_ACCURACY = ["coarse", "balanced", "precise"] as const;
type GatewayCallOptions = ReturnType<typeof readGatewayCallOptions>;
function resolveApproveScopes(commands: unknown): OperatorScope[] {
return resolveNodePairApprovalScopes(commands) as OperatorScope[];
}
async function resolveNodePairApproveScopes(
gatewayOpts: GatewayCallOptions,
requestId: string,
): Promise<OperatorScope[]> {
const pairing: {
pending?: Array<{
requestId?: string;
commands?: unknown;
requiredApproveScopes?: unknown;
}>;
} = await callGatewayTool("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);
}
// Flattened schema: runtime validates per-action requirements.
const NodesToolSchema = Type.Object({
action: stringEnum(NODES_TOOL_ACTIONS),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.Optional(Type.String()),
requestId: Type.Optional(Type.String()),
// notify
title: Type.Optional(Type.String()),
body: Type.Optional(Type.String()),
sound: Type.Optional(Type.String()),
priority: optionalStringEnum(NOTIFY_PRIORITIES),
delivery: optionalStringEnum(NOTIFY_DELIVERIES),
// camera_snap / camera_clip
facing: optionalStringEnum(CAMERA_FACING, {
description: "camera_snap: front/back/both; camera_clip: front/back only.",
}),
maxWidth: Type.Optional(Type.Number()),
quality: Type.Optional(Type.Number()),
delayMs: Type.Optional(Type.Number()),
deviceId: Type.Optional(Type.String()),
limit: Type.Optional(Type.Number()),
duration: Type.Optional(Type.String()),
durationMs: Type.Optional(Type.Number({ maximum: 300_000 })),
includeAudio: Type.Optional(Type.Boolean()),
// screen_record
fps: Type.Optional(Type.Number()),
screenIndex: Type.Optional(Type.Number()),
outPath: Type.Optional(Type.String()),
// location_get
maxAgeMs: Type.Optional(Type.Number()),
locationTimeoutMs: Type.Optional(Type.Number()),
desiredAccuracy: optionalStringEnum(LOCATION_ACCURACY),
// notifications_action
notificationAction: optionalStringEnum(NOTIFICATIONS_ACTIONS),
notificationKey: Type.Optional(Type.String()),
notificationReplyText: Type.Optional(Type.String()),
// invoke
invokeCommand: Type.Optional(Type.String()),
invokeParamsJson: Type.Optional(Type.String()),
invokeTimeoutMs: Type.Optional(Type.Number()),
});
export function createNodesTool(options?: {
agentSessionKey?: string;
agentChannel?: GatewayMessageChannel;
agentAccountId?: string;
currentChannelId?: string;
currentThreadTs?: string | number;
config?: OpenClawConfig;
modelHasVision?: boolean;
allowMediaInvokeCommands?: boolean;
}): AnyAgentTool {
const agentId = resolveSessionAgentId({
sessionKey: options?.agentSessionKey,
config: options?.config,
});
const imageSanitization = resolveImageSanitizationLimits(options?.config);
return {
label: "Nodes",
name: "nodes",
ownerOnly: isOpenClawOwnerOnlyCoreToolName("nodes"),
description:
"Discover and control paired nodes (status/describe/pairing/notify/camera/photos/screen/location/notifications/invoke).",
parameters: NodesToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const action = readStringParam(params, "action", { required: true });
const gatewayOpts = readGatewayCallOptions(params);
try {
switch (action) {
case "status":
return jsonResult(await callGatewayTool("node.list", gatewayOpts, {}));
case "describe": {
const node = readStringParam(params, "node", { required: true });
const nodeId = await resolveNodeId(gatewayOpts, node);
return jsonResult(await callGatewayTool("node.describe", gatewayOpts, { nodeId }));
}
case "pending":
return jsonResult(await callGatewayTool("node.pair.list", gatewayOpts, {}));
case "approve": {
const requestId = readStringParam(params, "requestId", {
required: true,
});
const scopes = await resolveNodePairApproveScopes(gatewayOpts, requestId);
return jsonResult(
await callGatewayTool(
"node.pair.approve",
gatewayOpts,
{
requestId,
},
{ scopes },
),
);
}
case "reject": {
const requestId = readStringParam(params, "requestId", {
required: true,
});
return jsonResult(
await callGatewayTool("node.pair.reject", gatewayOpts, {
requestId,
}),
);
}
case "notify": {
const node = readStringParam(params, "node", { required: true });
const title = typeof params.title === "string" ? params.title : "";
const body = typeof params.body === "string" ? params.body : "";
if (!title.trim() && !body.trim()) {
throw new Error("title or body required");
}
const nodeId = await resolveNodeId(gatewayOpts, node);
await callGatewayTool("node.invoke", gatewayOpts, {
nodeId,
command: "system.notify",
params: {
title: title.trim() || undefined,
body: body.trim() || undefined,
sound: typeof params.sound === "string" ? params.sound : undefined,
priority: typeof params.priority === "string" ? params.priority : undefined,
delivery: typeof params.delivery === "string" ? params.delivery : undefined,
},
idempotencyKey: crypto.randomUUID(),
});
return jsonResult({ ok: true });
}
case "camera_snap": {
return await executeNodeMediaAction({
action,
params,
gatewayOpts,
modelHasVision: options?.modelHasVision,
imageSanitization,
});
}
case "photos_latest": {
return await executeNodeMediaAction({
action,
params,
gatewayOpts,
modelHasVision: options?.modelHasVision,
imageSanitization,
});
}
case "camera_list":
case "notifications_list":
case "device_status":
case "device_info":
case "device_permissions":
case "device_health": {
return await executeNodeCommandAction({
action: action as NodeCommandAction,
input: params,
gatewayOpts,
allowMediaInvokeCommands: options?.allowMediaInvokeCommands,
mediaInvokeActions: MEDIA_INVOKE_ACTIONS,
});
}
case "notifications_action": {
return await executeNodeCommandAction({
action,
input: params,
gatewayOpts,
allowMediaInvokeCommands: options?.allowMediaInvokeCommands,
mediaInvokeActions: MEDIA_INVOKE_ACTIONS,
});
}
case "camera_clip": {
return await executeNodeMediaAction({
action,
params,
gatewayOpts,
modelHasVision: options?.modelHasVision,
imageSanitization,
});
}
case "screen_record": {
return await executeNodeMediaAction({
action,
params,
gatewayOpts,
modelHasVision: options?.modelHasVision,
imageSanitization,
});
}
case "location_get": {
return await executeNodeCommandAction({
action,
input: params,
gatewayOpts,
allowMediaInvokeCommands: options?.allowMediaInvokeCommands,
mediaInvokeActions: MEDIA_INVOKE_ACTIONS,
});
}
case "invoke": {
return await executeNodeCommandAction({
action,
input: params,
gatewayOpts,
allowMediaInvokeCommands: options?.allowMediaInvokeCommands,
mediaInvokeActions: MEDIA_INVOKE_ACTIONS,
});
}
default:
throw new Error(`Unknown action: ${action}`);
}
} catch (err) {
const nodeLabel =
typeof params.node === "string" && params.node.trim() ? params.node.trim() : "auto";
const gatewayLabel =
gatewayOpts.gatewayUrl && gatewayOpts.gatewayUrl.trim()
? gatewayOpts.gatewayUrl.trim()
: "default";
const agentLabel = agentId ?? "unknown";
let message = formatErrorMessage(err);
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.";
message = `pairing required before node invoke. ${approveHint}`;
}
throw new Error(
`agent=${agentLabel} node=${nodeLabel} gateway=${gatewayLabel} action=${action}: ${message}`,
{ cause: err },
);
}
},
};
}