Skip to content

fix(imessage): wire reply attachments through send-rich --file (with feature gate)#79864

Merged
omarshahine merged 3 commits into
openclaw:mainfrom
omarshahine:fix/imessage-reply-with-attachment
May 9, 2026
Merged

fix(imessage): wire reply attachments through send-rich --file (with feature gate)#79864
omarshahine merged 3 commits into
openclaw:mainfrom
omarshahine:fix/imessage-reply-with-attachment

Conversation

@omarshahine

@omarshahine omarshahine commented May 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Replaces #79822 (closed). Lets action: "reply" actually deliver an attachment in a single threaded send instead of either silently dropping the file (pre-#79822 behavior) or throwing unconditionally (post-#79822 behavior).

The threaded send path used to route through imsg send-rich --reply-to, which had no --file flag. The new flag landed in openclaw/imsg#114, so this PR plumbs attachments through sendRichMessage whenever the installed imsg build advertises that capability — and falls back to an explicit error on older builds, matching the behavior #79822 shipped as a stop-gap.

What changes

  • extensions/imessage/src/private-api-status.ts — adds cliCapabilities.sendRichSupportsAttachment?: boolean to IMessagePrivateApiStatus.
  • extensions/imessage/src/probe.ts — runs imsg send-rich --help once during the existing private-API probe and grep-checks the output for --file. Result is cached on the status object alongside selectors / rpcMethods. Failures resolve to false so callers fall back to the explicit error rather than silently dropping.
  • extensions/imessage/src/actions.runtime.tssendRichMessage accepts an optional attachment: { kind: "path"; path } | { kind: "buffer"; buffer; filename }. Path attachments append --file <path> directly; buffer attachments stage to a temp file via the existing withTempFile helper before invoking imsg.
  • extensions/imessage/src/actions.tsreply action extracts attachment params (filePath / path / media / mediaUrl / fileUrl / first mediaUrls entry / base64 buffer + filename), refuses http(s):// URLs with a targeted error so we don't silently duplicate the outbound media-resolver layer, and gates the rest on cliCapabilities.sendRichSupportsAttachment === true. On unsupported imsg builds the throw cites [codex] support threaded attachment replies imsg#114 and points callers at upload-file / send.
  • extensions/imessage/src/actions.test.ts — extends the existing reply test scaffolding with five new cases:
    • path-shaped attachment threads through (covering all five string aliases),
    • first usable mediaUrls entry threads through,
    • base64 buffer + filename decodes into a buffer-shaped attachment,
    • reply + attachment is rejected when sendRichSupportsAttachment is false (covering all six aliases including buffer),
    • reply + an http(s) URL is rejected with the targeted "threaded send path cannot fetch" error.

Why this shape

  • The capability lives at the CLI flag layer, so feature-detect via --help rather than a version pin — backports and pre-release builds still work, and we never need to bump a constant when imsg's release cadence changes.
  • The dual kind: "path"|"buffer" shape keeps temp-file lifecycle in the runtime where withTempFile already lives, instead of leaking that detail into the action handler. Path callers (the common Lobster image_generate cron path) get zero-copy delivery.
  • Throwing on http(s):// rather than fetching is intentional: the send action already has a full attachment-resolver pipeline (resolveOutboundAttachmentFromUrl, size limits, content-type negotiation). Re-implementing a subset on the reply path would invite divergence; the targeted error pushes the caller to send for that case.
  • The unsupported-imsg branch keeps the explicit error fix(imessage): reject attachments on reply instead of silently dropping them #79822 introduced. That gives users on older imsg versions the same loud-failure semantics, so a half-rolled-out fleet doesn't see a regression to silent drops.

Real behavior proof

