Outbound: allow text-only plugin adapters#32788
Conversation
🔒 Aisle Security AnalysisWe found 3 potential security issue(s) in this PR:
1. 🟡 Unredacted recipient identifier (
|
| 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.
tois the delivery target passed throughdeliverOutboundPayloads*and is used asconversationId/recipient across channels; in practice it can be a phone number, email, chat/room ID, etc.- The subsystem logger writes
metato the file logger without automatic redaction/scrubbing. - This introduces PII leakage into logs whenever a plugin outbound adapter lacks
sendMediaand 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
toentirely 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
sendmethod catches errors and returnserrorShape(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:
- 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");- At the API boundary (e.g., gateway
sendhandler), 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.
beforeCountis recorded, butmessageIdis not derived from the new results (those afterbeforeCount).- If
sendTextChunks()completes without pushing any new result (e.g., a plugin-providedchunkerreturns[]inlengthmode, causing the loop to send nothing), then:successbecomesfalse(results.length > beforeCount)- but
messageIdwill still be set to the previous payload’s last messageId (results.at(-1)), corrupting audit/hook metadata.
- Additionally, the event’s
contentis emitted as the untrimmedpayloadSummary.text, even though the actual delivered text isfallbackText = 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 offmessageId.
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
There was a problem hiding this comment.
💡 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".
| return sendText({ | ||
| ...resolveCtx(overrides), | ||
| text: caption, |
There was a problem hiding this comment.
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 SummaryThis PR relaxes the The core change is small and reasonable, but there is one functional concern and one observability gap:
Confidence Score: 3/5
Last reviewed commit: 357f81a |
| sendMedia: async (caption, mediaUrl, overrides) => { | ||
| if (sendMedia) { | ||
| return sendMedia({ | ||
| ...resolveCtx(overrides), | ||
| text: caption, | ||
| mediaUrl, | ||
| }); | ||
| } | ||
| return sendText({ | ||
| ...resolveCtx(overrides), | ||
| text: caption, | ||
| mediaUrl, | ||
| }), | ||
| }); | ||
| }, |
There was a problem hiding this 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:
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.| } | ||
| return sendText({ | ||
| ...resolveCtx(overrides), | ||
| text: caption, | ||
| mediaUrl, | ||
| }), | ||
| }); |
There was a problem hiding this 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:
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.| 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" }]); | ||
| }); |
There was a problem hiding this 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.:
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.|
Addressed the review feedback in
Please recheck when convenient. |
There was a problem hiding this comment.
💡 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".
src/infra/outbound/deliver.ts
Outdated
| const fallbackText = payloadSummary.text.trim(); | ||
| if (!fallbackText) { | ||
| continue; |
There was a problem hiding this comment.
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 👍 / 👎.
Summary
sendTextto be treated as valid direct-delivery handlerssendTextwhensendMediais not implementedFixes #32711
Testing