fix(qr): replace qrcode-terminal with qrcode-tui

Replace legacy qrcode-terminal usage with shared qrcode-tui media helpers, bound QR PNG rendering options, and raise bundled plugin host floors for the new SDK runtime surface.
This commit is contained in:
Vincent Koc
2026-04-23 13:06:14 -07:00
committed by GitHub
parent 6f74763f1d
commit ea25d7ed5b
29 changed files with 336 additions and 193 deletions

View File

@@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Plugins/QR: replace legacy `qrcode-terminal` QR rendering with bounded `qrcode-tui` helpers for plugin login/setup flows. (#65969) Thanks @vincentkoc.
- Auto-reply/system events: route async exec-event completion replies through the persisted session delivery context, so long-running command results return to the originating channel instead of being dropped when live origin metadata is missing. (#70258) Thanks @wzfukui.
- OpenAI/image generation: send reference-image edits as guarded multipart uploads instead of JSON data URLs, restoring complex multi-reference `gpt-image-2` edits. Fixes #70642. Thanks @dashhuang.
- QA channel/security: reject non-HTTP(S) inbound attachment URLs before media fetch, and log rejected schemes so suspicious or misconfigured payloads are visible during debugging. (#70708) Thanks @vincentkoc.

View File

@@ -16,7 +16,7 @@ Feishu/Lark is an all-in-one collaboration platform where teams chat, share docu
## Quick start
> **Requires OpenClaw 2026.4.10 or above.** Run `openclaw --version` to check. Upgrade with `openclaw update`.
> **Requires OpenClaw 2026.4.23 or above.** Run `openclaw --version` to check. Upgrade with `openclaw update`.
<Steps>
<Step title="Run the channel setup wizard">

View File

@@ -258,6 +258,11 @@ const kind = api.runtime.media.mediaKindFromMime("image/jpeg"); // "image"
const isVoice = api.runtime.media.isVoiceCompatibleAudio(filePath);
const metadata = await api.runtime.media.getImageMetadata(filePath);
const resized = await api.runtime.media.resizeToJpeg(buffer, { maxWidth: 800 });
const terminalQr = await api.runtime.media.renderQrTerminal("https://openclaw.ai");
const pngQr = await api.runtime.media.renderQrPngBase64("https://openclaw.ai", {
scale: 6, // 1-12
marginModules: 4, // 0-16
});
```
### `api.runtime.config`

View File

@@ -124,7 +124,7 @@ This script drives the interactive wizard via a pseudo-tty, verifies config/work
## QR import smoke (Docker)
Ensures `qrcode-terminal` loads under the supported Docker Node runtimes (Node 24 default, Node 22 compatible):
Ensures the maintained QR runtime helper loads under the supported Docker Node runtimes (Node 24 default, Node 22 compatible):
```bash
pnpm test:docker:qr

View File

@@ -5,7 +5,6 @@
"type": "module",
"dependencies": {
"@larksuiteoapi/node-sdk": "^1.61.1",
"qrcode-terminal": "^0.12.0",
"typebox": "1.1.28"
},
"devDependencies": {
@@ -13,7 +12,7 @@
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.4.20"
"openclaw": ">=2026.4.23"
},
"peerDependenciesMeta": {
"openclaw": {
@@ -41,10 +40,10 @@
"install": {
"npmSpec": "@openclaw/feishu",
"defaultChoice": "npm",
"minHostVersion": ">=2026.4.10"
"minHostVersion": ">=2026.4.23"
},
"compat": {
"pluginApi": ">=2026.4.20"
"pluginApi": ">=2026.4.23"
},
"build": {
"openclawVersion": "2026.4.20"

View File

@@ -5,8 +5,8 @@
* Replaces axios with native fetch, removes inquirer/ora/chalk in favor of
* the openclaw WizardPrompter surface.
*/
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { renderQrTerminal } from "./qr-terminal.js";
import type { FeishuDomain } from "./types.js";
// ---------------------------------------------------------------------------
@@ -252,9 +252,8 @@ export async function pollAppRegistration(params: {
* otherwise the pattern is corrupted and cannot be scanned.
*/
export async function printQrCode(url: string): Promise<void> {
const mod = await import("qrcode-terminal");
const qrcode = mod.default ?? mod;
qrcode.generate(url, { small: true });
const output = await renderQrTerminal(url, { small: true });
process.stdout.write(output.endsWith("\n") ? output : `${output}\n`);
}
/**

View File

@@ -0,0 +1 @@
export { renderQrTerminal } from "openclaw/plugin-sdk/media-runtime";

View File

@@ -1,12 +0,0 @@
declare module "qrcode-terminal" {
type GenerateOptions = {
small?: boolean;
};
type QrCodeTerminal = {
generate: (input: string, options?: GenerateOptions, cb?: (output: string) => void) => void;
};
const qrcode: QrCodeTerminal;
export default qrcode;
}

View File

@@ -6,7 +6,6 @@
"dependencies": {
"@whiskeysockets/baileys": "7.0.0-rc.9",
"jimp": "^1.6.1",
"qrcode-terminal": "^0.12.0",
"typebox": "1.1.28",
"undici": "8.1.0"
},
@@ -15,7 +14,7 @@
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.4.20"
"openclaw": ">=2026.4.23"
},
"peerDependenciesMeta": {
"openclaw": {
@@ -54,10 +53,10 @@
"install": {
"npmSpec": "@openclaw/whatsapp",
"defaultChoice": "npm",
"minHostVersion": ">=2026.4.10"
"minHostVersion": ">=2026.4.23"
},
"compat": {
"pluginApi": ">=2026.4.20"
"pluginApi": ">=2026.4.23"
},
"bundle": {
"stageRuntimeDependencies": true

View File

@@ -1,6 +1,4 @@
import { EventEmitter } from "node:events";
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { resetLogger, setLoggerOverride } from "openclaw/plugin-sdk/runtime-env";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { renderQrPngBase64 } from "./qr-image.js";
@@ -88,13 +86,4 @@ describe("renderQrPngBase64", () => {
const buf = Buffer.from(b64, "base64");
expect(buf.subarray(0, 8).toString("hex")).toBe("89504e470d0a1a0a");
});
it("avoids dynamic require of qrcode-terminal vendor modules", async () => {
const sourcePath = resolve(process.cwd(), "src/media/qr-image.ts");
const source = await readFile(sourcePath, "utf-8");
expect(source).not.toContain("createRequire(");
expect(source).not.toContain('require("qrcode-terminal/vendor/QRCode")');
expect(source).toContain("qrcode-terminal/vendor/QRCode/index.js");
expect(source).toContain("qrcode-terminal/vendor/QRCode/QRErrorCorrectLevel.js");
});
});

View File

@@ -0,0 +1 @@
export { renderQrTerminal } from "openclaw/plugin-sdk/media-runtime";

View File

@@ -1,12 +0,0 @@
declare module "qrcode-terminal" {
type GenerateOptions = {
small?: boolean;
};
type QrCodeTerminal = {
generate: (input: string, options?: GenerateOptions, cb?: (output: string) => void) => void;
};
const qrcode: QrCodeTerminal;
export default qrcode;
}

View File

@@ -21,6 +21,7 @@ import {
writeCredsJsonAtomically,
type CredsQueueWaitResult,
} from "./creds-persistence.js";
import { renderQrTerminal } from "./qr-terminal.js";
import { formatError, getStatusCode } from "./session-errors.js";
import {
DisconnectReason,
@@ -60,11 +61,6 @@ const LOGGED_OUT_STATUS = DisconnectReason?.loggedOut ?? 401;
const CREDS_FLUSH_TIMEOUT_MESSAGE =
"Queued WhatsApp creds save did not finish before auth bootstrap; skipping repair and continuing with primary creds.";
async function loadQrTerminal() {
const mod = await import("qrcode-terminal");
return mod.default ?? mod;
}
function enqueueSaveCreds(
authDir: string,
saveCreds: () => Promise<void> | void,
@@ -113,6 +109,11 @@ async function safeSaveCreds(
}
}
async function printTerminalQr(qr: string): Promise<void> {
const output = await renderQrTerminal(qr, { small: true });
process.stdout.write(output.endsWith("\n") ? output : `${output}\n`);
}
/**
* Create a Baileys socket backed by the multi-file auth store we keep on disk.
* Consumers can opt into QR printing for interactive login flows.
@@ -172,8 +173,9 @@ export async function createWaSocket(
opts.onQr?.(qr);
if (printQr) {
console.log("Scan this QR in WhatsApp (Linked Devices):");
const qrcode = await loadQrTerminal();
qrcode.generate(qr, { small: true });
void printTerminalQr(qr).catch((err) => {
sessionLogger.warn({ error: String(err) }, "failed rendering WhatsApp QR");
});
}
}
if (connection === "close") {

View File

@@ -610,9 +610,8 @@ vi.mock("./session.runtime.js", () => {
};
});
vi.mock("qrcode-terminal", () => ({
default: { generate: vi.fn() },
generate: vi.fn(),
vi.mock("./qr-terminal.js", () => ({
renderQrTerminal: vi.fn(async () => "ASCII-QR"),
}));
export const baileys = await import("./session.runtime.js");

View File

@@ -1553,6 +1553,7 @@
"@mariozechner/pi-tui": "0.69.0",
"@modelcontextprotocol/sdk": "1.29.0",
"@mozilla/readability": "^0.6.0",
"@vincentkoc/qrcode-tui": "0.2.1",
"ajv": "^8.18.0",
"chalk": "^5.6.2",
"chokidar": "^5.0.0",
@@ -1573,7 +1574,6 @@
"osc-progress": "^0.3.0",
"pdfjs-dist": "^5.6.205",
"proxy-agent": "^8.0.1",
"qrcode-terminal": "^0.12.0",
"semver": "7.7.4",
"sharp": "^0.34.5",
"sqlite-vec": "0.1.9",
@@ -1594,7 +1594,6 @@
"@types/express": "^5.0.6",
"@types/markdown-it": "^14.1.2",
"@types/node": "25.6.0",
"@types/qrcode-terminal": "^0.12.2",
"@types/ws": "^8.18.1",
"@typescript/native-preview": "7.0.0-dev.20260423.1",
"@vitest/coverage-v8": "^4.1.4",

172
pnpm-lock.yaml generated
View File

@@ -75,6 +75,9 @@ importers:
'@napi-rs/canvas':
specifier: ^0.1.89
version: 0.1.92
'@vincentkoc/qrcode-tui':
specifier: 0.2.1
version: 0.2.1
ajv:
specifier: ^8.18.0
version: 8.18.0
@@ -138,9 +141,6 @@ importers:
proxy-agent:
specifier: ^8.0.1
version: 8.0.1
qrcode-terminal:
specifier: ^0.12.0
version: 0.12.0
semver:
specifier: 7.7.4
version: 7.7.4
@@ -196,9 +196,6 @@ importers:
'@types/node':
specifier: 25.6.0
version: 25.6.0
'@types/qrcode-terminal':
specifier: ^0.12.2
version: 0.12.2
'@types/ws':
specifier: ^8.18.1
version: 8.18.1
@@ -547,9 +544,6 @@ importers:
'@larksuiteoapi/node-sdk':
specifier: ^1.61.1
version: 1.61.1
qrcode-terminal:
specifier: ^0.12.0
version: 0.12.0
typebox:
specifier: 1.1.28
version: 1.1.28
@@ -1343,9 +1337,6 @@ importers:
jimp:
specifier: ^1.6.1
version: 1.6.1
qrcode-terminal:
specifier: ^0.12.0
version: 0.12.0
typebox:
specifier: 1.1.28
version: 1.1.28
@@ -4197,9 +4188,6 @@ packages:
'@types/node@25.6.0':
resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==}
'@types/qrcode-terminal@0.12.2':
resolution: {integrity: sha512-v+RcIEJ+Uhd6ygSQ0u5YYY7ZM+la7GgPbs0V/7l/kFs2uO4S8BcIUEMoP7za4DNIqNnUD5npf0A/7kBhrCKG5Q==}
'@types/qs@6.15.0':
resolution: {integrity: sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==}
@@ -4291,6 +4279,11 @@ packages:
resolution: {integrity: sha512-N8/FHc/lmlMDCumMuTXyRHCxlov5KZY6unmJ9QR2GOw+OpROZMBsXYGwE+ZMtvN21ql9+Xb8KhGNBj08IrG3Wg==}
engines: {node: '>=16', npm: '>=8'}
'@vincentkoc/qrcode-tui@0.2.1':
resolution: {integrity: sha512-F2XVHMfasJ0q8G93gtcyU9Px0wMH6o6nIZLrZYSHc6dm9Pq3oCbHuVYYG/UQvJD0rhrGH3P9B6qgpCAqSDUw5w==}
engines: {node: '>=20'}
hasBin: true
'@vitest/browser-playwright@4.1.4':
resolution: {integrity: sha512-q3PchVhZINX23Pv+RERgAtDlp6wzVkID/smOPnZ5YGWpeWUe3jMNYppeVh15j4il3G7JIJty1d1Kicpm0HSMig==}
peerDependencies:
@@ -4699,6 +4692,10 @@ packages:
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
engines: {node: '>= 0.4'}
camelcase@5.3.1:
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
engines: {node: '>=6'}
ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
@@ -4772,6 +4769,9 @@ packages:
resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==}
engines: {node: 10.* || >= 12.*}
cliui@6.0.0:
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
cliui@7.0.4:
resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==}
@@ -4917,6 +4917,10 @@ packages:
supports-color:
optional: true
decamelize@1.2.0:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'}
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
@@ -4982,6 +4986,9 @@ packages:
resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
engines: {node: '>=0.3.1'}
dijkstrajs@1.0.3:
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
discord-api-types@0.38.45:
resolution: {integrity: sha512-DiI01i00FPv6n+hXcFkFxK8Y/rFRpKs6U6aP32N4T73nTbj37Eua3H/95TBpLktLWB6xnLXhYDGvyLq6zzYY2w==}
@@ -5274,6 +5281,10 @@ packages:
resolution: {integrity: sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==}
engines: {node: '>=4.0.0'}
find-up@4.1.0:
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
engines: {node: '>=8'}
flatbuffers@24.12.23:
resolution: {integrity: sha512-dLVCAISd5mhls514keQzmEG6QHmUUsNuWsb4tFafIUwvvgDjXhtfAYSKOzt5SWOy+qByV5pbsDZ+Vb7HUOBEdA==}
@@ -5907,6 +5918,10 @@ packages:
lit@3.3.2:
resolution: {integrity: sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ==}
locate-path@5.0.0:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'}
lodash.camelcase@4.3.0:
resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==}
@@ -6456,6 +6471,14 @@ packages:
resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==}
engines: {node: '>=4'}
p-limit@2.3.0:
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
engines: {node: '>=6'}
p-locate@4.1.0:
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
engines: {node: '>=8'}
p-queue@6.6.2:
resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==}
engines: {node: '>=8'}
@@ -6484,6 +6507,10 @@ packages:
resolution: {integrity: sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==}
engines: {node: '>=20'}
p-try@2.2.0:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'}
pac-proxy-agent@7.2.0:
resolution: {integrity: sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==}
engines: {node: '>= 14'}
@@ -6547,6 +6574,10 @@ packages:
partial-json@0.1.7:
resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==}
path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
path-expression-matcher@1.5.0:
resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==}
engines: {node: '>=14.0.0'}
@@ -6618,6 +6649,10 @@ packages:
engines: {node: '>=18'}
hasBin: true
pngjs@5.0.0:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
pngjs@6.0.0:
resolution: {integrity: sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==}
engines: {node: '>=12.13.0'}
@@ -6758,6 +6793,11 @@ packages:
resolution: {integrity: sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==}
hasBin: true
qrcode@1.5.4:
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
engines: {node: '>=10.13.0'}
hasBin: true
qs@6.14.2:
resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==}
engines: {node: '>=0.6'}
@@ -6870,6 +6910,9 @@ packages:
resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==}
engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'}
require-main-filename@2.0.0:
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
requires-port@1.0.0:
resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
@@ -7631,6 +7674,9 @@ packages:
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
which-module@2.0.1:
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
@@ -7660,6 +7706,10 @@ packages:
resolution: {integrity: sha512-0yweIbkINJodk27gX9LBGMzyQdBDan3s/dEAiwBOj+Mf0PPyWL6/rikalkv8EeD0E8jm4o5RXEOrFTP3NXbhJg==}
engines: {node: '>=12.17'}
wrap-ansi@6.2.0:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
@@ -7701,6 +7751,9 @@ packages:
xmlchars@2.2.0:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
y18n@4.0.3:
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
@@ -7717,6 +7770,10 @@ packages:
engines: {node: '>= 14.6'}
hasBin: true
yargs-parser@18.1.3:
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
engines: {node: '>=6'}
yargs-parser@20.2.9:
resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==}
engines: {node: '>=10'}
@@ -7725,6 +7782,10 @@ packages:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
yargs@15.4.1:
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
engines: {node: '>=8'}
yargs@16.2.0:
resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==}
engines: {node: '>=10'}
@@ -11095,8 +11156,6 @@ snapshots:
dependencies:
undici-types: 7.19.2
'@types/qrcode-terminal@0.12.2': {}
'@types/qs@6.15.0': {}
'@types/range-parser@1.2.7': {}
@@ -11172,6 +11231,10 @@ snapshots:
'@urbit/aura@3.0.0': {}
'@vincentkoc/qrcode-tui@0.2.1':
dependencies:
qrcode: 1.5.4
'@vitest/browser-playwright@4.1.4(playwright@1.59.1)(vite@8.0.9(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.4)':
dependencies:
'@vitest/browser': 4.1.4(vite@8.0.9(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.4)
@@ -11619,6 +11682,8 @@ snapshots:
call-bind-apply-helpers: 1.0.2
get-intrinsic: 1.3.0
camelcase@5.3.1: {}
ccount@2.0.1: {}
chai@6.2.2: {}
@@ -11681,6 +11746,12 @@ snapshots:
optionalDependencies:
'@colors/colors': 1.5.0
cliui@6.0.0:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 6.2.0
cliui@7.0.4:
dependencies:
string-width: 4.2.3
@@ -11820,6 +11891,8 @@ snapshots:
dependencies:
ms: 2.1.3
decamelize@1.2.0: {}
decimal.js@10.6.0: {}
decode-named-character-reference@1.3.0:
@@ -11871,6 +11944,8 @@ snapshots:
diff@8.0.4: {}
dijkstrajs@1.0.3: {}
discord-api-types@0.38.45: {}
discord-api-types@0.38.47: {}
@@ -12220,6 +12295,11 @@ snapshots:
dependencies:
array-back: 3.1.0
find-up@4.1.0:
dependencies:
locate-path: 5.0.0
path-exists: 4.0.0
flatbuffers@24.12.23: {}
follow-redirects@1.16.0: {}
@@ -13019,6 +13099,10 @@ snapshots:
lit-element: 4.2.2
lit-html: 3.3.2
locate-path@5.0.0:
dependencies:
p-locate: 4.1.0
lodash.camelcase@4.3.0: {}
lodash.clonedeep@4.5.0: {}
@@ -13811,6 +13895,14 @@ snapshots:
p-finally@1.0.0: {}
p-limit@2.3.0:
dependencies:
p-try: 2.2.0
p-locate@4.1.0:
dependencies:
p-limit: 2.3.0
p-queue@6.6.2:
dependencies:
eventemitter3: 4.0.7
@@ -13838,6 +13930,8 @@ snapshots:
p-timeout@7.0.1: {}
p-try@2.2.0: {}
pac-proxy-agent@7.2.0:
dependencies:
'@tootallnate/quickjs-emscripten': 0.23.0
@@ -13918,6 +14012,8 @@ snapshots:
partial-json@0.1.7: {}
path-exists@4.0.0: {}
path-expression-matcher@1.5.0: {}
path-is-absolute@1.0.1:
@@ -13983,6 +14079,8 @@ snapshots:
optionalDependencies:
fsevents: 2.3.2
pngjs@5.0.0: {}
pngjs@6.0.0: {}
pngjs@7.0.0: {}
@@ -14165,6 +14263,12 @@ snapshots:
qrcode-terminal@0.12.0: {}
qrcode@1.5.4:
dependencies:
dijkstrajs: 1.0.3
pngjs: 5.0.0
yargs: 15.4.1
qs@6.14.2:
dependencies:
side-channel: 1.1.0
@@ -14311,6 +14415,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
require-main-filename@2.0.0: {}
requires-port@1.0.0: {}
resolve-pkg-maps@1.0.0: {}
@@ -14448,8 +14554,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
set-blocking@2.0.0:
optional: true
set-blocking@2.0.0: {}
setimmediate@1.0.5: {}
@@ -15089,6 +15194,8 @@ snapshots:
tr46: 0.0.3
webidl-conversions: 3.0.1
which-module@2.0.1: {}
which@2.0.2:
dependencies:
isexe: 2.0.0
@@ -15118,6 +15225,12 @@ snapshots:
wordwrapjs@5.1.1: {}
wrap-ansi@6.2.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
@@ -15145,6 +15258,8 @@ snapshots:
xmlchars@2.2.0: {}
y18n@4.0.3: {}
y18n@5.0.8: {}
yallist@4.0.0: {}
@@ -15153,10 +15268,29 @@ snapshots:
yaml@2.8.3: {}
yargs-parser@18.1.3:
dependencies:
camelcase: 5.3.1
decamelize: 1.2.0
yargs-parser@20.2.9: {}
yargs-parser@21.1.1: {}
yargs@15.4.1:
dependencies:
cliui: 6.0.0
decamelize: 1.2.0
find-up: 4.1.0
get-caller-file: 2.0.5
require-directory: 2.1.1
require-main-filename: 2.0.0
set-blocking: 2.0.0
string-width: 4.2.3
which-module: 2.0.1
y18n: 4.0.3
yargs-parser: 18.1.3
yargs@16.2.0:
dependencies:
cliui: 7.0.4

View File

@@ -17,7 +17,7 @@ COPY --chown=appuser:appuser package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY --chown=appuser:appuser ui/package.json ./ui/package.json
COPY --chown=appuser:appuser patches ./patches
# This image only exercises the root qrcode-terminal dependency path.
# This image only exercises the root QR runtime dependency path.
# Keep the pre-install copy set limited to the manifests needed for root
# workspace resolution so unrelated extension edits do not bust the layer.
ARG OPENCLAW_QR_INSTALL_CACHE_BUSTER=stable

View File

@@ -26,5 +26,5 @@ DOCKER_BUILD_CMD+=(
)
run_logged qr-import-build "${DOCKER_BUILD_CMD[@]}"
echo "Running qrcode-terminal import smoke..."
run_logged qr-import-run docker run --rm -t "$IMAGE_NAME" node -e "import('qrcode-terminal').then((m)=>m.default.generate('qr-smoke',{small:true}))"
echo "Running qrcode-tui import smoke..."
run_logged qr-import-run docker run --rm -t "$IMAGE_NAME" node -e "import('@vincentkoc/qrcode-tui').then(async (m)=>{process.stdout.write(await m.renderTerminal('qr-smoke',{small:true}))})"

View File

@@ -10,9 +10,7 @@ const mocks = vi.hoisted(() => ({
resolvedConfig: config,
diagnostics: [] as string[],
})),
qrGenerate: vi.fn((_input: unknown, _opts: unknown, cb: (output: string) => void) => {
cb("ASCII-QR");
}),
renderTerminal: vi.fn(async () => "ASCII-QR"),
}));
const { defaultRuntime: runtime, resetRuntimeCapture } = createCliRuntimeCapture();
const runtimeLog = runtime.log;
@@ -27,6 +25,9 @@ vi.mock("../runtime.js", async () => {
});
vi.mock("../config/config.js", () => ({ loadConfig: mocks.loadConfig }));
vi.mock("../process/exec.js", () => ({ runCommandWithTimeout: mocks.runCommandWithTimeout }));
vi.mock("../media/qr-terminal.ts", () => ({
renderQrTerminal: mocks.renderTerminal,
}));
vi.mock("./command-secret-gateway.js", () => ({
resolveCommandSecretRefsViaGateway: mocks.resolveCommandSecretRefsViaGateway,
}));
@@ -36,16 +37,10 @@ vi.mock("../infra/device-bootstrap.js", () => ({
expiresAtMs: 123,
})),
}));
vi.mock("qrcode-terminal", () => ({
default: {
generate: mocks.qrGenerate,
},
}));
const loadConfig = mocks.loadConfig;
const runCommandWithTimeout = mocks.runCommandWithTimeout;
const resolveCommandSecretRefsViaGateway = mocks.resolveCommandSecretRefsViaGateway;
const qrGenerate = mocks.qrGenerate;
const renderTerminal = mocks.renderTerminal;
const { registerQrCli } = await import("./qr-cli.js");
@@ -196,7 +191,7 @@ describe("registerQrCli", () => {
bootstrapToken: "bootstrap-123",
});
expect(runtime.log).toHaveBeenCalledWith(expected);
expect(qrGenerate).not.toHaveBeenCalled();
expect(renderTerminal).not.toHaveBeenCalled();
expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled();
});
@@ -211,7 +206,7 @@ describe("registerQrCli", () => {
await runQr([]);
expect(qrGenerate).toHaveBeenCalledTimes(1);
expect(renderTerminal).toHaveBeenCalledTimes(1);
const output = runtimeLog.mock.calls.map((call) => readRuntimeCallText(call)).join("\n");
expect(output).toContain("Pairing QR");
expect(output).toContain("ASCII-QR");

View File

@@ -4,6 +4,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
import { hasConfiguredSecretInput } from "../config/types.secrets.js";
import { trimToUndefined } from "../gateway/credentials.js";
import { resolveRequiredConfiguredSecretRefInputString } from "../gateway/resolve-configured-secret-input-string.js";
import { renderQrTerminal } from "../media/qr-terminal.ts";
import { resolvePairingSetupFromConfig, encodePairingSetupCode } from "../pairing/setup-code.js";
import { runCommandWithTimeout } from "../process/exec.js";
import { defaultRuntime } from "../runtime.js";
@@ -23,20 +24,9 @@ type QrCliOptions = {
password?: string;
};
async function loadQrTerminal() {
const mod = await import("qrcode-terminal");
return mod.default ?? mod;
function renderQrAscii(data: string): Promise<string> {
return renderQrTerminal(data, { small: true });
}
async function renderQrAscii(data: string): Promise<string> {
const qrcode = await loadQrTerminal();
return new Promise((resolve) => {
qrcode.generate(data, { small: true }, (output: string) => {
resolve(output);
});
});
}
function readDevicePairPublicUrlFromConfig(cfg: OpenClawConfig): string | undefined {
const value = cfg.plugins?.entries?.["device-pair"]?.config?.["publicUrl"];
if (typeof value !== "string") {

View File

@@ -138,9 +138,7 @@ describe("docker build cache layout", () => {
/^COPY(?:\s+--chown=\S+)?\s+ui\/package\.json \.\/ui\/package\.json$/m,
),
).toBeLessThan(installIndex);
expect(dockerfile).toContain(
"This image only exercises the root qrcode-terminal dependency path.",
);
expect(dockerfile).toContain("This image only exercises the root QR runtime dependency path.");
expect(
indexOfPattern(
dockerfile,

View File

@@ -0,0 +1,53 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const renderPngBase64 = vi.hoisted(() => vi.fn(async () => "mocked-base64"));
vi.mock("@vincentkoc/qrcode-tui", () => ({
renderPngBase64,
}));
import { renderQrPngBase64 } from "./qr-image.ts";
describe("renderQrPngBase64", () => {
beforeEach(() => {
renderPngBase64.mockClear();
});
it("delegates PNG rendering to qrcode-tui", async () => {
await expect(renderQrPngBase64("openclaw", { scale: 8, marginModules: 2 })).resolves.toBe(
"mocked-base64",
);
expect(renderPngBase64).toHaveBeenCalledWith("openclaw", {
margin: 2,
scale: 8,
});
});
it("uses the default PNG rendering options", async () => {
await renderQrPngBase64("openclaw");
expect(renderPngBase64).toHaveBeenCalledWith("openclaw", {
margin: 4,
scale: 6,
});
});
it("floors finite PNG rendering options before delegating", async () => {
await renderQrPngBase64("openclaw", { scale: 8.9, marginModules: 2.9 });
expect(renderPngBase64).toHaveBeenCalledWith("openclaw", {
margin: 2,
scale: 8,
});
});
it.each([
["scale", 0, 4, "scale must be between 1 and 12."],
["scale", 13, 4, "scale must be between 1 and 12."],
["scale", Number.NaN, 4, "scale must be a finite number."],
["marginModules", 6, -1, "marginModules must be between 0 and 16."],
["marginModules", 6, 17, "marginModules must be between 0 and 16."],
["marginModules", 6, Number.POSITIVE_INFINITY, "marginModules must be a finite number."],
])("rejects invalid %s values", async (_name, scale, marginModules, message) => {
await expect(renderQrPngBase64("openclaw", { scale, marginModules })).rejects.toThrow(message);
expect(renderPngBase64).not.toHaveBeenCalled();
});
});

View File

@@ -1,68 +1,53 @@
import { encodePngRgba, fillPixel } from "./png-encode.ts";
import { loadQrCodeTuiRuntime } from "./qr-runtime.ts";
type QRCodeConstructor = new (
typeNumber: number,
errorCorrectLevel: unknown,
) => {
addData: (data: string) => void;
make: () => void;
getModuleCount: () => number;
isDark: (row: number, col: number) => boolean;
};
const DEFAULT_QR_PNG_SCALE = 6;
const DEFAULT_QR_PNG_MARGIN_MODULES = 4;
const MIN_QR_PNG_SCALE = 1;
const MAX_QR_PNG_SCALE = 12;
const MIN_QR_PNG_MARGIN_MODULES = 0;
const MAX_QR_PNG_MARGIN_MODULES = 16;
let qrCodeRuntimePromise: Promise<{
QRCode: QRCodeConstructor;
QRErrorCorrectLevel: Record<string, unknown>;
}> | null = null;
async function loadQrCodeRuntime() {
if (!qrCodeRuntimePromise) {
qrCodeRuntimePromise = Promise.all([
import("qrcode-terminal/vendor/QRCode/index.js"),
import("qrcode-terminal/vendor/QRCode/QRErrorCorrectLevel.js"),
]).then(([qrCodeModule, errorCorrectLevelModule]) => ({
QRCode: qrCodeModule.default as QRCodeConstructor,
QRErrorCorrectLevel: errorCorrectLevelModule.default,
}));
function resolveQrPngIntegerOption(params: {
name: string;
value: number | undefined;
defaultValue: number;
min: number;
max: number;
}): number {
if (params.value === undefined) {
return params.defaultValue;
}
return await qrCodeRuntimePromise;
}
async function createQrMatrix(input: string) {
const { QRCode, QRErrorCorrectLevel } = await loadQrCodeRuntime();
const qr = new QRCode(-1, QRErrorCorrectLevel.L);
qr.addData(input);
qr.make();
return qr;
if (!Number.isFinite(params.value)) {
throw new RangeError(`${params.name} must be a finite number.`);
}
const value = Math.floor(params.value);
if (value < params.min || value > params.max) {
throw new RangeError(`${params.name} must be between ${params.min} and ${params.max}.`);
}
return value;
}
export async function renderQrPngBase64(
input: string,
opts: { scale?: number; marginModules?: number } = {},
): Promise<string> {
const { scale = 6, marginModules = 4 } = opts;
const qr = await createQrMatrix(input);
const modules = qr.getModuleCount();
const size = (modules + marginModules * 2) * scale;
const buf = Buffer.alloc(size * size * 4, 255);
for (let row = 0; row < modules; row += 1) {
for (let col = 0; col < modules; col += 1) {
if (!qr.isDark(row, col)) {
continue;
}
const startX = (col + marginModules) * scale;
const startY = (row + marginModules) * scale;
for (let y = 0; y < scale; y += 1) {
const pixelY = startY + y;
for (let x = 0; x < scale; x += 1) {
const pixelX = startX + x;
fillPixel(buf, pixelX, pixelY, size, 0, 0, 0, 255);
}
}
}
}
const png = encodePngRgba(buf, size, size);
return png.toString("base64");
const scale = resolveQrPngIntegerOption({
name: "scale",
value: opts.scale,
defaultValue: DEFAULT_QR_PNG_SCALE,
min: MIN_QR_PNG_SCALE,
max: MAX_QR_PNG_SCALE,
});
const marginModules = resolveQrPngIntegerOption({
name: "marginModules",
value: opts.marginModules,
defaultValue: DEFAULT_QR_PNG_MARGIN_MODULES,
min: MIN_QR_PNG_MARGIN_MODULES,
max: MAX_QR_PNG_MARGIN_MODULES,
});
const { renderPngBase64 } = await loadQrCodeTuiRuntime();
return await renderPngBase64(input, {
margin: marginModules,
scale,
});
}

8
src/media/qr-runtime.ts Normal file
View File

@@ -0,0 +1,8 @@
let qrCodeTuiRuntimePromise: Promise<typeof import("@vincentkoc/qrcode-tui")> | null = null;
export async function loadQrCodeTuiRuntime() {
if (!qrCodeTuiRuntimePromise) {
qrCodeTuiRuntimePromise = import("@vincentkoc/qrcode-tui");
}
return await qrCodeTuiRuntimePromise;
}

9
src/media/qr-terminal.ts Normal file
View File

@@ -0,0 +1,9 @@
import { loadQrCodeTuiRuntime } from "./qr-runtime.ts";
export async function renderQrTerminal(
input: string,
opts: { small?: boolean } = {},
): Promise<string> {
const { renderTerminal } = await loadQrCodeTuiRuntime();
return await renderTerminal(input, { small: opts.small ?? true });
}

View File

@@ -15,6 +15,7 @@ export * from "../media/mime.js";
export * from "../media/outbound-attachment.js";
export * from "../media/png-encode.ts";
export * from "../media/qr-image.ts";
export * from "../media/qr-terminal.ts";
export * from "../media/read-response-with-limit.js";
export * from "../media/store.js";
export * from "../media/temp-files.js";

View File

@@ -18,7 +18,7 @@ const packageManifestContractTests: PackageManifestContractParams[] = [
{
pluginId: "feishu",
pluginLocalRuntimeDeps: ["@larksuiteoapi/node-sdk"],
mirroredRootRuntimeDeps: ["typebox", "qrcode-terminal"],
mirroredRootRuntimeDeps: ["typebox"],
minHostVersionBaseline: "2026.3.22",
},
{ pluginId: "google", pluginLocalRuntimeDeps: ["@google/genai"] },
@@ -105,7 +105,6 @@ const packageManifestContractTests: PackageManifestContractParams[] = [
{
pluginId: "whatsapp",
pluginLocalRuntimeDeps: ["@whiskeysockets/baileys", "jimp"],
mirroredRootRuntimeDeps: ["qrcode-terminal"],
minHostVersionBaseline: "2026.3.22",
},
{ pluginId: "zalo", minHostVersionBaseline: "2026.3.22" },

View File

@@ -1,22 +0,0 @@
declare module "qrcode-terminal" {
type GenerateOptions = {
small?: boolean;
};
type QrCodeTerminal = {
generate: (input: string, options?: GenerateOptions, cb?: (output: string) => void) => void;
};
const qrcode: QrCodeTerminal;
export default qrcode;
}
declare module "qrcode-terminal/vendor/QRCode/index.js" {
const QRCode: unknown;
export default QRCode;
}
declare module "qrcode-terminal/vendor/QRCode/QRErrorCorrectLevel.js" {
const QRErrorCorrectLevel: Record<string, unknown>;
export default QRErrorCorrectLevel;
}

View File

@@ -431,6 +431,29 @@ describe("runSetupWizard", () => {
expect(runTui).not.toHaveBeenCalled();
});
it("fails fast if the auth choice prompt returns nothing", async () => {
promptAuthChoiceGrouped.mockImplementationOnce(async () => undefined as never);
const prompter = buildWizardPrompter();
const runtime = createRuntime();
await expect(
runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
installDaemon: false,
skipProviders: true,
skipSkills: true,
skipSearch: true,
skipHealth: true,
skipUi: true,
},
runtime,
prompter,
),
).rejects.toThrow("auth choice is required");
});
async function runTuiHatchTest(params: {
writeBootstrapFile: boolean;
expectedMessage: string | undefined;