Skip to content

Commit 052dc59

Browse files
authored
fix: /pair qr fails to show QR code in chat surfaces (#97933)
* fix(pair): render qr per chat surface * test(plugin-sdk): update reply helper surface budget * fix(pair): keep qr metadata internal
1 parent f7cca68 commit 052dc59

11 files changed

Lines changed: 281 additions & 9 deletions

File tree

extensions/device-pair/index.test.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,12 @@ describe("device-pair /pair qr", () => {
290290
gatewayClientScopes: INTERNAL_SETUP_SCOPES,
291291
}),
292292
);
293-
const payload = result as { text?: string; mediaUrl?: string; sensitiveMedia?: boolean };
293+
const payload = result as {
294+
text?: string;
295+
mediaUrl?: string;
296+
channelData?: Record<string, unknown>;
297+
sensitiveMedia?: boolean;
298+
};
294299
const text = requireText(result);
295300

296301
expect(pluginApiMocks.renderQrPngDataUrl).toHaveBeenCalledTimes(1);
@@ -301,7 +306,11 @@ describe("device-pair /pair qr", () => {
301306
},
302307
});
303308
expect(text).toContain("Scan this QR code with the OpenClaw iOS app:");
304-
expect(payload.mediaUrl).toBe("data:image/png;base64,ZmFrZXBuZw==");
309+
expect(payload.mediaUrl).toBeUndefined();
310+
expect(payload.channelData?.openclawPairingQr).toEqual({
311+
setupCode: expect.any(String),
312+
expiresAtMs: expect.any(Number),
313+
});
305314
expect(payload.sensitiveMedia).toBe(true);
306315
expect(text).toContain("- Security: single-use bootstrap token");
307316
expect(text).toContain("**Important:** Run `/pair cleanup` after pairing finishes.");

extensions/device-pair/index.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
normalizeLowercaseStringOrEmpty,
88
normalizeOptionalString,
99
} from "openclaw/plugin-sdk/string-coerce-runtime";
10+
import { buildDevicePairPairingQrChannelData } from "./pairing-qr-channel-data.js";
1011

