Skip to content

Commit a98a9c7

Browse files
committed
test(outbound): cover reply hydration at the runner level
Lifts coverage of #79864 from unit-only to integration: drives runMessageAction({action: "reply", ...}) end to end and asserts - a remote-URL path is loaded into args.buffer + filename via the resolver before the channel handler runs - a host path outside mediaLocalRoots rejects in the resolver before the handler runs (handleAction is never called) - reply does not inherit sendAttachment's caption->message fallback, so the channel handler does not see a synthetic caption that would collide with the reply payload This pins the runner-side wiring that the imessage actions test already exercises at the handler boundary.
1 parent cf93925 commit a98a9c7

1 file changed

Lines changed: 154 additions & 0 deletions

File tree

src/infra/outbound/message-action-runner.media.test.ts

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,160 @@ describe("runMessageAction media behavior", () => {
548548
});
549549
});
550550

551+
describe("reply hydration", () => {
552+
// The reply action accepts attachments via the same media/path/filePath
553+
// params as send. Before openclaw#79864 the runner only hydrated
554+
// sendAttachment/setGroupIcon/upload-file, so a channel plugin's reply
555+
// handler saw the raw path and could forward it directly to its CLI —
556+
// bypassing localRoots, sandbox, and size checks. These tests pin the
557+
// wiring at the runner level: paths must arrive at the plugin handler
558+
// as a hydrated buffer, paths outside the resolver's policy must
559+
// reject before the handler runs, and reply must not inherit the
560+
// sendAttachment caption-fallback that would synthesize a bogus
561+
// caption from the agent's reply text.
562+
const cfg = {
563+
channels: {
564+
replychat: {
565+
enabled: true,
566+
},
567+
},
568+
} as OpenClawConfig;
569+
const handleActionMock = vi.fn();
570+
const replyPlugin: ChannelPlugin = {
571+
id: "replychat",
572+
meta: {
573+
id: "replychat",
574+
label: "ReplyChat",
575+
selectionLabel: "ReplyChat",
576+
docsPath: "/channels/replychat",
577+
blurb: "ReplyChat test plugin.",
578+
},
579+
capabilities: { chatTypes: ["direct", "group"], media: true },
580+
config: {
581+
listAccountIds: () => ["default"],
582+
resolveAccount: () => ({ enabled: true }),
583+
isConfigured: () => true,
584+
},
585+
actions: {
586+
describeMessageTool: () => ({ actions: ["reply"] }),
587+
supportsAction: ({ action }) => action === "reply",
588+
handleAction: async ({ params }) => {
589+
handleActionMock(params);
590+
return jsonResult({
591+
ok: true,
592+
buffer: params.buffer,
593+
filename: params.filename,
594+
caption: params.caption,
595+
contentType: params.contentType,
596+
text: params.text,
597+
message: params.message,
598+
});
599+
},
600+
},
601+
};
602+
603+
beforeEach(() => {
604+
handleActionMock.mockReset();
605+
setActivePluginRegistry(
606+
createTestRegistry([
607+
{
608+
pluginId: "replychat",
609+
source: "test",
610+
plugin: replyPlugin,
611+
},
612+
]),
613+
);
614+
vi.mocked(loadWebMedia).mockResolvedValue({
615+
buffer: Buffer.from("hello"),
616+
contentType: "image/png",
617+
kind: "image",
618+
fileName: "pic.png",
619+
});
620+
});
621+
622+
afterEach(() => {
623+
setActivePluginRegistry(createTestRegistry([]));
624+
vi.clearAllMocks();
625+
});
626+
627+
it("hydrates buffer and filename from a remote URL before the reply handler runs", async () => {
628+
const result = await runMessageAction({
629+
cfg,
630+
action: "reply",
631+
params: {
632+
channel: "replychat",
633+
target: "+15551234567",
634+
messageId: "parent-id",
635+
text: "look at this",
636+
media: "https://example.com/pic.png",
637+
},
638+
});
639+
640+
expect(result.kind).toBe("action");
641+
expect(handleActionMock).toHaveBeenCalledTimes(1);
642+
const handlerParams = handleActionMock.mock.calls[0]?.[0] as Record<string, unknown>;
643+
expect(handlerParams.buffer).toBe(Buffer.from("hello").toString("base64"));
644+
expect(handlerParams.filename).toBe("pic.png");
645+
expect(handlerParams.contentType).toBe("image/png");
646+
});
647+
648+
it("rejects host paths outside mediaLocalRoots before invoking the reply handler", async () => {
649+
// Use the real loader so its localRoots/workspaceOnly enforcement runs.
650+
const actual = await vi.importActual<typeof import("../../media/web-media.js")>(
651+
"../../media/web-media.js",
652+
);
653+
vi.mocked(loadWebMedia).mockImplementation(actual.loadWebMedia);
654+
655+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "msg-reply-bypass-"));
656+
try {
657+
const outsidePath = path.join(tempDir, "secret.txt");
658+
await fs.writeFile(outsidePath, "secret", "utf8");
659+
660+
await expect(
661+
runMessageAction({
662+
cfg: {
663+
...cfg,
664+
tools: { fs: { workspaceOnly: true } },
665+
},
666+
action: "reply",
667+
params: {
668+
channel: "replychat",
669+
target: "+15551234567",
670+
messageId: "parent-id",
671+
text: "look at this",
672+
path: outsidePath,
673+
},
674+
}),
675+
).rejects.toThrow(/allowed directory|path-not-allowed|workspace/i);
676+
expect(handleActionMock).not.toHaveBeenCalled();
677+
} finally {
678+
await fs.rm(tempDir, { recursive: true, force: true });
679+
}
680+
});
681+
682+
it("does not synthesize a caption from message on reply", async () => {
683+
// sendAttachment falls back caption -> message when caption is missing.
684+
// Reply has its own text/message body, so caption fallback would
685+
// invent a bogus caption param the channel handler shouldn't see.
686+
await runMessageAction({
687+
cfg,
688+
action: "reply",
689+
params: {
690+
channel: "replychat",
691+
target: "+15551234567",
692+
messageId: "parent-id",
693+
message: "look at this",
694+
media: "https://example.com/pic.png",
695+
},
696+
});
697+
698+
expect(handleActionMock).toHaveBeenCalledTimes(1);
699+
const handlerParams = handleActionMock.mock.calls[0]?.[0] as Record<string, unknown>;
700+
expect(handlerParams.caption).toBeUndefined();
701+
expect(handlerParams.message).toBe("look at this");
702+
});
703+
});
704+
551705
describe("plugin-owned media-source discovery routing", () => {
552706
const profilePlugin: ChannelPlugin = {
553707
...createChannelTestPluginBase({

0 commit comments

Comments
 (0)