fix(whatsapp): await write stream finish before returning encFilePath (#65896)

* fix(whatsapp): await write stream finish in encryptedStream to fix race-condition ENOENT crash

* fix(whatsapp): ship Baileys media hotfix on npm installs

* fix(whatsapp): keep Baileys hotfix postinstall best-effort

* fix(whatsapp): harden Baileys postinstall temp writes

* fix(whatsapp): preserve Baileys hotfix file mode

---------

Co-authored-by: termtek <termtek@ubuntu.tail2b72cd.ts.net>
This commit is contained in:
Frank Yang
2026-04-13 23:11:52 +08:00
committed by GitHub
parent 67593a8108
commit 4ecc8c0d0e
3 changed files with 408 additions and 2 deletions

View File

@@ -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

View File

@@ -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) {

View File

@@ -22,6 +22,35 @@ async function loadStageBundledPluginRuntimeDeps(): Promise<StageBundledPluginRu
return loaded.stageBundledPluginRuntimeDeps;
}
async function loadPostinstallBundledPluginsModule(): Promise<{
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 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();");
});
});