From a4ae95c1c6efe150ffb1d1e93309db4b580da143 Mon Sep 17 00:00:00 2001
From: Alix-007
Date: Fri, 10 Jul 2026 05:31:07 +0800
Subject: [PATCH] fix(device-pair): resolve QR senders by own key (#101652)
Co-authored-by: Peter Steinberger
---
extensions/device-pair/index.test.ts | 27 +++++++++++++++++++++++++++
extensions/device-pair/index.ts | 16 ++++++++--------
2 files changed, 35 insertions(+), 8 deletions(-)
diff --git a/extensions/device-pair/index.test.ts b/extensions/device-pair/index.test.ts
index 0c9bfd326d74..1db564d5b7c4 100644
--- a/extensions/device-pair/index.test.ts
+++ b/extensions/device-pair/index.test.ts
@@ -601,6 +601,33 @@ describe("device-pair /pair qr", () => {
expect(text).not.toContain("```");
});
+ it.each(["toString", "constructor", "__proto__"])(
+ "requires QR channel sender %s to be an own entry",
+ async (channel) => {
+ const loadAdapter = vi.fn(async () => undefined);
+ const command = registerPairCommand({
+ runtime: {
+ channel: { outbound: { loadAdapter } },
+ } as unknown as OpenClawPluginApi["runtime"],
+ });
+ const result = await command.handler(
+ createCommandContext({
+ channel,
+ senderId: "prototype-channel",
+ gatewayClientScopes: INTERNAL_SETUP_SCOPES,
+ }),
+ );
+ const text = requireText(result);
+
+ expect(pluginApiMocks.writeQrPngTempFile).not.toHaveBeenCalled();
+ expect(loadAdapter).not.toHaveBeenCalled();
+ expect(pluginApiMocks.revokeDeviceBootstrapToken).not.toHaveBeenCalled();
+ expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledTimes(1);
+ expect(text).toContain("QR image delivery is not available on this channel");
+ expect(text).toContain("Setup code:");
+ },
+ );
+
it("supports invalidating unused setup codes", async () => {
const command = registerPairCommand();
const result = await command?.handler(
diff --git a/extensions/device-pair/index.ts b/extensions/device-pair/index.ts
index 418bd1d6d7c5..4ef907a377ed 100644
--- a/extensions/device-pair/index.ts
+++ b/extensions/device-pair/index.ts
@@ -590,8 +590,9 @@ function formatQrInfoMarkdown(params: {
].join("\n");
}
-function canSendQrPngToChannel(channel: string): boolean {
- return channel in QR_CHANNEL_SENDERS;
+function resolveQrChannelSender(channel: string): QrChannelSender | undefined {
+ // Prototype names are not supported channel entries and must take the setup-code fallback.
+ return Object.hasOwn(QR_CHANNEL_SENDERS, channel) ? QR_CHANNEL_SENDERS[channel] : undefined;
}
function resolveQrReplyTarget(ctx: QrCommandContext): string {
@@ -634,16 +635,13 @@ async function issueSetupPayload(url: string, urls?: string[]): Promise {
const mediaLocalRoots = [path.dirname(params.qrFilePath)];
const accountId = normalizeOptionalString(params.ctx.accountId) || undefined;
- const sender = QR_CHANNEL_SENDERS[params.ctx.channel];
- if (!sender) {
- return false;
- }
const adapter = await params.api.runtime.channel.outbound.loadAdapter(params.ctx.channel);
const send = adapter?.sendMedia;
if (!send) {
@@ -653,7 +651,7 @@ async function sendQrPngToSupportedChannel(params: {
cfg: params.api.config,
to: params.target,
text: params.caption,
- ...sender.createOpts({
+ ...params.sender.createOpts({
ctx: params.ctx,
qrFilePath: params.qrFilePath,
mediaLocalRoots,
@@ -783,6 +781,7 @@ export default definePluginEntry({
if (action === "qr") {
const channel = ctx.channel;
+ const qrChannelSender = resolveQrChannelSender(channel);
const target = resolveQrReplyTarget(ctx);
let autoNotifyArmed = false;
@@ -807,7 +806,7 @@ export default definePluginEntry({
expiresAtMs: payload.expiresAtMs,
});
- if (target && canSendQrPngToChannel(channel)) {
+ if (target && qrChannelSender) {
let qrFilePath: string | undefined;
try {
const { resolvePreferredOpenClawTmpDir, writeQrPngTempFile } =
@@ -822,6 +821,7 @@ export default definePluginEntry({
const sent = await sendQrPngToSupportedChannel({
api,
ctx,
+ sender: qrChannelSender,
target,
caption: ["Scan this QR code with the OpenClaw iOS app:", "", ...infoLines].join(
"\n",