mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-15 22:03:18 +02:00
* 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>
470 lines
15 KiB
JavaScript
470 lines
15 KiB
JavaScript
#!/usr/bin/env node
|
|
// Runs after install to restore bundled extension runtime deps.
|
|
// Installed builds can lazy-load bundled plugin code through root dist chunks,
|
|
// so runtime dependencies declared in dist/extensions/*/package.json must also
|
|
// resolve from the package root node_modules. Source checkouts resolve bundled
|
|
// 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 { 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";
|
|
|
|
export const BUNDLED_PLUGIN_INSTALL_TARGETS = [];
|
|
|
|
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"));
|
|
}
|
|
|
|
function dependencySentinelPath(depName) {
|
|
return join("node_modules", ...depName.split("/"), "package.json");
|
|
}
|
|
|
|
const KNOWN_NATIVE_PLATFORMS = new Set([
|
|
"aix",
|
|
"android",
|
|
"darwin",
|
|
"freebsd",
|
|
"linux",
|
|
"openbsd",
|
|
"sunos",
|
|
"win32",
|
|
]);
|
|
const KNOWN_NATIVE_ARCHES = new Set(["arm", "arm64", "ia32", "ppc64", "riscv64", "s390x", "x64"]);
|
|
|
|
function packageNameTokens(name) {
|
|
return name
|
|
.toLowerCase()
|
|
.split(/[/@._-]+/u)
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function optionalDependencyTargetsRuntime(name, params = {}) {
|
|
const platform = params.platform ?? process.platform;
|
|
const arch = params.arch ?? process.arch;
|
|
const tokens = new Set(packageNameTokens(name));
|
|
const hasNativePlatformToken = [...tokens].some((token) => KNOWN_NATIVE_PLATFORMS.has(token));
|
|
const hasNativeArchToken = [...tokens].some((token) => KNOWN_NATIVE_ARCHES.has(token));
|
|
return hasNativePlatformToken && hasNativeArchToken && tokens.has(platform) && tokens.has(arch);
|
|
}
|
|
|
|
function runtimeDepNeedsInstall(params) {
|
|
const packageJsonPath = join(params.packageRoot, params.dep.sentinelPath);
|
|
if (!params.existsSync(packageJsonPath)) {
|
|
return true;
|
|
}
|
|
|
|
try {
|
|
const packageJson = params.readJson(packageJsonPath);
|
|
return Object.keys(packageJson.optionalDependencies ?? {}).some(
|
|
(childName) =>
|
|
optionalDependencyTargetsRuntime(childName, {
|
|
arch: params.arch,
|
|
platform: params.platform,
|
|
}) && !params.existsSync(join(params.packageRoot, dependencySentinelPath(childName))),
|
|
);
|
|
} catch {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
function collectRuntimeDeps(packageJson) {
|
|
return {
|
|
...packageJson.dependencies,
|
|
...packageJson.optionalDependencies,
|
|
};
|
|
}
|
|
|
|
export function discoverBundledPluginRuntimeDeps(params = {}) {
|
|
const extensionsDir = params.extensionsDir ?? DEFAULT_EXTENSIONS_DIR;
|
|
const pathExists = params.existsSync ?? existsSync;
|
|
const readDir = params.readdirSync ?? readdirSync;
|
|
const readJsonFile = params.readJson ?? readJson;
|
|
const deps = new Map(
|
|
BUNDLED_PLUGIN_INSTALL_TARGETS.map((target) => [
|
|
target.name,
|
|
{
|
|
name: target.name,
|
|
version: target.version,
|
|
sentinelPath: dependencySentinelPath(target.name),
|
|
pluginIds: [...(target.pluginIds ?? [])],
|
|
},
|
|
]),
|
|
);
|
|
|
|
if (!pathExists(extensionsDir)) {
|
|
return [...deps.values()].toSorted((a, b) => a.name.localeCompare(b.name));
|
|
}
|
|
|
|
for (const entry of readDir(extensionsDir, { withFileTypes: true })) {
|
|
if (!entry.isDirectory()) {
|
|
continue;
|
|
}
|
|
const pluginId = entry.name;
|
|
const packageJsonPath = join(extensionsDir, pluginId, "package.json");
|
|
if (!pathExists(packageJsonPath)) {
|
|
continue;
|
|
}
|
|
try {
|
|
const packageJson = readJsonFile(packageJsonPath);
|
|
for (const [name, version] of Object.entries(collectRuntimeDeps(packageJson))) {
|
|
const existing = deps.get(name);
|
|
if (existing) {
|
|
if (existing.version !== version) {
|
|
continue;
|
|
}
|
|
if (!existing.pluginIds.includes(pluginId)) {
|
|
existing.pluginIds.push(pluginId);
|
|
}
|
|
continue;
|
|
}
|
|
deps.set(name, {
|
|
name,
|
|
version,
|
|
sentinelPath: dependencySentinelPath(name),
|
|
pluginIds: [pluginId],
|
|
});
|
|
}
|
|
} catch {
|
|
// Ignore malformed plugin manifests; runtime will surface those separately.
|
|
}
|
|
}
|
|
|
|
return [...deps.values()]
|
|
.map((dep) => ({
|
|
...dep,
|
|
pluginIds: [...dep.pluginIds].toSorted((a, b) => a.localeCompare(b)),
|
|
}))
|
|
.toSorted((a, b) => a.name.localeCompare(b.name));
|
|
}
|
|
|
|
export function createNestedNpmInstallEnv(env = process.env) {
|
|
const nextEnv = { ...env };
|
|
delete nextEnv.npm_config_global;
|
|
delete nextEnv.npm_config_location;
|
|
delete nextEnv.npm_config_prefix;
|
|
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 (
|
|
pathExists(join(params.packageRoot, ".git")) &&
|
|
pathExists(join(params.packageRoot, "src")) &&
|
|
pathExists(join(params.packageRoot, "extensions"))
|
|
);
|
|
}
|
|
|
|
export function pruneBundledPluginSourceNodeModules(params = {}) {
|
|
const extensionsDir = params.extensionsDir ?? join(DEFAULT_PACKAGE_ROOT, "extensions");
|
|
const pathExists = params.existsSync ?? existsSync;
|
|
const readDir = params.readdirSync ?? readdirSync;
|
|
const removePath = params.rmSync ?? rmSync;
|
|
|
|
if (!pathExists(extensionsDir)) {
|
|
return;
|
|
}
|
|
|
|
for (const entry of readDir(extensionsDir, { withFileTypes: true })) {
|
|
if (!entry.isDirectory() || entry.isSymbolicLink()) {
|
|
continue;
|
|
}
|
|
|
|
const pluginDir = join(extensionsDir, entry.name);
|
|
if (!pathExists(join(pluginDir, "package.json"))) {
|
|
continue;
|
|
}
|
|
|
|
removePath(join(pluginDir, "node_modules"), { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
function shouldRunBundledPluginPostinstall(params) {
|
|
if (params.env?.[DISABLE_POSTINSTALL_ENV]?.trim()) {
|
|
return false;
|
|
}
|
|
if (!params.existsSync(params.extensionsDir)) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
export function runBundledPluginPostinstall(params = {}) {
|
|
const env = params.env ?? process.env;
|
|
const extensionsDir = params.extensionsDir ?? DEFAULT_EXTENSIONS_DIR;
|
|
const packageRoot = params.packageRoot ?? DEFAULT_PACKAGE_ROOT;
|
|
const spawn = params.spawnSync ?? spawnSync;
|
|
const pathExists = params.existsSync ?? existsSync;
|
|
const log = params.log ?? console;
|
|
if (env?.[DISABLE_POSTINSTALL_ENV]?.trim()) {
|
|
return;
|
|
}
|
|
if (isSourceCheckoutRoot({ packageRoot, existsSync: pathExists })) {
|
|
try {
|
|
pruneBundledPluginSourceNodeModules({
|
|
extensionsDir: join(packageRoot, "extensions"),
|
|
existsSync: pathExists,
|
|
readdirSync: params.readdirSync,
|
|
rmSync: params.rmSync,
|
|
});
|
|
} 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 (
|
|
!shouldRunBundledPluginPostinstall({
|
|
env,
|
|
extensionsDir,
|
|
packageRoot,
|
|
existsSync: pathExists,
|
|
})
|
|
) {
|
|
return;
|
|
}
|
|
const runtimeDeps =
|
|
params.runtimeDeps ??
|
|
discoverBundledPluginRuntimeDeps({ extensionsDir, existsSync: pathExists });
|
|
const missingSpecs = runtimeDeps
|
|
.filter((dep) =>
|
|
runtimeDepNeedsInstall({
|
|
dep,
|
|
existsSync: pathExists,
|
|
packageRoot,
|
|
arch: params.arch,
|
|
platform: params.platform,
|
|
readJson: params.readJson ?? readJson,
|
|
}),
|
|
)
|
|
.map((dep) => `${dep.name}@${dep.version}`);
|
|
|
|
if (missingSpecs.length === 0) {
|
|
applyBundledPluginRuntimeHotfixes({
|
|
packageRoot,
|
|
existsSync: pathExists,
|
|
readFileSync: params.readFileSync,
|
|
writeFileSync: params.writeFileSync,
|
|
log,
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const nestedEnv = createNestedNpmInstallEnv(env);
|
|
const npmRunner =
|
|
params.npmRunner ??
|
|
resolveNpmRunner({
|
|
env: nestedEnv,
|
|
execPath: params.execPath,
|
|
existsSync: pathExists,
|
|
platform: params.platform,
|
|
comSpec: params.comSpec,
|
|
npmArgs: [
|
|
"install",
|
|
"--omit=dev",
|
|
"--no-save",
|
|
"--package-lock=false",
|
|
"--legacy-peer-deps",
|
|
...missingSpecs,
|
|
],
|
|
});
|
|
const result = spawn(npmRunner.command, npmRunner.args, {
|
|
cwd: packageRoot,
|
|
encoding: "utf8",
|
|
env: npmRunner.env ?? nestedEnv,
|
|
stdio: "pipe",
|
|
shell: npmRunner.shell,
|
|
windowsVerbatimArguments: npmRunner.windowsVerbatimArguments,
|
|
});
|
|
if (result.status !== 0) {
|
|
const output = [result.stderr, result.stdout].filter(Boolean).join("\n").trim();
|
|
throw new Error(output || "npm install failed");
|
|
}
|
|
log.log(`[postinstall] installed bundled plugin deps: ${missingSpecs.join(", ")}`);
|
|
} catch (e) {
|
|
// 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) {
|
|
runBundledPluginPostinstall();
|
|
}
|