Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,17 @@
import { describe, expect, it, vi } from "vitest";

import {
createOpenClawQrTerminalLoaderSource,
createOpenClawQrTerminalSyncLoadHook,
createOpenClawQrTerminalLoadHook,
describeOpenClawQrTerminalPatchSkip,
isOpenClawQrTerminalRendererSource,
isQrcodePackage,
isQrcodeTerminalPackage,
isOpenClawQrTerminalRendererSource,
isReviewedOpenClawQrTerminalRendererIntegrity,
patchOpenClawQrTerminalRendererSource,
patchQrcode,
patchQrcodeTerminal,
REVIEWED_OPENCLAW_QR_TERMINAL_RENDERER_SHA256,
registerOpenClawQrTerminalSourceLoader,
warnWhatsappQrCompact,
} from "./whatsapp-qr-compact";
import { makeQrcodeLoadHook } from "./whatsapp-qr-compact-test-helpers";
Expand Down Expand Up @@ -168,25 +168,85 @@ describe("patchOpenClawQrTerminalRendererSource (#4522)", () => {
expect(patchOpenClawQrTerminalRendererSource(patched)).toBe(patched);
});

it("loader source computes renderer integrity before applying the source rewrite", () => {
const loader = createOpenClawQrTerminalLoaderSource();
it("passes unrelated module source through the synchronous load hook", () => {
const load = createOpenClawQrTerminalLoadHook();
const result = { format: "module", source: "const unrelated = true;" };

expect(load("file:///tmp/unrelated.mjs", {}, () => result)).toBe(result);
});

it("passes unsupported non-text module source through without throwing", () => {
const load = createOpenClawQrTerminalLoadHook();
const result = { format: "module", source: {} };

expect(load("file:///tmp/unsupported.mjs", {}, () => result)).toBe(result);
});

it("fails closed when the synchronous load hook sees an unreviewed renderer", () => {
const write = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
const load = createOpenClawQrTerminalLoadHook();
const result = { format: "module", source: OPENCLAW_QR_RENDERER_SOURCE };
try {
expect(load("file:///tmp/openclaw-renderer.mjs", {}, () => result)).toBe(result);
expect(write).toHaveBeenCalledWith(expect.stringContaining("integrity is unreviewed"));
} finally {
write.mockRestore();
}
});

it("rewrites a reviewed renderer through the synchronous load hook", () => {
const load = createOpenClawQrTerminalLoadHook(
() => REVIEWED_OPENCLAW_QR_TERMINAL_RENDERER_SHA256,
);
const result = { format: "module", source: Buffer.from(OPENCLAW_QR_RENDERER_SOURCE) };

expect(loader).toContain('import { createHash } from "node:crypto";');
expect(loader).toContain(REVIEWED_OPENCLAW_QR_TERMINAL_RENDERER_SHA256);
expect(loader).toContain("const integrity = sha256Hex(source);");
expect(loader).toContain("warnOpenClawQrPatchSkip(skipReason)");
expect(loader).toContain("patchOpenClawQrTerminalRendererSource(source, integrity)");
expect(load("file:///tmp/openclaw-renderer.mjs", {}, () => result)).toMatchObject({
format: "module",
source: expect.stringContaining("const COMPACT_MARGIN_MODULES = 4;"),
});
});

it("provides a synchronous registerHooks loader that composes with other preloads", () => {
const nextLoad = vi.fn(() => ({ format: "module", source: "export const value = 1;" }));
const hook = createOpenClawQrTerminalSyncLoadHook();
it("rewrites reviewed Uint8Array and ArrayBuffer module sources", () => {
const load = createOpenClawQrTerminalLoadHook(
() => REVIEWED_OPENCLAW_QR_TERMINAL_RENDERER_SHA256,
);
const bytes = new TextEncoder().encode(OPENCLAW_QR_RENDERER_SOURCE);

for (const source of [bytes, bytes.buffer]) {
const result = { format: "module", source };
expect(load("file:///tmp/openclaw-renderer.mjs", {}, () => result)).toMatchObject({
format: "module",
source: expect.stringContaining("const COMPACT_MARGIN_MODULES = 4;"),
});
}
});

const result = hook("file:///tmp/unrelated.mjs", {}, nextLoad);
it("registers a synchronous source hook for OpenClaw module loading (#6467)", () => {
const registerHooks = vi.fn();
const register = vi.fn();

expect(result).toEqual({ format: "module", source: "export const value = 1;" });
expect(result).not.toBeInstanceOf(Promise);
expect(nextLoad).toHaveBeenCalledTimes(1);
expect(registerOpenClawQrTerminalSourceLoader({ register, registerHooks })).toBe(true);
expect(registerHooks).toHaveBeenCalledOnce();
expect(registerHooks).toHaveBeenCalledWith({ load: expect.any(Function) });
expect(register).not.toHaveBeenCalled();
});

it("distinguishes unavailable and failed synchronous hook registration", () => {
const write = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
try {
expect(registerOpenClawQrTerminalSourceLoader({})).toBe(false);
expect(
registerOpenClawQrTerminalSourceLoader({
registerHooks() {
throw new Error("registration failed");
},
}),
).toBe(false);
expect(write).toHaveBeenNthCalledWith(1, expect.stringContaining("is unavailable"));
expect(write).toHaveBeenNthCalledWith(2, expect.stringContaining("registration failed"));
} finally {
write.mockRestore();
}
});

it("emits non-secret loader diagnostics when the source rewrite is skipped", () => {
Expand Down
148 changes: 50 additions & 98 deletions src/lib/messaging/channels/whatsapp/runtime/whatsapp-qr-compact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,62 +143,7 @@ function patchOpenClawQrTerminalRendererSource(source: string, integrity?: strin
.replace(xLoopFrom, xLoopTo);
}

function createOpenClawQrTerminalLoaderSource() {
return `
import { createHash } from "node:crypto";

const REVIEWED_OPENCLAW_QR_TERMINAL_RENDERER_SHA256 = ${JSON.stringify(
REVIEWED_OPENCLAW_QR_TERMINAL_RENDERER_SHA256,
)};
const isOpenClawQrTerminalRendererSource = ${isOpenClawQrTerminalRendererSource.toString()};
const isReviewedOpenClawQrTerminalRendererIntegrity = ${isReviewedOpenClawQrTerminalRendererIntegrity.toString()};
const describeOpenClawQrTerminalPatchSkip = ${describeOpenClawQrTerminalPatchSkip.toString()};
const patchOpenClawQrTerminalRendererSource = ${patchOpenClawQrTerminalRendererSource.toString()};

function decodeSource(source) {
if (typeof source === "string") return source;
if (source && typeof Buffer !== "undefined") return Buffer.from(source).toString("utf8");
return "";
}

function sha256Hex(source) {
return createHash("sha256").update(source).digest("hex");
}

function warnOpenClawQrPatchSkip(message) {
try {
process.stderr.write("[channels] WhatsApp compact-QR warning: " + message + "\\n");
} catch (_e) {
}
}

export async function load(url, context, nextLoad) {
const result = await nextLoad(url, context);
if (!result || result.format !== "module") return result;
const source = decodeSource(result.source);
if (!isOpenClawQrTerminalRendererSource(source)) return result;
const integrity = sha256Hex(source);
const skipReason = describeOpenClawQrTerminalPatchSkip(source, integrity);
if (skipReason) {
warnOpenClawQrPatchSkip(skipReason);
return result;
}
const patched = patchOpenClawQrTerminalRendererSource(source, integrity);
if (patched === source) return result;
return { ...result, source: patched };
}
`;
}

function warnWhatsappQrCompact(message) {
try {
process.stderr.write("[channels] WhatsApp compact-QR warning: " + message + "\n");
} catch (_e) {
// Best effort diagnostic only.
}
}

function openClawQrLoaderSourceToText(source) {
function decodeOpenClawQrTerminalSource(source) {
if (typeof source === "string") return source;
if (typeof Buffer !== "undefined") {
if (Buffer.isBuffer(source)) return source.toString("utf8");
Expand All @@ -208,25 +153,38 @@ function openClawQrLoaderSourceToText(source) {
return null;
}

function createOpenClawQrTerminalSyncLoadHook() {
var createHash = require("node:crypto").createHash;
return function nemoclawWhatsappQrLoadHook(urlValue, context, nextLoad) {
var result = nextLoad(urlValue, context);
function createOpenClawQrTerminalLoadHook(sha256Hex?) {
if (typeof sha256Hex !== "function") {
var createHash = require("node:crypto").createHash;
sha256Hex = function (source) {
return createHash("sha256").update(source).digest("hex");
};
}
return function load(url, context, nextLoad) {
var result = nextLoad(url, context);
if (!result || result.format !== "module") return result;
var source = openClawQrLoaderSourceToText(result.source);
var source = decodeOpenClawQrTerminalSource(result.source);
if (source === null || !isOpenClawQrTerminalRendererSource(source)) return result;
var integrity = createHash("sha256").update(source).digest("hex");
var integrity = sha256Hex(source);
var skipReason = describeOpenClawQrTerminalPatchSkip(source, integrity);
if (skipReason) {
warnWhatsappQrCompact(skipReason);
return result;
}
var patched = patchOpenClawQrTerminalRendererSource(source, integrity);
if (patched === source) return result;
return Object.assign({}, result, { source: patched });
return { ...result, source: patched };
};
}

function warnWhatsappQrCompact(message) {
try {
process.stderr.write("[channels] WhatsApp compact-QR warning: " + message + "\n");
} catch (_e) {
// Best effort diagnostic only.
}
}

// `qrcode` package main: renderQrTerminal() calls qrcode.toString(text, opts).
// Require an OWN toString (every object inherits Object.prototype.toString, so
// a plain `typeof mod.toString` check would also match qrcode's internal
Expand Down Expand Up @@ -383,61 +341,55 @@ function resolvePatchedModule(request, loaded) {
// The auto-install below still uses the exact same functions, so the runtime
// hook behaves identically.
export {
createOpenClawQrTerminalLoadHook,
describeOpenClawQrTerminalPatchSkip,
hasOwn,
isOpenClawQrTerminalRendererSource,
isQrcodePackage,
isQrcodeTerminalPackage,
renderWhatsappCompactTerminalQr,
renderQrcodePackageTerminal,
isReviewedOpenClawQrTerminalRendererIntegrity,
isOpenClawQrTerminalRendererSource,
describeOpenClawQrTerminalPatchSkip,
warnWhatsappQrCompact,
patchOpenClawQrTerminalRendererSource,
REVIEWED_OPENCLAW_QR_TERMINAL_RENDERER_SHA256,
createOpenClawQrTerminalLoaderSource,
createOpenClawQrTerminalSyncLoadHook,
patchQrcode,
patchQrcodeTerminal,
REVIEWED_OPENCLAW_QR_TERMINAL_RENDERER_SHA256,
registerOpenClawQrTerminalSourceLoader,
renderQrcodePackageTerminal,
renderWhatsappCompactTerminalQr,
resolvePatchedModule,
warnWhatsappQrCompact,
};

function installOpenClawQrTerminalSourceLoader(Module) {
if (process.__nemoclawWhatsappQrCompactSourceLoaderInstalled) return;
if (
!Module ||
(typeof Module.registerHooks !== "function" && typeof Module.register !== "function")
) {
function registerOpenClawQrTerminalSourceLoader(Module) {
// Keep this in the same synchronous hook chain as the Slack preload. Mixing
// Module.register()'s async loader with registerHooks() breaks OpenClaw's
// synchronous module loads because the async loader has no loadSync (#6467).
if (!Module || typeof Module.registerHooks !== "function") {
warnWhatsappQrCompact(
"OpenClaw QR renderer source loader registration is unavailable; explicit compact quiet-zone rewrite skipped",
);
return;
}
try {
Object.defineProperty(process, "__nemoclawWhatsappQrCompactSourceLoaderInstalled", {
value: true,
});
} catch (_e) {
process.__nemoclawWhatsappQrCompactSourceLoaderInstalled = true;
return false;
}

try {
// Node's synchronous registerHooks API must be used when available. Mixing
// an async Module.register loader with another preload's synchronous hook
// chain makes later ESM loads call loadSync on an async customization
// object (notably the Slack provider preload). Keeping every modern-Node
// preload in the same synchronous chain avoids that runtime failure.
if (typeof Module.registerHooks === "function") {
Module.registerHooks({ load: createOpenClawQrTerminalSyncLoadHook() });
return;
}
var loaderSource = createOpenClawQrTerminalLoaderSource();
var loaderUrl =
"data:text/javascript;base64," + Buffer.from(loaderSource, "utf8").toString("base64");
Module.register(loaderUrl);
Module.registerHooks({ load: createOpenClawQrTerminalLoadHook() });
return true;
} catch (_e) {
warnWhatsappQrCompact(
"OpenClaw QR renderer source loader registration failed; explicit compact quiet-zone rewrite skipped",
);
return false;
}
}

function installOpenClawQrTerminalSourceLoader(Module) {
if (process.__nemoclawWhatsappQrCompactSourceLoaderInstalled) return;
if (!registerOpenClawQrTerminalSourceLoader(Module)) return;
try {
Object.defineProperty(process, "__nemoclawWhatsappQrCompactSourceLoaderInstalled", {
value: true,
});
} catch (_e) {
process.__nemoclawWhatsappQrCompactSourceLoaderInstalled = true;
}
}

Expand Down
16 changes: 13 additions & 3 deletions test/e2e/live/messaging-providers-slack-runtime-proof.ts
Original file line number Diff line number Diff line change
Expand Up @@ -548,7 +548,7 @@ console.log(
);
`;

function parseInstalledSlackProof(stdout: string): InstalledSlackRuntimeProof {
export function parseInstalledSlackProof(stdout: string, stderr = ""): InstalledSlackRuntimeProof {
for (const line of stdout.trim().split(/\r?\n/u).reverse()) {
try {
const value = JSON.parse(line) as Partial<InstalledSlackRuntimeProof>;
Expand All @@ -569,7 +569,17 @@ function parseInstalledSlackProof(stdout: string): InstalledSlackRuntimeProof {
// Module discovery can emit non-JSON diagnostics before the proof record.
}
}
throw new Error(`installed Slack runtime proof did not emit a valid result:\n${stdout}`);
const diagnostics = [
stdout.trim() ? `stdout:\n${stdout.trim()}` : "",
stderr.trim() ? `stderr:\n${stderr.trim()}` : "",
]
.filter(Boolean)
.join("\n");
throw new Error(
`installed Slack runtime proof did not emit a valid result:\n${
diagnostics || "stdout and stderr were empty"
}`,
);
}

export async function runInstalledSlackRuntimeProof(
Expand All @@ -589,5 +599,5 @@ export async function runInstalledSlackRuntimeProof(
timeoutMs: 120_000,
});
expectExitZero(result, "installed OpenClaw Slack runtime proof");
return parseInstalledSlackProof(result.stdout);
return parseInstalledSlackProof(result.stdout, result.stderr);
}
Loading
Loading