diff --git a/src/agents/openclaw-tools.camera.test.ts b/src/agents/openclaw-tools.camera.test.ts index b389cdc8940..5fc01d07a82 100644 --- a/src/agents/openclaw-tools.camera.test.ts +++ b/src/agents/openclaw-tools.camera.test.ts @@ -1,5 +1,8 @@ -import * as fs from "node:fs/promises"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + readFileUtf8AndCleanup, + stubFetchTextResponse, +} from "../test-utils/camera-url-test-helpers.js"; const { callGateway } = vi.hoisted(() => ({ callGateway: vi.fn(), @@ -206,10 +209,7 @@ describe("nodes camera_snap", () => { }); it("downloads camera_snap url payloads when node remoteIp is available", async () => { - vi.stubGlobal( - "fetch", - vi.fn(async () => new Response("url-image", { status: 200 })), - ); + stubFetchTextResponse("url-image"); setupNodeInvokeMock({ remoteIp: "198.51.100.42", invokePayload: { @@ -230,18 +230,11 @@ describe("nodes camera_snap", () => { const mediaPath = String((result.content?.[0] as { text?: string } | undefined)?.text ?? "") .replace(/^MEDIA:/, "") .trim(); - try { - await expect(fs.readFile(mediaPath, "utf8")).resolves.toBe("url-image"); - } finally { - await fs.unlink(mediaPath).catch(() => {}); - } + await expect(readFileUtf8AndCleanup(mediaPath)).resolves.toBe("url-image"); }); it("rejects camera_snap url payloads when node remoteIp is missing", async () => { - vi.stubGlobal( - "fetch", - vi.fn(async () => new Response("url-image", { status: 200 })), - ); + stubFetchTextResponse("url-image"); setupNodeInvokeMock({ invokePayload: { format: "jpg", @@ -263,10 +256,7 @@ describe("nodes camera_snap", () => { describe("nodes camera_clip", () => { it("downloads camera_clip url payloads when node remoteIp is available", async () => { - vi.stubGlobal( - "fetch", - vi.fn(async () => new Response("url-clip", { status: 200 })), - ); + stubFetchTextResponse("url-clip"); setupNodeInvokeMock({ remoteIp: "198.51.100.42", invokePayload: { @@ -285,18 +275,11 @@ describe("nodes camera_clip", () => { const filePath = String((result.content?.[0] as { text?: string } | undefined)?.text ?? "") .replace(/^FILE:/, "") .trim(); - try { - await expect(fs.readFile(filePath, "utf8")).resolves.toBe("url-clip"); - } finally { - await fs.unlink(filePath).catch(() => {}); - } + await expect(readFileUtf8AndCleanup(filePath)).resolves.toBe("url-clip"); }); it("rejects camera_clip url payloads when node remoteIp is missing", async () => { - vi.stubGlobal( - "fetch", - vi.fn(async () => new Response("url-clip", { status: 200 })), - ); + stubFetchTextResponse("url-clip"); setupNodeInvokeMock({ invokePayload: { format: "mp4", diff --git a/src/agents/tools/nodes-tool.ts b/src/agents/tools/nodes-tool.ts index ede68a607b5..769fe28e0d9 100644 --- a/src/agents/tools/nodes-tool.ts +++ b/src/agents/tools/nodes-tool.ts @@ -7,8 +7,7 @@ import { parseCameraClipPayload, parseCameraSnapPayload, writeCameraClipPayloadToFile, - writeBase64ToFile, - writeUrlToFile, + writeCameraPayloadToFile, } from "../../cli/nodes-camera.js"; import { parseEnvPairs, parseTimeoutMs } from "../../cli/nodes-run.js"; import { @@ -295,16 +294,12 @@ export function createNodesTool(options?: { facing, ext: isJpeg ? "jpg" : "png", }); - if (payload.url) { - if (!resolvedNode.remoteIp) { - throw new Error("camera URL payload requires node remoteIp"); - } - await writeUrlToFile(filePath, payload.url, { - expectedHost: resolvedNode.remoteIp, - }); - } else if (payload.base64) { - await writeBase64ToFile(filePath, payload.base64); - } + await writeCameraPayloadToFile({ + filePath, + payload, + expectedHost: resolvedNode.remoteIp, + invalidPayloadMessage: "invalid camera.snap payload", + }); content.push({ type: "text", text: `MEDIA:${filePath}` }); if (payload.base64) { content.push({ diff --git a/src/agents/tools/nodes-utils.ts b/src/agents/tools/nodes-utils.ts index f10e0aa2852..aaa1f0397f4 100644 --- a/src/agents/tools/nodes-utils.ts +++ b/src/agents/tools/nodes-utils.ts @@ -1,6 +1,6 @@ import { parseNodeList, parsePairingList } from "../../shared/node-list-parse.js"; import type { NodeListNode } from "../../shared/node-list-types.js"; -import { resolveNodeIdFromCandidates } from "../../shared/node-match.js"; +import { resolveNodeFromNodeList, resolveNodeIdFromNodeList } from "../../shared/node-resolve.js"; import { callGatewayTool, type GatewayCallOptions } from "./gateway.js"; export type { NodeListNode }; @@ -142,17 +142,10 @@ export function resolveNodeIdFromList( query?: string, allowDefault = false, ): string { - const q = String(query ?? "").trim(); - if (!q) { - if (allowDefault) { - const picked = pickDefaultNode(nodes); - if (picked) { - return picked.nodeId; - } - } - throw new Error("node required"); - } - return resolveNodeIdFromCandidates(nodes, q); + return resolveNodeIdFromNodeList(nodes, query, { + allowDefault, + pickDefaultNode: pickDefaultNode, + }); } export async function resolveNodeId( @@ -169,6 +162,8 @@ export async function resolveNode( allowDefault = false, ): Promise { const nodes = await loadNodes(opts); - const nodeId = resolveNodeIdFromList(nodes, query, allowDefault); - return nodes.find((node) => node.nodeId === nodeId) ?? { nodeId }; + return resolveNodeFromNodeList(nodes, query, { + allowDefault, + pickDefaultNode: pickDefaultNode, + }); } diff --git a/src/cli/nodes-camera.test.ts b/src/cli/nodes-camera.test.ts index 9e0420541ae..3c8d8199b1f 100644 --- a/src/cli/nodes-camera.test.ts +++ b/src/cli/nodes-camera.test.ts @@ -1,6 +1,10 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { + readFileUtf8AndCleanup, + stubFetchResponse, +} from "../test-utils/camera-url-test-helpers.js"; import { withTempDir } from "../test-utils/temp-dir.js"; import { cameraTempPath, @@ -17,13 +21,6 @@ async function withCameraTempDir(run: (dir: string) => Promise): Promise { - function stubFetchResponse(response: Response) { - vi.stubGlobal( - "fetch", - vi.fn(async () => response), - ); - } - it("parses camera.snap payload", () => { expect( parseCameraSnapPayload({ @@ -88,7 +85,7 @@ describe("nodes camera helpers", () => { id: "clip1", }); expect(out).toBe(path.join(dir, "openclaw-camera-clip-front-clip1.mp4")); - await expect(fs.readFile(out, "utf8")).resolves.toBe("hi"); + await expect(readFileUtf8AndCleanup(out)).resolves.toBe("hi"); }); }); @@ -109,7 +106,7 @@ describe("nodes camera helpers", () => { expectedHost, }); expect(out).toBe(path.join(dir, "openclaw-camera-clip-back-clip2.mp4")); - await expect(fs.readFile(out, "utf8")).resolves.toBe("url-clip"); + await expect(readFileUtf8AndCleanup(out)).resolves.toBe("url-clip"); }); }); @@ -132,7 +129,7 @@ describe("nodes camera helpers", () => { await withCameraTempDir(async (dir) => { const out = path.join(dir, "x.bin"); await writeBase64ToFile(out, "aGk="); - await expect(fs.readFile(out, "utf8")).resolves.toBe("hi"); + await expect(readFileUtf8AndCleanup(out)).resolves.toBe("hi"); }); }); @@ -147,7 +144,7 @@ describe("nodes camera helpers", () => { await writeUrlToFile(out, "https://198.51.100.42/clip.mp4", { expectedHost: "198.51.100.42", }); - await expect(fs.readFile(out, "utf8")).resolves.toBe("url-content"); + await expect(readFileUtf8AndCleanup(out)).resolves.toBe("url-content"); }); }); diff --git a/src/cli/nodes-camera.ts b/src/cli/nodes-camera.ts index f6780881048..c8345937a35 100644 --- a/src/cli/nodes-camera.ts +++ b/src/cli/nodes-camera.ts @@ -182,6 +182,33 @@ export async function writeBase64ToFile(filePath: string, base64: string) { return { path: filePath, bytes: buf.length }; } +export function requireNodeRemoteIp(remoteIp?: string): string { + const normalized = remoteIp?.trim(); + if (!normalized) { + throw new Error("camera URL payload requires node remoteIp"); + } + return normalized; +} + +export async function writeCameraPayloadToFile(params: { + filePath: string; + payload: { url?: string; base64?: string }; + expectedHost?: string; + invalidPayloadMessage?: string; +}) { + if (params.payload.url) { + await writeUrlToFile(params.filePath, params.payload.url, { + expectedHost: requireNodeRemoteIp(params.expectedHost), + }); + return; + } + if (params.payload.base64) { + await writeBase64ToFile(params.filePath, params.payload.base64); + return; + } + throw new Error(params.invalidPayloadMessage ?? "invalid camera payload"); +} + export async function writeCameraClipPayloadToFile(params: { payload: CameraClipPayload; facing: CameraFacing; @@ -196,15 +223,11 @@ export async function writeCameraClipPayloadToFile(params: { tmpDir: params.tmpDir, id: params.id, }); - if (params.payload.url) { - if (!params.expectedHost) { - throw new Error("camera URL payload requires node remoteIp"); - } - await writeUrlToFile(filePath, params.payload.url, { expectedHost: params.expectedHost }); - } else if (params.payload.base64) { - await writeBase64ToFile(filePath, params.payload.base64); - } else { - throw new Error("invalid camera.clip payload"); - } + await writeCameraPayloadToFile({ + filePath, + payload: params.payload, + expectedHost: params.expectedHost, + invalidPayloadMessage: "invalid camera.clip payload", + }); return filePath; } diff --git a/src/cli/nodes-cli/register.camera.ts b/src/cli/nodes-cli/register.camera.ts index 0341acf707e..3bd7d1203dc 100644 --- a/src/cli/nodes-cli/register.camera.ts +++ b/src/cli/nodes-cli/register.camera.ts @@ -7,9 +7,8 @@ import { cameraTempPath, parseCameraClipPayload, parseCameraSnapPayload, - writeBase64ToFile, + writeCameraPayloadToFile, writeCameraClipPayloadToFile, - writeUrlToFile, } from "../nodes-camera.js"; import { parseDurationMs } from "../parse-duration.js"; import { getNodesTheme, runNodesCommand } from "./cli-utils.js"; @@ -166,14 +165,12 @@ export function registerNodesCameraCommands(nodes: Command) { facing, ext: payload.format === "jpeg" ? "jpg" : payload.format, }); - if (payload.url) { - if (!node.remoteIp) { - throw new Error("camera URL payload requires node remoteIp"); - } - await writeUrlToFile(filePath, payload.url, { expectedHost: node.remoteIp }); - } else if (payload.base64) { - await writeBase64ToFile(filePath, payload.base64); - } + await writeCameraPayloadToFile({ + filePath, + payload, + expectedHost: node.remoteIp, + invalidPayloadMessage: "invalid camera.snap payload", + }); results.push({ facing, path: filePath, diff --git a/src/cli/nodes-cli/rpc.ts b/src/cli/nodes-cli/rpc.ts index 8910e36d34b..e0ceebe2ba3 100644 --- a/src/cli/nodes-cli/rpc.ts +++ b/src/cli/nodes-cli/rpc.ts @@ -1,6 +1,6 @@ import type { Command } from "commander"; import { callGateway, randomIdempotencyKey } from "../../gateway/call.js"; -import { resolveNodeIdFromCandidates } from "../../shared/node-match.js"; +import { resolveNodeFromNodeList } from "../../shared/node-resolve.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js"; import { withProgress } from "../progress.js"; import { parseNodeList, parsePairingList } from "./format.js"; @@ -77,11 +77,6 @@ export async function resolveNodeId(opts: NodesRpcOpts, query: string) { } export async function resolveNode(opts: NodesRpcOpts, query: string): Promise { - const q = String(query ?? "").trim(); - if (!q) { - throw new Error("node required"); - } - let nodes: NodeListNode[] = []; try { const res = await callGatewayCli("node.list", opts, {}); @@ -97,6 +92,5 @@ export async function resolveNode(opts: NodesRpcOpts, query: string): Promise node.nodeId === nodeId) ?? { nodeId }; + return resolveNodeFromNodeList(nodes, query); } diff --git a/src/shared/node-resolve.ts b/src/shared/node-resolve.ts new file mode 100644 index 00000000000..6546dab6d62 --- /dev/null +++ b/src/shared/node-resolve.ts @@ -0,0 +1,33 @@ +import { type NodeMatchCandidate, resolveNodeIdFromCandidates } from "./node-match.js"; + +type ResolveNodeFromListOptions = { + allowDefault?: boolean; + pickDefaultNode?: (nodes: TNode[]) => TNode | null; +}; + +export function resolveNodeIdFromNodeList( + nodes: TNode[], + query?: string, + options: ResolveNodeFromListOptions = {}, +): string { + const q = String(query ?? "").trim(); + if (!q) { + if (options.allowDefault === true && options.pickDefaultNode) { + const picked = options.pickDefaultNode(nodes); + if (picked) { + return picked.nodeId; + } + } + throw new Error("node required"); + } + return resolveNodeIdFromCandidates(nodes, q); +} + +export function resolveNodeFromNodeList( + nodes: TNode[], + query?: string, + options: ResolveNodeFromListOptions = {}, +): TNode { + const nodeId = resolveNodeIdFromNodeList(nodes, query, options); + return nodes.find((node) => node.nodeId === nodeId) ?? ({ nodeId } as TNode); +} diff --git a/src/test-utils/camera-url-test-helpers.ts b/src/test-utils/camera-url-test-helpers.ts new file mode 100644 index 00000000000..6cbac483954 --- /dev/null +++ b/src/test-utils/camera-url-test-helpers.ts @@ -0,0 +1,21 @@ +import * as fs from "node:fs/promises"; +import { vi } from "vitest"; + +export function stubFetchResponse(response: Response) { + vi.stubGlobal( + "fetch", + vi.fn(async () => response), + ); +} + +export function stubFetchTextResponse(text: string, init?: ResponseInit) { + stubFetchResponse(new Response(text, { status: 200, ...init })); +} + +export async function readFileUtf8AndCleanup(filePath: string): Promise { + try { + return await fs.readFile(filePath, "utf8"); + } finally { + await fs.unlink(filePath).catch(() => {}); + } +}