Skip to content

Outbound: allow text-only plugin adapters#32788

Merged
shakkernerd merged 5 commits intoopenclaw:mainfrom
liuxiaopai-ai:fix-32711-text-only-plugin-outbound
Mar 4, 2026
Merged

Outbound: allow text-only plugin adapters#32788
shakkernerd merged 5 commits intoopenclaw:mainfrom
liuxiaopai-ai:fix-32711-text-only-plugin-outbound

Conversation

@liuxiaopai-ai
Copy link
Copy Markdown
Contributor

Summary

  • allow plugin outbound adapters that only implement sendText to be treated as valid direct-delivery handlers
  • add a media-path fallback so outbound media payloads degrade to caption text via sendText when sendMedia is not implemented
  • add a regression test covering text-only outbound plugin behavior
  • add CHANGELOG entry under 2026.3.3 Fixes

Fixes #32711

Testing

  • pnpm test src/infra/outbound/deliver.test.ts
  • pnpm format

@aisle-research-bot
Copy link
Copy Markdown

aisle-research-bot bot commented Mar 3, 2026

🔒 Aisle Security Analysis

We found 3 potential security issue(s) in this PR:

# Severity Title
1 🟡 Medium Unredacted recipient identifier (to) logged at warn level in outbound media fallback path
2 🔵 Low Information disclosure via detailed plugin capability error (sendMedia missing)
3 🔵 Low Incorrect message_sent messageId/content in media fallback path can reference previous payload

1. 🟡 Unredacted recipient identifier (to) logged at warn level in outbound media fallback path

Property Value
Severity Medium
CWE CWE-532
Location src/infra/outbound/deliver.ts:742-750

Description

The new media-fallback branch logs the raw outbound target identifier (to) at warn level.

  • to is the delivery target passed through deliverOutboundPayloads* and is used as conversationId/recipient across channels; in practice it can be a phone number, email, chat/room ID, etc.
  • The subsystem logger writes meta to the file logger without automatic redaction/scrubbing.
  • This introduces PII leakage into logs whenever a plugin outbound adapter lacks sendMedia and a payload contains media URLs.

Vulnerable code (new):

log.warn(
  "Plugin outbound adapter does not implement sendMedia; media URLs will be dropped and text fallback will be used",
  {
    channel,
    to,
    mediaCount: payloadSummary.mediaUrls.length,
  },
);

Recommendation

Avoid logging raw recipient identifiers.

  • Remove to entirely from this warning, or
  • Log a stable, non-reversible identifier (hash prefix) using the existing helper redactIdentifier().

Example fix:

import { redactIdentifier } from "../../logging/redact-identifier.js";

log.warn(
  "Plugin outbound adapter does not implement sendMedia; media URLs will be dropped and text fallback will be used",
  {
    channel,
    to: redactIdentifier(to),
    mediaCount: payloadSummary.mediaUrls.length,
  },
);

Optionally also downgrade to debug or add rate limiting if this can occur frequently in production.


2. 🔵 Information disclosure via detailed plugin capability error (sendMedia missing)

Property Value
Severity Low
CWE CWE-209
Location src/infra/outbound/deliver.ts:751-756

Description

deliverOutboundPayloadsCore throws a very specific error when a plugin outbound adapter lacks sendMedia and the outbound payload contains only media URLs (no text fallback). This error string discloses internal implementation/capability details (that a plugin outbound adapter is being used and it lacks sendMedia).

Why this matters:

  • The error is thrown from core delivery code and is likely propagated to external API clients.
  • For example, the gateway send method catches errors and returns errorShape(ErrorCodes.UNAVAILABLE, String(err)), which will include this message (typically prefixed with "Error: ").
  • This can enable fingerprinting of installed/configured plugins and their capabilities and may aid targeted abuse.

Vulnerable code (throws detailed internal message):

throw new Error(
  "Plugin outbound adapter does not implement sendMedia and no text fallback is available for media payload",
);

Recommendation

Avoid returning detailed internal capability/architecture messages to untrusted callers.

Recommended approach:

  1. Throw a generic, user-facing error message from delivery code (or throw a typed error with an internal code), e.g.:
// Option A: generic message
throw new Error("Media delivery is not supported for this channel");// Option B: typed error
class OutboundCapabilityError extends Error {
  code = "OUTBOUND_MEDIA_UNSUPPORTED" as const;
}
throw new OutboundCapabilityError("Media delivery is not supported for this channel");
  1. At the API boundary (e.g., gateway send handler), map internal errors to sanitized client messages and keep detailed diagnostics only in server logs/telemetry.

3. 🔵 Incorrect message_sent messageId/content in media fallback path can reference previous payload

Property Value
Severity Low
CWE CWE-840
Location src/infra/outbound/deliver.ts:757-764

Description

