fix(deps): give Baileys its long edge and materialize patched payloads (#177936)

Baileys imports long without declaring it, which a hoisted layout masked.
An exact packageExtensions entry adds the edge. Candidate installs select
pnpm's project virtual store so the patched Baileys bytes are materialized,
and update preflight refreshes bundled plugin dependency links after Git
exposure. Missing optional dependencies are skipped only when declared
optional.
This commit is contained in:
Jason (Json) 2026-10-20 10:01:25 -07:00 • committed by GitHub
parent c431e55eeb
commit 432bada3bd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 403 additions and 4 deletions

View file

@ -45,6 +45,19 @@ using that generation through finalization, rollback, and CLI cleanup, even when
the new build has different chunk filenames. The new Gateway starts from the
activated build.
Candidate pnpm installs use a project-local virtual dependency graph while
reusing the configured package store. This also applies when pnpm uses hoisted
links: pnpm 12 can otherwise materialize unpatched package bytes from a global
graph despite recording a patch hash. After source exposure changes dependency
topology, the updater refreshes bundled plugin links before final candidate
validation. The refresh links the packages it finds in each plugin's
`node_modules`. A link whose target is missing produces a warning and is skipped
when the plugin declares that package optional; any other broken link or
filesystem error stops preflight. The refresh does not check for dependencies
that were never installed.
These preparation changes require the installed updater to contain the fix;
staging a newer candidate cannot change an older updater's initial install.
Managed update helpers route diagnostic logs to stderr so debug logging cannot
interrupt their control handshake. This applies to the installed updater; a newer
candidate cannot change an older updater's pre-activation handshake.

3
pnpm-lock.yaml generated
View file

@ -209,7 +209,7 @@ overrides:
protobufjs: 8.8.1
uuid: 14.0.3
packageExtensionsChecksum: sha256-zZ8fyodhMTumshonC7kktCqTPsiHL3UAyS9vltFAlMo=
packageExtensionsChecksum: sha256-+aaViXQWMTYboC/2zljN8mTDYzt1x2ZpXFjRkJd9Eys=
patchedDependencies:
'@novnc/novnc@1.7.0': bfde0e1bda172da3525f9f5014d85ad28073325b74f09609084598a65d2f412d
@ -15499,6 +15499,7 @@ snapshots:
'@hapi/boom': 9.1.4
async-mutex: 0.5.0
libsignal: 6.0.0
long: 5.3.2
lru-cache: 11.5.3
music-metadata: 11.16.1(supports-color@10.2.2)
p-queue: 9.3.3

View file

@ -130,6 +130,10 @@ allowBuilds:
node-pty: true
packageExtensions:
# rc14 imports long directly; protobufjs's transitive edge is not visible in isolated graphs.
"baileys@7.0.0-rc14":
dependencies:
long: 5.3.2
# The transient npm lock generator also needs the peer marked optional.
baileys:
peerDependenciesMeta:

View file

@ -11,6 +11,13 @@ export function linkSourcePluginDependencies(pluginDir, distNodeModules) {
if (!fs.existsSync(sourceModules)) {
return;
}
const packageJson = JSON.parse(fs.readFileSync(path.join(pluginDir, "package.json"), "utf8"));
const optional = new Set([
...Object.keys(packageJson.optionalDependencies ?? {}),
...Object.entries(packageJson.peerDependenciesMeta ?? {})
.filter(([, metadata]) => metadata.optional === true)
.map(([name]) => name),
]);
const packages = fs.readdirSync(sourceModules).flatMap((name) => {
if (name.startsWith(".") && name !== ".bin") {
return [];
@ -26,7 +33,18 @@ export function linkSourcePluginDependencies(pluginDir, distNodeModules) {
const checkoutRoot = fs.realpathSync(path.resolve(pluginDir, "../.."));
for (const name of packages) {
const target = path.join(distNodeModules, name);
const source = fs.realpathSync(path.join(sourceModules, name));
let source;
try {
source = fs.realpathSync(path.join(sourceModules, name));
} catch (error) {
if (optional.has(name) && error?.code === "ENOENT") {
console.warn(
`[bundled-plugin-dependency-links] ${path.basename(pluginDir)}: optional dependency ${name} is unavailable; continuing without its link`,
);
continue;
}
throw error;
}
const canonical =
name === "openclaw" && source === checkoutRoot && fs.existsSync(hostAlias)
? fs.realpathSync(hostAlias)

View file

@ -15,6 +15,13 @@ export function resolvePnpmCandidateEnv(
// Set every spelling: inherited lower-case keys can win over upper-case overrides.
return {
...env,
// pnpm 12 selects the global graph independently of virtualStoreDir. Its
// hoisted/global materializer can also emit unpatched registry bytes while
// recording the patch hash. Candidates need the project materializer.
PNPM_CONFIG_VIRTUAL_STORE_TYPE: "project",
pnpm_config_virtual_store_type: "project",
NPM_CONFIG_VIRTUAL_STORE_TYPE: "project",
npm_config_virtual_store_type: "project",
PNPM_CONFIG_VIRTUAL_STORE_DIR: virtualStoreDir,
pnpm_config_virtual_store_dir: virtualStoreDir,
NPM_CONFIG_VIRTUAL_STORE_DIR: virtualStoreDir,

View file

@ -194,6 +194,7 @@ export async function prepareCandidateCommandEnv(
// Only the disposable worktree gets this override; retain all operator settings.
const workspace = parseDocument(original);
workspace.set("virtualStoreDir", "node_modules/.pnpm");
workspace.set("virtualStoreType", "project");
const isolated = workspace.toString();
const backupDirectory = await fs.mkdtemp(path.join(path.dirname(cwd), "workspace-original-"));
const backupFile = path.join(backupDirectory, "pnpm-workspace.yaml");

View file

@ -1,6 +1,7 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import { CommandProcessCleanupError } from "../process/exec-result.js";
@ -242,6 +243,110 @@ describe("reference source candidate preparation", () => {
});
}
it.each([false, true])(
"refreshes final dependency links after exposure (corrupt: %s)",
async (corrupt) => {
const linker = pathToFileURL(
path.resolve("scripts/lib/bundled-plugin-dependency-links.mjs"),
).href;
const script = "scripts/copy-bundled-plugin-metadata.mjs";
await fs.mkdir(path.join(root, "scripts"));
await fs.writeFile(
path.join(root, script),
[
'import fs from "node:fs";',
'import path from "node:path";',
"import { linkSourcePluginDependencies } from " + JSON.stringify(linker) + ";",
'const destination = path.resolve("dist/extensions/x/node_modules");',
"fs.rmSync(destination, { recursive: true, force: true });",
'linkSourcePluginDependencies(path.resolve("extensions/x"), destination);',
].join("\n"),
);
await fs.mkdir(path.join(root, "extensions/x"), { recursive: true });
await fs.writeFile(
path.join(root, "extensions/x/package.json"),
JSON.stringify({ dependencies: { payload: "1" } }),
);
await git(root, "add", script, "extensions/x/package.json");
await git(root, "commit", "-m", "candidate metadata entrypoint");
base = await git(root, "rev-parse", "HEAD");
let built = false;
let exposed = false;
let validated = false;
const runCommand: CommandRunner = async (argv, options) => {
if (argv[0] === "pnpm") {
if (argv[1] === "build") {
assert(options.cwd);
const candidate = options.cwd;
for (const directory of [
"dist/control-ui",
"extensions/x/node_modules",
"node_modules/first",
]) {
await fs.mkdir(path.join(candidate, directory), { recursive: true });
}
await writeFixtureFiles(candidate, {
"dist/control-ui/index.html": "ready",
"node_modules/first/index.js": "module.exports = 'old';",
});
await fs.symlink(
path.join(candidate, "node_modules/first"),
path.join(candidate, "extensions/x/node_modules/payload"),
"junction",
);
const linked = await runCommandWithTimeout(["node", script], {
...options,
cwd: candidate,
});
expect(linked.code, linked.stderr).toBe(0);
built = true;
}
return { code: 0, stdout: argv[1] === "--version" ? "12.0.0" : "", stderr: "" };
}
return runCommandWithTimeout(argv, options);
};
const result = await prepare(runCommand, {
prepareGitExposure: async (candidate) => {
expect(built).toBe(true);
await expectHead(root, base);
const link = path.join(candidate, "extensions/x/node_modules/payload");
await fs.rm(link);
await fs.rm(path.join(candidate, "node_modules/first"), { recursive: true });
if (corrupt) {
await fs.symlink("payload", link);
} else {
await fs.mkdir(path.join(candidate, "node_modules/final"), { recursive: true });
await writeFixtureFiles(candidate, {
"node_modules/final/index.js": "module.exports = 'final';",
});
await fs.symlink(path.join(candidate, "node_modules/final"), link, "junction");
}
exposed = true;
},
validateCandidate: async (candidate) => {
expect(exposed).toBe(true);
const imported = await runCommandWithTimeout(
[
"node",
"-e",
'require("node:assert/strict").equal(require(require("node:path").resolve("dist/extensions/x/node_modules/payload")), "final");',
],
{ cwd: candidate, timeoutMs: 5000 },
);
expect(imported.code, imported.stderr).toBe(0);
validated = true;
},
});
expect(result.status).toBe(corrupt ? "error" : "ok");
expect(validated).toBe(!corrupt);
expectStep(steps, {
name: "preflight-bundled-plugin-dependency-links",
exitCode: corrupt ? 1 : 0,
});
await expectHead(root, base);
},
);
it("refuses divergent main before install or copying operator inputs", async () => {
const beforeSha = await commit("local.txt", "local\n");
const targetRevision = await commitTarget("target.txt", "target\n");

View file

@ -1,9 +1,10 @@
import fs from "node:fs/promises";
import path from "node:path";
import { normalizeNullableString } from "@openclaw/normalization-core/string-coerce";
import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization";
import { hasCommandProcessCleanupError } from "../process/exec-result.js";
import { resolveControlUiAssetHealth } from "./control-ui-assets.js";
import { hasErrnoCode } from "./errno.js";
import { hasErrnoCode, ignoreMissingPath } from "./errno.js";
import { formatErrorMessage } from "./errors.js";
import { readPackageManagerSpec } from "./package-json.js";
import { DEV_BRANCH } from "./update-channels.js";
@ -330,6 +331,24 @@ async function testPreflightCandidate(
// Global source exposure can run package lifecycle scripts. Validate and retain
// the resulting candidate only after that preparation finishes.
await params.prepareGitExposure?.(params.worktreeDir, candidateSha, candidateCommand.env);
// Exposure can run another install after postbuild. Its final topology, not
// the earlier build graph, must own the packaged plugin dependency links.
const metadataScript = "scripts/copy-bundled-plugin-metadata.mjs";
if (
params.prepareGitExposure &&
(
await fs.stat(path.join(params.worktreeDir, metadataScript)).catch(ignoreMissingPath)
)?.isFile()
) {
const relink = await runCandidateCheck(
"bundled-plugin-dependency-links",
["node", metadataScript],
candidateCommand.env,
);
if (relink) {
return { status: classifyPreflightFailure(relink) };
}
}
await candidateCommand.restoreWorkspace?.();
params.steps.push(...((await params.validateCandidate(params.worktreeDir)) ?? []));
// Activation checks out candidateSha and promotes only generated runtime paths.

View file

@ -0,0 +1,174 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { expect, it } from "vitest";
import { parse, stringify } from "yaml";
import { copyBundledPluginMetadata } from "../../scripts/copy-bundled-plugin-metadata.mts";
import { pnpmLockfileDocuments } from "../../scripts/lib/pnpm-lockfile-documents.mjs";
import { prepareCandidateCommandEnv } from "../../src/infra/update-runner-git-commands.js";
import { resolveTestNodeExecPath } from "../../src/test-utils/node-process.js";
import { createScriptTestHarness } from "./test-helpers.js";
const { createTempDir } = createScriptTestHarness();
const repoRoot = fileURLToPath(new URL("../../", import.meta.url));
it("materializes patched Baileys with its own long edge and refreshes final dist imports", async () => {
// Reuse the installed CAFS packages offline, never its mutable graph/payloads.
const root = fs.realpathSync(createTempDir("openclaw-baileys-materialization-"));
const workspace = parse(fs.readFileSync(path.join(repoRoot, "pnpm-workspace.yaml"), "utf8"));
const documents = pnpmLockfileDocuments(
fs.readFileSync(path.join(repoRoot, "pnpm-lock.yaml"), "utf8"),
);
const lock = parse(documents.dependencies);
const installed = parse(
fs.readFileSync(path.join(repoRoot, "node_modules/.modules.yaml"), "utf8"),
);
const packageManager = JSON.parse(
fs.readFileSync(path.join(repoRoot, "package.json"), "utf8"),
).packageManager;
const whatsapp = lock.importers["extensions/whatsapp"].dependencies;
const dependencies: Record<string, { specifier: string; version: string }> = {
baileys: whatsapp.baileys,
"audio-decode": whatsapp["audio-decode"],
};
fs.writeFileSync(
path.join(root, "package.json"),
JSON.stringify({
private: true,
type: "module",
packageManager,
dependencies: Object.fromEntries(
Object.entries(dependencies).map(([name, entry]) => [name, entry.specifier]),
),
}),
);
fs.writeFileSync(
path.join(root, "pnpm-lock.yaml"),
"---\n" +
documents.environment +
"\n---\n" +
stringify({ ...lock, importers: { ".": { dependencies } } }),
);
for (const relative of Object.values(workspace.patchedDependencies) as string[]) {
fs.mkdirSync(path.dirname(path.join(root, relative)), { recursive: true });
fs.copyFileSync(path.join(repoRoot, relative), path.join(root, relative));
}
Object.assign(workspace, {
packages: ["."],
hoist: false,
virtualStoreType: "global",
// This offline fixture tests pinned payloads, not registry-age metadata.
minimumReleaseAge: 0,
minimumReleaseAgeStrict: false,
packageImportMethod: "auto",
storeDir: path.dirname(installed.storeDir),
});
const env = {
...process.env,
CI: undefined,
NODE_OPTIONS: undefined,
NODE_PATH: undefined,
HOME: path.join(root, "home"),
PNPM_CONFIG_VIRTUAL_STORE_TYPE: "global",
pnpm_config_virtual_store_type: "global",
};
const command = async (argv: string[], options: { cwd?: string; env?: NodeJS.ProcessEnv }) => {
assert(argv[0], "fixture command requires an executable");
const result = spawnSync(argv[0], argv.slice(1), {
...options,
encoding: "utf8",
timeout: 30_000,
});
if (result.error) {
throw result.error;
}
return { code: result.status, stdout: result.stdout, stderr: result.stderr };
};
const plugin = path.join(root, "extensions/whatsapp");
fs.mkdirSync(path.join(plugin, "node_modules"), { recursive: true });
fs.writeFileSync(path.join(plugin, "index.ts"), "export {};\n");
fs.writeFileSync(
path.join(plugin, "package.json"),
JSON.stringify({
name: "@openclaw/whatsapp",
type: "module",
dependencies: { baileys: "7.0.0-rc14" },
openclaw: {
extensions: ["./index.ts"],
build: { bundledDist: false },
release: { publishToNpm: true },
},
}),
);
fs.writeFileSync(
path.join(plugin, "openclaw.plugin.json"),
JSON.stringify({ id: "whatsapp", configSchema: { type: "object" } }),
);
const distPlugin = path.join(root, "dist/extensions/whatsapp");
const sibling = path.join(root, "dist/extensions/sibling/index.js");
fs.mkdirSync(path.dirname(sibling), { recursive: true });
fs.writeFileSync(sibling, "preserve sibling");
let previousTarget: string | undefined;
for (const nodeLinker of ["isolated", "hoisted"]) {
workspace.nodeLinker = nodeLinker;
const original = stringify(workspace);
fs.writeFileSync(path.join(root, "pnpm-workspace.yaml"), original);
const prepared = await prepareCandidateCommandEnv("pnpm", env, root, command, 30_000);
try {
const install = await command(
["pnpm", "install", "--offline", "--ignore-scripts", "--frozen-lockfile"],
{ cwd: root, env: prepared.env },
);
expect(install.code, install.stderr + install.stdout).toBe(0);
const source = fs.realpathSync(path.join(root, "node_modules/baileys"));
const sourceLink = path.join(plugin, "node_modules/baileys");
fs.rmSync(sourceLink, { force: true });
fs.symlinkSync(source, sourceLink, "junction");
if (previousTarget) {
// An install changed topology after postbuild. The prior dist link is stale.
expect(source).not.toBe(previousTarget);
}
copyBundledPluginMetadata({ repoRoot: root, pluginIds: new Set(["whatsapp"]) });
const probe = await command(
[
resolveTestNodeExecPath(),
"--input-type=module",
"--eval",
`import assert from "node:assert/strict";
import fs from "node:fs";
import { createRequire } from "node:module";
import { pathToFileURL } from "node:url";
const require = createRequire(process.cwd() + "/probe.cjs");
const entry = require.resolve("baileys");
const baileysRequire = createRequire(entry);
assert.equal(baileysRequire("long").fromString("9007199254740993").toString(), "9007199254740993");
const { getAudioWaveform } = await import(entry);
const wav = Buffer.alloc(44 + 4096 * 2);
wav.write("RIFF"); wav.writeUInt32LE(wav.length - 8, 4); wav.write("WAVEfmt ", 8);
wav.writeUInt32LE(16, 16); wav.writeUInt16LE(1, 20); wav.writeUInt16LE(1, 22);
wav.writeUInt32LE(8000, 24); wav.writeUInt32LE(16000, 28);
wav.writeUInt16LE(2, 32); wav.writeUInt16LE(16, 34); wav.write("data", 36);
wav.writeUInt32LE(wav.length - 44, 40);
for (let i = 0; i < 4096; i++) wav.writeInt16LE(Math.round(16000 * Math.sin(i / 10)), 44 + i * 2);
const waveform = await getAudioWaveform(wav);
assert.equal(waveform?.length, 64, "patched decoder must produce the voice-note waveform");
assert.ok([...waveform].some(value => value > 0));
assert.equal(await getAudioWaveform(Buffer.from("not audio")), undefined);
const manifest = JSON.parse(fs.readFileSync(new URL("../package.json", pathToFileURL(entry))));
assert.equal(manifest.peerDependencies["audio-decode"], "^3.12.0");
console.log("patched waveform: 64 samples; invalid audio: best-effort undefined");`,
],
{ cwd: distPlugin, env: prepared.env },
);
expect(probe.code, probe.stderr + probe.stdout).toBe(0);
expect(probe.stdout).toContain("patched waveform: 64 samples");
expect(fs.readFileSync(sibling, "utf8")).toBe("preserve sibling");
previousTarget = source;
} finally {
await prepared.restoreWorkspace?.();
}
expect(fs.readFileSync(path.join(root, "pnpm-workspace.yaml"), "utf8")).toBe(original);
}
}, 60_000);

View file

@ -1,6 +1,7 @@
import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { linkSourcePluginDependencies } from "../../scripts/lib/bundled-plugin-dependency-links.mjs";
import { createScriptTestHarness } from "./test-helpers.js";
@ -14,6 +15,7 @@ describe("linkSourcePluginDependencies", () => {
const destination = path.join(root, "dist", "extensions", "x", "node_modules");
const host = checkoutHost ? root : path.join(root, "installed", "openclaw");
fs.mkdirSync(modules, { recursive: true });
fs.writeFileSync(path.join(plugin, "package.json"), "{}");
for (const [name, target] of [
["openclaw", host],
["ancestor-alias", root],
@ -25,7 +27,14 @@ describe("linkSourcePluginDependencies", () => {
fs.symlinkSync(target, path.join(modules, name), "junction");
}
fs.writeFileSync(path.join(root, "payload", "index.cjs"), "module.exports = 'dependency';");
fs.writeFileSync(
path.join(root, "payload", "package.json"),
JSON.stringify({ main: "index.cjs" }),
);
linkSourcePluginDependencies(plugin, destination);
expect(createRequire(path.join(destination, "..", "probe.cjs"))("@fixture/dependency")).toBe(
"dependency",
);
expect(fs.existsSync(path.join(destination, "openclaw"))).toBe(!checkoutHost);
if (!checkoutHost) {
expect(fs.realpathSync(path.join(destination, "openclaw"))).toBe(host);
@ -36,4 +45,52 @@ describe("linkSourcePluginDependencies", () => {
);
expect(fs.realpathSync(path.join(destination, ".bin"))).toBe(path.join(root, "launchers"));
});
it.each(["optionalDependencies", "peerDependenciesMeta"])(
"warns for an unavailable declared optional dependency (%s), preserving usable siblings",
(field) => {
const root = fs.realpathSync(createTempDir("openclaw-optional-dependency-link-"));
const plugin = path.join(root, "extensions/x");
const modules = path.join(plugin, "node_modules");
const destination = path.join(root, "dist/extensions/x/node_modules");
fs.mkdirSync(modules, { recursive: true });
fs.writeFileSync(
path.join(plugin, "package.json"),
JSON.stringify({
[field]: { missing: field === "optionalDependencies" ? "1.0.0" : { optional: true } },
}),
);
fs.symlinkSync(path.join(root, "absent"), path.join(modules, "missing"), "junction");
fs.mkdirSync(path.join(modules, "usable"));
fs.writeFileSync(path.join(modules, "usable/index.js"), "module.exports = 'usable';");
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
linkSourcePluginDependencies(plugin, destination);
expect(createRequire(path.join(destination, "..", "probe.cjs"))("usable")).toBe("usable");
expect(warn).toHaveBeenCalledWith(
expect.stringContaining("optional dependency missing is unavailable"),
);
} finally {
warn.mockRestore();
}
// The same missing payload is not optional without a manifest declaration.
fs.rmSync(destination, { recursive: true, force: true });
fs.writeFileSync(path.join(plugin, "package.json"), "{}");
expect(() => linkSourcePluginDependencies(plugin, destination)).toThrow(/ENOENT/);
},
);
it("does not hide unknown filesystem corruption for optional dependencies", () => {
const root = fs.realpathSync(createTempDir("openclaw-invalid-dependency-link-"));
const plugin = path.join(root, "extensions/x");
const modules = path.join(plugin, "node_modules");
fs.mkdirSync(modules, { recursive: true });
fs.writeFileSync(
path.join(plugin, "package.json"),
JSON.stringify({ optionalDependencies: { cycle: "1" } }),
);
fs.symlinkSync("cycle", path.join(modules, "cycle"));
expect(() =>
linkSourcePluginDependencies(plugin, path.join(root, "dist/extensions/x/node_modules")),
).toThrow(/ELOOP/);
});
});