Behavior addressed: Production agent on macOS calls message with { action: "reply", channel: "imessage", target: "+1...", messageId: "...", media: "/.../ig_*.png", message: "🦞 Here it is." }. Today (pre-#114, post-#79822) this throws. Once #114 lands, this PR routes the attachment through imsg send-rich --reply-to ... --file <path> and the recipient sees a single threaded reply with the image attached.

Real environment tested: Live OpenClaw gateway on macOS 26.4.0 (Tahoe) M1 — openclaw 2026.5.6 (71a2042), imsg 0.8.0, IMCore v2 bridge, SIP disabled. iMessage channel is the native imessage extension. Same machine that hit the original silent-drop incident from #79822.

Exact steps or command run after this patch:

  1. Apply this PR's diff, restart the gateway: pnpm build && openclaw gateway restart.
  2. Force the unsupported-imsg branch on the running build (which is imsg 0.8.0, before feat: add ATLAS knowledge base integration #114 ships): probe runs against installed imsg, finds no --file flag in imsg send-rich --help, caches cliCapabilities.sendRichSupportsAttachment: false.
  3. Trigger a reply-with-media via the live agent and inspect the trajectory for the explicit error.

After-fix evidence:

Capability probe against the locally installed imsg 0.8.0 (this PR's gating signal):

$ imsg send-rich --help 2>&1 | rg -F -- '--file' || echo NOT_SUPPORTED
NOT_SUPPORTED

Targeted unit tests against the patched module on a live Mac (28/28 pass; new cases highlighted):

$ pnpm test extensions/imessage/src/actions.test.ts
[test] starting test/vitest/vitest.extension-imessage.config.ts
 RUN  v4.1.5 /Users/lobster/GitHub/openclaw/.worktrees/pr-79822
 Test Files  1 passed (1)
      Tests  28 passed (28)
$ pnpm exec oxfmt --check --threads=1 extensions/imessage/src/actions.ts extensions/imessage/src/actions.test.ts extensions/imessage/src/actions.runtime.ts extensions/imessage/src/probe.ts extensions/imessage/src/private-api-status.ts
All matched files use the correct format.

The five new cases, copied from the run with --reporter verbose:

✓ reply with attachment (openclaw/imsg#114 plumbing)
  ✓ threads a path attachment through to sendRichMessage when imsg supports send-rich --file
  ✓ threads the first usable mediaUrls entry through as a path attachment
  ✓ decodes a base64 buffer + filename into a buffer-shaped attachment
  ✓ rejects reply + attachment when imsg does not advertise send-rich --file
  ✓ rejects reply + http(s) media URL with a targeted error before touching the bridge

Observed result after fix:

  • On the patched gateway running today's imsg 0.8.0 (no send-rich --file): a reply carrying any of the seven attachment-bearing param aliases throws iMessage reply with an attachment needs an imsg build that exposes \send-rich --file` ([codex] support threaded attachment replies imsg#114). Upgrade imsg, or use action 'upload-file' (with filePath/filename) or action 'send' (with media) to deliver the file plus a separate 'reply' for any text.` — matching the explicit-error contract from fix(imessage): reject attachments on reply instead of silently dropping them #79822.
  • On a build that advertises send-rich --file (probe returns true): the same reply resolves to one bridge call carrying both --reply-to and --file, delivering the threaded reply with the attachment in a single message — verified by the new unit tests asserting runtime.sendRichMessage is called with attachment: { kind: "path", path } (and the buffer-shaped equivalent for base64 inputs).

What was not tested: End-to-end with imsg 0.9.x is not yet possible on this host because openclaw/imsg#114 is still open. As soon as it merges and lands in a release, a follow-up will swap the local imsg 0.8.0 for the new build and re-record the trajectory excerpt that today shows the throw — that gap is the only thing standing between this PR and a true post-fix delivery transcript.

Best-fix justification

  • Unit-tested both branches of the gate, so the "older imsg falls back to explicit error" contract is regression-locked alongside the "newer imsg threads the file" path.
  • No new schema or message-tool surface — agents that today pass media on a reply just stop seeing silent drops without changing prompts.
  • Capability detection is opt-in per cliPath and cached, so the cost is one extra imsg send-rich --help per probe interval rather than per call.

🤖 Generated with Claude Code


Codex review follow-ups (2026-05-09)

Codex review on this PR flagged the reply path for bypassing the existing outbound media resolver (mediaLocalRoots / sandbox / size limits). Two follow-up commits address the finding:

  • cf93925b8c fix(imessage,outbound): route reply attachments through media resolver

    • Extends hydrateAttachmentParamsForAction (src/infra/outbound/message-action-params.ts) to gate on reply alongside sendAttachment / setGroupIcon / upload-file. Reply runs with allowMessageCaptionFallback: false so it doesn't synthesize a bogus caption from message.
    • Tightens extractReplyAttachment (extensions/imessage/src/actions.ts) to consume only the hydrated buffer + filename. Path-shaped params without a buffer (hydration was skipped) reject loudly — defense in depth against any caller that bypasses the runner.
    • Drops the kind: "path" branch from sendRichMessage (extensions/imessage/src/actions.runtime.ts) so a raw filesystem path can no longer reach imsg --file regardless of code path.
  • a98a9c7427 test(outbound): cover reply hydration at the runner level

    • Adds three cases to src/infra/outbound/message-action-runner.media.test.ts that drive runMessageAction({action: "reply", ...}) end to end and assert: the resolver loads path/media into args.buffer before the channel handler runs; host paths outside mediaLocalRoots reject before the handler runs; reply doesn't inherit the caption-fallback.

Live proof on dev sandbox (2026-05-09)

Tested against a worktree-built dist (cf93925 / a98a9c7) running on an isolated ~/.openclaw-dev profile (port 19001) — same iMessage runtime and imsg binary as Lobster's prod gateway but with no port or state overlap.

Negative path — defense-in-depth bypass guard. The gateway's message.action RPC dispatches directly to the channel handler, bypassing the runner's hydration. The new bypass guard fires verbatim:

$ pnpm openclaw --dev gateway call message.action --params \
  '{"channel":"imessage","action":"reply","idempotencyKey":"smoke-1",
    "params":{"target":"+1...","messageId":"<guid>","text":"...",
              "media":"/.../imsgtest_small.png"}}'
Gateway call failed: iMessage reply rejected `media` because it did not
pass through the outbound media resolver. ...

Happy path — full hydrate-then-deliver. runMessageAction invoked directly via tsx (exercising the same code the agent message tool runs), against an imsg build with send-rich --file:

$ pnpm exec tsx /tmp/live-reply-test.ts
result: {
  "kind": "action", "channel": "imessage", "action": "reply",
  "handledBy": "plugin",
  "payload": {
    "ok": true,
    "messageId": "6A38DDA2-975A-43FC-8628-9361B9946CD3",
    "repliedTo": "63A13B2B-0BD5-43DA-8135-2830008F396C"
  }
}

Confirmed in chat.db (threaded reply with attachment, delivered):

$ sqlite3 -readonly ~/Library/Messages/chat.db \
    "SELECT thread_originator_guid, cache_has_attachments
     FROM message WHERE guid = '6A38DDA2-975A-43FC-8628-9361B9946CD3'"
63A13B2B-0BD5-43DA-8135-2830008F396C|1

thread_originator_guid matches the original message's GUID; cache_has_attachments=1. The recipient sees a single threaded iMessage reply with the image inline.

@openclaw-barnacle openclaw-barnacle Bot added channel: imessage Channel integration: imessage size: M maintainer Maintainer-authored PR labels May 9, 2026
@clawsweeper

clawsweeper Bot commented May 9, 2026

Copy link
Copy Markdown
Contributor

Codex review: needs maintainer review before merge.

Summary
The PR probes imsg send-rich --file, hydrates iMessage reply media through the outbound resolver, stages buffered attachments for send-rich, and adds focused handler and runner tests.

Reproducibility: yes. by source inspection: current main's iMessage reply action ignores attachment params and the shared hydration helper excludes reply, so message(action: "reply", media: ...) cannot pass a file to sendRichMessage. I did not rerun the macOS/iMessage live scenario in this read-only review.

Real behavior proof
Sufficient (terminal): The updated PR body includes after-fix terminal/live output and readonly chat.db proof showing a threaded iMessage reply with an attachment through a supported send-rich --file build.

Next step before merge
Protected-label handling, upstream imsg readiness, and exact-head CI failures need maintainer review rather than an automated repair.

Security
Cleared: The latest diff routes reply attachments through the shared resolver and no longer passes raw filesystem paths to imsg --file; no dependency, workflow, or secret-handling change adds a concrete supply-chain concern.

Review details

Best possible solution:

Land the resolver-backed, feature-gated implementation after maintainer review confirms upstream imsg readiness and exact-head checks are acceptable or green.

Do we have a high-confidence way to reproduce the issue?

Yes, by source inspection: current main's iMessage reply action ignores attachment params and the shared hydration helper excludes reply, so message(action: "reply", media: ...) cannot pass a file to sendRichMessage. I did not rerun the macOS/iMessage live scenario in this read-only review.

Is this the best way to solve the issue?

Yes, directionally: feature-detecting send-rich --file, reusing the outbound media resolver, and staging only validated buffers is the narrow owner-boundary fix. The remaining blockers are operational dependency and validation state, not a concrete code-shape problem in the latest diff.

What I checked:

Likely related people:

  • steipete: GitHub commit history shows fe79d85 introduced the native imsg message actions, and 538605f recently maintained the outbound media/file safety helper touched by this PR. (role: introduced behavior and recent adjacent maintainer; confidence: high; commits: fe79d85ae0cb, 538605ff44d2; files: extensions/imessage/src/actions.ts, extensions/imessage/src/actions.runtime.ts, src/infra/outbound/message-action-params.ts)
  • omarshahine: Omar Shahine appears in merged current-main history for iMessage private-API support and is also driving the upstream imsg capability this PR depends on, beyond being this PR's proposer. (role: adjacent iMessage private API owner; confidence: medium; commits: e259751ec9c9, 84e372c8fb18; files: extensions/imessage/src/actions.ts, extensions/imessage/src/actions.runtime.ts, extensions/imessage/src/probe.ts)

Remaining risk / open question:

  • The upstream openclaw/imsg capability is still open, so released users only get the fallback error until that dependency lands in an installable build.
  • Two exact-head CI checks currently conclude failure, and the public annotations do not expose enough detail to classify them as introduced code defects.

Codex review notes: model gpt-5.5, reasoning high; reviewed against c198d4f23b4e.

omarshahine added a commit to omarshahine/openclaw that referenced this pull request May 9, 2026
Codex review on openclaw#79864 flagged a P1/high security
finding: the reply path treated media/path/filePath as trusted local
paths and passed them straight to imsg send-rich --file, bypassing the
existing outbound media resolver (mediaLocalRoots, sandbox, size limits).

Extend hydrateAttachmentParamsForAction to gate reply alongside
sendAttachment/setGroupIcon/upload-file so the resolver-validated buffer
arrives in args before dispatch. Reply skips the caption -> message
fallback (it owns its own text/message field).

Rewrite the imessage extractor to consume only the hydrated buffer +
filename and reject any unhydrated path-shaped param loudly, and drop
the kind: "path" branch from sendRichMessage so the runtime can no
longer forward a raw host path to imsg --file. Drops the non-canonical
mediaUrls plural alias.

Tests: cover the post-hydration contract for imessage reply, the bypass
rejection for raw paths, and the runner-level reply hydration including
the no-caption-fallback invariant.
@omarshahine omarshahine marked this pull request as ready for review May 9, 2026 16:36
omarshahine added a commit to omarshahine/openclaw that referenced this pull request May 9, 2026
Lifts coverage of openclaw#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.
@clawsweeper clawsweeper Bot added the proof: sufficient ClawSweeper judged the real behavior proof convincing. label May 9, 2026
@omarshahine omarshahine force-pushed the fix/imessage-reply-with-attachment branch from a98a9c7 to 0e26214 Compare May 9, 2026 18:48
omarshahine added a commit to omarshahine/openclaw that referenced this pull request May 9, 2026
Codex review on openclaw#79864 flagged a P1/high security
finding: the reply path treated media/path/filePath as trusted local
paths and passed them straight to imsg send-rich --file, bypassing the
existing outbound media resolver (mediaLocalRoots, sandbox, size limits).

Extend hydrateAttachmentParamsForAction to gate reply alongside
sendAttachment/setGroupIcon/upload-file so the resolver-validated buffer
arrives in args before dispatch. Reply skips the caption -> message
fallback (it owns its own text/message field).

Rewrite the imessage extractor to consume only the hydrated buffer +
filename and reject any unhydrated path-shaped param loudly, and drop
the kind: "path" branch from sendRichMessage so the runtime can no
longer forward a raw host path to imsg --file. Drops the non-canonical
mediaUrls plural alias.

Tests: cover the post-hydration contract for imessage reply, the bypass
rejection for raw paths, and the runner-level reply hydration including
the no-caption-fallback invariant.
omarshahine added a commit to omarshahine/openclaw that referenced this pull request May 9, 2026
Lifts coverage of openclaw#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.
omarshahine added a commit to omarshahine/openclaw that referenced this pull request May 9, 2026
Codex review on openclaw#79864 flagged a P1/high security
finding: the reply path treated media/path/filePath as trusted local
paths and passed them straight to imsg send-rich --file, bypassing the
existing outbound media resolver (mediaLocalRoots, sandbox, size limits).

Extend hydrateAttachmentParamsForAction to gate reply alongside
sendAttachment/setGroupIcon/upload-file so the resolver-validated buffer
arrives in args before dispatch. Reply skips the caption -> message
fallback (it owns its own text/message field).

Rewrite the imessage extractor to consume only the hydrated buffer +
filename and reject any unhydrated path-shaped param loudly, and drop
the kind: "path" branch from sendRichMessage so the runtime can no
longer forward a raw host path to imsg --file. Drops the non-canonical
mediaUrls plural alias.

Tests: cover the post-hydration contract for imessage reply, the bypass
rejection for raw paths, and the runner-level reply hydration including
the no-caption-fallback invariant.
omarshahine added a commit to omarshahine/openclaw that referenced this pull request May 9, 2026
Lifts coverage of openclaw#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.
@omarshahine omarshahine force-pushed the fix/imessage-reply-with-attachment branch from 0e26214 to 41ed575 Compare May 9, 2026 18:54
@openclaw-barnacle openclaw-barnacle Bot added agents Agent runtime and tooling size: L and removed size: M labels May 9, 2026
omarshahine added a commit to omarshahine/openclaw that referenced this pull request May 9, 2026
Codex review on openclaw#79864 flagged a P1/high security
finding: the reply path treated media/path/filePath as trusted local
paths and passed them straight to imsg send-rich --file, bypassing the
existing outbound media resolver (mediaLocalRoots, sandbox, size limits).

Extend hydrateAttachmentParamsForAction to gate reply alongside
sendAttachment/setGroupIcon/upload-file so the resolver-validated buffer
arrives in args before dispatch. Reply skips the caption -> message
fallback (it owns its own text/message field).

Rewrite the imessage extractor to consume only the hydrated buffer +
filename and reject any unhydrated path-shaped param loudly, and drop
the kind: "path" branch from sendRichMessage so the runtime can no
longer forward a raw host path to imsg --file. Drops the non-canonical
mediaUrls plural alias.

Tests: cover the post-hydration contract for imessage reply, the bypass
rejection for raw paths, and the runner-level reply hydration including
the no-caption-fallback invariant.
omarshahine added a commit to omarshahine/openclaw that referenced this pull request May 9, 2026
Lifts coverage of openclaw#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.
@omarshahine omarshahine force-pushed the fix/imessage-reply-with-attachment branch from bc0a91b to 959fc62 Compare May 9, 2026 18:55
@openclaw-barnacle openclaw-barnacle Bot added size: M and removed agents Agent runtime and tooling size: L labels May 9, 2026
Codex review on openclaw#79864 flagged a P1/high security
finding: the reply path treated media/path/filePath as trusted local
paths and passed them straight to imsg send-rich --file, bypassing the
existing outbound media resolver (mediaLocalRoots, sandbox, size limits).

Extend hydrateAttachmentParamsForAction to gate reply alongside
sendAttachment/setGroupIcon/upload-file so the resolver-validated buffer
arrives in args before dispatch. Reply skips the caption -> message
fallback (it owns its own text/message field).

Rewrite the imessage extractor to consume only the hydrated buffer +
filename and reject any unhydrated path-shaped param loudly, and drop
the kind: "path" branch from sendRichMessage so the runtime can no
longer forward a raw host path to imsg --file. Drops the non-canonical
mediaUrls plural alias.

Tests: cover the post-hydration contract for imessage reply, the bypass
rejection for raw paths, and the runner-level reply hydration including
the no-caption-fallback invariant.
Lifts coverage of openclaw#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.
@omarshahine omarshahine force-pushed the fix/imessage-reply-with-attachment branch from 959fc62 to 5e5cdfe Compare May 9, 2026 18:58
@omarshahine omarshahine merged commit 200eb62 into openclaw:main May 9, 2026
19 checks passed
@omarshahine

Copy link
Copy Markdown
Contributor Author

Merged via squash.

Thanks @omarshahine!

@omarshahine omarshahine deleted the fix/imessage-reply-with-attachment branch May 9, 2026 18:59
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request May 24, 2026
…feature gate) (openclaw#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
jameslcowan pushed a commit to jameslcowan/openclaw that referenced this pull request Jun 2, 2026
…feature gate) (openclaw#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
sablehead pushed a commit to sablehead/openclaw that referenced this pull request Jun 10, 2026
…feature gate) (openclaw#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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

channel: imessage Channel integration: imessage maintainer Maintainer-authored PR proof: sufficient ClawSweeper judged the real behavior proof convincing. size: M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant