diff --git a/src/infra/outbound/message-action-runner.media.test.ts b/src/infra/outbound/message-action-runner.media.test.ts new file mode 100644 index 00000000000..287f8e3c677 --- /dev/null +++ b/src/infra/outbound/message-action-runner.media.test.ts @@ -0,0 +1,422 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { slackPlugin } from "../../../extensions/slack/src/channel.js"; +import { jsonResult } from "../../agents/tools/common.js"; +import type { ChannelPlugin } from "../../channels/plugins/types.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import { setActivePluginRegistry } from "../../plugins/runtime.js"; +import { createTestRegistry } from "../../test-utils/channel-plugins.js"; +import { loadWebMedia } from "../../web/media.js"; +import { resolvePreferredOpenClawTmpDir } from "../tmp-openclaw-dir.js"; +import { runMessageAction } from "./message-action-runner.js"; + +vi.mock("../../web/media.js", async () => { + const actual = await vi.importActual("../../web/media.js"); + return { + ...actual, + loadWebMedia: vi.fn(actual.loadWebMedia), + }; +}); + +const slackConfig = { + channels: { + slack: { + botToken: "xoxb-test", + appToken: "xapp-test", + }, + }, +} as OpenClawConfig; + +async function withSandbox(test: (sandboxDir: string) => Promise) { + const sandboxDir = await fs.mkdtemp(path.join(os.tmpdir(), "msg-sandbox-")); + try { + await test(sandboxDir); + } finally { + await fs.rm(sandboxDir, { recursive: true, force: true }); + } +} + +const runDrySend = (params: { + cfg: OpenClawConfig; + actionParams: Record; + sandboxRoot?: string; +}) => + runMessageAction({ + cfg: params.cfg, + action: "send", + params: params.actionParams as never, + dryRun: true, + sandboxRoot: params.sandboxRoot, + }); + +async function expectSandboxMediaRewrite(params: { + sandboxDir: string; + media?: string; + message?: string; + expectedRelativePath: string; +}) { + const result = await runDrySend({ + cfg: slackConfig, + actionParams: { + channel: "slack", + target: "#C12345678", + ...(params.media ? { media: params.media } : {}), + ...(params.message ? { message: params.message } : {}), + }, + sandboxRoot: params.sandboxDir, + }); + + expect(result.kind).toBe("send"); + if (result.kind !== "send") { + throw new Error("expected send result"); + } + expect(result.sendResult?.mediaUrl).toBe( + path.join(params.sandboxDir, params.expectedRelativePath), + ); +} + +let createPluginRuntime: typeof import("../../plugins/runtime/index.js").createPluginRuntime; +let setSlackRuntime: typeof import("../../../extensions/slack/src/runtime.js").setSlackRuntime; + +function installSlackRuntime() { + const runtime = createPluginRuntime(); + setSlackRuntime(runtime); +} + +describe("runMessageAction media behavior", () => { + beforeAll(async () => { + ({ createPluginRuntime } = await import("../../plugins/runtime/index.js")); + ({ setSlackRuntime } = await import("../../../extensions/slack/src/runtime.js")); + }); + + describe("sendAttachment hydration", () => { + const cfg = { + channels: { + bluebubbles: { + enabled: true, + serverUrl: "http://localhost:1234", + password: "test-password", + }, + }, + } as OpenClawConfig; + const attachmentPlugin: ChannelPlugin = { + id: "bluebubbles", + meta: { + id: "bluebubbles", + label: "BlueBubbles", + selectionLabel: "BlueBubbles", + docsPath: "/channels/bluebubbles", + blurb: "BlueBubbles test plugin.", + }, + capabilities: { chatTypes: ["direct", "group"], media: true }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({ enabled: true }), + isConfigured: () => true, + }, + actions: { + listActions: () => ["sendAttachment", "setGroupIcon"], + supportsAction: ({ action }) => action === "sendAttachment" || action === "setGroupIcon", + handleAction: async ({ params }) => + jsonResult({ + ok: true, + buffer: params.buffer, + filename: params.filename, + caption: params.caption, + contentType: params.contentType, + }), + }, + }; + + beforeEach(() => { + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "bluebubbles", + source: "test", + plugin: attachmentPlugin, + }, + ]), + ); + vi.mocked(loadWebMedia).mockResolvedValue({ + buffer: Buffer.from("hello"), + contentType: "image/png", + kind: "image", + fileName: "pic.png", + }); + }); + + afterEach(() => { + setActivePluginRegistry(createTestRegistry([])); + vi.clearAllMocks(); + }); + + async function restoreRealMediaLoader() { + const actual = + await vi.importActual("../../web/media.js"); + vi.mocked(loadWebMedia).mockImplementation(actual.loadWebMedia); + } + + async function expectRejectsLocalAbsolutePathWithoutSandbox(params: { + action: "sendAttachment" | "setGroupIcon"; + target: string; + message?: string; + tempPrefix: string; + }) { + await restoreRealMediaLoader(); + + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), params.tempPrefix)); + try { + const outsidePath = path.join(tempDir, "secret.txt"); + await fs.writeFile(outsidePath, "secret", "utf8"); + + const actionParams: Record = { + channel: "bluebubbles", + target: params.target, + media: outsidePath, + }; + if (params.message) { + actionParams.message = params.message; + } + + await expect( + runMessageAction({ + cfg, + action: params.action, + params: actionParams, + }), + ).rejects.toThrow(/allowed directory|path-not-allowed/i); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + } + + it("hydrates buffer and filename from media for sendAttachment", async () => { + const result = await runMessageAction({ + cfg, + action: "sendAttachment", + params: { + channel: "bluebubbles", + target: "+15551234567", + media: "https://example.com/pic.png", + message: "caption", + }, + }); + + expect(result.kind).toBe("action"); + expect(result.payload).toMatchObject({ + ok: true, + filename: "pic.png", + caption: "caption", + contentType: "image/png", + }); + expect((result.payload as { buffer?: string }).buffer).toBe( + Buffer.from("hello").toString("base64"), + ); + const call = vi.mocked(loadWebMedia).mock.calls[0]; + expect(call?.[1]).toEqual( + expect.objectContaining({ + localRoots: expect.any(Array), + }), + ); + expect((call?.[1] as { sandboxValidated?: boolean } | undefined)?.sandboxValidated).not.toBe( + true, + ); + }); + + it("rewrites sandboxed media paths for sendAttachment", async () => { + await withSandbox(async (sandboxDir) => { + await runMessageAction({ + cfg, + action: "sendAttachment", + params: { + channel: "bluebubbles", + target: "+15551234567", + media: "./data/pic.png", + message: "caption", + }, + sandboxRoot: sandboxDir, + }); + + const call = vi.mocked(loadWebMedia).mock.calls[0]; + expect(call?.[0]).toBe(path.join(sandboxDir, "data", "pic.png")); + expect(call?.[1]).toEqual( + expect.objectContaining({ + sandboxValidated: true, + }), + ); + }); + }); + + it("rewrites sandboxed media paths for setGroupIcon", async () => { + await withSandbox(async (sandboxDir) => { + await runMessageAction({ + cfg, + action: "setGroupIcon", + params: { + channel: "bluebubbles", + target: "group:123", + media: "./icons/group.png", + }, + sandboxRoot: sandboxDir, + }); + + const call = vi.mocked(loadWebMedia).mock.calls[0]; + expect(call?.[0]).toBe(path.join(sandboxDir, "icons", "group.png")); + expect(call?.[1]).toEqual( + expect.objectContaining({ + sandboxValidated: true, + }), + ); + }); + }); + + it("rejects local absolute path for sendAttachment when sandboxRoot is missing", async () => { + await expectRejectsLocalAbsolutePathWithoutSandbox({ + action: "sendAttachment", + target: "+15551234567", + message: "caption", + tempPrefix: "msg-attachment-", + }); + }); + + it("rejects local absolute path for setGroupIcon when sandboxRoot is missing", async () => { + await expectRejectsLocalAbsolutePathWithoutSandbox({ + action: "setGroupIcon", + target: "group:123", + tempPrefix: "msg-group-icon-", + }); + }); + }); + + describe("sandboxed media validation", () => { + beforeEach(() => { + installSlackRuntime(); + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "slack", + source: "test", + plugin: slackPlugin, + }, + ]), + ); + }); + + afterEach(() => { + setActivePluginRegistry(createTestRegistry([])); + }); + + it.each(["/etc/passwd", "file:///etc/passwd"])( + "rejects out-of-sandbox media reference: %s", + async (media) => { + await withSandbox(async (sandboxDir) => { + await expect( + runDrySend({ + cfg: slackConfig, + actionParams: { + channel: "slack", + target: "#C12345678", + media, + message: "", + }, + sandboxRoot: sandboxDir, + }), + ).rejects.toThrow(/sandbox/i); + }); + }, + ); + + it("rejects data URLs in media params", async () => { + await expect( + runDrySend({ + cfg: slackConfig, + actionParams: { + channel: "slack", + target: "#C12345678", + media: "data:image/png;base64,abcd", + message: "", + }, + }), + ).rejects.toThrow(/data:/i); + }); + + it("rewrites sandbox-relative media paths", async () => { + await withSandbox(async (sandboxDir) => { + await expectSandboxMediaRewrite({ + sandboxDir, + media: "./data/file.txt", + message: "", + expectedRelativePath: path.join("data", "file.txt"), + }); + }); + }); + + it("rewrites /workspace media paths to host sandbox root", async () => { + await withSandbox(async (sandboxDir) => { + await expectSandboxMediaRewrite({ + sandboxDir, + media: "/workspace/data/file.txt", + message: "", + expectedRelativePath: path.join("data", "file.txt"), + }); + }); + }); + + it("rewrites MEDIA directives under sandbox", async () => { + await withSandbox(async (sandboxDir) => { + await expectSandboxMediaRewrite({ + sandboxDir, + message: "Hello\nMEDIA: ./data/note.ogg", + expectedRelativePath: path.join("data", "note.ogg"), + }); + }); + }); + + it("allows media paths under preferred OpenClaw tmp root", async () => { + const tmpRoot = resolvePreferredOpenClawTmpDir(); + await fs.mkdir(tmpRoot, { recursive: true }); + const sandboxDir = await fs.mkdtemp(path.join(os.tmpdir(), "msg-sandbox-")); + try { + const tmpFile = path.join(tmpRoot, "test-media-image.png"); + const result = await runMessageAction({ + cfg: slackConfig, + action: "send", + params: { + channel: "slack", + target: "#C12345678", + media: tmpFile, + message: "", + }, + sandboxRoot: sandboxDir, + dryRun: true, + }); + + expect(result.kind).toBe("send"); + if (result.kind !== "send") { + throw new Error("expected send result"); + } + expect(result.sendResult?.mediaUrl).toBe(path.resolve(tmpFile)); + const hostTmpOutsideOpenClaw = path.join(os.tmpdir(), "outside-openclaw", "test-media.png"); + await expect( + runMessageAction({ + cfg: slackConfig, + action: "send", + params: { + channel: "slack", + target: "#C12345678", + media: hostTmpOutsideOpenClaw, + message: "", + }, + sandboxRoot: sandboxDir, + dryRun: true, + }), + ).rejects.toThrow(/sandbox/i); + } finally { + await fs.rm(sandboxDir, { recursive: true, force: true }); + } + }); + }); +}); diff --git a/src/infra/outbound/message-action-runner.test.ts b/src/infra/outbound/message-action-runner.test.ts index a24c762687f..3858bae845e 100644 --- a/src/infra/outbound/message-action-runner.test.ts +++ b/src/infra/outbound/message-action-runner.test.ts @@ -1,28 +1,13 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { slackPlugin } from "../../../extensions/slack/src/channel.js"; import { telegramPlugin } from "../../../extensions/telegram/src/channel.js"; import { whatsappPlugin } from "../../../extensions/whatsapp/src/channel.js"; -import { jsonResult } from "../../agents/tools/common.js"; -import type { ChannelPlugin } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; import { createTestRegistry } from "../../test-utils/channel-plugins.js"; import { createIMessageTestPlugin } from "../../test-utils/imessage-test-plugin.js"; -import { loadWebMedia } from "../../web/media.js"; -import { resolvePreferredOpenClawTmpDir } from "../tmp-openclaw-dir.js"; import { runMessageAction } from "./message-action-runner.js"; -vi.mock("../../web/media.js", async () => { - const actual = await vi.importActual("../../web/media.js"); - return { - ...actual, - loadWebMedia: vi.fn(actual.loadWebMedia), - }; -}); - const slackConfig = { channels: { slack: { @@ -40,15 +25,6 @@ const whatsappConfig = { }, } as OpenClawConfig; -async function withSandbox(test: (sandboxDir: string) => Promise) { - const sandboxDir = await fs.mkdtemp(path.join(os.tmpdir(), "msg-sandbox-")); - try { - await test(sandboxDir); - } finally { - await fs.rm(sandboxDir, { recursive: true, force: true }); - } -} - const runDryAction = (params: { cfg: OpenClawConfig; action: "send" | "thread-reply" | "broadcast"; @@ -79,32 +55,6 @@ const runDrySend = (params: { action: "send", }); -async function expectSandboxMediaRewrite(params: { - sandboxDir: string; - media?: string; - message?: string; - expectedRelativePath: string; -}) { - const result = await runDrySend({ - cfg: slackConfig, - actionParams: { - channel: "slack", - target: "#C12345678", - ...(params.media ? { media: params.media } : {}), - ...(params.message ? { message: params.message } : {}), - }, - sandboxRoot: params.sandboxDir, - }); - - expect(result.kind).toBe("send"); - if (result.kind !== "send") { - throw new Error("expected send result"); - } - expect(result.sendResult?.mediaUrl).toBe( - path.join(params.sandboxDir, params.expectedRelativePath), - ); -} - let createPluginRuntime: typeof import("../../plugins/runtime/index.js").createPluginRuntime; let setSlackRuntime: typeof import("../../../extensions/slack/src/runtime.js").setSlackRuntime; let setTelegramRuntime: typeof import("../../../extensions/telegram/src/runtime.js").setTelegramRuntime; @@ -511,309 +461,3 @@ describe("runMessageAction context isolation", () => { await expect(run(controller.signal)).rejects.toMatchObject({ name: "AbortError" }); }); }); - -describe("runMessageAction sendAttachment hydration", () => { - const cfg = { - channels: { - bluebubbles: { - enabled: true, - serverUrl: "http://localhost:1234", - password: "test-password", - }, - }, - } as OpenClawConfig; - const attachmentPlugin: ChannelPlugin = { - id: "bluebubbles", - meta: { - id: "bluebubbles", - label: "BlueBubbles", - selectionLabel: "BlueBubbles", - docsPath: "/channels/bluebubbles", - blurb: "BlueBubbles test plugin.", - }, - capabilities: { chatTypes: ["direct", "group"], media: true }, - config: { - listAccountIds: () => ["default"], - resolveAccount: () => ({ enabled: true }), - isConfigured: () => true, - }, - actions: { - listActions: () => ["sendAttachment", "setGroupIcon"], - supportsAction: ({ action }) => action === "sendAttachment" || action === "setGroupIcon", - handleAction: async ({ params }) => - jsonResult({ - ok: true, - buffer: params.buffer, - filename: params.filename, - caption: params.caption, - contentType: params.contentType, - }), - }, - }; - - beforeEach(() => { - setActivePluginRegistry( - createTestRegistry([ - { - pluginId: "bluebubbles", - source: "test", - plugin: attachmentPlugin, - }, - ]), - ); - vi.mocked(loadWebMedia).mockResolvedValue({ - buffer: Buffer.from("hello"), - contentType: "image/png", - kind: "image", - fileName: "pic.png", - }); - }); - - afterEach(() => { - setActivePluginRegistry(createTestRegistry([])); - vi.clearAllMocks(); - }); - - async function restoreRealMediaLoader() { - const actual = await vi.importActual("../../web/media.js"); - vi.mocked(loadWebMedia).mockImplementation(actual.loadWebMedia); - } - - async function expectRejectsLocalAbsolutePathWithoutSandbox(params: { - action: "sendAttachment" | "setGroupIcon"; - target: string; - message?: string; - tempPrefix: string; - }) { - await restoreRealMediaLoader(); - - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), params.tempPrefix)); - try { - const outsidePath = path.join(tempDir, "secret.txt"); - await fs.writeFile(outsidePath, "secret", "utf8"); - - const actionParams: Record = { - channel: "bluebubbles", - target: params.target, - media: outsidePath, - }; - if (params.message) { - actionParams.message = params.message; - } - - await expect( - runMessageAction({ - cfg, - action: params.action, - params: actionParams, - }), - ).rejects.toThrow(/allowed directory|path-not-allowed/i); - } finally { - await fs.rm(tempDir, { recursive: true, force: true }); - } - } - - it("hydrates buffer and filename from media for sendAttachment", async () => { - const result = await runMessageAction({ - cfg, - action: "sendAttachment", - params: { - channel: "bluebubbles", - target: "+15551234567", - media: "https://example.com/pic.png", - message: "caption", - }, - }); - - expect(result.kind).toBe("action"); - expect(result.payload).toMatchObject({ - ok: true, - filename: "pic.png", - caption: "caption", - contentType: "image/png", - }); - expect((result.payload as { buffer?: string }).buffer).toBe( - Buffer.from("hello").toString("base64"), - ); - const call = vi.mocked(loadWebMedia).mock.calls[0]; - expect(call?.[1]).toEqual( - expect.objectContaining({ - localRoots: expect.any(Array), - }), - ); - expect((call?.[1] as { sandboxValidated?: boolean } | undefined)?.sandboxValidated).not.toBe( - true, - ); - }); - - it("rewrites sandboxed media paths for sendAttachment", async () => { - await withSandbox(async (sandboxDir) => { - await runMessageAction({ - cfg, - action: "sendAttachment", - params: { - channel: "bluebubbles", - target: "+15551234567", - media: "./data/pic.png", - message: "caption", - }, - sandboxRoot: sandboxDir, - }); - - const call = vi.mocked(loadWebMedia).mock.calls[0]; - expect(call?.[0]).toBe(path.join(sandboxDir, "data", "pic.png")); - expect(call?.[1]).toEqual( - expect.objectContaining({ - sandboxValidated: true, - }), - ); - }); - }); - - it("rejects local absolute path for sendAttachment when sandboxRoot is missing", async () => { - await expectRejectsLocalAbsolutePathWithoutSandbox({ - action: "sendAttachment", - target: "+15551234567", - message: "caption", - tempPrefix: "msg-attachment-", - }); - }); - - it("rejects local absolute path for setGroupIcon when sandboxRoot is missing", async () => { - await expectRejectsLocalAbsolutePathWithoutSandbox({ - action: "setGroupIcon", - target: "group:123", - tempPrefix: "msg-group-icon-", - }); - }); -}); - -describe("runMessageAction sandboxed media validation", () => { - beforeEach(() => { - installChannelRuntimes({ includeTelegram: false, includeWhatsApp: false }); - setActivePluginRegistry( - createTestRegistry([ - { - pluginId: "slack", - source: "test", - plugin: slackPlugin, - }, - ]), - ); - }); - - afterEach(() => { - setActivePluginRegistry(createTestRegistry([])); - }); - - it.each(["/etc/passwd", "file:///etc/passwd"])( - "rejects out-of-sandbox media reference: %s", - async (media) => { - await withSandbox(async (sandboxDir) => { - await expect( - runDrySend({ - cfg: slackConfig, - actionParams: { - channel: "slack", - target: "#C12345678", - media, - message: "", - }, - sandboxRoot: sandboxDir, - }), - ).rejects.toThrow(/sandbox/i); - }); - }, - ); - - it("rejects data URLs in media params", async () => { - await expect( - runDrySend({ - cfg: slackConfig, - actionParams: { - channel: "slack", - target: "#C12345678", - media: "data:image/png;base64,abcd", - message: "", - }, - }), - ).rejects.toThrow(/data:/i); - }); - - it("rewrites sandbox-relative media paths", async () => { - await withSandbox(async (sandboxDir) => { - await expectSandboxMediaRewrite({ - sandboxDir, - media: "./data/file.txt", - message: "", - expectedRelativePath: path.join("data", "file.txt"), - }); - }); - }); - - it("rewrites /workspace media paths to host sandbox root", async () => { - await withSandbox(async (sandboxDir) => { - await expectSandboxMediaRewrite({ - sandboxDir, - media: "/workspace/data/file.txt", - message: "", - expectedRelativePath: path.join("data", "file.txt"), - }); - }); - }); - - it("rewrites MEDIA directives under sandbox", async () => { - await withSandbox(async (sandboxDir) => { - await expectSandboxMediaRewrite({ - sandboxDir, - message: "Hello\nMEDIA: ./data/note.ogg", - expectedRelativePath: path.join("data", "note.ogg"), - }); - }); - }); - - it("allows media paths under preferred OpenClaw tmp root", async () => { - const tmpRoot = resolvePreferredOpenClawTmpDir(); - await fs.mkdir(tmpRoot, { recursive: true }); - const sandboxDir = await fs.mkdtemp(path.join(os.tmpdir(), "msg-sandbox-")); - try { - const tmpFile = path.join(tmpRoot, "test-media-image.png"); - const result = await runMessageAction({ - cfg: slackConfig, - action: "send", - params: { - channel: "slack", - target: "#C12345678", - media: tmpFile, - message: "", - }, - sandboxRoot: sandboxDir, - dryRun: true, - }); - - expect(result.kind).toBe("send"); - if (result.kind !== "send") { - throw new Error("expected send result"); - } - // runMessageAction normalizes media paths through platform resolution. - expect(result.sendResult?.mediaUrl).toBe(path.resolve(tmpFile)); - const hostTmpOutsideOpenClaw = path.join(os.tmpdir(), "outside-openclaw", "test-media.png"); - await expect( - runMessageAction({ - cfg: slackConfig, - action: "send", - params: { - channel: "slack", - target: "#C12345678", - media: hostTmpOutsideOpenClaw, - message: "", - }, - sandboxRoot: sandboxDir, - dryRun: true, - }), - ).rejects.toThrow(/sandbox/i); - } finally { - await fs.rm(sandboxDir, { recursive: true, force: true }); - } - }); -});