Spaces:
Paused
Paused
| import fs from "node:fs/promises"; | |
| import os from "node:os"; | |
| import path from "node:path"; | |
| import { describe, expect, it, vi } from "vitest"; | |
| import type { ChannelPlugin } from "../../channels/plugins/types.js"; | |
| import type { MessageActionRunResult } from "../../infra/outbound/message-action-runner.js"; | |
| import { setActivePluginRegistry } from "../../plugins/runtime.js"; | |
| import { createTestRegistry } from "../../test-utils/channel-plugins.js"; | |
| import { createMessageTool } from "./message-tool.js"; | |
| const mocks = vi.hoisted(() => ({ | |
| runMessageAction: vi.fn(), | |
| })); | |
| vi.mock("../../infra/outbound/message-action-runner.js", async () => { | |
| const actual = await vi.importActual< | |
| typeof import("../../infra/outbound/message-action-runner.js") | |
| >("../../infra/outbound/message-action-runner.js"); | |
| return { | |
| ...actual, | |
| runMessageAction: mocks.runMessageAction, | |
| }; | |
| }); | |
| describe("message tool agent routing", () => { | |
| it("derives agentId from the session key", async () => { | |
| mocks.runMessageAction.mockClear(); | |
| mocks.runMessageAction.mockResolvedValue({ | |
| kind: "send", | |
| action: "send", | |
| channel: "telegram", | |
| handledBy: "plugin", | |
| payload: {}, | |
| dryRun: true, | |
| } satisfies MessageActionRunResult); | |
| const tool = createMessageTool({ | |
| agentSessionKey: "agent:alpha:main", | |
| config: {} as never, | |
| }); | |
| await tool.execute("1", { | |
| action: "send", | |
| target: "telegram:123", | |
| message: "hi", | |
| }); | |
| const call = mocks.runMessageAction.mock.calls[0]?.[0]; | |
| expect(call?.agentId).toBe("alpha"); | |
| expect(call?.sessionKey).toBeUndefined(); | |
| }); | |
| }); | |
| describe("message tool path passthrough", () => { | |
| it("does not convert path to media for send", async () => { | |
| mocks.runMessageAction.mockClear(); | |
| mocks.runMessageAction.mockResolvedValue({ | |
| kind: "send", | |
| action: "send", | |
| channel: "telegram", | |
| to: "telegram:123", | |
| handledBy: "plugin", | |
| payload: {}, | |
| dryRun: true, | |
| } satisfies MessageActionRunResult); | |
| const tool = createMessageTool({ | |
| config: {} as never, | |
| }); | |
| await tool.execute("1", { | |
| action: "send", | |
| target: "telegram:123", | |
| path: "~/Downloads/voice.ogg", | |
| message: "", | |
| }); | |
| const call = mocks.runMessageAction.mock.calls[0]?.[0]; | |
| expect(call?.params?.path).toBe("~/Downloads/voice.ogg"); | |
| expect(call?.params?.media).toBeUndefined(); | |
| }); | |
| it("does not convert filePath to media for send", async () => { | |
| mocks.runMessageAction.mockClear(); | |
| mocks.runMessageAction.mockResolvedValue({ | |
| kind: "send", | |
| action: "send", | |
| channel: "telegram", | |
| to: "telegram:123", | |
| handledBy: "plugin", | |
| payload: {}, | |
| dryRun: true, | |
| } satisfies MessageActionRunResult); | |
| const tool = createMessageTool({ | |
| config: {} as never, | |
| }); | |
| await tool.execute("1", { | |
| action: "send", | |
| target: "telegram:123", | |
| filePath: "./tmp/note.m4a", | |
| message: "", | |
| }); | |
| const call = mocks.runMessageAction.mock.calls[0]?.[0]; | |
| expect(call?.params?.filePath).toBe("./tmp/note.m4a"); | |
| expect(call?.params?.media).toBeUndefined(); | |
| }); | |
| }); | |
| describe("message tool description", () => { | |
| const bluebubblesPlugin: 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: () => ({}), | |
| }, | |
| messaging: { | |
| normalizeTarget: (raw) => { | |
| const trimmed = raw.trim().replace(/^bluebubbles:/i, ""); | |
| const lower = trimmed.toLowerCase(); | |
| if (lower.startsWith("chat_guid:")) { | |
| const guid = trimmed.slice("chat_guid:".length); | |
| const parts = guid.split(";"); | |
| if (parts.length === 3 && parts[1] === "-") { | |
| return parts[2]?.trim() || trimmed; | |
| } | |
| return `chat_guid:${guid}`; | |
| } | |
| return trimmed; | |
| }, | |
| }, | |
| actions: { | |
| listActions: () => | |
| ["react", "renameGroup", "addParticipant", "removeParticipant", "leaveGroup"] as const, | |
| }, | |
| }; | |
| it("hides BlueBubbles group actions for DM targets", () => { | |
| setActivePluginRegistry( | |
| createTestRegistry([{ pluginId: "bluebubbles", source: "test", plugin: bluebubblesPlugin }]), | |
| ); | |
| const tool = createMessageTool({ | |
| config: {} as never, | |
| currentChannelProvider: "bluebubbles", | |
| currentChannelId: "bluebubbles:chat_guid:iMessage;-;+15551234567", | |
| }); | |
| expect(tool.description).not.toContain("renameGroup"); | |
| expect(tool.description).not.toContain("addParticipant"); | |
| expect(tool.description).not.toContain("removeParticipant"); | |
| expect(tool.description).not.toContain("leaveGroup"); | |
| setActivePluginRegistry(createTestRegistry([])); | |
| }); | |
| }); | |
| describe("message tool sandbox path validation", () => { | |
| it("rejects filePath that escapes sandbox root", async () => { | |
| const sandboxDir = await fs.mkdtemp(path.join(os.tmpdir(), "msg-sandbox-")); | |
| try { | |
| const tool = createMessageTool({ | |
| config: {} as never, | |
| sandboxRoot: sandboxDir, | |
| }); | |
| await expect( | |
| tool.execute("1", { | |
| action: "send", | |
| target: "telegram:123", | |
| filePath: "/etc/passwd", | |
| message: "", | |
| }), | |
| ).rejects.toThrow(/sandbox/i); | |
| } finally { | |
| await fs.rm(sandboxDir, { recursive: true, force: true }); | |
| } | |
| }); | |
| it("rejects path param with traversal sequence", async () => { | |
| const sandboxDir = await fs.mkdtemp(path.join(os.tmpdir(), "msg-sandbox-")); | |
| try { | |
| const tool = createMessageTool({ | |
| config: {} as never, | |
| sandboxRoot: sandboxDir, | |
| }); | |
| await expect( | |
| tool.execute("1", { | |
| action: "send", | |
| target: "telegram:123", | |
| path: "../../../etc/shadow", | |
| message: "", | |
| }), | |
| ).rejects.toThrow(/sandbox/i); | |
| } finally { | |
| await fs.rm(sandboxDir, { recursive: true, force: true }); | |
| } | |
| }); | |
| it("allows filePath inside sandbox root", async () => { | |
| mocks.runMessageAction.mockClear(); | |
| mocks.runMessageAction.mockResolvedValue({ | |
| kind: "send", | |
| action: "send", | |
| channel: "telegram", | |
| to: "telegram:123", | |
| handledBy: "plugin", | |
| payload: {}, | |
| dryRun: true, | |
| } satisfies MessageActionRunResult); | |
| const sandboxDir = await fs.mkdtemp(path.join(os.tmpdir(), "msg-sandbox-")); | |
| try { | |
| const tool = createMessageTool({ | |
| config: {} as never, | |
| sandboxRoot: sandboxDir, | |
| }); | |
| await tool.execute("1", { | |
| action: "send", | |
| target: "telegram:123", | |
| filePath: "./data/file.txt", | |
| message: "", | |
| }); | |
| expect(mocks.runMessageAction).toHaveBeenCalledTimes(1); | |
| } finally { | |
| await fs.rm(sandboxDir, { recursive: true, force: true }); | |
| } | |
| }); | |
| it("skips validation when no sandboxRoot is set", async () => { | |
| mocks.runMessageAction.mockClear(); | |
| mocks.runMessageAction.mockResolvedValue({ | |
| kind: "send", | |
| action: "send", | |
| channel: "telegram", | |
| to: "telegram:123", | |
| handledBy: "plugin", | |
| payload: {}, | |
| dryRun: true, | |
| } satisfies MessageActionRunResult); | |
| const tool = createMessageTool({ | |
| config: {} as never, | |
| }); | |
| await tool.execute("1", { | |
| action: "send", | |
| target: "telegram:123", | |
| filePath: "/etc/passwd", | |
| message: "", | |
| }); | |
| // Without sandboxRoot the validation is skipped — unsandboxed sessions work normally. | |
| expect(mocks.runMessageAction).toHaveBeenCalledTimes(1); | |
| }); | |
| }); | |