In deliverOutboundPayloadsCore, the new !handler.supportsMedia fallback path emits a message_sent event with a messageId derived from results.at(-1) even when no new delivery result was added for this payload.

  • beforeCount is recorded, but messageId is not derived from the new results (those after beforeCount).
  • If sendTextChunks() completes without pushing any new result (e.g., a plugin-provided chunker returns [] in length mode, causing the loop to send nothing), then:
    • success becomes false (results.length > beforeCount)
    • but messageId will still be set to the previous payload’s last messageId (results.at(-1)), corrupting audit/hook metadata.
  • Additionally, the event’s content is emitted as the untrimmed payloadSummary.text, even though the actual delivered text is fallbackText = payloadSummary.text.trim(). This can create discrepancies in auditing/hook processing.

Security impact (integrity):

  • Downstream consumers of message_sent (plugins/internal hooks) may associate a failure (or content) with the wrong message ID, leading to misleading audit trails, incorrect correlation, or erroneous automated actions keyed off messageId.

Vulnerable code:

const beforeCount = results.length;
await sendTextChunks(fallbackText, sendOverrides);
const messageId = results.at(-1)?.messageId;
emitMessageSent({
  success: results.length > beforeCount,
  content: payloadSummary.text,
  messageId,
});

Recommendation

Only attach a messageId when the current payload actually produced new delivery results, and derive it from the newly appended slice. Also emit the actual delivered content.

Example fix:

const beforeCount = results.length;
await sendTextChunks(fallbackText, sendOverrides);
const newResults = results.slice(beforeCount);
const messageId = newResults.at(-1)?.messageId;

emitMessageSent({
  success: newResults.length > 0,
  content: fallbackText,
  messageId: newResults.length > 0 ? messageId : undefined,
});

Additionally, consider hardening sendTextChunks to ensure non-empty text always produces at least one send (or throws) even if a plugin chunker returns an empty array.


Analyzed PR: #32788 at commit c77bfc3

Last updated on: 2026-03-04T19:06:44Z

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 357f81a3f1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +188 to 190
return sendText({
...resolveCtx(overrides),
text: caption,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Collapse media fallback into a single text send

When sendMedia is absent, this path calls sendText for every media URL because deliverOutboundPayloads still iterates attachments and passes an empty caption after the first one. For payloads with multiple media URLs, text-only adapters will emit one real message followed by one or more blank messages (text: ""), and adapters/APIs that reject empty bodies will fail the delivery even though the original text was valid. Please guard this fallback so media degradation sends text only once (or skips empty captions).

Useful? React with 👍 / 👎.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 3, 2026

Greptile Summary

This PR relaxes the createPluginHandler guard so plugin outbound adapters that only implement sendText (without sendMedia) are treated as valid direct-delivery handlers, and adds a sendMedia fallback that degrades to sendText for media payloads when sendMedia is absent.

The core change is small and reasonable, but there is one functional concern and one observability gap:

  • Silent empty-string messages on multi-URL payloads — the delivery loop in deliverOutboundPayloadsCore iterates every mediaUrl in a payload and calls handler.sendMedia(caption, url). Only the first URL receives the real caption; subsequent ones pass caption = "". With the new fallback, each of those empty-caption calls reaches sendText({ text: "" }), meaning a text-only adapter will emit blank messages for every media URL after the first.
  • No logging when mediaUrl is dropped — the fallback silently discards the mediaUrl without any warn/debug log entry, making it hard for plugin authors or operators to notice that media is not being delivered.
  • The regression test only covers the single-URL case; a multi-URL test case would pin the intended semantics and guard against future regressions.

Confidence Score: 3/5

  • Safe to merge for single-media-URL payloads, but carries a regression risk for multi-URL payloads where the fallback path will emit spurious empty-text messages.
  • The guard relaxation and single-URL fallback are correct. The multi-URL edge case (empty-caption calls to sendText) is a genuine logic gap not covered by tests, and the lack of any log when media is silently dropped reduces observability. These issues lower confidence from an otherwise straightforward change.
  • src/infra/outbound/deliver.ts — specifically the sendMedia fallback block (lines 180–192) and how it interacts with the multi-URL iteration loop in deliverOutboundPayloadsCore.

Last reviewed commit: 357f81a

Copy link
Copy Markdown
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 files reviewed, 3 comments

Edit Code Review Agent Settings | Greptile

Comment on lines +180 to +192
sendMedia: async (caption, mediaUrl, overrides) => {
if (sendMedia) {
return sendMedia({
...resolveCtx(overrides),
text: caption,
mediaUrl,
});
}
return sendText({
...resolveCtx(overrides),
text: caption,
mediaUrl,
}),
});
},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multiple media URLs produce spurious empty-text messages

