diff --git a/CHANGELOG.md b/CHANGELOG.md index 94826af85..d0de743d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ Docs: https://docs.openclaw.ai - Gateway/Security hardening: tie loopback-origin dev allowance to actual local socket clients (not Host header claims), add explicit warnings/metrics when `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback` accepts websocket origins, harden safe-regex detection for quantified ambiguous alternation patterns (for example `(a|aa)+`), and bound large regex-evaluation inputs for session-filter and log-redaction paths. - Security/Skills archive extraction: unify tar extraction safety checks across tar.gz and tar.bz2 install flows, enforce tar compressed-size limits, and fail closed if tar.bz2 archives change between preflight and extraction to prevent bypasses of entry-type/size guardrails. Thanks @GCXWLP for reporting. - Security/Web tools SSRF guard: keep DNS pinning for untrusted `web_fetch` and citation-redirect URL checks when proxy env vars are set, and require explicit dangerous opt-in before env-proxy routing can bypass pinned dispatch for trusted/operator-controlled endpoints. Thanks @tdjackey for reporting. +- Security/Nodes camera URL downloads: bind node `camera.snap`/`camera.clip` URL payload downloads to the resolved node host, enforce fail-closed behavior when node `remoteIp` is unavailable, and use SSRF-guarded fetch with redirect host/protocol checks to prevent off-node fetch pivots. Thanks @tdjackey for reporting. - Gateway/Security canonicalization hardening: decode plugin route path variants to canonical fixpoint (with bounded depth), fail closed on canonicalization anomalies, and enforce gateway auth for deeply encoded `/api/channels/*` variants to prevent alternate-path auth bypass through plugin handlers. Thanks @tdjackey for reporting. - Security/Prompt spoofing hardening: stop injecting queued runtime events into user-role prompt text, route them through trusted system-prompt context, and neutralize inbound spoof markers like `[System Message]` and line-leading `System:` in untrusted message content. (#30448) - Auto-reply/followup queue: avoid stale callback reuse across idle-window restarts by caching the followup runner only when a drain actually starts, preserving enqueue ordering after empty-finalize paths. (#31902) Thanks @Lanfei. diff --git a/src/agents/openclaw-tools.camera.test.ts b/src/agents/openclaw-tools.camera.test.ts index c44b5aa2c..b389cdc89 100644 --- a/src/agents/openclaw-tools.camera.test.ts +++ b/src/agents/openclaw-tools.camera.test.ts @@ -1,3 +1,4 @@ +import * as fs from "node:fs/promises"; import { beforeEach, describe, expect, it, vi } from "vitest"; const { callGateway } = vi.hoisted(() => ({ @@ -43,9 +44,15 @@ async function executeNodes(input: Record) { type NodesToolResult = Awaited>; type GatewayMockResult = Record | null | undefined; -function mockNodeList(commands?: string[]) { +function mockNodeList(params?: { commands?: string[]; remoteIp?: string }) { return { - nodes: [{ nodeId: NODE_ID, ...(commands ? { commands } : {}) }], + nodes: [ + { + nodeId: NODE_ID, + ...(params?.commands ? { commands: params.commands } : {}), + ...(params?.remoteIp ? { remoteIp: params.remoteIp } : {}), + }, + ], }; } @@ -66,12 +73,13 @@ function expectFirstTextContains(result: NodesToolResult, expectedText: string) function setupNodeInvokeMock(params: { commands?: string[]; + remoteIp?: string; onInvoke?: (invokeParams: unknown) => GatewayMockResult | Promise; invokePayload?: unknown; }) { callGateway.mockImplementation(async ({ method, params: invokeParams }: GatewayCall) => { if (method === "node.list") { - return mockNodeList(params.commands); + return mockNodeList({ commands: params.commands, remoteIp: params.remoteIp }); } if (method === "node.invoke") { if (params.onInvoke) { @@ -108,7 +116,7 @@ function setupSystemRunGateway(params: { }) { callGateway.mockImplementation(async ({ method, params: gatewayParams }: GatewayCall) => { if (method === "node.list") { - return mockNodeList(["system.run"]); + return mockNodeList({ commands: ["system.run"] }); } if (method === "node.invoke") { const command = (gatewayParams as { command?: string } | undefined)?.command; @@ -126,6 +134,7 @@ function setupSystemRunGateway(params: { beforeEach(() => { callGateway.mockClear(); + vi.unstubAllGlobals(); }); describe("nodes camera_snap", () => { @@ -195,6 +204,116 @@ describe("nodes camera_snap", () => { }), ).rejects.toThrow(/facing=both is not allowed when deviceId is set/i); }); + + it("downloads camera_snap url payloads when node remoteIp is available", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("url-image", { status: 200 })), + ); + setupNodeInvokeMock({ + remoteIp: "198.51.100.42", + invokePayload: { + format: "jpg", + url: "https://198.51.100.42/snap.jpg", + width: 1, + height: 1, + }, + }); + + const result = await executeNodes({ + action: "camera_snap", + node: NODE_ID, + facing: "front", + }); + + expect(result.content?.[0]).toMatchObject({ type: "text" }); + 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(() => {}); + } + }); + + it("rejects camera_snap url payloads when node remoteIp is missing", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("url-image", { status: 200 })), + ); + setupNodeInvokeMock({ + invokePayload: { + format: "jpg", + url: "https://198.51.100.42/snap.jpg", + width: 1, + height: 1, + }, + }); + + await expect( + executeNodes({ + action: "camera_snap", + node: NODE_ID, + facing: "front", + }), + ).rejects.toThrow(/node remoteip/i); + }); +}); + +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 })), + ); + setupNodeInvokeMock({ + remoteIp: "198.51.100.42", + invokePayload: { + format: "mp4", + url: "https://198.51.100.42/clip.mp4", + durationMs: 1200, + hasAudio: false, + }, + }); + + const result = await executeNodes({ + action: "camera_clip", + node: NODE_ID, + facing: "front", + }); + 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(() => {}); + } + }); + + it("rejects camera_clip url payloads when node remoteIp is missing", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("url-clip", { status: 200 })), + ); + setupNodeInvokeMock({ + invokePayload: { + format: "mp4", + url: "https://198.51.100.42/clip.mp4", + durationMs: 1200, + hasAudio: false, + }, + }); + + await expect( + executeNodes({ + action: "camera_clip", + node: NODE_ID, + facing: "front", + }), + ).rejects.toThrow(/node remoteip/i); + }); }); describe("nodes notifications_list", () => { diff --git a/src/agents/tools/nodes-tool.ts b/src/agents/tools/nodes-tool.ts index 9a867e356..ede68a607 100644 --- a/src/agents/tools/nodes-tool.ts +++ b/src/agents/tools/nodes-tool.ts @@ -28,7 +28,7 @@ import { optionalStringEnum, stringEnum } from "../schema/typebox.js"; import { sanitizeToolResultImages } from "../tool-images.js"; import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js"; import { callGatewayTool, readGatewayCallOptions } from "./gateway.js"; -import { listNodes, resolveNodeIdFromList, resolveNodeId } from "./nodes-utils.js"; +import { listNodes, resolveNode, resolveNodeId, resolveNodeIdFromList } from "./nodes-utils.js"; const NODES_TOOL_ACTIONS = [ "status", @@ -230,7 +230,8 @@ export function createNodesTool(options?: { } case "camera_snap": { const node = readStringParam(params, "node", { required: true }); - const nodeId = await resolveNodeId(gatewayOpts, node); + const resolvedNode = await resolveNode(gatewayOpts, node); + const nodeId = resolvedNode.nodeId; const facingRaw = typeof params.facing === "string" ? params.facing.toLowerCase() : "front"; const facings: CameraFacing[] = @@ -295,7 +296,12 @@ export function createNodesTool(options?: { ext: isJpeg ? "jpg" : "png", }); if (payload.url) { - await writeUrlToFile(filePath, 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); } @@ -373,7 +379,8 @@ export function createNodesTool(options?: { } case "camera_clip": { const node = readStringParam(params, "node", { required: true }); - const nodeId = await resolveNodeId(gatewayOpts, node); + const resolvedNode = await resolveNode(gatewayOpts, node); + const nodeId = resolvedNode.nodeId; const facing = typeof params.facing === "string" ? params.facing.toLowerCase() : "front"; if (facing !== "front" && facing !== "back") { @@ -407,6 +414,7 @@ export function createNodesTool(options?: { const filePath = await writeCameraClipPayloadToFile({ payload, facing, + expectedHost: resolvedNode.remoteIp, }); return { content: [{ type: "text", text: `FILE:${filePath}` }], diff --git a/src/agents/tools/nodes-utils.ts b/src/agents/tools/nodes-utils.ts index e4d6e4280..f10e0aa28 100644 --- a/src/agents/tools/nodes-utils.ts +++ b/src/agents/tools/nodes-utils.ts @@ -160,6 +160,15 @@ export async function resolveNodeId( query?: string, allowDefault = false, ) { - const nodes = await loadNodes(opts); - return resolveNodeIdFromList(nodes, query, allowDefault); + return (await resolveNode(opts, query, allowDefault)).nodeId; +} + +export async function resolveNode( + opts: GatewayCallOptions, + query?: string, + allowDefault = false, +): Promise { + const nodes = await loadNodes(opts); + const nodeId = resolveNodeIdFromList(nodes, query, allowDefault); + return nodes.find((node) => node.nodeId === nodeId) ?? { nodeId }; } diff --git a/src/cli/nodes-camera.test.ts b/src/cli/nodes-camera.test.ts index bd78480fd..9e0420541 100644 --- a/src/cli/nodes-camera.test.ts +++ b/src/cli/nodes-camera.test.ts @@ -95,22 +95,39 @@ describe("nodes camera helpers", () => { it("writes camera clip payload from url", async () => { stubFetchResponse(new Response("url-clip", { status: 200 })); await withCameraTempDir(async (dir) => { + const expectedHost = "198.51.100.42"; const out = await writeCameraClipPayloadToFile({ payload: { format: "mp4", - url: "https://example.com/clip.mp4", + url: `https://${expectedHost}/clip.mp4`, durationMs: 200, hasAudio: false, }, facing: "back", tmpDir: dir, id: "clip2", + expectedHost, }); expect(out).toBe(path.join(dir, "openclaw-camera-clip-back-clip2.mp4")); await expect(fs.readFile(out, "utf8")).resolves.toBe("url-clip"); }); }); + it("rejects camera clip url payloads without node remoteIp", async () => { + stubFetchResponse(new Response("url-clip", { status: 200 })); + await expect( + writeCameraClipPayloadToFile({ + payload: { + format: "mp4", + url: "https://198.51.100.42/clip.mp4", + durationMs: 200, + hasAudio: false, + }, + facing: "back", + }), + ).rejects.toThrow(/node remoteip/i); + }); + it("writes base64 to file", async () => { await withCameraTempDir(async (dir) => { const out = path.join(dir, "x.bin"); @@ -127,11 +144,22 @@ describe("nodes camera helpers", () => { stubFetchResponse(new Response("url-content", { status: 200 })); await withCameraTempDir(async (dir) => { const out = path.join(dir, "x.bin"); - await writeUrlToFile(out, "https://example.com/clip.mp4"); + 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"); }); }); + it("rejects url host mismatches", async () => { + stubFetchResponse(new Response("url-content", { status: 200 })); + await expect( + writeUrlToFile("/tmp/ignored", "https://198.51.100.42/clip.mp4", { + expectedHost: "198.51.100.43", + }), + ).rejects.toThrow(/must match node host/i); + }); + it("rejects invalid url payload responses", async () => { const cases: Array<{ name: string; @@ -141,12 +169,12 @@ describe("nodes camera helpers", () => { }> = [ { name: "non-https url", - url: "http://example.com/x.bin", + url: "http://198.51.100.42/x.bin", expectedMessage: /only https/i, }, { name: "oversized content-length", - url: "https://example.com/huge.bin", + url: "https://198.51.100.42/huge.bin", response: new Response("tiny", { status: 200, headers: { "content-length": String(999_999_999) }, @@ -155,13 +183,13 @@ describe("nodes camera helpers", () => { }, { name: "non-ok status", - url: "https://example.com/down.bin", + url: "https://198.51.100.42/down.bin", response: new Response("down", { status: 503, statusText: "Service Unavailable" }), expectedMessage: /503/i, }, { name: "empty response body", - url: "https://example.com/empty.bin", + url: "https://198.51.100.42/empty.bin", response: new Response(null, { status: 200 }), expectedMessage: /empty response body/i, }, @@ -171,9 +199,10 @@ describe("nodes camera helpers", () => { if (testCase.response) { stubFetchResponse(testCase.response); } - await expect(writeUrlToFile("/tmp/ignored", testCase.url), testCase.name).rejects.toThrow( - testCase.expectedMessage, - ); + await expect( + writeUrlToFile("/tmp/ignored", testCase.url, { expectedHost: "198.51.100.42" }), + testCase.name, + ).rejects.toThrow(testCase.expectedMessage); } }); @@ -188,9 +217,9 @@ describe("nodes camera helpers", () => { await withCameraTempDir(async (dir) => { const out = path.join(dir, "broken.bin"); - await expect(writeUrlToFile(out, "https://example.com/broken.bin")).rejects.toThrow( - /stream exploded/i, - ); + await expect( + writeUrlToFile(out, "https://198.51.100.42/broken.bin", { expectedHost: "198.51.100.42" }), + ).rejects.toThrow(/stream exploded/i); await expect(fs.stat(out)).rejects.toThrow(); }); }); diff --git a/src/cli/nodes-camera.ts b/src/cli/nodes-camera.ts index 55a40d7cc..f67808810 100644 --- a/src/cli/nodes-camera.ts +++ b/src/cli/nodes-camera.ts @@ -1,5 +1,7 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; +import { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js"; +import { normalizeHostname } from "../infra/net/hostname.js"; import { resolveCliName } from "./cli-name.js"; import { asBoolean, @@ -72,64 +74,103 @@ export function cameraTempPath(opts: { return path.join(tmpDir, `${cliName}-camera-${opts.kind}${facingPart}-${id}${ext}`); } -export async function writeUrlToFile(filePath: string, url: string) { +export async function writeUrlToFile( + filePath: string, + url: string, + opts: { expectedHost: string }, +) { const parsed = new URL(url); if (parsed.protocol !== "https:") { throw new Error(`writeUrlToFile: only https URLs are allowed, got ${parsed.protocol}`); } - - const res = await fetch(url); - if (!res.ok) { - throw new Error(`failed to download ${url}: ${res.status} ${res.statusText}`); + const expectedHost = normalizeHostname(opts.expectedHost); + if (!expectedHost) { + throw new Error("writeUrlToFile: expectedHost is required"); } - - const contentLengthRaw = res.headers.get("content-length"); - const contentLength = contentLengthRaw ? Number.parseInt(contentLengthRaw, 10) : undefined; - if ( - typeof contentLength === "number" && - Number.isFinite(contentLength) && - contentLength > MAX_CAMERA_URL_DOWNLOAD_BYTES - ) { + if (normalizeHostname(parsed.hostname) !== expectedHost) { throw new Error( - `writeUrlToFile: content-length ${contentLength} exceeds max ${MAX_CAMERA_URL_DOWNLOAD_BYTES}`, + `writeUrlToFile: url host ${parsed.hostname} must match node host ${opts.expectedHost}`, ); } - const body = res.body; - if (!body) { - throw new Error(`failed to download ${url}: empty response body`); - } + const policy = { + allowPrivateNetwork: true, + allowedHostnames: [expectedHost], + hostnameAllowlist: [expectedHost], + }; - const fileHandle = await fs.open(filePath, "w"); + let release: () => Promise = async () => {}; let bytes = 0; - let thrown: unknown; try { - const reader = body.getReader(); - while (true) { - const { done, value } = await reader.read(); - if (done) { - break; - } - if (!value || value.byteLength === 0) { - continue; - } - bytes += value.byteLength; - if (bytes > MAX_CAMERA_URL_DOWNLOAD_BYTES) { - throw new Error( - `writeUrlToFile: downloaded ${bytes} bytes, exceeds max ${MAX_CAMERA_URL_DOWNLOAD_BYTES}`, - ); - } - await fileHandle.write(value); + const guarded = await fetchWithSsrFGuard({ + url, + auditContext: "writeUrlToFile", + policy, + }); + release = guarded.release; + const finalUrl = new URL(guarded.finalUrl); + if (finalUrl.protocol !== "https:") { + throw new Error(`writeUrlToFile: redirect resolved to non-https URL ${guarded.finalUrl}`); + } + if (normalizeHostname(finalUrl.hostname) !== expectedHost) { + throw new Error( + `writeUrlToFile: redirect host ${finalUrl.hostname} must match node host ${opts.expectedHost}`, + ); + } + const res = guarded.response; + if (!res.ok) { + throw new Error(`failed to download ${url}: ${res.status} ${res.statusText}`); } - } catch (err) { - thrown = err; - } finally { - await fileHandle.close(); - } - if (thrown) { - await fs.unlink(filePath).catch(() => {}); - throw thrown; + const contentLengthRaw = res.headers.get("content-length"); + const contentLength = contentLengthRaw ? Number.parseInt(contentLengthRaw, 10) : undefined; + if ( + typeof contentLength === "number" && + Number.isFinite(contentLength) && + contentLength > MAX_CAMERA_URL_DOWNLOAD_BYTES + ) { + throw new Error( + `writeUrlToFile: content-length ${contentLength} exceeds max ${MAX_CAMERA_URL_DOWNLOAD_BYTES}`, + ); + } + + const body = res.body; + if (!body) { + throw new Error(`failed to download ${url}: empty response body`); + } + + const fileHandle = await fs.open(filePath, "w"); + let thrown: unknown; + try { + const reader = body.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + if (!value || value.byteLength === 0) { + continue; + } + bytes += value.byteLength; + if (bytes > MAX_CAMERA_URL_DOWNLOAD_BYTES) { + throw new Error( + `writeUrlToFile: downloaded ${bytes} bytes, exceeds max ${MAX_CAMERA_URL_DOWNLOAD_BYTES}`, + ); + } + await fileHandle.write(value); + } + } catch (err) { + thrown = err; + } finally { + await fileHandle.close(); + } + + if (thrown) { + await fs.unlink(filePath).catch(() => {}); + throw thrown; + } + } finally { + await release(); } return { path: filePath, bytes }; @@ -146,6 +187,7 @@ export async function writeCameraClipPayloadToFile(params: { facing: CameraFacing; tmpDir?: string; id?: string; + expectedHost?: string; }): Promise { const filePath = cameraTempPath({ kind: "clip", @@ -155,7 +197,10 @@ export async function writeCameraClipPayloadToFile(params: { id: params.id, }); if (params.payload.url) { - await writeUrlToFile(filePath, 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 { diff --git a/src/cli/nodes-cli/register.camera.ts b/src/cli/nodes-cli/register.camera.ts index e86ab8546..0341acf70 100644 --- a/src/cli/nodes-cli/register.camera.ts +++ b/src/cli/nodes-cli/register.camera.ts @@ -13,7 +13,13 @@ import { } from "../nodes-camera.js"; import { parseDurationMs } from "../parse-duration.js"; import { getNodesTheme, runNodesCommand } from "./cli-utils.js"; -import { buildNodeInvokeParams, callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js"; +import { + buildNodeInvokeParams, + callGatewayCli, + nodesCallOpts, + resolveNode, + resolveNodeId, +} from "./rpc.js"; import type { NodesRpcOpts } from "./types.js"; const parseFacing = (value: string): CameraFacing => { @@ -102,7 +108,8 @@ export function registerNodesCameraCommands(nodes: Command) { .option("--invoke-timeout ", "Node invoke timeout in ms (default 20000)", "20000") .action(async (opts: NodesRpcOpts) => { await runNodesCommand("camera snap", async () => { - const nodeId = await resolveNodeId(opts, String(opts.node ?? "")); + const node = await resolveNode(opts, String(opts.node ?? "")); + const nodeId = node.nodeId; const facingOpt = String(opts.facing ?? "both") .trim() .toLowerCase(); @@ -160,7 +167,10 @@ export function registerNodesCameraCommands(nodes: Command) { ext: payload.format === "jpeg" ? "jpg" : payload.format, }); if (payload.url) { - await writeUrlToFile(filePath, 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); } @@ -198,7 +208,8 @@ export function registerNodesCameraCommands(nodes: Command) { .option("--invoke-timeout ", "Node invoke timeout in ms (default 90000)", "90000") .action(async (opts: NodesRpcOpts & { audio?: boolean }) => { await runNodesCommand("camera clip", async () => { - const nodeId = await resolveNodeId(opts, String(opts.node ?? "")); + const node = await resolveNode(opts, String(opts.node ?? "")); + const nodeId = node.nodeId; const facing = parseFacing(String(opts.facing ?? "front")); const durationMs = parseDurationMs(String(opts.duration ?? "3000")); const includeAudio = opts.audio !== false; @@ -226,6 +237,7 @@ export function registerNodesCameraCommands(nodes: Command) { const filePath = await writeCameraClipPayloadToFile({ payload, facing, + expectedHost: node.remoteIp, }); if (opts.json) { diff --git a/src/cli/nodes-cli/rpc.ts b/src/cli/nodes-cli/rpc.ts index 977193547..8910e36d3 100644 --- a/src/cli/nodes-cli/rpc.ts +++ b/src/cli/nodes-cli/rpc.ts @@ -73,6 +73,10 @@ export function unauthorizedHintForMessage(message: string): string | null { } export async function resolveNodeId(opts: NodesRpcOpts, query: string) { + return (await resolveNode(opts, query)).nodeId; +} + +export async function resolveNode(opts: NodesRpcOpts, query: string): Promise { const q = String(query ?? "").trim(); if (!q) { throw new Error("node required"); @@ -93,5 +97,6 @@ export async function resolveNodeId(opts: NodesRpcOpts, query: string) { remoteIp: n.remoteIp, })); } - return resolveNodeIdFromCandidates(nodes, q); + const nodeId = resolveNodeIdFromCandidates(nodes, q); + return nodes.find((node) => node.nodeId === nodeId) ?? { nodeId }; } diff --git a/src/cli/program.nodes-media.e2e.test.ts b/src/cli/program.nodes-media.e2e.test.ts index b47a931d4..bee3d95b0 100644 --- a/src/cli/program.nodes-media.e2e.test.ts +++ b/src/cli/program.nodes-media.e2e.test.ts @@ -325,7 +325,7 @@ describe("cli program (nodes media)", () => { command: "camera.snap" as const, payload: { format: "jpg", - url: "https://example.com/photo.jpg", + url: `https://${IOS_NODE.remoteIp}/photo.jpg`, width: 640, height: 480, }, @@ -337,7 +337,7 @@ describe("cli program (nodes media)", () => { command: "camera.clip" as const, payload: { format: "mp4", - url: "https://example.com/clip.mp4", + url: `https://${IOS_NODE.remoteIp}/clip.mp4`, durationMs: 5000, hasAudio: true, },