diff --git a/CHANGELOG.md b/CHANGELOG.md index b7238136a5a..d5701b6cfa7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts b/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts index 4f9457679db..b4422f7a4e3 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts @@ -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; + 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 { +export function getProviderUsageMocks(): AnyMocks { return providerUsageMocks; } @@ -77,7 +83,7 @@ const modelCatalogMocks = vi.hoisted(() => ({ resetModelCatalogCacheForTest: vi.fn(), })); -export function getModelCatalogMocks(): Record { +export function getModelCatalogMocks(): AnyMocks { return modelCatalogMocks; } @@ -89,7 +95,7 @@ const webSessionMocks = vi.hoisted(() => ({ readWebSelfId: vi.fn().mockReturnValue({ e164: "+1999" }), })); -export function getWebSessionMocks(): Record { +export function getWebSessionMocks(): AnyMocks { return webSessionMocks; } diff --git a/src/auto-reply/reply/agent-runner.heartbeat-typing.test-harness.ts b/src/auto-reply/reply/agent-runner.heartbeat-typing.test-harness.ts index 01db5693c11..c5e54ac8ecc 100644 --- a/src/auto-reply/reply/agent-runner.heartbeat-typing.test-harness.ts +++ b/src/auto-reply/reply/agent-runner.heartbeat-typing.test-harness.ts @@ -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; } diff --git a/src/auto-reply/reply/agent-runner.memory-flush.test-harness.ts b/src/auto-reply/reply/agent-runner.memory-flush.test-harness.ts index af5ecd487f9..6168b2ed36d 100644 --- a/src/auto-reply/reply/agent-runner.memory-flush.test-harness.ts +++ b/src/auto-reply/reply/agent-runner.memory-flush.test-harness.ts @@ -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; } diff --git a/src/imessage/monitor.test-harness.ts b/src/imessage/monitor.test-harness.ts index ba5e07ca41f..8ae52ea205d 100644 --- a/src/imessage/monitor.test-harness.ts +++ b/src/imessage/monitor.test-harness.ts @@ -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; } diff --git a/src/web/auto-reply.test-harness.ts b/src/web/auto-reply.test-harness.ts index 36c785adba9..6ead596455f 100644 --- a/src/web/auto-reply.test-harness.ts +++ b/src/web/auto-reply.test-harness.ts @@ -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) | undefined; const listenerFactory = async (opts: { onMessage: (msg: WebInboundMessage) => Promise; @@ -134,7 +138,7 @@ export function createWebListenerFactoryCapture() { }; } -export function createWebInboundDeliverySpies() { +export function createWebInboundDeliverySpies(): AnyExport { return { sendMedia: vi.fn(), reply: vi.fn().mockResolvedValue(undefined), diff --git a/src/web/media.test.ts b/src/web/media.test.ts index 594b6d8e75f..dc8eff56131 100644 --- a/src/web/media.test.ts +++ b/src/web/media.test.ts @@ -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(); diff --git a/src/web/media.ts b/src/web/media.ts index f7507223a34..244d6767ec0 100644 --- a/src/web/media.ts +++ b/src/web/media.ts @@ -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 { diff --git a/src/web/monitor-inbox.test-harness.ts b/src/web/monitor-inbox.test-harness.ts index 27602f83ad0..5d5eeed9052 100644 --- a/src/web/monitor-inbox.test-harness.ts +++ b/src/web/monitor-inbox.test-harness.ts @@ -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> = 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; signalRepository: { lidMapping: { - getPNForLID: MockFn; + getPNForLID: AnyMockFn; }; }; user: { id: string };