diff --git a/src/lib/messaging/channels/whatsapp/runtime/whatsapp-qr-compact.test.ts b/src/lib/messaging/channels/whatsapp/runtime/whatsapp-qr-compact.test.ts index 4c0e310503a..0d2e82f44c2 100644 --- a/src/lib/messaging/channels/whatsapp/runtime/whatsapp-qr-compact.test.ts +++ b/src/lib/messaging/channels/whatsapp/runtime/whatsapp-qr-compact.test.ts @@ -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"; @@ -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", () => { diff --git a/src/lib/messaging/channels/whatsapp/runtime/whatsapp-qr-compact.ts b/src/lib/messaging/channels/whatsapp/runtime/whatsapp-qr-compact.ts index 888f8fbb22e..3b8d18fbd37 100644 --- a/src/lib/messaging/channels/whatsapp/runtime/whatsapp-qr-compact.ts +++ b/src/lib/messaging/channels/whatsapp/runtime/whatsapp-qr-compact.ts @@ -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"); @@ -208,14 +153,19 @@ 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); @@ -223,10 +173,18 @@ function createOpenClawQrTerminalSyncLoadHook() { } 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 @@ -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; } } diff --git a/test/e2e/live/messaging-providers-slack-runtime-proof.ts b/test/e2e/live/messaging-providers-slack-runtime-proof.ts index f7a3d811678..a3874d0fde6 100644 --- a/test/e2e/live/messaging-providers-slack-runtime-proof.ts +++ b/test/e2e/live/messaging-providers-slack-runtime-proof.ts @@ -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; @@ -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( @@ -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); } diff --git a/test/e2e/live/messaging-providers.test.ts b/test/e2e/live/messaging-providers.test.ts index 8cb3992c271..21534067704 100644 --- a/test/e2e/live/messaging-providers.test.ts +++ b/test/e2e/live/messaging-providers.test.ts @@ -547,39 +547,46 @@ process.exit(Array.isArray(channels) && channels.some((c) => c?.channelId === "w "M-W10: WeChat accounts index contains configured account", ); - const runtimeChannels = await sandboxOutput( + const runtimeChannelsResult = await runSandboxShell( sandbox, - "timeout 45 openclaw channels list --all --json --no-color 2>/dev/null || true", - "openclaw-channels-list-messaging-providers", - redactionValues, + "timeout 45 openclaw channels list --all --json --no-color", + { + artifactName: "openclaw-channels-list-messaging-providers", + redactionValues, + }, ); + expectExitZero(runtimeChannelsResult, "OpenClaw channels list"); + const runtimeChannels = runtimeChannelsResult.stdout.trim(); if (!runtimeChannels) { - await skipNote(artifacts, skips, "M6e-M6h: OpenClaw channels list returned no output"); - } else { - const parsedRuntime = JSON.parse(runtimeChannels) as { - chat?: Record; - }; - for (const [assertionId, channel, accountId] of [ - ["M6e", "telegram", "default"], - ["M6f", "discord", "default"], - ["M6g", "slack", "default"], - ] as const) { - const entry = parsedRuntime.chat?.[channel]; - check( - entry?.installed === true && - entry.origin === "configured" && - Array.isArray(entry.accounts) && - entry.accounts.includes(accountId), - `${assertionId}: OpenClaw channels list reports ${channel} installed/configured`, - ); - } - const whatsappRuntime = parsedRuntime.chat?.whatsapp; + throw new Error( + `OpenClaw channels list did not emit channel state:\n${ + runtimeChannelsResult.stderr.trim() || "stderr was empty" + }`, + ); + } + const parsedRuntime = JSON.parse(runtimeChannels) as { + chat?: Record; + }; + for (const [assertionId, channel, accountId] of [ + ["M6e", "telegram", "default"], + ["M6f", "discord", "default"], + ["M6g", "slack", "default"], + ] as const) { + const entry = parsedRuntime.chat?.[channel]; check( - whatsappRuntime?.installed === true && - (whatsappRuntime.origin === "available" || whatsappRuntime.origin === "configured"), - "M6h: OpenClaw channels list reports WhatsApp plugin installed", + entry?.installed === true && + entry.origin === "configured" && + Array.isArray(entry.accounts) && + entry.accounts.includes(accountId), + `${assertionId}: OpenClaw channels list reports ${channel} installed/configured`, ); } + const whatsappRuntime = parsedRuntime.chat?.whatsapp; + check( + whatsappRuntime?.installed === true && + (whatsappRuntime.origin === "available" || whatsappRuntime.origin === "configured"), + "M6h: OpenClaw channels list reports WhatsApp plugin installed", + ); // Probe the allowed Telegram bot API path (/bot/**). The bare root // path is blocked by the Telegram egress policy by design (asserted by M14), diff --git a/test/e2e/support/messaging-providers-runtime-proofs.test.ts b/test/e2e/support/messaging-providers-runtime-proofs.test.ts index 607169c976f..758666b6013 100644 --- a/test/e2e/support/messaging-providers-runtime-proofs.test.ts +++ b/test/e2e/support/messaging-providers-runtime-proofs.test.ts @@ -15,7 +15,10 @@ import { OPENSHELL_EXEC_ARGUMENT_LIMIT_BYTES, parseRuntimeProofPort, } from "../live/messaging-providers-helpers.ts"; -import { SLACK_INSTALLED_RUNTIME_PROOF_SOURCE } from "../live/messaging-providers-slack-runtime-proof.ts"; +import { + parseInstalledSlackProof, + SLACK_INSTALLED_RUNTIME_PROOF_SOURCE, +} from "../live/messaging-providers-slack-runtime-proof.ts"; import { TELEGRAM_INSTALLED_RUNTIME_PROOF_SOURCE } from "../live/messaging-providers-telegram-runtime-proof.ts"; const FAKE_TELEGRAM_API = path.resolve(import.meta.dirname, "../lib/fake-telegram-api.cjs"); @@ -152,6 +155,44 @@ describe("messaging provider installed-runtime proofs", () => { expect(SLACK_INSTALLED_RUNTIME_PROOF_SOURCE).toContain("/api/chat.postMessage"); }); + it("reports loader stderr without accepting stderr as a Slack proof (#6467)", () => { + const proof = JSON.stringify({ + ok: true, + proof: "openclaw-pipeline-runtime", + allowedReplyTarget: "channel:C0E2ESLACK", + deniedPrepared: true, + deniedFeedbackMethod: "chat.postEphemeral", + deniedFeedbackCount: 1, + messageId: "1710000000.000201", + channelId: "C0E2ESLACK", + }); + const stderr = [ + "[channels] [slack] provider failed to start: this[#customizations].loadSync is not a function", + proof, + ].join("\n"); + + expect(() => parseInstalledSlackProof("", stderr)).toThrow( + /stderr:.*loadSync is not a function/su, + ); + }); + + it("continues to accept only a complete Slack proof from stdout (#6467)", () => { + const proof = { + ok: true as const, + proof: "openclaw-pipeline-runtime" as const, + allowedReplyTarget: "channel:C0E2ESLACK", + deniedPrepared: true as const, + deniedFeedbackMethod: "chat.postEphemeral" as const, + deniedFeedbackCount: 1 as const, + messageId: "1710000000.000201", + channelId: "C0E2ESLACK", + }; + + expect(parseInstalledSlackProof(`diagnostic\n${JSON.stringify(proof)}`, "warning")).toEqual( + proof, + ); + }); + it("requires the reviewed Slack pipeline/runtime proof in the default 2026.6.10 live lane", () => { expect(LIVE_MESSAGING_PROVIDERS_SOURCE).toContain( 'installedSlackProof.proof === "openclaw-pipeline-runtime"', @@ -161,6 +202,21 @@ describe("messaging provider installed-runtime proofs", () => { ); }); + it("requires channel-list output without suppressing loader failures (#6467)", () => { + expect(LIVE_MESSAGING_PROVIDERS_SOURCE).toContain( + '"timeout 45 openclaw channels list --all --json --no-color"', + ); + expect(LIVE_MESSAGING_PROVIDERS_SOURCE).toContain( + "OpenClaw channels list did not emit channel state", + ); + expect(LIVE_MESSAGING_PROVIDERS_SOURCE).not.toContain( + "OpenClaw channels list returned no output", + ); + expect(LIVE_MESSAGING_PROVIDERS_SOURCE).not.toContain( + "openclaw channels list --all --json --no-color 2>/dev/null || true", + ); + }); + it("keeps Telegram on runtime-api.js with a fake send boundary", () => { expectValidModuleSource(TELEGRAM_INSTALLED_RUNTIME_PROOF_SOURCE); expect(TELEGRAM_INSTALLED_RUNTIME_PROOF_SOURCE).toContain( diff --git a/test/openclaw-slack-deny-feedback-patch.test.ts b/test/openclaw-slack-deny-feedback-patch.test.ts index d11ef1f2a14..0c4a8f30843 100644 --- a/test/openclaw-slack-deny-feedback-patch.test.ts +++ b/test/openclaw-slack-deny-feedback-patch.test.ts @@ -93,14 +93,15 @@ function runGuardProbe( options: { loadMode?: "require" | "import"; requireGuardTwice?: boolean; - withWhatsappPreload?: boolean; + whatsappPreloadOrder?: "before-slack" | "after-slack"; } = {}, ) { const script = ` const guard = ${JSON.stringify(SLACK_GUARD)}; -${options.withWhatsappPreload ? `require(${JSON.stringify(WHATSAPP_QR_COMPACT)});` : ""} +${options.whatsappPreloadOrder === "before-slack" ? `require(${JSON.stringify(WHATSAPP_QR_COMPACT)});` : ""} require(guard); ${options.requireGuardTwice ? "require(guard);" : ""} +${options.whatsappPreloadOrder === "after-slack" ? `require(${JSON.stringify(WHATSAPP_QR_COMPACT)});` : ""} const { pathToFileURL } = require("node:url"); let prepareSlackMessage; async function loadPrepareSlackMessage() { @@ -258,7 +259,7 @@ describe("OpenClaw Slack denial-feedback patch", () => { try { const { result, output } = runGuardProbe(prepareFile, { loadMode: "import", - withWhatsappPreload: true, + whatsappPreloadOrder: "before-slack", }); expect(result.status, `${result.stdout}${result.stderr}`).toBe(0); expect(fs.readFileSync(prepareFile, "utf-8")).not.toContain( @@ -278,6 +279,26 @@ describe("OpenClaw Slack denial-feedback patch", () => { } }); + it("composes Slack-before-WhatsApp synchronous loaders for ESM imports (#6467)", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-slack-whatsapp-loaders-")); + const prepareFile = writeSlackPackage(tmp, { moduleType: "esm" }); + try { + const { result, output } = runGuardProbe(prepareFile, { + loadMode: "import", + whatsappPreloadOrder: "after-slack", + }); + expect(result.status, `${result.stdout}${result.stderr}`).toBe(0); + expect(result.stderr).not.toMatch(/loadSync|returned for the "source" from the "load" hook/u); + + const mention = output?.mention as { result: unknown; calls: FeedbackCall[] }; + expect(mention.result).toBeNull(); + expect(mention.calls).toHaveLength(1); + expect(mention.calls[0].method).toBe("chat.postEphemeral"); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + it("is idempotent across repeated runs", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-slack-deny-idem-")); const prepareFile = writeSlackPackage(tmp);