When a payload carries more than one mediaUrl, the delivery loop (lines 732–747 in deliverOutboundPayloadsCore) iterates every URL and calls handler.sendMedia(caption, url, ...). For the very first URL the caption equals payloadSummary.text; for every subsequent URL it is "".

With the text-only fallback path introduced here, that means:

  • URL 1 → sendText({ text: "<real caption>" }) — correct
  • URL 2 → sendText({ text: "" }) — sends a blank/empty message
  • URL N → sendText({ text: "" }) — same

These empty sendText calls will be pushed into results, inflating the result array and potentially causing empty messages to be delivered to the end user.

The simplest fix is to skip the fallback call when the caption is empty:

sendMedia: async (caption, mediaUrl, overrides) => {
  if (sendMedia) {
    return sendMedia({
      ...resolveCtx(overrides),
      text: caption,
      mediaUrl,
    });
  }
  // Only fall back to sendText when there is something worth sending.
  if (!caption) {
    return sendText({
      ...resolveCtx(overrides),
      text: caption,
    });
  }
  return sendText({
    ...resolveCtx(overrides),
    text: caption,
  });
},

Actually, the simpler guard is:

sendMedia: async (caption, mediaUrl, overrides) => {
  if (sendMedia) {
    return sendMedia({
      ...resolveCtx(overrides),
      text: caption,
      mediaUrl,
    });
  }
  if (!caption) {
    // Nothing to send — return a no-op sentinel or throw; sending an empty
    // string produces a blank message on the channel.
    return sendText({ ...resolveCtx(overrides), text: caption });
  }
  return sendText({ ...resolveCtx(overrides), text: caption });
},

At minimum, the test suite should add a multi-media case to document/guard the expected behaviour.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/infra/outbound/deliver.ts
Line: 180-192

Comment:
**Multiple media URLs produce spurious empty-text messages**

When a payload carries more than one `mediaUrl`, the delivery loop (lines 732–747 in `deliverOutboundPayloadsCore`) iterates every URL and calls `handler.sendMedia(caption, url, ...)`.  For the very first URL the caption equals `payloadSummary.text`; for every subsequent URL it is `""`.

With the text-only fallback path introduced here, that means:
- URL 1 → `sendText({ text: "<real caption>" })` — correct
- URL 2 → `sendText({ text: "" })` — sends a blank/empty message
- URL N → `sendText({ text: "" })` — same

These empty `sendText` calls will be pushed into `results`, inflating the result array and potentially causing empty messages to be delivered to the end user.

The simplest fix is to skip the fallback call when the caption is empty:

```typescript
sendMedia: async (caption, mediaUrl, overrides) => {
  if (sendMedia) {
    return sendMedia({
      ...resolveCtx(overrides),
      text: caption,
      mediaUrl,
    });
  }
  // Only fall back to sendText when there is something worth sending.
  if (!caption) {
    return sendText({
      ...resolveCtx(overrides),
      text: caption,
    });
  }
  return sendText({
    ...resolveCtx(overrides),
    text: caption,
  });
},
```

Actually, the simpler guard is:

```typescript
sendMedia: async (caption, mediaUrl, overrides) => {
  if (sendMedia) {
    return sendMedia({
      ...resolveCtx(overrides),
      text: caption,
      mediaUrl,
    });
  }
  if (!caption) {
    // Nothing to send — return a no-op sentinel or throw; sending an empty
    // string produces a blank message on the channel.
    return sendText({ ...resolveCtx(overrides), text: caption });
  }
  return sendText({ ...resolveCtx(overrides), text: caption });
},
```

At minimum, the test suite should add a multi-media case to document/guard the expected behaviour.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +187 to +191
}
return sendText({
...resolveCtx(overrides),
text: caption,
mediaUrl,
}),
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Silent media loss — no diagnostic log

When the fallback is taken the mediaUrl is silently discarded. Plugin authors who ship a text-only adapter may not realise their media content is never delivered, and operators will have no way to detect this from logs.

Consider emitting a log.warn (or at least log.debug) here so the behaviour is observable:

      if (sendMedia) {
        return sendMedia({
          ...resolveCtx(overrides),
          text: caption,
          mediaUrl,
        });
      }
      log.warn(
        "Plugin outbound adapter does not implement sendMedia; falling back to sendText (mediaUrl will not be delivered)",
        { channel: params.channel, mediaUrl },
      );
      return sendText({
        ...resolveCtx(overrides),
        text: caption,
      });
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/infra/outbound/deliver.ts
Line: 187-191

Comment:
**Silent media loss — no diagnostic log**

When the fallback is taken the `mediaUrl` is silently discarded. Plugin authors who ship a text-only adapter may not realise their media content is never delivered, and operators will have no way to detect this from logs.

Consider emitting a `log.warn` (or at least `log.debug`) here so the behaviour is observable:

```typescript
      if (sendMedia) {
        return sendMedia({
          ...resolveCtx(overrides),
          text: caption,
          mediaUrl,
        });
      }
      log.warn(
        "Plugin outbound adapter does not implement sendMedia; falling back to sendText (mediaUrl will not be delivered)",
        { channel: params.channel, mediaUrl },
      );
      return sendText({
        ...resolveCtx(overrides),
        text: caption,
      });
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +893 to +922
it("falls back to sendText when plugin outbound omits sendMedia", async () => {
const sendText = vi.fn().mockResolvedValue({ channel: "matrix", messageId: "mx-1" });
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "matrix",
source: "test",
plugin: createOutboundTestPlugin({
id: "matrix",
outbound: { deliveryMode: "direct", sendText },
}),
},
]),
);

const results = await deliverOutboundPayloads({
cfg: {},
channel: "matrix",
to: "!room:1",
payloads: [{ text: "caption", mediaUrl: "https://example.com/file.png" }],
});

expect(sendText).toHaveBeenCalledTimes(1);
expect(sendText).toHaveBeenCalledWith(
expect.objectContaining({
text: "caption",
}),
);
expect(results).toEqual([{ channel: "matrix", messageId: "mx-1" }]);
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test does not cover the multi-media-URL edge case

The new test exercises a single-URL payload, but the real regression risk from this change is a payload with multiple mediaUrls. For that case the delivery loop calls handler.sendMedia("", url, …) on every URL after the first, which — with the current fallback — forwards an empty string to sendText.

Consider adding a companion case, e.g.:

it("falls back to sendText for each media URL and skips empty captions", async () => {
  const sendText = vi.fn().mockResolvedValue({ channel: "matrix", messageId: "mx-1" });
  // ... same registry setup ...

  const results = await deliverOutboundPayloads({
    cfg: {},
    channel: "matrix",
    to: "!room:1",
    payloads: [{ text: "caption", mediaUrls: ["https://example.com/a.png", "https://example.com/b.png"] }],
  });

  // Verify behaviour: e.g. only one sendText call for non-empty caption,
  // or two calls if empty-caption fallbacks are intentional.
  expect(sendText).toHaveBeenCalledTimes(/* expected count */);
});

This pins the intended semantics and prevents a regression if the multi-URL path is later refactored.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/infra/outbound/deliver.test.ts
Line: 893-922

Comment:
**Test does not cover the multi-media-URL edge case**

The new test exercises a single-URL payload, but the real regression risk from this change is a payload with **multiple** `mediaUrls`.  For that case the delivery loop calls `handler.sendMedia("", url, …)` on every URL after the first, which — with the current fallback — forwards an empty string to `sendText`.

Consider adding a companion case, e.g.:

```typescript
it("falls back to sendText for each media URL and skips empty captions", async () => {
  const sendText = vi.fn().mockResolvedValue({ channel: "matrix", messageId: "mx-1" });
  // ... same registry setup ...

  const results = await deliverOutboundPayloads({
    cfg: {},
    channel: "matrix",
    to: "!room:1",
    payloads: [{ text: "caption", mediaUrls: ["https://example.com/a.png", "https://example.com/b.png"] }],
  });

  // Verify behaviour: e.g. only one sendText call for non-empty caption,
  // or two calls if empty-caption fallbacks are intentional.
  expect(sendText).toHaveBeenCalledTimes(/* expected count */);
});
```

This pins the intended semantics and prevents a regression if the multi-URL path is later refactored.

How can I resolve this? If you propose a fix, please make it concise.

@liuxiaopai-ai
Copy link
Copy Markdown
Contributor Author

Addressed the review feedback in dc7240ca4:

  • Avoided multi-URL empty-message regressions when sendMedia is missing.
    • deliverOutboundPayloadsCore now detects adapters without media support and performs a single text fallback instead of iterating media URLs through sendMedia fallback.
  • Added observability for media drop fallback.
    • Emits a warning when media URLs are dropped because plugin outbound only implements sendText.
  • Added regression coverage for multi-media fallback behavior.
    • New test asserts one text send for two media URLs and validates warning metadata.

Please recheck when convenient.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dc7240ca4c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +743 to +745
const fallbackText = payloadSummary.text.trim();
if (!fallbackText) {
continue;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Treat dropped media-only payloads as delivery failures

When a text-only plugin receives a media-only payload (payloadSummary.text empty), this branch logs and continues without throwing or emitting a message_sent event, so deliverOutboundPayloads completes successfully and the queue entry is acked even though no message was sent. In that scenario (image/file-only replies on adapters without sendMedia), the payload is silently lost; this should be surfaced as a failed delivery rather than treated as success.

Useful? React with 👍 / 👎.

liuxiaopai-ai and others added 3 commits