Skip to content

Commit 200eb62

Browse files
authored
fix(imessage): wire reply attachments through send-rich --file (with feature gate) (#79864)
Merged via squash. Prepared head SHA: 5e5cdfe Co-authored-by: omarshahine <10343873+omarshahine@users.noreply.github.com> Co-authored-by: omarshahine <10343873+omarshahine@users.noreply.github.com> Reviewed-by: @omarshahine
1 parent bbabd9b commit 200eb62

9 files changed

Lines changed: 481 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai
1515
- Slack: wake the resolved thread session after interactive reply button/select clicks and carry Slack delivery context through the queued interaction event, so clicks continue the visible conversation. Fixes #79676 and #61502. (#79836) Thanks @velvet-shark, @tianxiaochannel-oss88, and @Saicheg.
1616
- WhatsApp/streaming: send only the new suffix when text-end block replies repeat prior preambles across tool-call cycles, preventing cumulative WhatsApp preamble messages. Fixes #78946. (#79120) Thanks @brokemac79 and @papawattu.
1717
- Tests/security audit: sandbox `audit-exec-surface.test.ts` under a per-case OpenClaw home tempdir, redirecting `OPENCLAW_HOME` (which wins over `HOME`/`USERPROFILE` in `resolveRawHomeDir`) alongside `HOME` and `USERPROFILE`, so its `saveExecApprovals(...)` calls never touch the live `~/.openclaw/exec-approvals.json` on the host running the suite. Sibling exec-approvals tests already used the tempdir pattern; this file did not, so running `pnpm test` against a contributor's local checkout was silently truncating their real approvals to `{ "version": 1, "agents": {} }`. (#79885) Thanks @omarshahine.
18+
- Channels/iMessage: wire `action: "reply"` attachments through `imsg send-rich --file` when the installed imsg build advertises that capability (probed once via `imsg send-rich --help` and cached on the private-API status). Reply now hydrates `media`/`mediaUrl`/`fileUrl`/`mediaUrls[0]`/`filePath`/`path`/base64 `buffer`+`filename` through the shared outbound resolver, stages buffers via the existing `withTempFile` helper, rejects `http(s)://` URL attachments with a targeted error pointing callers at `send`'s full attachment-resolver pipeline, and falls back to the explicit `imsg#114 not landed yet` error on older imsg builds. Depends on the upstream `openclaw/imsg#114` capability landing in an installable release; until then the new path stays gated and users see the same explicit fallback `#79822` introduced. (#79864) Thanks @omarshahine.
1819

1920
## 2026.5.9
2021

extensions/imessage/src/actions.runtime.ts

Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,15 @@ export const imessageActionsRuntime = {
395395
effectId?: string;
396396
replyToMessageId?: string;
397397
partIndex?: number;
398+
// Optional attachment as an in-memory buffer that we stage to a temp
399+
// file before invoking imsg. The buffer must already have been loaded
400+
// by the outbound media resolver (mediaLocalRoots/sandbox/size limits)
401+
// — this runtime intentionally does not accept a raw filesystem path,
402+
// because that would let an attacker-controlled path bypass the
403+
// resolver and let imsg send any host-readable file. Requires an imsg
404+
// build that accepts `send-rich --file` (openclaw/imsg#114); callers
405+
// must feature-detect via the cached private-api status first.
406+
attachment?: { kind: "buffer"; buffer: Uint8Array; filename: string };
398407
options: IMessageBridgeActionOptions;
399408
}): Promise<IMessageBridgeSendResult> {
400409
// Extract markdown bold/italic/underline/strikethrough into typed-run
@@ -403,21 +412,31 @@ export const imessageActionsRuntime = {
403412
// any caller that hits the bridge via `imsg send-rich` benefits without
404413
// needing to pre-format the text themselves.
405414
const formatted = extractMarkdownFormatRuns(params.text);
406-
const result = await runIMessageCliJson(
407-
[
408-
"send-rich",
409-
"--chat",
410-
params.chatGuid,
411-
"--text",
412-
formatted.text,
413-
"--part",
414-
String(params.partIndex ?? 0),
415-
...(params.effectId ? ["--effect", params.effectId] : []),
416-
...(params.replyToMessageId ? ["--reply-to", params.replyToMessageId] : []),
417-
...(formatted.ranges.length > 0 ? ["--format", JSON.stringify(formatted.ranges)] : []),
418-
],
419-
params.options,
420-
);
415+
const buildArgs = (filePath?: string): string[] => [
416+
"send-rich",
417+
"--chat",
418+
params.chatGuid,
419+
"--text",
420+
formatted.text,
421+
"--part",
422+
String(params.partIndex ?? 0),
423+
...(params.effectId ? ["--effect", params.effectId] : []),
424+
...(params.replyToMessageId ? ["--reply-to", params.replyToMessageId] : []),
425+
...(formatted.ranges.length > 0 ? ["--format", JSON.stringify(formatted.ranges)] : []),
426+
...(filePath ? ["--file", filePath] : []),
427+
];
428+
429+
if (params.attachment) {
430+
return await withTempFile(
431+
{ buffer: params.attachment.buffer, filename: params.attachment.filename },
432+
async (filePath) => {
433+
const result = await runIMessageCliJson(buildArgs(filePath), params.options);
434+
return { messageId: resolveMessageId(result) };
435+
},
436+
);
437+
}
438+
439+
const result = await runIMessageCliJson(buildArgs(), params.options);
421440
return { messageId: resolveMessageId(result) };
422441
},
423442

extensions/imessage/src/actions.test.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,144 @@ describe("imessage message actions", () => {
278278
);
279279
});
280280

281+
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+
281419
describe("phone-number target end-to-end (regressions caught the hard way)", () => {
282420
it("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

extensions/imessage/src/actions.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,51 @@ function decodeBase64Buffer(params: Record<string, unknown>, action: string): Ui
240240
return Uint8Array.from(Buffer.from(base64Buffer, "base64"));
241241
}
242242

243+
// Path-shaped attachment params the message-tool schema declares. We only
244+
// look at these to detect an unhydrated bypass attempt — the resolver in
245+
// hydrateAttachmentParamsForAction is responsible for loading them into
246+
// `buffer`/`filename` after enforcing localRoots, sandbox, and size limits.
247+
const REPLY_ATTACHMENT_PATH_PARAM_NAMES: readonly string[] = [
248+
"filePath",
249+
"path",
250+
"media",
251+
"mediaUrl",
252+
"fileUrl",
253+
] as const;
254+
255+
type ReplyAttachmentSpec = { kind: "buffer"; buffer: Uint8Array; filename: string };
256+
257+
// Reply attachments must arrive hydrated: the core message-action runner
258+
// loads `path`/`media`/`mediaUrl`/`filePath`/`fileUrl` through the outbound
259+
// media resolver (mediaLocalRoots / sandbox / size limits / SSRF) and writes
260+
// the result into `buffer` + `filename`. We deliberately do not consume raw
261+
// path params here — accepting them would let an agent send any host file
262+
// imsg can read, bypassing the resolver. If a path-shaped param is present
263+
// without a corresponding `buffer`, the caller skipped hydration (most
264+
// likely calling handleAction directly in a test); fail loudly instead.
265+
function extractReplyAttachment(
266+
params: Record<string, unknown>,
267+
): { spec: ReplyAttachmentSpec; sourceParam: string } | { spec: null; bypassParam: string } | null {
268+
const buffer = readStringParam(params, "buffer");
269+
if (buffer) {
270+
const filename = readStringParam(params, "filename") ?? "attachment.bin";
271+
return {
272+
spec: {
273+
kind: "buffer",
274+
buffer: Uint8Array.from(Buffer.from(buffer, "base64")),
275+
filename,
276+
},
277+
sourceParam: "buffer",
278+
};
279+
}
280+
for (const name of REPLY_ATTACHMENT_PATH_PARAM_NAMES) {
281+
if (readStringParam(params, name)) {
282+
return { spec: null, bypassParam: name };
283+
}
284+
}
285+
return null;
286+
}
287+
243288
// Whitelist of expressive-send effect IDs the bridge accepts. Restricting
244289
// to a fixed set lets us return a clear error for typos ("invisible_ink"
245290
// vs "invisibleink") instead of silently forwarding gibberish to the
@@ -468,13 +513,36 @@ export const imessageMessageActions: ChannelMessageActionAdapter = {
468513
if (!text) {
469514
throw new Error("iMessage reply requires text or message.");
470515
}
516+
const attachment = extractReplyAttachment(params);
517+
if (attachment) {
518+
if (attachment.spec === null) {
519+
throw new Error(
520+
`iMessage reply rejected \`${attachment.bypassParam}\` because it did not pass through the outbound media resolver. ` +
521+
'Pass a base64 `buffer` + `filename` directly, or invoke message(action: "reply") through the runner so the resolver ' +
522+
"can validate the path against mediaLocalRoots/sandbox/size before sending.",
523+
);
524+
}
525+
// Reply-with-attachment requires the `imsg send-rich --file` flag
526+
// (openclaw/imsg#114). Older imsg builds reject the option, so
527+
// refuse loudly here rather than letting send-rich ship the text
528+
// alone and silently drop the attachment — the original symptom
529+
// of openclaw/openclaw#79822.
530+
if (privateApiStatus?.cliCapabilities?.sendRichSupportsAttachment !== true) {
531+
throw new Error(
532+
"iMessage reply with an attachment needs an imsg build that exposes `send-rich --file` " +
533+
"(openclaw/imsg#114). Upgrade imsg, or use action 'upload-file' (with filePath/filename) " +
534+
"or action 'send' (with media) to deliver the file plus a separate 'reply' for any text.",
535+
);
536+
}
537+
}
471538
const partIndex = readNumberParam(params, "partIndex", { integer: true });
472539
const resolvedChatGuid = await chatGuid();
473540
const result = await runtime.sendRichMessage({
474541
chatGuid: resolvedChatGuid,
475542
text,
476543
replyToMessageId: resolvedMessageId,
477544
partIndex: typeof partIndex === "number" ? partIndex : undefined,
545+
attachment: attachment?.spec ?? undefined,
478546
options: { ...opts, chatGuid: resolvedChatGuid },
479547
});
480548
return jsonResult({ ok: true, messageId: result.messageId, repliedTo: resolvedMessageId });

extensions/imessage/src/private-api-status.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@ export type IMessagePrivateApiStatus = {
33
v2Ready: boolean;
44
selectors: Record<string, boolean>;
55
rpcMethods: string[];
6+
// CLI-flag-level capabilities probed from `imsg <cmd> --help`. Only fields
7+
// we actively branch on are listed; missing entries mean "not yet probed"
8+
// and callers should treat them as unsupported.
9+
cliCapabilities?: {
10+
sendRichSupportsAttachment?: boolean;
11+
};
612
error?: string;
713
};
814

extensions/imessage/src/probe.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,28 @@ function rpcMethodsFromPayload(payload: Record<string, unknown>): string[] {
150150
return raw.filter((entry): entry is string => typeof entry === "string");
151151
}
152152

153+
// Probe whether the installed imsg CLI accepts `--file` on the `send-rich`
154+
// subcommand (added by openclaw/imsg#114, which lets a single bridge call
155+
// combine `--reply-to` and an attachment). We grep the help output rather
156+
// than trying a real send so the probe is side-effect-free, and we resolve
157+
// to `false` on any failure (timeout, non-zero exit, missing binary) so
158+
// callers fall back to the legacy throw rather than silently dropping.
159+
async function probeSendRichSupportsAttachment(
160+
cliPath: string,
161+
timeoutMs: number,
162+
): Promise<boolean> {
163+
try {
164+
const result = await runCommandWithTimeout([cliPath, "send-rich", "--help"], { timeoutMs });
165+
if (result.code !== 0) {
166+
return false;
167+
}
168+
const combined = `${result.stdout}\n${result.stderr}`;
169+
return /(?:^|\s)--file\b/m.test(combined);
170+
} catch {
171+
return false;
172+
}
173+
}
174+
153175
export function clearIMessagePrivateApiCache(cliPath?: string): void {
154176
if (cliPath) {
155177
const key = cliPath.trim() || "imsg";
@@ -181,11 +203,19 @@ export async function probeIMessagePrivateApi(
181203
const rpcMethods = payload ? rpcMethodsFromPayload(payload) : [];
182204
const advancedFeatures = payload?.advanced_features === true;
183205
const v2Ready = payload?.v2_ready === true;
206+
// Probe `imsg send-rich --help` for the `--file` flag added by
207+
// openclaw/imsg#114. We do this even when the bridge is unavailable
208+
// because the help output ships with the CLI binary itself, and the
209+
// result is what gates whether reply-with-attachment can route through
210+
// the threaded send path. Treat any failure as "not supported" so
211+
// callers fall back to the legacy throw rather than silently dropping.
212+
const sendRichSupportsAttachment = await probeSendRichSupportsAttachment(key, timeoutMs);
184213
const status: NonNullable<IMessageProbe["privateApi"]> = {
185214
available: result.code === 0 && advancedFeatures && v2Ready,
186215
v2Ready,
187216
selectors,
188217
rpcMethods,
218+
cliCapabilities: { sendRichSupportsAttachment },
189219
...(result.code === 0
190220
? !payload && firstLineSnippet
191221
? {
@@ -208,6 +238,7 @@ export async function probeIMessagePrivateApi(
208238
v2Ready: false,
209239
selectors: {},
210240
rpcMethods: [],
241+
cliCapabilities: { sendRichSupportsAttachment: false },
211242
error: String(err),
212243
};
213244
setCachedIMessagePrivateApiStatus(key, status, Date.now() + PRIVATE_API_NEGATIVE_TTL_MS);

0 commit comments

Comments
 (0)