fix(media): strip MEDIA: prefix in loadWebMediaInternal (#13107)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 9d95e6af5a
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:
Marcus Castro
2026-02-14 17:41:26 -03:00
committed by GitHub
parent 1bde33c0bc
commit 07850e8a93
9 changed files with 110 additions and 40 deletions

View File

@@ -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.
- 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.
- 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/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.

View File

@@ -1,7 +1,13 @@
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";
// 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(() => ({
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
compactEmbeddedPiSession: vi.fn(),
@@ -11,19 +17,19 @@ const piEmbeddedMocks = vi.hoisted(() => ({
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
}));
export function getAbortEmbeddedPiRunMock(): MockInstance {
export function getAbortEmbeddedPiRunMock(): AnyMock {
return piEmbeddedMocks.abortEmbeddedPiRun;
}
export function getCompactEmbeddedPiSessionMock(): MockInstance {
export function getCompactEmbeddedPiSessionMock(): AnyMock {
return piEmbeddedMocks.compactEmbeddedPiSession;
}
export function getRunEmbeddedPiAgentMock(): MockInstance {
export function getRunEmbeddedPiAgentMock(): AnyMock {
return piEmbeddedMocks.runEmbeddedPiAgent;
}
export function getQueueEmbeddedPiMessageMock(): MockInstance {
export function getQueueEmbeddedPiMessageMock(): AnyMock {
return piEmbeddedMocks.queueEmbeddedPiMessage;
}
@@ -49,7 +55,7 @@ const providerUsageMocks = vi.hoisted(() => ({
resolveUsageProviderId: vi.fn((provider: string) => provider.split("/")[0]),
}));
export function getProviderUsageMocks(): Record<string, MockInstance> {
export function getProviderUsageMocks(): AnyMocks {
return providerUsageMocks;
}
@@ -77,7 +83,7 @@ const modelCatalogMocks = vi.hoisted(() => ({
resetModelCatalogCacheForTest: vi.fn(),
}));
export function getModelCatalogMocks(): Record<string, MockInstance> {
export function getModelCatalogMocks(): AnyMocks {
return modelCatalogMocks;
}
@@ -89,7 +95,7 @@ const webSessionMocks = vi.hoisted(() => ({
readWebSelfId: vi.fn().mockReturnValue({ e164: "+1999" }),
}));
export function getWebSessionMocks(): Record<string, MockInstance> {
export function getWebSessionMocks(): AnyMocks {
return webSessionMocks;
}

View File

@@ -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 { TypingMode } from "../../config/types.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 { 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(() => ({
runEmbeddedPiAgentMock: vi.fn(),
}));
export function getRunEmbeddedPiAgentMock(): MockInstance {
export function getRunEmbeddedPiAgentMock(): AnyMock {
return state.runEmbeddedPiAgentMock;
}

View File

@@ -1,10 +1,14 @@
import fs from "node:fs/promises";
import path from "node:path";
import { type MockInstance, vi } from "vitest";
import { vi } from "vitest";
import type { TemplateContext } from "../templating.js";
import type { FollowupRun, QueueSettings } from "./queue.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 = {
prompt?: string;
extraSystemPrompt?: string;
@@ -16,11 +20,11 @@ const state = vi.hoisted(() => ({
runCliAgentMock: vi.fn(),
}));
export function getRunEmbeddedPiAgentMock(): MockInstance {
export function getRunEmbeddedPiAgentMock(): AnyMock {
return state.runEmbeddedPiAgentMock;
}
export function getRunCliAgentMock(): MockInstance {
export function getRunCliAgentMock(): AnyMock {
return state.runCliAgentMock;
}

View File

@@ -1,7 +1,11 @@
import { beforeEach, type MockInstance, vi } from "vitest";
import { beforeEach, vi } from "vitest";
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(() => ({
requestMock: vi.fn(),
stopMock: vi.fn(),
@@ -15,39 +19,39 @@ const state = vi.hoisted(() => ({
closeResolve: undefined as (() => void) | undefined,
}));
export function getRequestMock(): MockInstance {
export function getRequestMock(): AnyMock {
return state.requestMock;
}
export function getStopMock(): MockInstance {
export function getStopMock(): AnyMock {
return state.stopMock;
}
export function getSendMock(): MockInstance {
export function getSendMock(): AnyMock {
return state.sendMock;
}
export function getReplyMock(): MockInstance {
export function getReplyMock(): AnyMock {
return state.replyMock;
}
export function getUpdateLastRouteMock(): MockInstance {
export function getUpdateLastRouteMock(): AnyMock {
return state.updateLastRouteMock;
}
export function getReadAllowFromStoreMock(): MockInstance {
export function getReadAllowFromStoreMock(): AnyMock {
return state.readAllowFromStoreMock;
}
export function getUpsertPairingRequestMock(): MockInstance {
export function getUpsertPairingRequestMock(): AnyMock {
return state.upsertPairingRequestMock;
}
export function getNotificationHandler() {
export function getNotificationHandler(): NotificationHandler | undefined {
return state.notificationHandler;
}
export function getCloseResolve() {
export function getCloseResolve(): (() => void) | undefined {
return state.closeResolve;
}

View File

@@ -14,6 +14,10 @@ import {
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";
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;
const listenerFactory = async (opts: {
onMessage: (msg: WebInboundMessage) => Promise<void>;
@@ -134,7 +138,7 @@ export function createWebListenerFactoryCapture() {
};
}
export function createWebInboundDeliverySpies() {
export function createWebInboundDeliverySpies(): AnyExport {
return {
sendMedia: vi.fn(),
reply: vi.fn().mockResolvedValue(undefined),

View File

@@ -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 () => {
const { buffer, file } = await createLargeTestJpeg();

View File

@@ -173,6 +173,9 @@ async function loadWebMediaInternal(
localRoots,
readFile: readFileOverride,
} = 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.)
if (mediaUrl.startsWith("file://")) {
try {

View File

@@ -3,9 +3,12 @@ import fsSync from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, vi } from "vitest";
import type { MockFn } from "../test-utils/vitest-mock-fn.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_WEB_INBOX_CONFIG = {
@@ -21,28 +24,24 @@ export const DEFAULT_WEB_INBOX_CONFIG = {
},
} as const;
export const mockLoadConfig: MockFn<() => typeof DEFAULT_WEB_INBOX_CONFIG> = vi
.fn()
.mockReturnValue(DEFAULT_WEB_INBOX_CONFIG);
export const mockLoadConfig: AnyMockFn = vi.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()
.mockResolvedValue([]);
export const upsertPairingRequestMock: MockFn<
(...args: unknown[]) => Promise<{ code: string; created: boolean }>
> = vi.fn().mockResolvedValue({ code: "PAIRCODE", created: true });
.mockResolvedValue({ code: "PAIRCODE", created: true });
export type MockSock = {
ev: EventEmitter;
ws: { close: MockFn };
sendPresenceUpdate: MockFn;
sendMessage: MockFn;
readMessages: MockFn;
updateMediaMessage: MockFn;
ws: { close: AnyMockFn };
sendPresenceUpdate: AnyMockFn;
sendMessage: AnyMockFn;
readMessages: AnyMockFn;
updateMediaMessage: AnyMockFn;
logger: Record<string, unknown>;
signalRepository: {
lidMapping: {
getPNForLID: MockFn;
getPNForLID: AnyMockFn;
};
};
user: { id: string };