mirror of
https://fastgit.cc/github.com/openclaw/openclaw
synced 2026-04-20 21:02:10 +08:00
fix(whatsapp): harden Baileys media upload hotfix (#65966)
Merged via squash.
Prepared head SHA: b5db59b8fe
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Reviewed-by: @frankekn
This commit is contained in:
@@ -44,6 +44,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Discord/native commands: return the real status card for native `/status` interactions instead of falling through to the synthetic `✅ Done.` ack when the generic dispatcher produces no visible reply. (#54629) Thanks @tkozzer and @vincentkoc.
|
||||
- Hooks/Ollama: let LLM-backed session-memory slug generation honor an explicit `agents.defaults.timeoutSeconds` override instead of always aborting after 15 seconds, so slow local Ollama runs stop silently dropping back to generic filenames. (#66237) Thanks @dmak and @vincentkoc.
|
||||
- Media/transcription: remap `.aac` filenames to `.m4a` for OpenAI-compatible audio uploads so AAC voice notes stop failing MIME-sensitive transcription endpoints. (#66446) Thanks @ben-z.
|
||||
- WhatsApp/Baileys media upload: keep encrypted upload POSTs streaming while still guarding generic-agent dispatcher wiring, so large outbound media sends avoid full-buffer RSS spikes and OOM regressions. (#65966) Thanks @frankekn.
|
||||
- UI/chat: replace marked.js with markdown-it so maliciously crafted markdown can no longer freeze the Control UI via ReDoS. (#46707) Thanks @zhangfnf.
|
||||
- Auto-reply/send policy: keep `sendPolicy: "deny"` from blocking inbound message processing, so the agent still runs its turn while all outbound delivery is suppressed for observer-style setups. (#65461, #53328) Thanks @omarshahine.
|
||||
- BlueBubbles: lazy-refresh the Private API server-info cache on send when reply threading or message effects are requested but status is unknown, so sends no longer silently degrade to plain messages when the 10-minute cache expires. (#65447, #43764) Thanks @omarshahine.
|
||||
|
||||
@@ -47,12 +47,12 @@
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.4.12"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.4.12"
|
||||
},
|
||||
"bundle": {
|
||||
"stageRuntimeDependencies": true
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.4.12"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
"publishToNpm": true
|
||||
|
||||
@@ -127,7 +127,11 @@ describe("web session", () => {
|
||||
fetchAgent?: unknown;
|
||||
};
|
||||
expect(passed.agent).toBeDefined();
|
||||
expect(passed.fetchAgent).toBe(passed.agent);
|
||||
expect(passed.fetchAgent).toBeDefined();
|
||||
expect(passed.fetchAgent).not.toBe(passed.agent);
|
||||
expect(typeof (passed.fetchAgent as { dispatch?: unknown } | undefined)?.dispatch).toBe(
|
||||
"function",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not create a proxy agent when no env proxy is configured", async () => {
|
||||
|
||||
@@ -125,6 +125,7 @@ export async function createWaSocket(
|
||||
const { state, saveCreds } = await useMultiFileAuthState(authDir);
|
||||
const { version } = await fetchLatestBaileysVersion();
|
||||
const agent = await resolveEnvProxyAgent(sessionLogger);
|
||||
const fetchAgent = await resolveEnvFetchDispatcher(sessionLogger, agent);
|
||||
const sock = makeWASocket({
|
||||
auth: {
|
||||
creds: state.creds,
|
||||
@@ -137,7 +138,9 @@ export async function createWaSocket(
|
||||
syncFullHistory: false,
|
||||
markOnlineOnConnect: false,
|
||||
agent,
|
||||
fetchAgent: agent,
|
||||
// Baileys types still model `fetchAgent` as a Node agent even though the
|
||||
// runtime path accepts an undici dispatcher for upload fetches.
|
||||
fetchAgent: fetchAgent as Agent | undefined,
|
||||
});
|
||||
|
||||
sock.ev.on("creds.update", () => enqueueSaveCreds(authDir, saveCreds, sessionLogger));
|
||||
@@ -199,6 +202,58 @@ async function resolveEnvProxyAgent(
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveEnvFetchDispatcher(
|
||||
logger: ReturnType<typeof getChildLogger>,
|
||||
agent?: unknown,
|
||||
): Promise<unknown> {
|
||||
const proxyUrl = resolveProxyUrlFromAgent(agent);
|
||||
const envProxyUrl = resolveEnvHttpsProxyUrl();
|
||||
if (!proxyUrl && !envProxyUrl) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const { EnvHttpProxyAgent, ProxyAgent } = await import("undici");
|
||||
return proxyUrl
|
||||
? new ProxyAgent({ allowH2: false, uri: proxyUrl })
|
||||
: new EnvHttpProxyAgent({ allowH2: false });
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{ error: String(error) },
|
||||
"Failed to initialize env proxy dispatcher for WhatsApp media uploads",
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveProxyUrlFromAgent(agent: unknown): string | undefined {
|
||||
if (typeof agent !== "object" || agent === null || !("proxy" in agent)) {
|
||||
return undefined;
|
||||
}
|
||||
const proxy = (agent as { proxy?: unknown }).proxy;
|
||||
if (proxy instanceof URL) {
|
||||
return proxy.toString();
|
||||
}
|
||||
return typeof proxy === "string" && proxy.length > 0 ? proxy : undefined;
|
||||
}
|
||||
|
||||
function resolveEnvHttpsProxyUrl(env: NodeJS.ProcessEnv = process.env): string | undefined {
|
||||
const lowerHttpsProxy = normalizeEnvProxyValue(env.https_proxy);
|
||||
const lowerHttpProxy = normalizeEnvProxyValue(env.http_proxy);
|
||||
const httpsProxy =
|
||||
lowerHttpsProxy !== undefined ? lowerHttpsProxy : normalizeEnvProxyValue(env.HTTPS_PROXY);
|
||||
const httpProxy =
|
||||
lowerHttpProxy !== undefined ? lowerHttpProxy : normalizeEnvProxyValue(env.HTTP_PROXY);
|
||||
return httpsProxy ?? httpProxy ?? undefined;
|
||||
}
|
||||
|
||||
function normalizeEnvProxyValue(value: string | undefined): string | null | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
export async function waitForWaConnection(sock: ReturnType<typeof makeWASocket>) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
type OffCapable = {
|
||||
|
||||
@@ -1523,6 +1523,9 @@
|
||||
"strip-ansi": "^7.2.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@whiskeysockets/baileys@7.0.0-rc.9": "patches/@whiskeysockets__baileys@7.0.0-rc.9.patch"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
46
patches/@whiskeysockets__baileys@7.0.0-rc.9.patch
Normal file
46
patches/@whiskeysockets__baileys@7.0.0-rc.9.patch
Normal file
@@ -0,0 +1,46 @@
|
||||
diff --git a/lib/Utils/messages-media.js b/lib/Utils/messages-media.js
|
||||
index 0d32dfb4882dfe029ba8804772d7d89404b08e76..73809fcd1d52362aef0c35cb7416c29d86482df0 100644
|
||||
--- a/lib/Utils/messages-media.js
|
||||
+++ b/lib/Utils/messages-media.js
|
||||
@@ -353,9 +353,17 @@
|
||||
const fileSha256 = sha256Plain.digest();
|
||||
const fileEncSha256 = sha256Enc.digest();
|
||||
encFileWriteStream.write(mac);
|
||||
+ // Create finish promises before calling end() to avoid missing the event
|
||||
+ const encFinishPromise = once(encFileWriteStream, 'finish');
|
||||
+ const originalFinishPromise = originalFileStream ? once(originalFileStream, 'finish') : Promise.resolve();
|
||||
encFileWriteStream.end();
|
||||
originalFileStream?.end?.();
|
||||
stream.destroy();
|
||||
+ // Wait for write streams to fully flush to disk before returning encFilePath.
|
||||
+ // Without this await, the caller may open a read stream on the file before
|
||||
+ // the OS has created it, causing a race-condition ENOENT crash.
|
||||
+ await encFinishPromise;
|
||||
+ await originalFinishPromise;
|
||||
logger?.debug('encrypted data successfully');
|
||||
return {
|
||||
mediaKey,
|
||||
@@ -520,11 +528,10 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let result;
|
||||
try {
|
||||
const stream = createReadStream(filePath);
|
||||
const response = await fetch(url, {
|
||||
- dispatcher: fetchAgent,
|
||||
method: 'POST',
|
||||
body: stream,
|
||||
headers: {
|
||||
...(() => {
|
||||
const hdrs = options?.headers;
|
||||
@@ -535,6 +542,11 @@
|
||||
'Content-Type': 'application/octet-stream',
|
||||
Origin: DEFAULT_ORIGIN
|
||||
},
|
||||
+ // Baileys passes a generic agent here in some runtimes. Undici's
|
||||
+ // `dispatcher` only works with Dispatcher-compatible implementations,
|
||||
+ // so only wire it through when the object actually implements
|
||||
+ // `dispatch`.
|
||||
+ ...(typeof fetchAgent?.dispatch === 'function' ? { dispatcher: fetchAgent } : {}),
|
||||
duplex: 'half',
|
||||
// Note: custom agents/proxy require undici Agent; omitted here.
|
||||
signal: timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined
|
||||
11
pnpm-lock.yaml
generated
11
pnpm-lock.yaml
generated
@@ -27,6 +27,11 @@ overrides:
|
||||
|
||||
packageExtensionsChecksum: sha256-n+P/SQo4Pf+dHYpYn1Y6wL4cJEVoVzZ835N0OEp4TM8=
|
||||
|
||||
patchedDependencies:
|
||||
'@whiskeysockets/baileys@7.0.0-rc.9':
|
||||
hash: 23ec8efe1484afa57c51b96955ba331d1467521a8e676a18c2690da7e70a6201
|
||||
path: patches/@whiskeysockets__baileys@7.0.0-rc.9.patch
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
@@ -114,7 +119,7 @@ importers:
|
||||
version: 7.15.0
|
||||
'@whiskeysockets/baileys':
|
||||
specifier: 7.0.0-rc.9
|
||||
version: 7.0.0-rc.9(audio-decode@2.2.3)(jimp@1.6.1)(sharp@0.34.5)
|
||||
version: 7.0.0-rc.9(patch_hash=23ec8efe1484afa57c51b96955ba331d1467521a8e676a18c2690da7e70a6201)(audio-decode@2.2.3)(jimp@1.6.1)(sharp@0.34.5)
|
||||
ajv:
|
||||
specifier: ^8.18.0
|
||||
version: 8.18.0
|
||||
@@ -1221,7 +1226,7 @@ importers:
|
||||
dependencies:
|
||||
'@whiskeysockets/baileys':
|
||||
specifier: 7.0.0-rc.9
|
||||
version: 7.0.0-rc.9(audio-decode@2.2.3)(jimp@1.6.1)(sharp@0.34.5)
|
||||
version: 7.0.0-rc.9(patch_hash=23ec8efe1484afa57c51b96955ba331d1467521a8e676a18c2690da7e70a6201)(audio-decode@2.2.3)(jimp@1.6.1)(sharp@0.34.5)
|
||||
jimp:
|
||||
specifier: ^1.6.1
|
||||
version: 1.6.1
|
||||
@@ -11423,7 +11428,7 @@ snapshots:
|
||||
'@wasm-audio-decoders/common': 9.0.7
|
||||
optional: true
|
||||
|
||||
'@whiskeysockets/baileys@7.0.0-rc.9(audio-decode@2.2.3)(jimp@1.6.1)(sharp@0.34.5)':
|
||||
'@whiskeysockets/baileys@7.0.0-rc.9(patch_hash=23ec8efe1484afa57c51b96955ba331d1467521a8e676a18c2690da7e70a6201)(audio-decode@2.2.3)(jimp@1.6.1)(sharp@0.34.5)':
|
||||
dependencies:
|
||||
'@cacheable/node-cache': 1.7.6
|
||||
'@hapi/boom': 9.1.4
|
||||
|
||||
@@ -55,6 +55,47 @@ const BAILEYS_MEDIA_HOTFIX_REPLACEMENT = [
|
||||
" await Promise.all([encFinishPromise, originalFinishPromise]);",
|
||||
" logger?.debug('encrypted data successfully');",
|
||||
].join("\n");
|
||||
const BAILEYS_MEDIA_HOTFIX_SEQUENTIAL_REPLACEMENT = [
|
||||
" encFileWriteStream.write(mac);",
|
||||
" const encFinishPromise = once(encFileWriteStream, 'finish');",
|
||||
" const originalFinishPromise = originalFileStream ? once(originalFileStream, 'finish') : Promise.resolve();",
|
||||
" encFileWriteStream.end();",
|
||||
" originalFileStream?.end?.();",
|
||||
" stream.destroy();",
|
||||
" await encFinishPromise;",
|
||||
" await originalFinishPromise;",
|
||||
" logger?.debug('encrypted data successfully');",
|
||||
].join("\n");
|
||||
const BAILEYS_MEDIA_HOTFIX_FINISH_PROMISES_RE =
|
||||
/const\s+encFinishPromise\s*=\s*once\(encFileWriteStream,\s*'finish'\);\s*\n[\s\S]*const\s+originalFinishPromise\s*=\s*originalFileStream\s*\?\s*once\(originalFileStream,\s*'finish'\)\s*:\s*Promise\.resolve\(\);/u;
|
||||
const BAILEYS_MEDIA_HOTFIX_PROMISE_ALL_RE =
|
||||
/await\s+Promise\.all\(\[\s*encFinishPromise\s*,\s*originalFinishPromise\s*\]\);/u;
|
||||
const BAILEYS_MEDIA_HOTFIX_SEQUENTIAL_AWAITS_RE =
|
||||
/await\s+encFinishPromise;\s*(?:\/\/[^\n]*\n|\s)*await\s+originalFinishPromise;/u;
|
||||
const BAILEYS_MEDIA_DISPATCHER_NEEDLE = [
|
||||
" const response = await fetch(url, {",
|
||||
" dispatcher: fetchAgent,",
|
||||
" method: 'POST',",
|
||||
].join("\n");
|
||||
const BAILEYS_MEDIA_DISPATCHER_REPLACEMENT = [
|
||||
" const response = await fetch(url, {",
|
||||
" method: 'POST',",
|
||||
].join("\n");
|
||||
const BAILEYS_MEDIA_DISPATCHER_HEADER_NEEDLE = [
|
||||
" 'Content-Type': 'application/octet-stream',",
|
||||
" Origin: DEFAULT_ORIGIN",
|
||||
" },",
|
||||
].join("\n");
|
||||
const BAILEYS_MEDIA_DISPATCHER_HEADER_REPLACEMENT = [
|
||||
" 'Content-Type': 'application/octet-stream',",
|
||||
" Origin: DEFAULT_ORIGIN",
|
||||
" },",
|
||||
" // Baileys passes a generic agent here in some runtimes. Undici's",
|
||||
" // `dispatcher` only works with Dispatcher-compatible implementations,",
|
||||
" // so only wire it through when the object actually implements",
|
||||
" // `dispatch`.",
|
||||
" ...(typeof fetchAgent?.dispatch === 'function' ? { dispatcher: fetchAgent } : {}),",
|
||||
].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;
|
||||
@@ -243,23 +284,59 @@ export function applyBaileysEncryptedStreamFinishHotfix(params = {}) {
|
||||
}
|
||||
|
||||
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 };
|
||||
let patchedText = currentText;
|
||||
let applied = false;
|
||||
|
||||
const encryptedStreamAlreadyPatched =
|
||||
patchedText.includes(BAILEYS_MEDIA_HOTFIX_REPLACEMENT) ||
|
||||
patchedText.includes(BAILEYS_MEDIA_HOTFIX_SEQUENTIAL_REPLACEMENT) ||
|
||||
(BAILEYS_MEDIA_HOTFIX_FINISH_PROMISES_RE.test(patchedText) &&
|
||||
(BAILEYS_MEDIA_HOTFIX_PROMISE_ALL_RE.test(patchedText) ||
|
||||
BAILEYS_MEDIA_HOTFIX_SEQUENTIAL_AWAITS_RE.test(patchedText)));
|
||||
const encryptedStreamPatchable = patchedText.includes(BAILEYS_MEDIA_HOTFIX_NEEDLE);
|
||||
|
||||
let encryptedStreamResolved = encryptedStreamAlreadyPatched;
|
||||
if (!encryptedStreamResolved && encryptedStreamPatchable) {
|
||||
if (!BAILEYS_MEDIA_ONCE_IMPORT_RE.test(patchedText)) {
|
||||
return { applied: false, reason: "missing_once_import", targetPath };
|
||||
}
|
||||
if (!BAILEYS_MEDIA_ASYNC_CONTEXT_RE.test(patchedText)) {
|
||||
return { applied: false, reason: "not_async_context", targetPath };
|
||||
}
|
||||
patchedText = patchedText.replace(
|
||||
BAILEYS_MEDIA_HOTFIX_NEEDLE,
|
||||
BAILEYS_MEDIA_HOTFIX_REPLACEMENT,
|
||||
);
|
||||
applied = true;
|
||||
encryptedStreamResolved = true;
|
||||
}
|
||||
|
||||
const patchedText = currentText.replace(
|
||||
BAILEYS_MEDIA_HOTFIX_NEEDLE,
|
||||
BAILEYS_MEDIA_HOTFIX_REPLACEMENT,
|
||||
const dispatcherAlreadyPatched = patchedText.includes(
|
||||
"...(typeof fetchAgent?.dispatch === 'function' ? { dispatcher: fetchAgent } : {}),",
|
||||
);
|
||||
const dispatcherPatchable =
|
||||
patchedText.includes(BAILEYS_MEDIA_DISPATCHER_NEEDLE) &&
|
||||
patchedText.includes(BAILEYS_MEDIA_DISPATCHER_HEADER_NEEDLE);
|
||||
let dispatcherResolved = dispatcherAlreadyPatched;
|
||||
|
||||
if (!dispatcherResolved && dispatcherPatchable) {
|
||||
patchedText = patchedText
|
||||
.replace(BAILEYS_MEDIA_DISPATCHER_NEEDLE, BAILEYS_MEDIA_DISPATCHER_REPLACEMENT)
|
||||
.replace(
|
||||
BAILEYS_MEDIA_DISPATCHER_HEADER_NEEDLE,
|
||||
BAILEYS_MEDIA_DISPATCHER_HEADER_REPLACEMENT,
|
||||
);
|
||||
applied = true;
|
||||
dispatcherResolved = true;
|
||||
}
|
||||
|
||||
if (!dispatcherResolved) {
|
||||
return { applied: false, reason: "unexpected_content", targetPath };
|
||||
}
|
||||
|
||||
if (!applied) {
|
||||
return { applied: false, reason: "already_patched" };
|
||||
}
|
||||
const tempPath = createTempPath(targetPath);
|
||||
const tempFd = openFile(tempPath, "wx", initialTargetValidation.mode);
|
||||
let tempFdClosed = false;
|
||||
@@ -298,12 +375,12 @@ function applyBundledPluginRuntimeHotfixes(params = {}) {
|
||||
const log = params.log ?? console;
|
||||
const baileysResult = applyBaileysEncryptedStreamFinishHotfix(params);
|
||||
if (baileysResult.applied) {
|
||||
log.log("[postinstall] patched @whiskeysockets/baileys encryptedStream flush ordering");
|
||||
log.log("[postinstall] patched @whiskeysockets/baileys runtime hotfixes");
|
||||
return;
|
||||
}
|
||||
if (baileysResult.reason !== "missing" && baileysResult.reason !== "already_patched") {
|
||||
log.warn(
|
||||
`[postinstall] could not patch @whiskeysockets/baileys encryptedStream: ${baileysResult.reason}`,
|
||||
`[postinstall] could not patch @whiskeysockets/baileys runtime hotfixes: ${baileysResult.reason}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,13 @@ function writeJson(filePath, value) {
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
function readOptionalUtf8(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
return fs.readFileSync(filePath, "utf8");
|
||||
}
|
||||
|
||||
function removePathIfExists(targetPath) {
|
||||
fs.rmSync(targetPath, { recursive: true, force: true });
|
||||
}
|
||||
@@ -42,15 +49,43 @@ function replaceDir(targetPath, sourcePath) {
|
||||
removePathIfExists(sourcePath);
|
||||
}
|
||||
|
||||
function dependencyPathSegments(depName) {
|
||||
if (typeof depName !== "string" || depName.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const segments = depName.split("/");
|
||||
if (depName.startsWith("@")) {
|
||||
if (segments.length !== 2) {
|
||||
return null;
|
||||
}
|
||||
const [scope, name] = segments;
|
||||
if (
|
||||
!/^@[A-Za-z0-9._-]+$/.test(scope) ||
|
||||
!/^[A-Za-z0-9._-]+$/.test(name) ||
|
||||
scope === "@." ||
|
||||
scope === "@.."
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return [scope, name];
|
||||
}
|
||||
if (segments.length !== 1 || !/^[A-Za-z0-9._-]+$/.test(segments[0])) {
|
||||
return null;
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
function dependencyNodeModulesPath(nodeModulesDir, depName) {
|
||||
return path.join(nodeModulesDir, ...depName.split("/"));
|
||||
const segments = dependencyPathSegments(depName);
|
||||
return segments ? path.join(nodeModulesDir, ...segments) : null;
|
||||
}
|
||||
|
||||
function readInstalledDependencyVersion(nodeModulesDir, depName) {
|
||||
const packageJsonPath = path.join(
|
||||
dependencyNodeModulesPath(nodeModulesDir, depName),
|
||||
"package.json",
|
||||
);
|
||||
const depRoot = dependencyNodeModulesPath(nodeModulesDir, depName);
|
||||
if (depRoot === null) {
|
||||
return null;
|
||||
}
|
||||
const packageJsonPath = path.join(depRoot, "package.json");
|
||||
if (!fs.existsSync(packageJsonPath)) {
|
||||
return null;
|
||||
}
|
||||
@@ -62,6 +97,15 @@ function dependencyVersionSatisfied(spec, installedVersion) {
|
||||
return semverSatisfies(installedVersion, spec, { includePrerelease: false });
|
||||
}
|
||||
|
||||
function readInstalledDependencyVersionFromRoot(depRoot) {
|
||||
const packageJsonPath = path.join(depRoot, "package.json");
|
||||
if (!fs.existsSync(packageJsonPath)) {
|
||||
return null;
|
||||
}
|
||||
const version = readJson(packageJsonPath).version;
|
||||
return typeof version === "string" ? version : null;
|
||||
}
|
||||
|
||||
const defaultStagedRuntimeDepGlobalPruneSuffixes = [".d.ts", ".map"];
|
||||
const defaultStagedRuntimeDepPruneRules = new Map([
|
||||
// Type declarations only; runtime resolves through lib/es entrypoints.
|
||||
@@ -103,7 +147,7 @@ const defaultStagedRuntimeDepPruneRules = new Map([
|
||||
["@jimp/plugin-quantize", { paths: ["src/__image_snapshots__"] }],
|
||||
["@jimp/plugin-threshold", { paths: ["src/__image_snapshots__"] }],
|
||||
]);
|
||||
const runtimeDepsStagingVersion = 2;
|
||||
const runtimeDepsStagingVersion = 3;
|
||||
|
||||
function resolveRuntimeDepPruneConfig(params = {}) {
|
||||
return {
|
||||
@@ -113,38 +157,247 @@ function resolveRuntimeDepPruneConfig(params = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function collectInstalledRuntimeClosure(rootNodeModulesDir, dependencySpecs) {
|
||||
function resolveInstalledDependencyRoot(params) {
|
||||
const candidates = [];
|
||||
if (params.parentPackageRoot) {
|
||||
const nestedDepRoot = dependencyNodeModulesPath(
|
||||
path.join(params.parentPackageRoot, "node_modules"),
|
||||
params.depName,
|
||||
);
|
||||
if (nestedDepRoot !== null) {
|
||||
candidates.push(nestedDepRoot);
|
||||
}
|
||||
}
|
||||
const rootDepRoot = dependencyNodeModulesPath(params.rootNodeModulesDir, params.depName);
|
||||
if (rootDepRoot !== null) {
|
||||
candidates.push(rootDepRoot);
|
||||
}
|
||||
|
||||
for (const depRoot of candidates) {
|
||||
const installedVersion = readInstalledDependencyVersionFromRoot(depRoot);
|
||||
if (installedVersion !== null && dependencyVersionSatisfied(params.spec, installedVersion)) {
|
||||
return depRoot;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function collectInstalledRuntimeDependencyRoots(rootNodeModulesDir, dependencySpecs) {
|
||||
const packageCache = new Map();
|
||||
const closure = new Set();
|
||||
const queue = Object.entries(dependencySpecs);
|
||||
const directRoots = [];
|
||||
const allRoots = [];
|
||||
const queue = Object.entries(dependencySpecs).map(([depName, spec]) => ({
|
||||
depName,
|
||||
spec,
|
||||
parentPackageRoot: null,
|
||||
direct: true,
|
||||
}));
|
||||
const seen = new Set();
|
||||
|
||||
while (queue.length > 0) {
|
||||
const [depName, spec] = queue.shift();
|
||||
const current = queue.shift();
|
||||
const depRoot = resolveInstalledDependencyRoot({
|
||||
depName: current.depName,
|
||||
spec: current.spec,
|
||||
parentPackageRoot: current.parentPackageRoot,
|
||||
rootNodeModulesDir,
|
||||
});
|
||||
if (depRoot === null) {
|
||||
return null;
|
||||
}
|
||||
const canonicalDepRoot = fs.realpathSync(depRoot);
|
||||
|
||||
const seenKey = `${current.depName}\0${canonicalDepRoot}`;
|
||||
if (seen.has(seenKey)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(seenKey);
|
||||
|
||||
const record = { name: current.depName, root: depRoot, realRoot: canonicalDepRoot };
|
||||
allRoots.push(record);
|
||||
if (current.direct) {
|
||||
directRoots.push(record);
|
||||
}
|
||||
|
||||
const packageJson =
|
||||
packageCache.get(canonicalDepRoot) ?? readJson(path.join(depRoot, "package.json"));
|
||||
packageCache.set(canonicalDepRoot, packageJson);
|
||||
for (const [childName, childSpec] of Object.entries(packageJson.dependencies ?? {})) {
|
||||
queue.push({
|
||||
depName: childName,
|
||||
spec: childSpec,
|
||||
parentPackageRoot: depRoot,
|
||||
direct: false,
|
||||
});
|
||||
}
|
||||
for (const [childName, childSpec] of Object.entries(packageJson.optionalDependencies ?? {})) {
|
||||
queue.push({
|
||||
depName: childName,
|
||||
spec: childSpec,
|
||||
parentPackageRoot: depRoot,
|
||||
direct: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { allRoots, directRoots };
|
||||
}
|
||||
|
||||
function pathIsInsideCopiedRoot(candidateRoot, copiedRoot) {
|
||||
return candidateRoot === copiedRoot || candidateRoot.startsWith(`${copiedRoot}${path.sep}`);
|
||||
}
|
||||
|
||||
function findContainingRealRoot(candidatePath, allowedRealRoots) {
|
||||
return (
|
||||
allowedRealRoots.find((rootPath) => pathIsInsideCopiedRoot(candidatePath, rootPath)) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function copyMaterializedDependencyTree(params) {
|
||||
const { activeRoots, allowedRealRoots, sourcePath, targetPath } = params;
|
||||
const sourceStats = fs.lstatSync(sourcePath);
|
||||
|
||||
if (sourceStats.isSymbolicLink()) {
|
||||
let resolvedPath;
|
||||
try {
|
||||
resolvedPath = fs.realpathSync(sourcePath);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const containingRoot = findContainingRealRoot(resolvedPath, allowedRealRoots);
|
||||
if (containingRoot === null) {
|
||||
return false;
|
||||
}
|
||||
if (activeRoots.has(containingRoot)) {
|
||||
return true;
|
||||
}
|
||||
const nextActiveRoots = new Set(activeRoots);
|
||||
nextActiveRoots.add(containingRoot);
|
||||
return copyMaterializedDependencyTree({
|
||||
activeRoots: nextActiveRoots,
|
||||
allowedRealRoots,
|
||||
sourcePath: resolvedPath,
|
||||
targetPath,
|
||||
});
|
||||
}
|
||||
|
||||
if (sourceStats.isDirectory()) {
|
||||
fs.mkdirSync(targetPath, { recursive: true });
|
||||
for (const entry of fs
|
||||
.readdirSync(sourcePath, { withFileTypes: true })
|
||||
.toSorted((left, right) => left.name.localeCompare(right.name))) {
|
||||
if (
|
||||
!copyMaterializedDependencyTree({
|
||||
activeRoots,
|
||||
allowedRealRoots,
|
||||
sourcePath: path.join(sourcePath, entry.name),
|
||||
targetPath: path.join(targetPath, entry.name),
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (sourceStats.isFile()) {
|
||||
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||
fs.copyFileSync(sourcePath, targetPath);
|
||||
fs.chmodSync(targetPath, sourceStats.mode);
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function selectRuntimeDependencyRootsToCopy(resolution) {
|
||||
const rootsToCopy = [];
|
||||
|
||||
for (const record of resolution.directRoots) {
|
||||
rootsToCopy.push(record);
|
||||
}
|
||||
|
||||
for (const record of resolution.allRoots) {
|
||||
if (rootsToCopy.some((entry) => pathIsInsideCopiedRoot(record.realRoot, entry.realRoot))) {
|
||||
continue;
|
||||
}
|
||||
rootsToCopy.push(record);
|
||||
}
|
||||
|
||||
return rootsToCopy;
|
||||
}
|
||||
|
||||
function resolveInstalledDirectDependencyNames(rootNodeModulesDir, dependencySpecs) {
|
||||
const directDependencyNames = [];
|
||||
for (const [depName, spec] of Object.entries(dependencySpecs)) {
|
||||
const installedVersion = readInstalledDependencyVersion(rootNodeModulesDir, depName);
|
||||
if (installedVersion === null || !dependencyVersionSatisfied(spec, installedVersion)) {
|
||||
return null;
|
||||
}
|
||||
if (closure.has(depName)) {
|
||||
directDependencyNames.push(depName);
|
||||
}
|
||||
return directDependencyNames;
|
||||
}
|
||||
|
||||
function appendDirectoryFingerprint(hash, rootDir, currentDir = rootDir) {
|
||||
const entries = fs
|
||||
.readdirSync(currentDir, { withFileTypes: true })
|
||||
.toSorted((left, right) => left.name.localeCompare(right.name));
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(currentDir, entry.name);
|
||||
const relativePath = path.relative(rootDir, fullPath).replace(/\\/g, "/");
|
||||
if (entry.isSymbolicLink()) {
|
||||
hash.update(`symlink:${relativePath}->${fs.readlinkSync(fullPath).replace(/\\/g, "/")}\n`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const packageJsonPath = path.join(
|
||||
dependencyNodeModulesPath(rootNodeModulesDir, depName),
|
||||
"package.json",
|
||||
);
|
||||
const packageJson = packageCache.get(depName) ?? readJson(packageJsonPath);
|
||||
packageCache.set(depName, packageJson);
|
||||
closure.add(depName);
|
||||
|
||||
for (const [childName, childSpec] of Object.entries(packageJson.dependencies ?? {})) {
|
||||
queue.push([childName, childSpec]);
|
||||
if (entry.isDirectory()) {
|
||||
hash.update(`dir:${relativePath}\n`);
|
||||
appendDirectoryFingerprint(hash, rootDir, fullPath);
|
||||
continue;
|
||||
}
|
||||
for (const [childName, childSpec] of Object.entries(packageJson.optionalDependencies ?? {})) {
|
||||
queue.push([childName, childSpec]);
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
const stat = fs.statSync(fullPath);
|
||||
hash.update(`file:${relativePath}:${stat.size}\n`);
|
||||
hash.update(fs.readFileSync(fullPath));
|
||||
}
|
||||
}
|
||||
|
||||
return [...closure];
|
||||
function createInstalledRuntimeClosureFingerprint(rootNodeModulesDir, dependencyNames) {
|
||||
const hash = createHash("sha256");
|
||||
for (const depName of [...dependencyNames].toSorted((left, right) => left.localeCompare(right))) {
|
||||
const depRoot = dependencyNodeModulesPath(rootNodeModulesDir, depName);
|
||||
if (depRoot === null || !fs.existsSync(depRoot)) {
|
||||
return null;
|
||||
}
|
||||
hash.update(`package:${depName}\n`);
|
||||
appendDirectoryFingerprint(hash, depRoot);
|
||||
}
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
function resolveInstalledRuntimeClosureFingerprint(params) {
|
||||
const dependencySpecs = {
|
||||
...params.packageJson.dependencies,
|
||||
...params.packageJson.optionalDependencies,
|
||||
};
|
||||
if (Object.keys(dependencySpecs).length === 0 || !fs.existsSync(params.rootNodeModulesDir)) {
|
||||
return null;
|
||||
}
|
||||
const resolution = collectInstalledRuntimeDependencyRoots(
|
||||
params.rootNodeModulesDir,
|
||||
dependencySpecs,
|
||||
);
|
||||
if (resolution === null) {
|
||||
return null;
|
||||
}
|
||||
return createInstalledRuntimeClosureFingerprint(
|
||||
params.rootNodeModulesDir,
|
||||
selectRuntimeDependencyRootsToCopy(resolution).map((record) => record.name),
|
||||
);
|
||||
}
|
||||
|
||||
function walkFiles(rootDir, visitFile) {
|
||||
@@ -180,6 +433,9 @@ function pruneDependencyFilesBySuffixes(depRoot, suffixes) {
|
||||
|
||||
function pruneStagedInstalledDependencyCargo(nodeModulesDir, depName, pruneConfig) {
|
||||
const depRoot = dependencyNodeModulesPath(nodeModulesDir, depName);
|
||||
if (depRoot === null) {
|
||||
return;
|
||||
}
|
||||
const pruneRule = pruneConfig.pruneRules.get(depName);
|
||||
for (const relativePath of pruneRule?.paths ?? []) {
|
||||
removePathIfExists(path.join(depRoot, relativePath));
|
||||
@@ -272,13 +528,21 @@ function resolveRuntimeDepsStampPath(pluginDir) {
|
||||
return path.join(pluginDir, ".openclaw-runtime-deps-stamp.json");
|
||||
}
|
||||
|
||||
function createRuntimeDepsFingerprint(packageJson, pruneConfig) {
|
||||
function createRuntimeDepsFingerprint(packageJson, pruneConfig, params = {}) {
|
||||
const repoRoot = params.repoRoot;
|
||||
const lockfilePath =
|
||||
typeof repoRoot === "string" && repoRoot.length > 0
|
||||
? path.join(repoRoot, "pnpm-lock.yaml")
|
||||
: null;
|
||||
const rootLockfile = lockfilePath ? readOptionalUtf8(lockfilePath) : null;
|
||||
return createHash("sha256")
|
||||
.update(
|
||||
JSON.stringify({
|
||||
globalPruneSuffixes: pruneConfig.globalPruneSuffixes,
|
||||
packageJson,
|
||||
pruneRules: [...pruneConfig.pruneRules.entries()],
|
||||
rootInstalledRuntimeFingerprint: params.rootInstalledRuntimeFingerprint ?? null,
|
||||
rootLockfile,
|
||||
version: runtimeDepsStagingVersion,
|
||||
}),
|
||||
)
|
||||
@@ -307,10 +571,19 @@ function stageInstalledRootRuntimeDeps(params) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const dependencyNames = collectInstalledRuntimeClosure(rootNodeModulesDir, dependencySpecs);
|
||||
if (dependencyNames === null) {
|
||||
const directDependencyNames = resolveInstalledDirectDependencyNames(
|
||||
rootNodeModulesDir,
|
||||
dependencySpecs,
|
||||
);
|
||||
if (directDependencyNames === null) {
|
||||
return false;
|
||||
}
|
||||
const resolution = collectInstalledRuntimeDependencyRoots(rootNodeModulesDir, dependencySpecs);
|
||||
if (resolution === null) {
|
||||
return false;
|
||||
}
|
||||
const rootsToCopy = selectRuntimeDependencyRootsToCopy(resolution);
|
||||
const allowedRealRoots = rootsToCopy.map((record) => record.realRoot);
|
||||
|
||||
const nodeModulesDir = path.join(pluginDir, "node_modules");
|
||||
const stampPath = resolveRuntimeDepsStampPath(pluginDir);
|
||||
@@ -323,11 +596,27 @@ function stageInstalledRootRuntimeDeps(params) {
|
||||
);
|
||||
|
||||
try {
|
||||
for (const depName of dependencyNames) {
|
||||
const sourcePath = dependencyNodeModulesPath(rootNodeModulesDir, depName);
|
||||
const targetPath = dependencyNodeModulesPath(stagedNodeModulesDir, depName);
|
||||
for (const record of rootsToCopy.toSorted((left, right) =>
|
||||
left.name.localeCompare(right.name),
|
||||
)) {
|
||||
const sourcePath = record.realRoot;
|
||||
const targetPath = dependencyNodeModulesPath(stagedNodeModulesDir, record.name);
|
||||
if (targetPath === null) {
|
||||
return false;
|
||||
}
|
||||
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||
fs.cpSync(sourcePath, targetPath, { recursive: true, force: true, dereference: true });
|
||||
const sourceRootReal = findContainingRealRoot(sourcePath, allowedRealRoots);
|
||||
if (
|
||||
sourceRootReal === null ||
|
||||
!copyMaterializedDependencyTree({
|
||||
activeRoots: new Set([sourceRootReal]),
|
||||
allowedRealRoots,
|
||||
sourcePath,
|
||||
targetPath,
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
pruneStagedRuntimeDependencyCargo(stagedNodeModulesDir, pruneConfig);
|
||||
|
||||
@@ -435,7 +724,14 @@ export function stageBundledPluginRuntimeDeps(params = {}) {
|
||||
removePathIfExists(stampPath);
|
||||
continue;
|
||||
}
|
||||
const fingerprint = createRuntimeDepsFingerprint(packageJson, pruneConfig);
|
||||
const rootInstalledRuntimeFingerprint = resolveInstalledRuntimeClosureFingerprint({
|
||||
packageJson,
|
||||
rootNodeModulesDir: path.join(repoRoot, "node_modules"),
|
||||
});
|
||||
const fingerprint = createRuntimeDepsFingerprint(packageJson, pruneConfig, {
|
||||
repoRoot,
|
||||
rootInstalledRuntimeFingerprint,
|
||||
});
|
||||
const stamp = readRuntimeDepsStamp(stampPath);
|
||||
if (fs.existsSync(nodeModulesDir) && stamp?.fingerprint === fingerprint) {
|
||||
continue;
|
||||
|
||||
@@ -63,6 +63,117 @@ function writeRepoFile(repoRoot: string, relativePath: string, value: string) {
|
||||
fs.writeFileSync(fullPath, value, "utf8");
|
||||
}
|
||||
|
||||
function createBaileysMessagesMediaSource(params?: {
|
||||
dispatcherPatched?: boolean;
|
||||
dispatcherHeaderDrifted?: boolean;
|
||||
encryptedStreamPatched?: boolean;
|
||||
encryptedStreamPatchedSequentially?: boolean;
|
||||
encryptedStreamPatchedSequentiallyWithComments?: boolean;
|
||||
encryptedStreamUnrecognized?: boolean;
|
||||
}) {
|
||||
const encryptedLines = params?.encryptedStreamUnrecognized
|
||||
? [
|
||||
" encFileWriteStream.write(mac);",
|
||||
" logger?.debug('encrypted data changed upstream');",
|
||||
]
|
||||
: params?.encryptedStreamPatchedSequentiallyWithComments
|
||||
? [
|
||||
" encFileWriteStream.write(mac);",
|
||||
" const encFinishPromise = once(encFileWriteStream, 'finish');",
|
||||
" const originalFinishPromise = originalFileStream ? once(originalFileStream, 'finish') : Promise.resolve();",
|
||||
" encFileWriteStream.end();",
|
||||
" originalFileStream?.end?.();",
|
||||
" stream.destroy();",
|
||||
" // Wait for write streams to fully flush to disk before returning encFilePath.",
|
||||
" // Without this await, the caller may open a read stream on the file before",
|
||||
" // the OS has created it, causing a race-condition ENOENT crash.",
|
||||
" await encFinishPromise;",
|
||||
" await originalFinishPromise;",
|
||||
" logger?.debug('encrypted data successfully');",
|
||||
]
|
||||
: params?.encryptedStreamPatchedSequentially
|
||||
? [
|
||||
" encFileWriteStream.write(mac);",
|
||||
" const encFinishPromise = once(encFileWriteStream, 'finish');",
|
||||
" const originalFinishPromise = originalFileStream ? once(originalFileStream, 'finish') : Promise.resolve();",
|
||||
" encFileWriteStream.end();",
|
||||
" originalFileStream?.end?.();",
|
||||
" stream.destroy();",
|
||||
" await encFinishPromise;",
|
||||
" await originalFinishPromise;",
|
||||
" logger?.debug('encrypted data successfully');",
|
||||
]
|
||||
: params?.encryptedStreamPatched
|
||||
? [
|
||||
" 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');",
|
||||
]
|
||||
: [
|
||||
" encFileWriteStream.write(mac);",
|
||||
" encFileWriteStream.end();",
|
||||
" originalFileStream?.end?.();",
|
||||
" stream.destroy();",
|
||||
" logger?.debug('encrypted data successfully');",
|
||||
];
|
||||
const dispatcherLines = params?.dispatcherPatched
|
||||
? [
|
||||
" const response = await fetch(url, {",
|
||||
" method: 'POST',",
|
||||
" body: stream,",
|
||||
" headers: {",
|
||||
" 'Content-Type': 'application/octet-stream',",
|
||||
" Origin: DEFAULT_ORIGIN",
|
||||
" },",
|
||||
" // Baileys passes a generic agent here in some runtimes. Undici's",
|
||||
" // `dispatcher` only works with Dispatcher-compatible implementations,",
|
||||
" // so only wire it through when the object actually implements",
|
||||
" // `dispatch`.",
|
||||
" ...(typeof fetchAgent?.dispatch === 'function' ? { dispatcher: fetchAgent } : {}),",
|
||||
" duplex: 'half',",
|
||||
" });",
|
||||
]
|
||||
: params?.dispatcherHeaderDrifted
|
||||
? [
|
||||
" const response = await fetch(url, {",
|
||||
" dispatcher: fetchAgent,",
|
||||
" method: 'POST',",
|
||||
" body: stream,",
|
||||
" headers: {",
|
||||
" Origin: DEFAULT_ORIGIN,",
|
||||
" 'Content-Type': 'application/octet-stream'",
|
||||
" },",
|
||||
" duplex: 'half',",
|
||||
" });",
|
||||
]
|
||||
: [
|
||||
" const response = await fetch(url, {",
|
||||
" dispatcher: fetchAgent,",
|
||||
" method: 'POST',",
|
||||
" body: stream,",
|
||||
" headers: {",
|
||||
" 'Content-Type': 'application/octet-stream',",
|
||||
" Origin: DEFAULT_ORIGIN",
|
||||
" },",
|
||||
" duplex: 'half',",
|
||||
" });",
|
||||
];
|
||||
return [
|
||||
"import { once } from 'events';",
|
||||
"const encryptedStream = async () => {",
|
||||
...encryptedLines,
|
||||
"};",
|
||||
"const upload = async () => {",
|
||||
...dispatcherLines,
|
||||
"};",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanupTrackedTempDirs(tempDirs);
|
||||
});
|
||||
@@ -208,16 +319,7 @@ describe("stageBundledPluginRuntimeDeps", () => {
|
||||
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"),
|
||||
createBaileysMessagesMediaSource(),
|
||||
);
|
||||
|
||||
const { applyBaileysEncryptedStreamFinishHotfix } = await loadPostinstallBundledPluginsModule();
|
||||
@@ -234,6 +336,171 @@ describe("stageBundledPluginRuntimeDeps", () => {
|
||||
expect(fs.readFileSync(targetPath, "utf8")).toContain(
|
||||
"await Promise.all([encFinishPromise, originalFinishPromise]);",
|
||||
);
|
||||
expect(fs.readFileSync(targetPath, "utf8")).toContain(
|
||||
"...(typeof fetchAgent?.dispatch === 'function' ? { dispatcher: fetchAgent } : {}),",
|
||||
);
|
||||
expect(fs.readFileSync(targetPath, "utf8")).not.toContain("dispatcher: fetchAgent,");
|
||||
});
|
||||
|
||||
it("patches the Baileys dispatcher guard when the flush hotfix is already present", async () => {
|
||||
const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-hotfix-dispatcher-");
|
||||
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",
|
||||
createBaileysMessagesMediaSource({ encryptedStreamPatched: true }),
|
||||
);
|
||||
|
||||
const { applyBaileysEncryptedStreamFinishHotfix } = await loadPostinstallBundledPluginsModule();
|
||||
const result = applyBaileysEncryptedStreamFinishHotfix({ packageRoot: repoRoot });
|
||||
|
||||
expect(result).toEqual({
|
||||
applied: true,
|
||||
reason: "patched",
|
||||
targetPath,
|
||||
});
|
||||
expect(fs.readFileSync(targetPath, "utf8")).toContain(
|
||||
"await Promise.all([encFinishPromise, originalFinishPromise]);",
|
||||
);
|
||||
expect(fs.readFileSync(targetPath, "utf8")).toContain(
|
||||
"...(typeof fetchAgent?.dispatch === 'function' ? { dispatcher: fetchAgent } : {}),",
|
||||
);
|
||||
});
|
||||
|
||||
it("patches the Baileys dispatcher guard even when the encryptedStream block changed", async () => {
|
||||
const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-hotfix-dispatcher-only-");
|
||||
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",
|
||||
createBaileysMessagesMediaSource({ encryptedStreamUnrecognized: true }),
|
||||
);
|
||||
|
||||
const { applyBaileysEncryptedStreamFinishHotfix } = await loadPostinstallBundledPluginsModule();
|
||||
const result = applyBaileysEncryptedStreamFinishHotfix({ packageRoot: repoRoot });
|
||||
|
||||
expect(result).toEqual({
|
||||
applied: true,
|
||||
reason: "patched",
|
||||
targetPath,
|
||||
});
|
||||
expect(fs.readFileSync(targetPath, "utf8")).toContain(
|
||||
"logger?.debug('encrypted data changed upstream');",
|
||||
);
|
||||
expect(fs.readFileSync(targetPath, "utf8")).toContain(
|
||||
"...(typeof fetchAgent?.dispatch === 'function' ? { dispatcher: fetchAgent } : {}),",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails when the dispatcher block drifts even if encryptedStream is patchable", async () => {
|
||||
const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-hotfix-dispatcher-drifted-");
|
||||
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",
|
||||
createBaileysMessagesMediaSource({ dispatcherHeaderDrifted: true }),
|
||||
);
|
||||
|
||||
const originalText = fs.readFileSync(targetPath, "utf8");
|
||||
const { applyBaileysEncryptedStreamFinishHotfix } = await loadPostinstallBundledPluginsModule();
|
||||
const result = applyBaileysEncryptedStreamFinishHotfix({ packageRoot: repoRoot });
|
||||
|
||||
expect(result).toEqual({
|
||||
applied: false,
|
||||
reason: "unexpected_content",
|
||||
targetPath,
|
||||
});
|
||||
expect(fs.readFileSync(targetPath, "utf8")).toBe(originalText);
|
||||
});
|
||||
|
||||
it("patches the Baileys dispatcher guard when sequential awaits include comments", async () => {
|
||||
const repoRoot = makeRepoRoot(
|
||||
"openclaw-stage-bundled-runtime-hotfix-dispatcher-sequential-comments-",
|
||||
);
|
||||
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",
|
||||
createBaileysMessagesMediaSource({ encryptedStreamPatchedSequentiallyWithComments: true }),
|
||||
);
|
||||
|
||||
const { applyBaileysEncryptedStreamFinishHotfix } = await loadPostinstallBundledPluginsModule();
|
||||
const result = applyBaileysEncryptedStreamFinishHotfix({ packageRoot: repoRoot });
|
||||
|
||||
expect(result).toEqual({
|
||||
applied: true,
|
||||
reason: "patched",
|
||||
targetPath,
|
||||
});
|
||||
expect(fs.readFileSync(targetPath, "utf8")).toContain(
|
||||
"...(typeof fetchAgent?.dispatch === 'function' ? { dispatcher: fetchAgent } : {}),",
|
||||
);
|
||||
expect(fs.readFileSync(targetPath, "utf8")).toContain("await encFinishPromise;");
|
||||
expect(fs.readFileSync(targetPath, "utf8")).toContain("await originalFinishPromise;");
|
||||
});
|
||||
|
||||
it("patches the Baileys dispatcher guard when the flush hotfix uses sequential awaits", async () => {
|
||||
const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-hotfix-dispatcher-sequential-");
|
||||
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",
|
||||
createBaileysMessagesMediaSource({ encryptedStreamPatchedSequentially: true }),
|
||||
);
|
||||
|
||||
const { applyBaileysEncryptedStreamFinishHotfix } = await loadPostinstallBundledPluginsModule();
|
||||
const result = applyBaileysEncryptedStreamFinishHotfix({ packageRoot: repoRoot });
|
||||
|
||||
expect(result).toEqual({
|
||||
applied: true,
|
||||
reason: "patched",
|
||||
targetPath,
|
||||
});
|
||||
expect(fs.readFileSync(targetPath, "utf8")).toContain("await encFinishPromise;");
|
||||
expect(fs.readFileSync(targetPath, "utf8")).toContain("await originalFinishPromise;");
|
||||
expect(fs.readFileSync(targetPath, "utf8")).toContain(
|
||||
"...(typeof fetchAgent?.dispatch === 'function' ? { dispatcher: fetchAgent } : {}),",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves the original module read mode when replacing Baileys", async () => {
|
||||
@@ -250,16 +517,7 @@ describe("stageBundledPluginRuntimeDeps", () => {
|
||||
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"),
|
||||
createBaileysMessagesMediaSource(),
|
||||
);
|
||||
fs.chmodSync(targetPath, 0o644);
|
||||
|
||||
@@ -315,16 +573,7 @@ describe("stageBundledPluginRuntimeDeps", () => {
|
||||
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"),
|
||||
createBaileysMessagesMediaSource(),
|
||||
);
|
||||
|
||||
const { applyBaileysEncryptedStreamFinishHotfix } = await loadPostinstallBundledPluginsModule();
|
||||
@@ -341,7 +590,7 @@ describe("stageBundledPluginRuntimeDeps", () => {
|
||||
targetPath,
|
||||
error: "read-only filesystem",
|
||||
});
|
||||
expect(fs.readFileSync(targetPath, "utf8")).toContain("encFileWriteStream.end();");
|
||||
expect(fs.readFileSync(targetPath, "utf8")).toContain("dispatcher: fetchAgent,");
|
||||
});
|
||||
|
||||
it("refuses pre-created symlink temp paths instead of following them", async () => {
|
||||
@@ -363,16 +612,7 @@ describe("stageBundledPluginRuntimeDeps", () => {
|
||||
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"),
|
||||
createBaileysMessagesMediaSource(),
|
||||
);
|
||||
writeRepoFile(repoRoot, "redirected-temp-target.js", "const untouched = true;\n");
|
||||
fs.symlinkSync(redirectedTarget, attackerTempPath);
|
||||
@@ -389,6 +629,6 @@ describe("stageBundledPluginRuntimeDeps", () => {
|
||||
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();");
|
||||
expect(fs.readFileSync(targetPath, "utf8")).toContain("dispatcher: fetchAgent,");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,4 +62,20 @@ describe("collectBuiltBundledPluginStagedRuntimeDependencyErrors", () => {
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the WhatsApp bundled plugin opted into staged runtime dependencies", () => {
|
||||
const packageJson = JSON.parse(
|
||||
fs.readFileSync(path.join(process.cwd(), "extensions/whatsapp/package.json"), "utf8"),
|
||||
) as {
|
||||
dependencies?: Record<string, string>;
|
||||
openclaw?: {
|
||||
bundle?: {
|
||||
stageRuntimeDependencies?: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
expect(packageJson.dependencies?.["@whiskeysockets/baileys"]).toBe("7.0.0-rc.9");
|
||||
expect(packageJson.openclaw?.bundle?.stageRuntimeDependencies).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -123,6 +123,77 @@ describe("stageBundledPluginRuntimeDeps", () => {
|
||||
expect(fs.readFileSync(path.join(pluginDir, "node_modules", "marker.txt"), "utf8")).toBe("2\n");
|
||||
});
|
||||
|
||||
it("restages when the root pnpm lockfile changes", () => {
|
||||
const { pluginDir, repoRoot } = createBundledPluginFixture({
|
||||
packageJson: {
|
||||
name: "@openclaw/fixture-plugin",
|
||||
version: "1.0.0",
|
||||
dependencies: { "left-pad": "1.3.0" },
|
||||
openclaw: { bundle: { stageRuntimeDependencies: true } },
|
||||
},
|
||||
});
|
||||
fs.writeFileSync(path.join(repoRoot, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n", "utf8");
|
||||
|
||||
let installCount = 0;
|
||||
const stageOnce = () =>
|
||||
stageBundledPluginRuntimeDeps({
|
||||
cwd: repoRoot,
|
||||
installPluginRuntimeDepsImpl: ({ fingerprint }: { fingerprint: string }) => {
|
||||
installCount += 1;
|
||||
const nodeModulesDir = path.join(pluginDir, "node_modules");
|
||||
fs.mkdirSync(nodeModulesDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(nodeModulesDir, "marker.txt"), `${installCount}\n`, "utf8");
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, ".openclaw-runtime-deps-stamp.json"),
|
||||
`${JSON.stringify({ fingerprint }, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
stageOnce();
|
||||
fs.writeFileSync(
|
||||
path.join(repoRoot, "pnpm-lock.yaml"),
|
||||
"lockfileVersion: '9.0'\npatchedDependencies:\n left-pad@1.3.0: patches/left-pad.patch\n",
|
||||
"utf8",
|
||||
);
|
||||
stageOnce();
|
||||
|
||||
expect(installCount).toBe(2);
|
||||
expect(fs.readFileSync(path.join(pluginDir, "node_modules", "marker.txt"), "utf8")).toBe("2\n");
|
||||
});
|
||||
|
||||
it("restages when installed root runtime dependency contents change", () => {
|
||||
const { pluginDir, repoRoot } = createBundledPluginFixture({
|
||||
packageJson: {
|
||||
name: "@openclaw/fixture-plugin",
|
||||
version: "1.0.0",
|
||||
dependencies: { direct: "1.0.0" },
|
||||
openclaw: { bundle: { stageRuntimeDependencies: true } },
|
||||
},
|
||||
});
|
||||
const directDir = path.join(repoRoot, "node_modules", "direct");
|
||||
fs.mkdirSync(directDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(directDir, "package.json"),
|
||||
'{ "name": "direct", "version": "1.0.0" }\n',
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(path.join(directDir, "index.js"), "module.exports = 'first';\n", "utf8");
|
||||
|
||||
stageBundledPluginRuntimeDeps({ cwd: repoRoot });
|
||||
expect(
|
||||
fs.readFileSync(path.join(pluginDir, "node_modules", "direct", "index.js"), "utf8"),
|
||||
).toBe("module.exports = 'first';\n");
|
||||
|
||||
fs.writeFileSync(path.join(directDir, "index.js"), "module.exports = 'second';\n", "utf8");
|
||||
stageBundledPluginRuntimeDeps({ cwd: repoRoot });
|
||||
|
||||
expect(
|
||||
fs.readFileSync(path.join(pluginDir, "node_modules", "direct", "index.js"), "utf8"),
|
||||
).toBe("module.exports = 'second';\n");
|
||||
});
|
||||
|
||||
it("stages runtime deps from the root node_modules when already installed", () => {
|
||||
const { pluginDir, repoRoot } = createBundledPluginFixture({
|
||||
packageJson: {
|
||||
@@ -189,6 +260,269 @@ describe("stageBundledPluginRuntimeDeps", () => {
|
||||
).toBe("module.exports = 'transitive';\n");
|
||||
});
|
||||
|
||||
it("stages nested dependency trees from installed direct package roots", () => {
|
||||
const { pluginDir, repoRoot } = createBundledPluginFixture({
|
||||
packageJson: {
|
||||
name: "@openclaw/fixture-plugin",
|
||||
version: "1.0.0",
|
||||
dependencies: { direct: "1.0.0" },
|
||||
openclaw: { bundle: { stageRuntimeDependencies: true } },
|
||||
},
|
||||
});
|
||||
const directDir = path.join(repoRoot, "node_modules", "direct");
|
||||
const nestedDir = path.join(directDir, "node_modules", "nested");
|
||||
fs.mkdirSync(nestedDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(directDir, "package.json"),
|
||||
'{ "name": "direct", "version": "1.0.0", "dependencies": { "nested": "^1.0.0" } }\n',
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(path.join(directDir, "index.js"), "module.exports = 'direct';\n", "utf8");
|
||||
fs.writeFileSync(
|
||||
path.join(nestedDir, "package.json"),
|
||||
'{ "name": "nested", "version": "1.0.0" }\n',
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(path.join(nestedDir, "index.js"), "module.exports = 'nested';\n", "utf8");
|
||||
|
||||
stageBundledPluginRuntimeDeps({ cwd: repoRoot });
|
||||
|
||||
expect(
|
||||
fs.readFileSync(path.join(pluginDir, "node_modules", "direct", "index.js"), "utf8"),
|
||||
).toBe("module.exports = 'direct';\n");
|
||||
expect(
|
||||
fs.readFileSync(
|
||||
path.join(pluginDir, "node_modules", "direct", "node_modules", "nested", "index.js"),
|
||||
"utf8",
|
||||
),
|
||||
).toBe("module.exports = 'nested';\n");
|
||||
});
|
||||
|
||||
it("falls back to install when a dependency tree contains an unowned symlinked directory", () => {
|
||||
const { pluginDir, repoRoot } = createBundledPluginFixture({
|
||||
packageJson: {
|
||||
name: "@openclaw/fixture-plugin",
|
||||
version: "1.0.0",
|
||||
dependencies: { direct: "1.0.0" },
|
||||
openclaw: { bundle: { stageRuntimeDependencies: true } },
|
||||
},
|
||||
});
|
||||
const directDir = path.join(repoRoot, "node_modules", "direct");
|
||||
const linkedTargetDir = path.join(repoRoot, "linked-target");
|
||||
const linkedPath = path.join(directDir, "node_modules", "linked");
|
||||
fs.mkdirSync(path.join(directDir, "node_modules"), { recursive: true });
|
||||
fs.mkdirSync(linkedTargetDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(directDir, "package.json"),
|
||||
'{ "name": "direct", "version": "1.0.0" }\n',
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(path.join(directDir, "index.js"), "module.exports = 'direct';\n", "utf8");
|
||||
fs.writeFileSync(path.join(linkedTargetDir, "marker.txt"), "first\n", "utf8");
|
||||
fs.symlinkSync(linkedTargetDir, linkedPath);
|
||||
|
||||
let installCount = 0;
|
||||
stageBundledPluginRuntimeDeps({
|
||||
cwd: repoRoot,
|
||||
installPluginRuntimeDepsImpl: ({ fingerprint }: { fingerprint: string }) => {
|
||||
installCount += 1;
|
||||
const nodeModulesDir = path.join(pluginDir, "node_modules");
|
||||
fs.mkdirSync(nodeModulesDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(nodeModulesDir, "marker.txt"), "installed\n", "utf8");
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, ".openclaw-runtime-deps-stamp.json"),
|
||||
`${JSON.stringify({ fingerprint }, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
expect(installCount).toBe(1);
|
||||
expect(
|
||||
fs.existsSync(path.join(pluginDir, "node_modules", "direct", "node_modules", "linked")),
|
||||
).toBe(false);
|
||||
expect(fs.readFileSync(path.join(pluginDir, "node_modules", "marker.txt"), "utf8")).toBe(
|
||||
"installed\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("dedupes cyclic dependency aliases by canonical root", () => {
|
||||
const { pluginDir, repoRoot } = createBundledPluginFixture({
|
||||
packageJson: {
|
||||
name: "@openclaw/fixture-plugin",
|
||||
version: "1.0.0",
|
||||
dependencies: { a: "1.0.0" },
|
||||
openclaw: { bundle: { stageRuntimeDependencies: true } },
|
||||
},
|
||||
});
|
||||
const rootNodeModulesDir = path.join(repoRoot, "node_modules");
|
||||
const storeDir = path.join(repoRoot, ".store");
|
||||
const aStoreDir = path.join(storeDir, "a");
|
||||
const bStoreDir = path.join(storeDir, "b");
|
||||
fs.mkdirSync(path.join(aStoreDir, "node_modules"), { recursive: true });
|
||||
fs.mkdirSync(path.join(bStoreDir, "node_modules"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(aStoreDir, "package.json"),
|
||||
'{ "name": "a", "version": "1.0.0", "dependencies": { "b": "1.0.0" } }\n',
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(path.join(aStoreDir, "index.js"), "module.exports = 'a';\n", "utf8");
|
||||
fs.writeFileSync(
|
||||
path.join(bStoreDir, "package.json"),
|
||||
'{ "name": "b", "version": "1.0.0", "dependencies": { "a": "1.0.0" } }\n',
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(path.join(bStoreDir, "index.js"), "module.exports = 'b';\n", "utf8");
|
||||
fs.mkdirSync(rootNodeModulesDir, { recursive: true });
|
||||
fs.symlinkSync(aStoreDir, path.join(rootNodeModulesDir, "a"));
|
||||
fs.symlinkSync(bStoreDir, path.join(rootNodeModulesDir, "b"));
|
||||
fs.symlinkSync(bStoreDir, path.join(aStoreDir, "node_modules", "b"));
|
||||
fs.symlinkSync(aStoreDir, path.join(bStoreDir, "node_modules", "a"));
|
||||
|
||||
stageBundledPluginRuntimeDeps({ cwd: repoRoot });
|
||||
|
||||
expect(fs.readFileSync(path.join(pluginDir, "node_modules", "a", "index.js"), "utf8")).toBe(
|
||||
"module.exports = 'a';\n",
|
||||
);
|
||||
expect(
|
||||
fs.readFileSync(
|
||||
path.join(pluginDir, "node_modules", "a", "node_modules", "b", "index.js"),
|
||||
"utf8",
|
||||
),
|
||||
).toBe("module.exports = 'b';\n");
|
||||
});
|
||||
|
||||
it("falls back to install when a dependency name escapes node_modules", () => {
|
||||
const { pluginDir, repoRoot } = createBundledPluginFixture({
|
||||
packageJson: {
|
||||
name: "@openclaw/fixture-plugin",
|
||||
version: "1.0.0",
|
||||
dependencies: { "../escape": "1.0.0" },
|
||||
openclaw: { bundle: { stageRuntimeDependencies: true } },
|
||||
},
|
||||
});
|
||||
|
||||
let installCount = 0;
|
||||
stageBundledPluginRuntimeDeps({
|
||||
cwd: repoRoot,
|
||||
installPluginRuntimeDepsImpl: ({ fingerprint }: { fingerprint: string }) => {
|
||||
installCount += 1;
|
||||
const nodeModulesDir = path.join(pluginDir, "node_modules");
|
||||
fs.mkdirSync(nodeModulesDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(nodeModulesDir, "marker.txt"), "installed\n", "utf8");
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, ".openclaw-runtime-deps-stamp.json"),
|
||||
`${JSON.stringify({ fingerprint }, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
expect(installCount).toBe(1);
|
||||
expect(fs.existsSync(path.join(pluginDir, "escape"))).toBe(false);
|
||||
expect(fs.readFileSync(path.join(pluginDir, "node_modules", "marker.txt"), "utf8")).toBe(
|
||||
"installed\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to install when a staged dependency tree contains a symlink outside copied roots", () => {
|
||||
const { pluginDir, repoRoot } = createBundledPluginFixture({
|
||||
packageJson: {
|
||||
name: "@openclaw/fixture-plugin",
|
||||
version: "1.0.0",
|
||||
dependencies: { direct: "1.0.0" },
|
||||
openclaw: { bundle: { stageRuntimeDependencies: true } },
|
||||
},
|
||||
});
|
||||
const directDir = path.join(repoRoot, "node_modules", "direct");
|
||||
const escapedDir = path.join(repoRoot, "outside-root");
|
||||
fs.mkdirSync(path.join(directDir, "node_modules"), { recursive: true });
|
||||
fs.mkdirSync(escapedDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(directDir, "package.json"),
|
||||
'{ "name": "direct", "version": "1.0.0" }\n',
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(path.join(directDir, "index.js"), "module.exports = 'direct';\n", "utf8");
|
||||
fs.writeFileSync(path.join(escapedDir, "secret.txt"), "host secret\n", "utf8");
|
||||
fs.symlinkSync(escapedDir, path.join(directDir, "node_modules", "escaped"));
|
||||
|
||||
let installCount = 0;
|
||||
stageBundledPluginRuntimeDeps({
|
||||
cwd: repoRoot,
|
||||
installPluginRuntimeDepsImpl: ({ fingerprint }: { fingerprint: string }) => {
|
||||
installCount += 1;
|
||||
const nodeModulesDir = path.join(pluginDir, "node_modules");
|
||||
fs.mkdirSync(nodeModulesDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(nodeModulesDir, "marker.txt"), "installed\n", "utf8");
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, ".openclaw-runtime-deps-stamp.json"),
|
||||
`${JSON.stringify({ fingerprint }, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
expect(installCount).toBe(1);
|
||||
expect(
|
||||
fs.existsSync(
|
||||
path.join(pluginDir, "node_modules", "direct", "node_modules", "escaped", "secret.txt"),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(fs.readFileSync(path.join(pluginDir, "node_modules", "marker.txt"), "utf8")).toBe(
|
||||
"installed\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to install when the root transitive closure is incomplete", () => {
|
||||
const { pluginDir, repoRoot } = createBundledPluginFixture({
|
||||
packageJson: {
|
||||
name: "@openclaw/fixture-plugin",
|
||||
version: "1.0.0",
|
||||
dependencies: { direct: "1.0.0" },
|
||||
openclaw: { bundle: { stageRuntimeDependencies: true } },
|
||||
},
|
||||
});
|
||||
const directDir = path.join(repoRoot, "node_modules", "direct");
|
||||
fs.mkdirSync(directDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(directDir, "package.json"),
|
||||
'{ "name": "direct", "version": "1.0.0", "dependencies": { "missing-transitive": "^1.0.0" } }\n',
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(path.join(directDir, "index.js"), "module.exports = 'direct';\n", "utf8");
|
||||
|
||||
let installCount = 0;
|
||||
stageBundledPluginRuntimeDeps({
|
||||
cwd: repoRoot,
|
||||
installPluginRuntimeDepsImpl: ({ fingerprint }: { fingerprint: string }) => {
|
||||
installCount += 1;
|
||||
const nodeModulesDir = path.join(pluginDir, "node_modules", "direct");
|
||||
fs.mkdirSync(nodeModulesDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(nodeModulesDir, "package.json"),
|
||||
'{ "name": "direct", "version": "1.0.0" }\n',
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(nodeModulesDir, "index.js"),
|
||||
"module.exports = 'installed';\n",
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, ".openclaw-runtime-deps-stamp.json"),
|
||||
`${JSON.stringify({ fingerprint }, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
expect(installCount).toBe(1);
|
||||
expect(
|
||||
fs.readFileSync(path.join(pluginDir, "node_modules", "direct", "index.js"), "utf8"),
|
||||
).toBe("module.exports = 'installed';\n");
|
||||
});
|
||||
|
||||
it("removes global non-runtime suffixes from staged runtime dependencies", () => {
|
||||
const { pluginDir, repoRoot } = createBundledPluginFixture({
|
||||
packageJson: {
|
||||
|
||||
Reference in New Issue
Block a user