mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:30:42 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user