mirror of
https://github.com/farcasclaudiu/openclaw.git
synced 2026-06-29 05:02:04 +03:00
fix(media): strip MEDIA: prefix in loadWebMediaInternal (#13107)
Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: 9d95e6af5aad7fb18f0ab3f941a0043ec18ca604 Co-authored-by: mcaxtr <7562095+mcaxtr@users.noreply.github.com> Co-authored-by: steipete <58493+steipete@users.noreply.github.com> Reviewed-by: @steipete
This commit is contained in:
@@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Sessions/Agents: harden transcript path resolution for mismatched agent context by preserving explicit store roots and adding safe absolute-path fallback to the correct agent sessions directory. (#16288) Thanks @robbyczgw-cla.
|
- Sessions/Agents: harden transcript path resolution for mismatched agent context by preserving explicit store roots and adding safe absolute-path fallback to the correct agent sessions directory. (#16288) Thanks @robbyczgw-cla.
|
||||||
- BlueBubbles: include sender identity in group chat envelopes and pass clean message text to the agent prompt, aligning with iMessage/Signal formatting. (#16210) Thanks @zerone0x.
|
- BlueBubbles: include sender identity in group chat envelopes and pass clean message text to the agent prompt, aligning with iMessage/Signal formatting. (#16210) Thanks @zerone0x.
|
||||||
- WhatsApp: honor per-account `dmPolicy` overrides (account-level settings now take precedence over channel defaults for inbound DMs). (#10082) Thanks @mcaxtr.
|
- WhatsApp: honor per-account `dmPolicy` overrides (account-level settings now take precedence over channel defaults for inbound DMs). (#10082) Thanks @mcaxtr.
|
||||||
|
- Media: accept `MEDIA:`-prefixed paths (lenient whitespace) when loading outbound media to prevent `ENOENT` for tool-returned local media paths. (#13107) Thanks @mcaxtr.
|
||||||
- Security/Node Host: enforce `system.run` rawCommand/argv consistency to prevent allowlist/approval bypass. Thanks @christos-eth.
|
- Security/Node Host: enforce `system.run` rawCommand/argv consistency to prevent allowlist/approval bypass. Thanks @christos-eth.
|
||||||
- Security/Exec approvals: prevent safeBins allowlist bypass via shell expansion (host exec allowlist mode only; not enabled by default). Thanks @christos-eth.
|
- Security/Exec approvals: prevent safeBins allowlist bypass via shell expansion (host exec allowlist mode only; not enabled by default). Thanks @christos-eth.
|
||||||
- Security/Gateway: block `system.execApprovals.*` via `node.invoke` (use `exec.approvals.node.*` instead). Thanks @christos-eth.
|
- Security/Gateway: block `system.execApprovals.*` via `node.invoke` (use `exec.approvals.node.*` instead). Thanks @christos-eth.
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { afterEach, type MockInstance, vi } from "vitest";
|
import { afterEach, vi } from "vitest";
|
||||||
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
||||||
|
|
||||||
|
// Avoid exporting vitest mock types (TS2742 under pnpm + d.ts emit).
|
||||||
|
// oxlint-disable-next-line typescript/no-explicit-any
|
||||||
|
type AnyMock = any;
|
||||||
|
// oxlint-disable-next-line typescript/no-explicit-any
|
||||||
|
type AnyMocks = Record<string, any>;
|
||||||
|
|
||||||
const piEmbeddedMocks = vi.hoisted(() => ({
|
const piEmbeddedMocks = vi.hoisted(() => ({
|
||||||
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
|
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
|
||||||
compactEmbeddedPiSession: vi.fn(),
|
compactEmbeddedPiSession: vi.fn(),
|
||||||
@@ -11,19 +17,19 @@ const piEmbeddedMocks = vi.hoisted(() => ({
|
|||||||
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
|
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export function getAbortEmbeddedPiRunMock(): MockInstance {
|
export function getAbortEmbeddedPiRunMock(): AnyMock {
|
||||||
return piEmbeddedMocks.abortEmbeddedPiRun;
|
return piEmbeddedMocks.abortEmbeddedPiRun;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCompactEmbeddedPiSessionMock(): MockInstance {
|
export function getCompactEmbeddedPiSessionMock(): AnyMock {
|
||||||
return piEmbeddedMocks.compactEmbeddedPiSession;
|
return piEmbeddedMocks.compactEmbeddedPiSession;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRunEmbeddedPiAgentMock(): MockInstance {
|
export function getRunEmbeddedPiAgentMock(): AnyMock {
|
||||||
return piEmbeddedMocks.runEmbeddedPiAgent;
|
return piEmbeddedMocks.runEmbeddedPiAgent;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getQueueEmbeddedPiMessageMock(): MockInstance {
|
export function getQueueEmbeddedPiMessageMock(): AnyMock {
|
||||||
return piEmbeddedMocks.queueEmbeddedPiMessage;
|
return piEmbeddedMocks.queueEmbeddedPiMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +55,7 @@ const providerUsageMocks = vi.hoisted(() => ({
|
|||||||
resolveUsageProviderId: vi.fn((provider: string) => provider.split("/")[0]),
|
resolveUsageProviderId: vi.fn((provider: string) => provider.split("/")[0]),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export function getProviderUsageMocks(): Record<string, MockInstance> {
|
export function getProviderUsageMocks(): AnyMocks {
|
||||||
return providerUsageMocks;
|
return providerUsageMocks;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,7 +83,7 @@ const modelCatalogMocks = vi.hoisted(() => ({
|
|||||||
resetModelCatalogCacheForTest: vi.fn(),
|
resetModelCatalogCacheForTest: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export function getModelCatalogMocks(): Record<string, MockInstance> {
|
export function getModelCatalogMocks(): AnyMocks {
|
||||||
return modelCatalogMocks;
|
return modelCatalogMocks;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +95,7 @@ const webSessionMocks = vi.hoisted(() => ({
|
|||||||
readWebSelfId: vi.fn().mockReturnValue({ e164: "+1999" }),
|
readWebSelfId: vi.fn().mockReturnValue({ e164: "+1999" }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export function getWebSessionMocks(): Record<string, MockInstance> {
|
export function getWebSessionMocks(): AnyMocks {
|
||||||
return webSessionMocks;
|
return webSessionMocks;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { beforeEach, type MockInstance, vi } from "vitest";
|
import { beforeEach, vi } from "vitest";
|
||||||
import type { SessionEntry } from "../../config/sessions.js";
|
import type { SessionEntry } from "../../config/sessions.js";
|
||||||
import type { TypingMode } from "../../config/types.js";
|
import type { TypingMode } from "../../config/types.js";
|
||||||
import type { TemplateContext } from "../templating.js";
|
import type { TemplateContext } from "../templating.js";
|
||||||
@@ -6,11 +6,15 @@ import type { GetReplyOptions } from "../types.js";
|
|||||||
import type { FollowupRun, QueueSettings } from "./queue.js";
|
import type { FollowupRun, QueueSettings } from "./queue.js";
|
||||||
import { createMockTypingController } from "./test-helpers.js";
|
import { createMockTypingController } from "./test-helpers.js";
|
||||||
|
|
||||||
|
// Avoid exporting vitest mock types (TS2742 under pnpm + d.ts emit).
|
||||||
|
// oxlint-disable-next-line typescript/no-explicit-any
|
||||||
|
type AnyMock = any;
|
||||||
|
|
||||||
const state = vi.hoisted(() => ({
|
const state = vi.hoisted(() => ({
|
||||||
runEmbeddedPiAgentMock: vi.fn(),
|
runEmbeddedPiAgentMock: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export function getRunEmbeddedPiAgentMock(): MockInstance {
|
export function getRunEmbeddedPiAgentMock(): AnyMock {
|
||||||
return state.runEmbeddedPiAgentMock;
|
return state.runEmbeddedPiAgentMock;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { type MockInstance, vi } from "vitest";
|
import { vi } from "vitest";
|
||||||
import type { TemplateContext } from "../templating.js";
|
import type { TemplateContext } from "../templating.js";
|
||||||
import type { FollowupRun, QueueSettings } from "./queue.js";
|
import type { FollowupRun, QueueSettings } from "./queue.js";
|
||||||
import { createMockTypingController } from "./test-helpers.js";
|
import { createMockTypingController } from "./test-helpers.js";
|
||||||
|
|
||||||
|
// Avoid exporting vitest mock types (TS2742 under pnpm + d.ts emit).
|
||||||
|
// oxlint-disable-next-line typescript/no-explicit-any
|
||||||
|
type AnyMock = any;
|
||||||
|
|
||||||
type EmbeddedRunParams = {
|
type EmbeddedRunParams = {
|
||||||
prompt?: string;
|
prompt?: string;
|
||||||
extraSystemPrompt?: string;
|
extraSystemPrompt?: string;
|
||||||
@@ -16,11 +20,11 @@ const state = vi.hoisted(() => ({
|
|||||||
runCliAgentMock: vi.fn(),
|
runCliAgentMock: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export function getRunEmbeddedPiAgentMock(): MockInstance {
|
export function getRunEmbeddedPiAgentMock(): AnyMock {
|
||||||
return state.runEmbeddedPiAgentMock;
|
return state.runEmbeddedPiAgentMock;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRunCliAgentMock(): MockInstance {
|
export function getRunCliAgentMock(): AnyMock {
|
||||||
return state.runCliAgentMock;
|
return state.runCliAgentMock;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { beforeEach, type MockInstance, vi } from "vitest";
|
import { beforeEach, vi } from "vitest";
|
||||||
|
|
||||||
type NotificationHandler = (msg: { method: string; params?: unknown }) => void;
|
type NotificationHandler = (msg: { method: string; params?: unknown }) => void;
|
||||||
|
|
||||||
|
// Avoid exporting vitest mock types (TS2742 under pnpm + d.ts emit).
|
||||||
|
// oxlint-disable-next-line typescript/no-explicit-any
|
||||||
|
type AnyMock = any;
|
||||||
|
|
||||||
const state = vi.hoisted(() => ({
|
const state = vi.hoisted(() => ({
|
||||||
requestMock: vi.fn(),
|
requestMock: vi.fn(),
|
||||||
stopMock: vi.fn(),
|
stopMock: vi.fn(),
|
||||||
@@ -15,39 +19,39 @@ const state = vi.hoisted(() => ({
|
|||||||
closeResolve: undefined as (() => void) | undefined,
|
closeResolve: undefined as (() => void) | undefined,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export function getRequestMock(): MockInstance {
|
export function getRequestMock(): AnyMock {
|
||||||
return state.requestMock;
|
return state.requestMock;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getStopMock(): MockInstance {
|
export function getStopMock(): AnyMock {
|
||||||
return state.stopMock;
|
return state.stopMock;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSendMock(): MockInstance {
|
export function getSendMock(): AnyMock {
|
||||||
return state.sendMock;
|
return state.sendMock;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getReplyMock(): MockInstance {
|
export function getReplyMock(): AnyMock {
|
||||||
return state.replyMock;
|
return state.replyMock;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getUpdateLastRouteMock(): MockInstance {
|
export function getUpdateLastRouteMock(): AnyMock {
|
||||||
return state.updateLastRouteMock;
|
return state.updateLastRouteMock;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getReadAllowFromStoreMock(): MockInstance {
|
export function getReadAllowFromStoreMock(): AnyMock {
|
||||||
return state.readAllowFromStoreMock;
|
return state.readAllowFromStoreMock;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getUpsertPairingRequestMock(): MockInstance {
|
export function getUpsertPairingRequestMock(): AnyMock {
|
||||||
return state.upsertPairingRequestMock;
|
return state.upsertPairingRequestMock;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getNotificationHandler() {
|
export function getNotificationHandler(): NotificationHandler | undefined {
|
||||||
return state.notificationHandler;
|
return state.notificationHandler;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCloseResolve() {
|
export function getCloseResolve(): (() => void) | undefined {
|
||||||
return state.closeResolve;
|
return state.closeResolve;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ import {
|
|||||||
|
|
||||||
export { resetBaileysMocks, resetLoadConfigMock, setLoadConfigMock } from "./test-helpers.js";
|
export { resetBaileysMocks, resetLoadConfigMock, setLoadConfigMock } from "./test-helpers.js";
|
||||||
|
|
||||||
|
// Avoid exporting inferred vitest mock types (TS2742 under pnpm + d.ts emit).
|
||||||
|
// oxlint-disable-next-line typescript/no-explicit-any
|
||||||
|
type AnyExport = any;
|
||||||
|
|
||||||
export const TEST_NET_IP = "203.0.113.10";
|
export const TEST_NET_IP = "203.0.113.10";
|
||||||
|
|
||||||
vi.mock("../agents/pi-embedded.js", () => ({
|
vi.mock("../agents/pi-embedded.js", () => ({
|
||||||
@@ -119,7 +123,7 @@ export function installWebAutoReplyUnitTestHooks(opts?: { pinDns?: boolean }) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createWebListenerFactoryCapture() {
|
export function createWebListenerFactoryCapture(): AnyExport {
|
||||||
let capturedOnMessage: ((msg: WebInboundMessage) => Promise<void>) | undefined;
|
let capturedOnMessage: ((msg: WebInboundMessage) => Promise<void>) | undefined;
|
||||||
const listenerFactory = async (opts: {
|
const listenerFactory = async (opts: {
|
||||||
onMessage: (msg: WebInboundMessage) => Promise<void>;
|
onMessage: (msg: WebInboundMessage) => Promise<void>;
|
||||||
@@ -134,7 +138,7 @@ export function createWebListenerFactoryCapture() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createWebInboundDeliverySpies() {
|
export function createWebInboundDeliverySpies(): AnyExport {
|
||||||
return {
|
return {
|
||||||
sendMedia: vi.fn(),
|
sendMedia: vi.fn(),
|
||||||
reply: vi.fn().mockResolvedValue(undefined),
|
reply: vi.fn().mockResolvedValue(undefined),
|
||||||
|
|||||||
@@ -108,6 +108,51 @@ describe("web media loading", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("strips MEDIA: prefix before reading local file", async () => {
|
||||||
|
const buffer = await sharp({
|
||||||
|
create: { width: 2, height: 2, channels: 3, background: "#0000ff" },
|
||||||
|
})
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
const file = await writeTempFile(buffer, ".png");
|
||||||
|
|
||||||
|
const result = await loadWebMedia(`MEDIA:${file}`, 1024 * 1024);
|
||||||
|
|
||||||
|
expect(result.kind).toBe("image");
|
||||||
|
expect(result.buffer.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips MEDIA: prefix with whitespace after colon", async () => {
|
||||||
|
const buffer = await sharp({
|
||||||
|
create: { width: 2, height: 2, channels: 3, background: "#0000ff" },
|
||||||
|
})
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
const file = await writeTempFile(buffer, ".png");
|
||||||
|
|
||||||
|
const result = await loadWebMedia(`MEDIA: ${file}`, 1024 * 1024);
|
||||||
|
|
||||||
|
expect(result.kind).toBe("image");
|
||||||
|
expect(result.buffer.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips MEDIA: prefix with extra whitespace (LLM-friendly)", async () => {
|
||||||
|
const buffer = await sharp({
|
||||||
|
create: { width: 2, height: 2, channels: 3, background: "#0000ff" },
|
||||||
|
})
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
const file = await writeTempFile(buffer, ".png");
|
||||||
|
|
||||||
|
const result = await loadWebMedia(` MEDIA : ${file}`, 1024 * 1024);
|
||||||
|
|
||||||
|
expect(result.kind).toBe("image");
|
||||||
|
expect(result.buffer.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
it("compresses large local images under the provided cap", async () => {
|
it("compresses large local images under the provided cap", async () => {
|
||||||
const { buffer, file } = await createLargeTestJpeg();
|
const { buffer, file } = await createLargeTestJpeg();
|
||||||
|
|
||||||
|
|||||||
@@ -173,6 +173,9 @@ async function loadWebMediaInternal(
|
|||||||
localRoots,
|
localRoots,
|
||||||
readFile: readFileOverride,
|
readFile: readFileOverride,
|
||||||
} = options;
|
} = options;
|
||||||
|
// Strip MEDIA: prefix used by agent tools (e.g. TTS) to tag media paths.
|
||||||
|
// Be lenient: LLM output may add extra whitespace (e.g. " MEDIA : /tmp/x.png").
|
||||||
|
mediaUrl = mediaUrl.replace(/^\s*MEDIA\s*:\s*/i, "");
|
||||||
// Use fileURLToPath for proper handling of file:// URLs (handles file://localhost/path, etc.)
|
// Use fileURLToPath for proper handling of file:// URLs (handles file://localhost/path, etc.)
|
||||||
if (mediaUrl.startsWith("file://")) {
|
if (mediaUrl.startsWith("file://")) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ import fsSync from "node:fs";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, beforeEach, vi } from "vitest";
|
import { afterEach, beforeEach, vi } from "vitest";
|
||||||
import type { MockFn } from "../test-utils/vitest-mock-fn.js";
|
|
||||||
import { resetLogger, setLoggerOverride } from "../logging.js";
|
import { resetLogger, setLoggerOverride } from "../logging.js";
|
||||||
|
|
||||||
|
// Avoid exporting vitest mock types (TS2742 under pnpm + d.ts emit).
|
||||||
|
// oxlint-disable-next-line typescript/no-explicit-any
|
||||||
|
type AnyMockFn = any;
|
||||||
|
|
||||||
export const DEFAULT_ACCOUNT_ID = "default";
|
export const DEFAULT_ACCOUNT_ID = "default";
|
||||||
|
|
||||||
export const DEFAULT_WEB_INBOX_CONFIG = {
|
export const DEFAULT_WEB_INBOX_CONFIG = {
|
||||||
@@ -21,28 +24,24 @@ export const DEFAULT_WEB_INBOX_CONFIG = {
|
|||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const mockLoadConfig: MockFn<() => typeof DEFAULT_WEB_INBOX_CONFIG> = vi
|
export const mockLoadConfig: AnyMockFn = vi.fn().mockReturnValue(DEFAULT_WEB_INBOX_CONFIG);
|
||||||
.fn()
|
|
||||||
.mockReturnValue(DEFAULT_WEB_INBOX_CONFIG);
|
|
||||||
|
|
||||||
export const readAllowFromStoreMock: MockFn<(...args: unknown[]) => Promise<unknown[]>> = vi
|
export const readAllowFromStoreMock: AnyMockFn = vi.fn().mockResolvedValue([]);
|
||||||
|
export const upsertPairingRequestMock: AnyMockFn = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValue([]);
|
.mockResolvedValue({ code: "PAIRCODE", created: true });
|
||||||
export const upsertPairingRequestMock: MockFn<
|
|
||||||
(...args: unknown[]) => Promise<{ code: string; created: boolean }>
|
|
||||||
> = vi.fn().mockResolvedValue({ code: "PAIRCODE", created: true });
|
|
||||||
|
|
||||||
export type MockSock = {
|
export type MockSock = {
|
||||||
ev: EventEmitter;
|
ev: EventEmitter;
|
||||||
ws: { close: MockFn };
|
ws: { close: AnyMockFn };
|
||||||
sendPresenceUpdate: MockFn;
|
sendPresenceUpdate: AnyMockFn;
|
||||||
sendMessage: MockFn;
|
sendMessage: AnyMockFn;
|
||||||
readMessages: MockFn;
|
readMessages: AnyMockFn;
|
||||||
updateMediaMessage: MockFn;
|
updateMediaMessage: AnyMockFn;
|
||||||
logger: Record<string, unknown>;
|
logger: Record<string, unknown>;
|
||||||
signalRepository: {
|
signalRepository: {
|
||||||
lidMapping: {
|
lidMapping: {
|
||||||
getPNForLID: MockFn;
|
getPNForLID: AnyMockFn;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
user: { id: string };
|
user: { id: string };
|
||||||
|
|||||||
Reference in New Issue
Block a user