























@@ -278,6 +278,144 @@ describe("imessage message actions", () => {
278278);
279279});
280280281+describe("reply with attachment (openclaw/imsg#114 plumbing)", () => {
282+// The core message-action runner hydrates path/media/filePath/etc.
283+// through the outbound media resolver (mediaLocalRoots/sandbox/size)
284+// before reaching this handler, writing the result into `buffer` +
285+// `filename`. These tests cover the post-hydration contract: the
286+// handler trusts only the buffer and refuses any unhydrated path
287+// param so an agent cannot bypass the resolver.
288+const stringPath = "/tmp/cute-lobster.png";
289+const base64Png = Buffer.from("PNGDATA").toString("base64");
290+291+function readLastAttachment():
292+| {
293+kind?: string;
294+buffer?: Uint8Array;
295+filename?: string;
296+}
297+| undefined {
298+const call = runtimeMock.sendRichMessage.mock.calls.at(-1)?.[0] as
299+| { attachment?: { kind: string; buffer?: Uint8Array; filename?: string } }
300+| undefined;
301+return call?.attachment;
302+}
303+304+it("threads a hydrated buffer attachment through to sendRichMessage when imsg supports send-rich --file", async () => {
305+probeMock.getCachedIMessagePrivateApiStatus.mockReturnValue({
306+available: true,
307+v2Ready: true,
308+selectors: {},
309+cliCapabilities: { sendRichSupportsAttachment: true },
310+});
311+runtimeMock.resolveChatGuidForTarget.mockResolvedValue("iMessage;+;resolved-ident");
312+runtimeMock.sendRichMessage.mockResolvedValue({ messageId: "reply-guid" });
313+314+await imessageMessageActions.handleAction?.({
315+action: "reply",
316+cfg: cfg(),
317+params: {
318+chatIdentifier: "team-thread",
319+messageId: "message-guid",
320+text: "🦞 here it is",
321+buffer: base64Png,
322+filename: "card.png",
323+},
324+} as never);
325+expect(runtimeMock.sendRichMessage).toHaveBeenCalledWith(
326+expect.objectContaining({ replyToMessageId: "message-guid" }),
327+);
328+const attachment = readLastAttachment();
329+expect(attachment?.kind).toBe("buffer");
330+expect(attachment?.filename).toBe("card.png");
331+expect(Buffer.from(attachment?.buffer ?? new Uint8Array()).toString()).toBe("PNGDATA");
332+});
333+334+it("falls back to attachment.bin when filename is missing (post-hydration)", async () => {
335+probeMock.getCachedIMessagePrivateApiStatus.mockReturnValue({
336+available: true,
337+v2Ready: true,
338+selectors: {},
339+cliCapabilities: { sendRichSupportsAttachment: true },
340+});
341+runtimeMock.resolveChatGuidForTarget.mockResolvedValue("iMessage;+;resolved-ident");
342+runtimeMock.sendRichMessage.mockResolvedValue({ messageId: "reply-guid" });
343+344+await imessageMessageActions.handleAction?.({
345+action: "reply",
346+cfg: cfg(),
347+params: {
348+chatIdentifier: "team-thread",
349+messageId: "message-guid",
350+text: "🦞 here it is",
351+buffer: base64Png,
352+},
353+} as never);
354+expect(readLastAttachment()?.filename).toBe("attachment.bin");
355+});
356+357+it("rejects unhydrated path-shaped params so agents cannot bypass the media resolver", async () => {
358+// The runner's hydrateAttachmentParamsForAction loads any
359+// path/media/filePath/mediaUrl/fileUrl through the media resolver
360+// and writes the result into `buffer`. If we ever see a path-shaped
361+// param without a `buffer`, hydration was skipped — refuse instead
362+// of forwarding a raw host path to imsg.
363+probeMock.getCachedIMessagePrivateApiStatus.mockReturnValue({
364+available: true,
365+v2Ready: true,
366+selectors: {},
367+cliCapabilities: { sendRichSupportsAttachment: true },
368+});
369+runtimeMock.resolveChatGuidForTarget.mockResolvedValue("iMessage;+;resolved-ident");
370+371+for (const field of ["filePath", "path", "media", "mediaUrl", "fileUrl"]) {
372+runtimeMock.sendRichMessage.mockClear();
373+await expect(
374+imessageMessageActions.handleAction?.({
375+action: "reply",
376+cfg: cfg(),
377+params: {
378+chatIdentifier: "team-thread",
379+messageId: "message-guid",
380+text: "🦞 here it is",
381+[field]: stringPath,
382+},
383+} as never),
384+).rejects.toThrow(/did not pass through the outbound media resolver/);
385+expect(runtimeMock.sendRichMessage).not.toHaveBeenCalled();
386+}
387+});
388+389+it("rejects reply + attachment when imsg does not advertise send-rich --file", async () => {
390+// Older imsg builds reject `--file` on send-rich, so refuse loudly
391+// here rather than letting send-rich ship the text alone and silently
392+// drop the attachment (the original openclaw/openclaw#79822 symptom).
393+probeMock.getCachedIMessagePrivateApiStatus.mockReturnValue({
394+available: true,
395+v2Ready: true,
396+selectors: {},
397+cliCapabilities: { sendRichSupportsAttachment: false },
398+});
399+runtimeMock.resolveChatGuidForTarget.mockResolvedValue("iMessage;+;resolved-ident");
400+401+runtimeMock.sendRichMessage.mockClear();
402+await expect(
403+imessageMessageActions.handleAction?.({
404+action: "reply",
405+cfg: cfg(),
406+params: {
407+chatIdentifier: "team-thread",
408+messageId: "message-guid",
409+text: "🦞 here it is",
410+buffer: base64Png,
411+filename: "card.png",
412+},
413+} as never),
414+).rejects.toThrow(/needs an imsg build that exposes `send-rich --file`/);
415+expect(runtimeMock.sendRichMessage).not.toHaveBeenCalled();
416+});
417+});
418+281419describe("phone-number target end-to-end (regressions caught the hard way)", () => {
282420it("synthesizes iMessage;-;<phone> chat_identifier from a handle target and sends through to sendReaction", async () => {
283421// Scenario from prod: agent calls react with `target:"+12069106512"` and a
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。