diff --git a/CHANGELOG.md b/CHANGELOG.md index 187603b3280..46367249f60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai - Plugins/status: report the registered context-engine IDs in `plugins inspect` instead of the owning plugin ID, so non-matching engine IDs and multi-engine plugins are classified correctly. (#58766) thanks @zhuisDEV - Context engines: reject resolved plugin engines whose reported `info.id` does not match their registered slot id, so malformed engines fail fast before id-based runtime branches can misbehave. (#63222) Thanks @fuller-stack-dev. +- WhatsApp: patch installed Baileys media encryption writes during OpenClaw postinstall so the default npm/install.sh delivery path waits for encrypted media files to finish flushing before readback, avoiding transient `ENOENT` crashes on image sends. (#65896) Thanks @frankekn. ## 2026.4.12 ### Changes diff --git a/scripts/postinstall-bundled-plugins.mjs b/scripts/postinstall-bundled-plugins.mjs index 513e5bd5d5d..0c2e002d72b 100644 --- a/scripts/postinstall-bundled-plugins.mjs +++ b/scripts/postinstall-bundled-plugins.mjs @@ -6,8 +6,21 @@ // plugin deps from the workspace root, so stale plugin-local node_modules must // not linger under extensions/* and shadow the root graph. import { spawnSync } from "node:child_process"; -import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs"; -import { dirname, join } from "node:path"; +import { randomUUID } from "node:crypto"; +import { + chmodSync, + closeSync, + existsSync, + lstatSync, + openSync, + readdirSync, + readFileSync, + realpathSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { basename, dirname, isAbsolute, join, relative } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { resolveNpmRunner } from "./npm-runner.mjs"; @@ -17,6 +30,34 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const DEFAULT_EXTENSIONS_DIR = join(__dirname, "..", "dist", "extensions"); const DEFAULT_PACKAGE_ROOT = join(__dirname, ".."); const DISABLE_POSTINSTALL_ENV = "OPENCLAW_DISABLE_BUNDLED_PLUGIN_POSTINSTALL"; +const BAILEYS_MEDIA_FILE = join( + "node_modules", + "@whiskeysockets", + "baileys", + "lib", + "Utils", + "messages-media.js", +); +const BAILEYS_MEDIA_HOTFIX_NEEDLE = [ + " encFileWriteStream.write(mac);", + " encFileWriteStream.end();", + " originalFileStream?.end?.();", + " stream.destroy();", + " logger?.debug('encrypted data successfully');", +].join("\n"); +const BAILEYS_MEDIA_HOTFIX_REPLACEMENT = [ + " encFileWriteStream.write(mac);", + " const encFinishPromise = once(encFileWriteStream, 'finish');", + " const originalFinishPromise = originalFileStream ? once(originalFileStream, 'finish') : Promise.resolve();", + " encFileWriteStream.end();", + " originalFileStream?.end?.();", + " stream.destroy();", + " await Promise.all([encFinishPromise, originalFinishPromise]);", + " logger?.debug('encrypted data successfully');", +].join("\n"); +const BAILEYS_MEDIA_ONCE_IMPORT_RE = /import\s+\{\s*once\s*\}\s+from\s+['"]events['"]/u; +const BAILEYS_MEDIA_ASYNC_CONTEXT_RE = + /async\s+function\s+encryptedStream|encryptedStream\s*=\s*async/u; function readJson(filePath) { return JSON.parse(readFileSync(filePath, "utf8")); @@ -152,6 +193,121 @@ export function createNestedNpmInstallEnv(env = process.env) { return nextEnv; } +export function applyBaileysEncryptedStreamFinishHotfix(params = {}) { + const packageRoot = params.packageRoot ?? DEFAULT_PACKAGE_ROOT; + const pathExists = params.existsSync ?? existsSync; + const pathLstat = params.lstatSync ?? lstatSync; + const readFile = params.readFileSync ?? readFileSync; + const resolveRealPath = params.realpathSync ?? realpathSync; + const chmodFile = params.chmodSync ?? chmodSync; + const openFile = params.openSync ?? openSync; + const closeFile = params.closeSync ?? closeSync; + const renameFile = params.renameSync ?? renameSync; + const removePath = params.rmSync ?? rmSync; + const createTempPath = + params.createTempPath ?? + ((unsafeTargetPath) => + join( + dirname(unsafeTargetPath), + `.${basename(unsafeTargetPath)}.openclaw-hotfix-${randomUUID()}`, + )); + const writeFile = + params.writeFileSync ?? ((filePath, value) => writeFileSync(filePath, value, "utf8")); + const targetPath = join(packageRoot, BAILEYS_MEDIA_FILE); + const nodeModulesRoot = join(packageRoot, "node_modules"); + + function validateTargetPath() { + if (!pathExists(targetPath)) { + return { ok: false, reason: "missing" }; + } + + const targetStats = pathLstat(targetPath); + if (!targetStats.isFile() || targetStats.isSymbolicLink()) { + return { ok: false, reason: "unsafe_target", targetPath }; + } + + const nodeModulesRootReal = resolveRealPath(nodeModulesRoot); + const targetPathReal = resolveRealPath(targetPath); + const relativeTargetPath = relative(nodeModulesRootReal, targetPathReal); + if (relativeTargetPath.startsWith("..") || isAbsolute(relativeTargetPath)) { + return { ok: false, reason: "path_escape", targetPath }; + } + + return { ok: true, targetPathReal, mode: targetStats.mode & 0o777 }; + } + + try { + const initialTargetValidation = validateTargetPath(); + if (!initialTargetValidation.ok) { + return { applied: false, reason: initialTargetValidation.reason, targetPath }; + } + + const currentText = readFile(targetPath, "utf8"); + if (currentText.includes(BAILEYS_MEDIA_HOTFIX_REPLACEMENT)) { + return { applied: false, reason: "already_patched" }; + } + if (!currentText.includes(BAILEYS_MEDIA_HOTFIX_NEEDLE)) { + return { applied: false, reason: "unexpected_content" }; + } + if (!BAILEYS_MEDIA_ONCE_IMPORT_RE.test(currentText)) { + return { applied: false, reason: "missing_once_import", targetPath }; + } + if (!BAILEYS_MEDIA_ASYNC_CONTEXT_RE.test(currentText)) { + return { applied: false, reason: "not_async_context", targetPath }; + } + + const patchedText = currentText.replace( + BAILEYS_MEDIA_HOTFIX_NEEDLE, + BAILEYS_MEDIA_HOTFIX_REPLACEMENT, + ); + const tempPath = createTempPath(targetPath); + const tempFd = openFile(tempPath, "wx", initialTargetValidation.mode); + let tempFdClosed = false; + try { + writeFile(tempFd, patchedText, "utf8"); + closeFile(tempFd); + tempFdClosed = true; + const finalTargetValidation = validateTargetPath(); + if (!finalTargetValidation.ok) { + return { applied: false, reason: finalTargetValidation.reason, targetPath }; + } + renameFile(tempPath, targetPath); + chmodFile(targetPath, initialTargetValidation.mode); + } finally { + if (!tempFdClosed) { + try { + closeFile(tempFd); + } catch { + // ignore failed-open cleanup + } + } + removePath(tempPath, { force: true }); + } + return { applied: true, reason: "patched", targetPath }; + } catch (error) { + return { + applied: false, + reason: "error", + targetPath, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +function applyBundledPluginRuntimeHotfixes(params = {}) { + const log = params.log ?? console; + const baileysResult = applyBaileysEncryptedStreamFinishHotfix(params); + if (baileysResult.applied) { + log.log("[postinstall] patched @whiskeysockets/baileys encryptedStream flush ordering"); + return; + } + if (baileysResult.reason !== "missing" && baileysResult.reason !== "already_patched") { + log.warn( + `[postinstall] could not patch @whiskeysockets/baileys encryptedStream: ${baileysResult.reason}`, + ); + } +} + export function isSourceCheckoutRoot(params) { const pathExists = params.existsSync ?? existsSync; return ( @@ -216,6 +372,13 @@ export function runBundledPluginPostinstall(params = {}) { } catch (e) { log.warn(`[postinstall] could not prune bundled plugin source node_modules: ${String(e)}`); } + applyBundledPluginRuntimeHotfixes({ + packageRoot, + existsSync: pathExists, + readFileSync: params.readFileSync, + writeFileSync: params.writeFileSync, + log, + }); return; } if ( @@ -245,6 +408,13 @@ export function runBundledPluginPostinstall(params = {}) { .map((dep) => `${dep.name}@${dep.version}`); if (missingSpecs.length === 0) { + applyBundledPluginRuntimeHotfixes({ + packageRoot, + existsSync: pathExists, + readFileSync: params.readFileSync, + writeFileSync: params.writeFileSync, + log, + }); return; } @@ -284,6 +454,14 @@ export function runBundledPluginPostinstall(params = {}) { // Non-fatal: gateway will surface the missing dep via doctor. log.warn(`[postinstall] could not install bundled plugin deps: ${String(e)}`); } + + applyBundledPluginRuntimeHotfixes({ + packageRoot, + existsSync: pathExists, + readFileSync: params.readFileSync, + writeFileSync: params.writeFileSync, + log, + }); } if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { diff --git a/src/plugins/stage-bundled-plugin-runtime-deps.test.ts b/src/plugins/stage-bundled-plugin-runtime-deps.test.ts index e9330840c61..d056bdc15cc 100644 --- a/src/plugins/stage-bundled-plugin-runtime-deps.test.ts +++ b/src/plugins/stage-bundled-plugin-runtime-deps.test.ts @@ -22,6 +22,35 @@ async function loadStageBundledPluginRuntimeDeps(): Promise void; + packageRoot?: string; + createTempPath?: (targetPath: string) => string; + writeFileSync?: (pathOrFd: string | number, value: string, encoding?: string) => void; + }) => { + applied: boolean; + reason: string; + targetPath?: string; + error?: string; + }; +}> { + const moduleUrl = new URL("../../scripts/postinstall-bundled-plugins.mjs", import.meta.url); + return (await import(moduleUrl.href)) as { + applyBaileysEncryptedStreamFinishHotfix: (params?: { + chmodSync?: (path: string, mode: number) => void; + packageRoot?: string; + createTempPath?: (targetPath: string) => string; + writeFileSync?: (pathOrFd: string | number, value: string, encoding?: string) => void; + }) => { + applied: boolean; + reason: string; + targetPath?: string; + error?: string; + }; + }; +} + const tempDirs: string[] = []; function makeRepoRoot(prefix: string): string { @@ -164,4 +193,202 @@ describe("stageBundledPluginRuntimeDeps", () => { expect(installs[0]?.peerDependencies).toBeUndefined(); expect(installs[0]?.peerDependenciesMeta).toBeUndefined(); }); + + it("patches installed Baileys encryptedStream flush ordering for shipped runtime deps", async () => { + const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-hotfix-"); + const targetPath = path.join( + repoRoot, + "node_modules", + "@whiskeysockets", + "baileys", + "lib", + "Utils", + "messages-media.js", + ); + writeRepoFile( + repoRoot, + "node_modules/@whiskeysockets/baileys/lib/Utils/messages-media.js", + [ + "import { once } from 'events';", + "const encryptedStream = async () => {", + " encFileWriteStream.write(mac);", + " encFileWriteStream.end();", + " originalFileStream?.end?.();", + " stream.destroy();", + " logger?.debug('encrypted data successfully');", + "};", + ].join("\n"), + ); + + const { applyBaileysEncryptedStreamFinishHotfix } = await loadPostinstallBundledPluginsModule(); + const result = applyBaileysEncryptedStreamFinishHotfix({ packageRoot: repoRoot }); + + expect(result).toEqual({ + applied: true, + reason: "patched", + targetPath, + }); + expect(fs.readFileSync(targetPath, "utf8")).toContain( + "const encFinishPromise = once(encFileWriteStream, 'finish');", + ); + expect(fs.readFileSync(targetPath, "utf8")).toContain( + "await Promise.all([encFinishPromise, originalFinishPromise]);", + ); + }); + + it("preserves the original module read mode when replacing Baileys", async () => { + const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-hotfix-mode-"); + const targetPath = path.join( + repoRoot, + "node_modules", + "@whiskeysockets", + "baileys", + "lib", + "Utils", + "messages-media.js", + ); + writeRepoFile( + repoRoot, + "node_modules/@whiskeysockets/baileys/lib/Utils/messages-media.js", + [ + "import { once } from 'events';", + "const encryptedStream = async () => {", + " encFileWriteStream.write(mac);", + " encFileWriteStream.end();", + " originalFileStream?.end?.();", + " stream.destroy();", + " logger?.debug('encrypted data successfully');", + "};", + ].join("\n"), + ); + fs.chmodSync(targetPath, 0o644); + + const { applyBaileysEncryptedStreamFinishHotfix } = await loadPostinstallBundledPluginsModule(); + const result = applyBaileysEncryptedStreamFinishHotfix({ packageRoot: repoRoot }); + + expect(result).toEqual({ + applied: true, + reason: "patched", + targetPath, + }); + expect(fs.statSync(targetPath).mode & 0o777).toBe(0o644); + }); + + it("refuses symlink targets for the Baileys hotfix", async () => { + const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-hotfix-symlink-"); + const targetPath = path.join( + repoRoot, + "node_modules", + "@whiskeysockets", + "baileys", + "lib", + "Utils", + "messages-media.js", + ); + const redirectedTarget = path.join(repoRoot, "redirected-messages-media.js"); + writeRepoFile(repoRoot, "redirected-messages-media.js", "const untouched = true;\n"); + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.symlinkSync(redirectedTarget, targetPath); + + const { applyBaileysEncryptedStreamFinishHotfix } = await loadPostinstallBundledPluginsModule(); + const result = applyBaileysEncryptedStreamFinishHotfix({ packageRoot: repoRoot }); + + expect(result).toEqual({ + applied: false, + reason: "unsafe_target", + targetPath, + }); + expect(fs.readFileSync(redirectedTarget, "utf8")).toBe("const untouched = true;\n"); + }); + + it("downgrades Baileys hotfix write failures to a non-fatal result", async () => { + const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-hotfix-write-failure-"); + const targetPath = path.join( + repoRoot, + "node_modules", + "@whiskeysockets", + "baileys", + "lib", + "Utils", + "messages-media.js", + ); + writeRepoFile( + repoRoot, + "node_modules/@whiskeysockets/baileys/lib/Utils/messages-media.js", + [ + "import { once } from 'events';", + "const encryptedStream = async () => {", + " encFileWriteStream.write(mac);", + " encFileWriteStream.end();", + " originalFileStream?.end?.();", + " stream.destroy();", + " logger?.debug('encrypted data successfully');", + "};", + ].join("\n"), + ); + + const { applyBaileysEncryptedStreamFinishHotfix } = await loadPostinstallBundledPluginsModule(); + const result = applyBaileysEncryptedStreamFinishHotfix({ + packageRoot: repoRoot, + writeFileSync() { + throw new Error("read-only filesystem"); + }, + }); + + expect(result).toEqual({ + applied: false, + reason: "error", + targetPath, + error: "read-only filesystem", + }); + expect(fs.readFileSync(targetPath, "utf8")).toContain("encFileWriteStream.end();"); + }); + + it("refuses pre-created symlink temp paths instead of following them", async () => { + const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-hotfix-temp-symlink-"); + const targetPath = path.join( + repoRoot, + "node_modules", + "@whiskeysockets", + "baileys", + "lib", + "Utils", + "messages-media.js", + ); + const redirectedTarget = path.join(repoRoot, "redirected-temp-target.js"); + const attackerTempPath = path.join( + path.dirname(targetPath), + ".messages-media.js.attacker-temp", + ); + writeRepoFile( + repoRoot, + "node_modules/@whiskeysockets/baileys/lib/Utils/messages-media.js", + [ + "import { once } from 'events';", + "const encryptedStream = async () => {", + " encFileWriteStream.write(mac);", + " encFileWriteStream.end();", + " originalFileStream?.end?.();", + " stream.destroy();", + " logger?.debug('encrypted data successfully');", + "};", + ].join("\n"), + ); + writeRepoFile(repoRoot, "redirected-temp-target.js", "const untouched = true;\n"); + fs.symlinkSync(redirectedTarget, attackerTempPath); + + const { applyBaileysEncryptedStreamFinishHotfix } = await loadPostinstallBundledPluginsModule(); + const result = applyBaileysEncryptedStreamFinishHotfix({ + packageRoot: repoRoot, + createTempPath() { + return attackerTempPath; + }, + }); + + expect(result.applied).toBe(false); + expect(result.reason).toBe("error"); + expect(result.error).toContain("EEXIST"); + expect(fs.readFileSync(redirectedTarget, "utf8")).toBe("const untouched = true;\n"); + expect(fs.readFileSync(targetPath, "utf8")).toContain("encFileWriteStream.end();"); + }); });