1112
type DevicePairApiModule = typeof import("./api.js");
1213
type NotifyModule = typeof import("./notify.js");
@@ -834,10 +835,9 @@ export default definePluginEntry({
834835

835836
api.logger.info?.(`device-pair: QR fallback channel=${channel} target=${target}`);
836837
if (channel === "webchat") {
837-
let qrDataUrl: string;
838838
try {
839839
const { renderQrPngDataUrl } = await loadDevicePairApiModule();
840-
qrDataUrl = await renderQrPngDataUrl(setupCode);
840+
await renderQrPngDataUrl(setupCode);
841841
} catch (err) {
842842
const { revokeDeviceBootstrapToken } = await loadDevicePairApiModule();
843843
api.logger.warn?.(
@@ -862,7 +862,10 @@ export default definePluginEntry({
862862
expiresAtMs: payload.expiresAtMs,
863863
}),
864864
].join("\n"),
865-
mediaUrl: qrDataUrl,
865+
channelData: buildDevicePairPairingQrChannelData({
866+
setupCode,
867+
expiresAtMs: payload.expiresAtMs,
868+
}),
866869
sensitiveMedia: true,
867870
};
868871
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// Private device-pair -> Gateway live-display envelope.
2+
// Keep this local so pairing QR metadata does not become public Plugin SDK API.
3+
export const DEVICE_PAIR_PAIRING_QR_CHANNEL_DATA_KEY = "openclawPairingQr";
4+
5+
export type DevicePairPairingQrChannelData = {
6+
setupCode: string;
7+
expiresAtMs: number;
8+
};
9+
10+
export function buildDevicePairPairingQrChannelData(
11+
params: DevicePairPairingQrChannelData,
12+
): Record<string, unknown> {
13+
return {
14+
[DEVICE_PAIR_PAIRING_QR_CHANNEL_DATA_KEY]: {
15+
setupCode: params.setupCode,
16+
expiresAtMs: params.expiresAtMs,
17+
},
18+
};
19+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// Reply payload tests cover internal reply metadata contracts.
2+
import { describe, expect, it } from "vitest";
3+
import { buildPairingQrReplyChannelData, readPairingQrReplyChannelData } from "./reply-payload.js";
4+
5+
describe("pairing QR reply channel data", () => {
6+
it("builds and reads the private pairing QR payload metadata", () => {
7+
const channelData = buildPairingQrReplyChannelData({
8+
setupCode: "setup-code",
9+
expiresAtMs: 1_800_000_000_000,
10+
});
11+
12+
expect(readPairingQrReplyChannelData({ channelData })).toEqual({
13+
setupCode: "setup-code",
14+
expiresAtMs: 1_800_000_000_000,
15+
});
16+
});
17+
18+
it("ignores malformed pairing QR metadata", () => {
19+
expect(
20+
readPairingQrReplyChannelData({
21+
channelData: {
22+
openclawPairingQr: {
23+
setupCode: "",
24+
expiresAtMs: 0,
25+
},
26+
},
27+
}),
28+
).toBeUndefined();
29+
});
30+
});

src/auto-reply/reply-payload.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,47 @@ export type ReplyPayload = {
6262
channelData?: Record<string, unknown>;
6363
};
6464

65+
// Private device-pair -> Gateway live-display envelope key. Do not re-export
66+
// through Plugin SDK; this is not a third-party plugin contract.
67+
export const PAIRING_QR_REPLY_CHANNEL_DATA_KEY = "openclawPairingQr";
68+
69+
export type PairingQrReplyChannelData = {
70+
setupCode: string;
71+
expiresAtMs: number;
72+
};
73+
74+
function normalizePairingQrSetupCode(value: unknown): string | undefined {
75+
return typeof value === "string" && value.trim() ? value : undefined;
76+
}
77+
78+
function normalizePairingQrExpiresAtMs(value: unknown): number | undefined {
79+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
80+
}
81+
82+
export function buildPairingQrReplyChannelData(
83+
params: PairingQrReplyChannelData,
84+
): Record<string, unknown> {
85+
return {
86+
[PAIRING_QR_REPLY_CHANNEL_DATA_KEY]: {
87+
setupCode: params.setupCode,
88+
expiresAtMs: params.expiresAtMs,
89+
},
90+
};
91+
}
92+
93+
export function readPairingQrReplyChannelData(
94+
payload: Pick<ReplyPayload, "channelData">,
95+
): PairingQrReplyChannelData | undefined {
96+
const raw = payload.channelData?.[PAIRING_QR_REPLY_CHANNEL_DATA_KEY];
97+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
98+
return undefined;
99+
}
100+
const record = raw as Record<string, unknown>;
101+
const setupCode = normalizePairingQrSetupCode(record.setupCode);
102+
const expiresAtMs = normalizePairingQrExpiresAtMs(record.expiresAtMs);
103+
return setupCode && expiresAtMs ? { setupCode, expiresAtMs } : undefined;
104+
}
105+
65106
/** Metadata for fast-auto progress notices. */
66107
export const FAST_MODE_AUTO_PROGRESS_KIND = "fast-mode-auto";
67108

src/gateway/server-methods/chat.directive-tags.test.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ import {
1414
import { ErrorCodes } from "../../../packages/gateway-protocol/src/index.js";
1515
import { CHAT_SEND_SESSION_KEY_MAX_LENGTH } from "../../../packages/gateway-protocol/src/schema.js";
1616
import type { ModelCatalogEntry } from "../../agents/model-catalog.types.js";
17-
import { setReplyPayloadMetadata } from "../../auto-reply/reply-payload.js";
17+
import {
18+
buildPairingQrReplyChannelData,
19+
setReplyPayloadMetadata,
20+
} from "../../auto-reply/reply-payload.js";
1821
import type { MsgContext } from "../../auto-reply/templating.js";
1922
import { appendSessionTranscriptMessage } from "../../config/sessions/transcript-append.js";
2023
import { resolveMirroredTranscriptText } from "../../config/sessions/transcript-mirror.js";
@@ -33,6 +36,7 @@ const mockState = vi.hoisted(() => ({
3336
text?: string;
3437
mediaUrl?: string;
3538
mediaUrls?: string[];
39+
channelData?: Record<string, unknown>;
3640
spokenText?: string;
3741
audioAsVoice?: boolean;
3842
trustedLocalMedia?: boolean;
@@ -48,6 +52,7 @@ const mockState = vi.hoisted(() => ({
4852
text?: string;
4953
mediaUrl?: string;
5054
mediaUrls?: string[];
55+
channelData?: Record<string, unknown>;
5156
spokenText?: string;
5257
ttsSupplement?: { spokenText: string };
5358
audioAsVoice?: boolean;
@@ -2454,6 +2459,54 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
24542459
expect(transcriptUpdate).toBeTruthy();
24552460
});
24562461

2462+
it("broadcasts sensitive pairing QR display without persisting QR content", async () => {
2463+
createTranscriptFixture("openclaw-chat-send-command-pair-qr-");
2464+
const setupCode = "openclaw-test-pairing-setup-code";
2465+
mockState.dispatchedReplies = [
2466+
{
2467+
kind: "final",
2468+
payload: {
2469+
text: "Scan this QR code with the OpenClaw iOS app:",
2470+
channelData: buildPairingQrReplyChannelData({
2471+
setupCode,
2472+
expiresAtMs: Date.now() + 10 * 60_000,
2473+
}),
2474+
sensitiveMedia: true,
2475+
},
2476+
},
2477+
];
2478+
const respond = vi.fn();
2479+
const context = createChatContext();
2480+
2481+
const payload = await runNonStreamingChatSend({
2482+
context,
2483+
respond,
2484+
idempotencyKey: "idem-command-pair-qr",
2485+
message: "/pair qr",
2486+
});
2487+
2488+
const content = getMessageContent(payload);
2489+
expect(content[0]).toEqual({
2490+
type: "text",
2491+
text: "Scan this QR code with the OpenClaw iOS app:",
2492+
});
2493+
expect(content[1]).toEqual(
2494+
expect.objectContaining({
2495+
type: "openclaw_pairing_qr",
2496+
image_url: expect.stringMatching(/^data:image\/png;base64,/u),
2497+
terminalText: expect.stringContaining("█"),
2498+
sensitive: true,
2499+
}),
2500+
);
2501+
const transcriptMessages = await readActiveAssistantTranscriptMessages();
2502+
const serializedTranscript = JSON.stringify(transcriptMessages);
2503+
expect(serializedTranscript).toContain("Scan this QR code with the OpenClaw iOS app:");
2504+
expect(serializedTranscript).not.toContain("openclaw_pairing_qr");
2505+
expect(serializedTranscript).not.toContain("data:image/png");
2506+
expect(serializedTranscript).not.toContain("terminalText");
2507+
expect(serializedTranscript).not.toContain(setupCode);
2508+
});
2509+
24572510
it("keeps visible slash-command finals alongside earlier block text", async () => {
24582511
createTranscriptFixture("openclaw-chat-send-command-block-text-final-");
24592512
mockState.dispatchedReplies = [

src/gateway/server-methods/chat.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import { dispatchInboundMessage } from "../../auto-reply/dispatch.js";
5151
import {
5252
getReplyPayloadMetadata,
5353
isReplyPayloadStatusNotice,
54+
readPairingQrReplyChannelData,
5455
type ReplyPayload,
5556
} from "../../auto-reply/reply-payload.js";
5657
import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js";
@@ -84,6 +85,8 @@ import {
8485
} from "../../media/local-roots.js";
8586
import { parseInboundMediaUri } from "../../media/media-reference.js";
8687
import type { PromptImageOrderEntry } from "../../media/prompt-image-order.js";
88+
import { renderQrPngDataUrl } from "../../media/qr-image.js";
89+
import { renderQrTerminal } from "../../media/qr-terminal.js";
8790
import {
8891
deleteMediaBuffer,
8992
MEDIA_MAX_BYTES,
@@ -857,12 +860,35 @@ function buildTranscriptReplyText(payloads: ReplyPayload[]): string {
857860

858861
function hasSensitiveMediaPayload(payloads: ReplyPayload[]): boolean {
859862
return payloads.some(
860-
(payload) => payload.sensitiveMedia === true && isMediaBearingPayload(payload),
863+
(payload) =>
864+
payload.sensitiveMedia === true &&
865+
(isMediaBearingPayload(payload) || Boolean(readPairingQrReplyChannelData(payload))),
861866
);
862867
}
863868

864869
type AssistantDisplayContentBlock = Record<string, unknown>;
865870

871+
async function buildPairingQrAssistantContentBlock(
872+
payload: ReplyPayload,
873+
): Promise<AssistantDisplayContentBlock | undefined> {
874+
const qr = readPairingQrReplyChannelData(payload);
875+
if (!qr) {
876+
return undefined;
877+
}
878+
const [imageUrl, terminalText] = await Promise.all([
879+
renderQrPngDataUrl(qr.setupCode),
880+
renderQrTerminal(qr.setupCode, { small: true }),
881+
]);
882+
return {
883+
type: "openclaw_pairing_qr",
884+
image_url: imageUrl,
885+
terminalText,
886+
alt: "OpenClaw pairing QR code",
887+
expiresAtMs: qr.expiresAtMs,
888+
sensitive: true,
889+
};
890+
}
891+
866892
function sanitizeAssistantDisplayText(value?: string | null): string | undefined {
867893
if (!value) {
868894
return undefined;
@@ -896,8 +922,10 @@ async function buildAssistantDisplayContentFromReplyPayloads(params: {
896922
payloads: ReplyPayload[];
897923
managedImageLocalRoots?: Parameters<typeof createManagedOutgoingImageBlocks>[0]["localRoots"];
898924
includeSensitiveMedia?: boolean;
925+
includeSensitiveDisplay?: boolean;
899926
onLocalAudioAccessDenied?: (message: string) => void;
900927
onManagedImagePrepareError?: (message: string) => void;
928+
onSensitiveDisplayPrepareError?: (message: string) => void;
901929
}): Promise<AssistantDisplayContentBlock[] | undefined> {
902930
const rawTextPayloadCount = params.payloads.filter(
903931
(payload) =>
@@ -919,6 +947,16 @@ async function buildAssistantDisplayContentFromReplyPayloads(params: {
919947
} else if (typeof payload.text === "string" && payload.text.trim().length > 0) {
920948
strippedTextPayloadCount += 1;
921949
}
950+
if (params.includeSensitiveDisplay === true) {
951+
try {
952+
const pairingQrBlock = await buildPairingQrAssistantContentBlock(payload);
953+
if (pairingQrBlock) {
954+
content.push(pairingQrBlock);
955+
}
956+
} catch (err) {
957+
params.onSensitiveDisplayPrepareError?.(formatForLog(err));
958+
}
959+
}
922960
if (params.includeSensitiveMedia === false && payload.sensitiveMedia === true) {
923961
continue;
924962
}
@@ -4642,6 +4680,7 @@ export const chatHandlers: GatewayRequestHandlers = {
46424680
payloads: finalPayloads,
46434681
managedImageLocalRoots: mediaLocalRoots,
46444682
includeSensitiveMedia: false,
4683+
includeSensitiveDisplay: true,
46454684
onLocalAudioAccessDenied: (message) => {
46464685
context.logGateway.warn(
46474686
`webchat audio embedding denied local path: ${message}`,
@@ -4652,6 +4691,11 @@ export const chatHandlers: GatewayRequestHandlers = {
46524691
`webchat image embedding skipped attachment: ${message}`,
46534692
);
46544693
},
4694+
onSensitiveDisplayPrepareError: (message) => {
4695+
context.logGateway.warn(
4696+
`webchat sensitive display skipped attachment: ${message}`,
4697+
);
4698+
},
46554699
});
46564700
const mediaMessage = await buildWebchatAssistantMediaMessage(finalPayloads, {
46574701
localRoots: mediaLocalRoots,

src/tui/tui-formatters.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -364,12 +364,38 @@ export function extractContentFromMessage(message: unknown): string {
364364

365365
function extractAssistantRenderableContent(record: Record<string, unknown>): string {
366366
const visible = sanitizeRenderableText(extractAssistantVisibleText(record) ?? "").trim();
367-
if (visible) {
368-
return visible;
367+
const pairingQr = extractPairingQrTerminalText(record);
368+
const content = [visible, pairingQr].filter(Boolean).join("\n\n").trim();
369+
if (content) {
370+
return content;
369371
}
370372
return formatAssistantErrorFromRecord(record);
371373
}
372374

375+
function extractPairingQrTerminalText(record: Record<string, unknown>): string {
376+
const content = record.content;
377+
if (!Array.isArray(content)) {
378+
return "";
379+
}
380+
const parts: string[] = [];
381+
for (const block of content) {
382+
if (!block || typeof block !== "object") {
383+
continue;
384+
}
385+
const blockRecord = block as Record<string, unknown>;
386+
if (
387+
blockRecord.type === "openclaw_pairing_qr" &&
388+
typeof blockRecord.terminalText === "string"
389+
) {
390+
const text = sanitizeRenderableText(blockRecord.terminalText).trim();
391+
if (text) {
392+
parts.push(text);
393+
}
394+
}
395+
}
396+
return parts.join("\n\n").trim();
397+
}
398+
373399
function extractTextBlocks(content: unknown, opts?: { includeThinking?: boolean }): string {
374400
if (typeof content === "string") {
375401
return sanitizeRenderableText(content).trim();

0 commit comments

Comments
 (0)