mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-18 07:47:27 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user