fix(pair): render /pair qr as media#70047
Conversation
Greptile SummaryThis PR routes
Confidence Score: 4/5Not safe to merge until the NO_REPLY / suppressed-token filtering is added and the replyToId regression is addressed. Two P1 defects: the missing isSuppressedControlReplyText guard will cause visible NO_REPLY text in image-bearing messages and a test regression, and replyToId threading is silently dropped for all media replies. The core QR rendering fix is correct and well-structured, but these two gaps need to be resolved first. src/gateway/server-methods/chat-webchat-media.ts (buildWebchatAssistantMessageFromReplyPayloads — missing suppressed-text filter and reply-threading directives) Prompt To Fix All With AIThis is a comment left during a code review.
Path: src/gateway/server-methods/chat-webchat-media.ts
Line: 150-155
Comment:
**`NO_REPLY` / suppressed control tokens leak into content blocks**
`buildTranscriptReplyText` guards `payload.text` with `isSuppressedControlReplyText(text)` before including it, but this function does not. As a result, when a media-bearing payload carries `text: "NO_REPLY"` (or `ANNOUNCE_SKIP` / `REPLY_SKIP`), the trimmed token is pushed into both `transcriptTextParts` and `content`, so `transcriptText` becomes `"NO_REPLY"` and the content block is `{ type: "text", text: "NO_REPLY" }` instead of the intended `"Image reply"` synthetic fallback.
The test at line 1914 of `chat.directive-tags.test.ts` sets exactly `{ text: "NO_REPLY", mediaUrl: "..." }` and expects `{ type: "text", text: "Image reply" }` — this assertion will fail with the code as written.
```ts
const text = payload.text?.trim();
if (text && !isSuppressedControlReplyText(text)) {
transcriptTextParts.push(text);
content.push({ type: "text", text });
}
```
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: src/gateway/server-methods/chat-webchat-media.ts
Line: 139-193
Comment:
**`replyToId` / `replyToCurrent` directives silently dropped for media replies**
`buildTranscriptReplyText` prepends `[[reply_to:...]]` / `[[reply_to_current]]` when `payload.replyToId` or `payload.replyToCurrent` are set. This function ignores those fields entirely, so whenever `mediaMessage` takes precedence over `combinedReply` in `chat.ts`, the threading context is lost from the stored transcript message. Consider mirroring the directive-tag handling here, or falling back to the `combinedReply`-derived message for the transcript `message` string while still using `mediaMessage.content` for the content blocks.
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: src/gateway/server-methods/chat.ts
Line: 2091
Comment:
**`appendedWebchatAgentAudio` variable name is stale**
The variable `appendedWebchatAgentAudio` (declared earlier in scope, unchanged in this PR) is now used to gate both audio and image media appends. The name is misleading and could cause confusion during future work on this guard. Consider renaming it to `appendedWebchatAgentMedia` for consistency with the other identifiers renamed in this PR (`appendWebchatAgentMediaTranscriptIfNeeded`, `assistant-media`).
How can I resolve this? If you propose a fix, please make it concise.Reviews (1): Last reviewed commit: "Merge branch 'main' into fix/pair-qr-ren..." | Re-trigger Greptile |
|
Resolved in A note on scope: the linked Aisle comment currently shows 3 findings as of April 22, 2026, but I hardened the full 5-objection boundary from the earlier review cycle so this path does not come back with a revised variant of the same issue class.
Additional hardening included in the same patch:
Validation run:
|
🔒 Aisle Security AnalysisWe found 3 potential security issue(s) in this PR:
1. 🟠 Audit log bypass via untrusted `sensitiveMedia` flag suppressing transcript persistence
DescriptionThe gateway uses Because
This creates an audit/evidence gap: users still receive the media, but transcript storage does not retain what was sent. Vulnerable behavior: // buildTranscriptReplyText(): drops media URLs from persisted transcript text
for (const mediaUrl of parts.mediaUrls) {
if (payload.sensitiveMedia === true) {
continue;
}
lines.push(`MEDIA:${trimmed}`);
}
// appendAssistantTranscriptMessage(): drops persisted `content`
...(payload.sensitiveMedia === true ? {} : { content: mediaMessage.content })Additionally, the plugin SDK explicitly preserves // plugin-sdk/reply-payload.ts
const sensitiveMedia = payload.sensitiveMedia === true ? true : undefined;so callers outside core can trigger the suppression. RecommendationTreat Recommended fixes (pick one):
// src/plugin-sdk/reply-payload.ts
export type ReplyPayload = Omit<InternalReplyPayload, "trustedLocalMedia" | "sensitiveMedia">;
export function normalizeOutboundReplyPayload(payload: Record<string, unknown>): OutboundReplyPayload {
// ...do NOT forward sensitiveMedia from untrusted payloads
return { text, mediaUrls, mediaUrl, replyToId };
}
const allowSensitive = isTrustedOrigin(ctx) && payload.sensitiveMedia === true;
if (!allowSensitive) payload = { ...payload, sensitiveMedia: undefined };
This prevents malicious plugins from hiding delivered content from audit logs while preserving the intended privacy behavior for approved internal flows. 2. 🟠 Internal-only trustedLocalMedia flag forwarded to outbound channel plugins via deliverOutboundPayloads
DescriptionOutbound delivery forwards the full internal
Vulnerable code (payload forwarded without boundary normalization): const effectivePayload = await renderPresentationForDelivery(handler, hookResult.payload);
...
const delivery = await handler.sendPayload(effectivePayload, sendOverrides);RecommendationEnforce a runtime outbound plugin boundary before calling any outbound adapter methods:
Example (conceptual): import { normalizeOutboundReplyPayload } from "../../plugin-sdk/reply-payload";
// before calling plugin adapter
const outbound = normalizeOutboundReplyPayload(effectivePayload as Record<string, unknown>);
await handler.sendPayload(outbound, sendOverrides);If internal delivery paths need 3. 🟡 Potential client-side DoS via decompression-bomb image data URLs in webchat assistant messages
Description
Although the code caps encoded size (1.5MB decoded base64 estimate), it does not validate image dimensions/pixel count before the web client decodes/renders it. Many image formats (notably GIF, BMP, and even PNG) can be crafted as decompression bombs where a relatively small encoded payload expands into extremely large pixel buffers when decoded, causing:
This is a security issue when an attacker can cause such a payload to be delivered to other users via chat/websocket (availability impact). Vulnerable code: const match = /^data:(image\/[a-z0-9.+-]+);base64,([a-z0-9+/=\s]+)$/i.exec(trimmed);
...
if (!ALLOWED_WEBCHAT_DATA_IMAGE_MEDIA_TYPES.has(mediaType)) {
return null;
}
if (estimateBase64DecodedBytes(base64Data) > MAX_WEBCHAT_IMAGE_DATA_BYTES) {
return null;
}
return trimmed;RecommendationValidate images server-side before forwarding to the webchat client. Options (prefer the first):
Example sketch (dimension check): import sharp from "sharp";
async function validateImage(base64Data: string) {
const buf = Buffer.from(base64Data.replace(/\s+/g, ""), "base64");
const meta = await sharp(buf, { animated: false, limitInputPixels: 16_000_000 }).metadata();
if (!meta.width || !meta.height) throw new Error("invalid image");
if (meta.width * meta.height > 16_000_000) throw new Error("image too large");
}This prevents small encoded payloads from causing large decode-time allocations in the client. Analyzed PR: #70047 at commit Last updated on: 2026-04-22T08:34:22Z |
|
Explicit resolution for #70047 (comment): Fixed in
Validation:
I also ran |
Updated changelog to include recent fixes and enhancements.
* fix(pair): render pair qr as media * fix(gateway): preserve media reply threading * fix(gateway): harden webchat media replies * fix(plugin-sdk): keep trustedLocalMedia internal * docs(changelog): note pair qr media fix * Update CHANGELOG with recent fixes and enhancements Updated changelog to include recent fixes and enhancements.
* fix(pair): render pair qr as media * fix(gateway): preserve media reply threading * fix(gateway): harden webchat media replies * fix(plugin-sdk): keep trustedLocalMedia internal * docs(changelog): note pair qr media fix * Update CHANGELOG with recent fixes and enhancements Updated changelog to include recent fixes and enhancements.
* fix(pair): render pair qr as media * fix(gateway): preserve media reply threading * fix(gateway): harden webchat media replies * fix(plugin-sdk): keep trustedLocalMedia internal * docs(changelog): note pair qr media fix * Update CHANGELOG with recent fixes and enhancements Updated changelog to include recent fixes and enhancements.
* fix(pair): render pair qr as media * fix(gateway): preserve media reply threading * fix(gateway): harden webchat media replies * fix(plugin-sdk): keep trustedLocalMedia internal * docs(changelog): note pair qr media fix * Update CHANGELOG with recent fixes and enhancements Updated changelog to include recent fixes and enhancements.
* fix(pair): render pair qr as media * fix(gateway): preserve media reply threading * fix(gateway): harden webchat media replies * fix(plugin-sdk): keep trustedLocalMedia internal * docs(changelog): note pair qr media fix * Update CHANGELOG with recent fixes and enhancements Updated changelog to include recent fixes and enhancements.
* fix(pair): render pair qr as media * fix(gateway): preserve media reply threading * fix(gateway): harden webchat media replies * fix(plugin-sdk): keep trustedLocalMedia internal * docs(changelog): note pair qr media fix * Update CHANGELOG with recent fixes and enhancements Updated changelog to include recent fixes and enhancements.
* fix(pair): render pair qr as media * fix(gateway): preserve media reply threading * fix(gateway): harden webchat media replies * fix(plugin-sdk): keep trustedLocalMedia internal * docs(changelog): note pair qr media fix * Update CHANGELOG with recent fixes and enhancements Updated changelog to include recent fixes and enhancements.
* fix(pair): render pair qr as media * fix(gateway): preserve media reply threading * fix(gateway): harden webchat media replies * fix(plugin-sdk): keep trustedLocalMedia internal * docs(changelog): note pair qr media fix * Update CHANGELOG with recent fixes and enhancements Updated changelog to include recent fixes and enhancements.
Fixes
/pair qron internal chat surfaces where the QR was showing up as raw code/text instead of an actual scannable image ✨The bug was not in QR generation itself. The device-pair command was returning the QR for webchat as markdown text with an inline
data:image/...payload, and the gateway chat path only had special handling for audio media. That meant image-bearing replies could degrade into text-oriented output, so Control UI and related internal surfaces showed raw code-like content instead of a rendered QR image.This change moves the pair QR flow onto the normal media path:
/pair qrnow returns structured media for the generated QR instead of embedding it in markdown textMEDIA:...text for internal chat renderingTests added/updated:
/pair qrreturning structured media on webchatMEDIA:textValidation:
node scripts/test-projects.mjs extensions/device-pair/index.test.ts src/gateway/server-methods/chat-webchat-media.test.ts src/gateway/server-methods/chat.directive-tags.test.ts