fix(plugins): verify bundled runtime deps installs

Verify bundled runtime dependency installs before reporting success, so a clean npm exit cannot hide packages missing from the managed runtime-deps root.

Also updates the bundle command test mock for the current plugin enable-state API.

Local proof:
- `pnpm test src/plugins/bundle-commands.test.ts`
- `pnpm test src/plugins/bundled-runtime-deps.test.ts src/commands/doctor-bundled-plugin-runtime-deps.test.ts src/plugins/loader.test.ts`
- `pnpm check:changed`

Co-authored-by: Colin <colin@solvely.net>
This commit is contained in:
Colin Johnson
2026-04-25 23:32:33 -04:00
committed by GitHub
parent 96d90091c4
commit 21082d2ede
4 changed files with 73 additions and 14 deletions

View File

@@ -109,6 +109,7 @@ Docs: https://docs.openclaw.ai
- Plugins/Bonjour: stop ciao mDNS watchdog failures from looping forever when the advertiser stays stuck in `probing` or `announcing`; Bonjour now disables itself for the current Gateway process after repeated failed restarts while the Gateway keeps running. Fixes #69011. Thanks @siddharthaagarwalofficial-ux, @FiredMosquito831, and @spikefcz.
- Gateway/Fly.io: seed Control UI allowed origins from the actual runtime bind and port so CLI-driven non-loopback starts do not crash before config exists. Fixes #71823.
- Gateway/proxy: bootstrap env proxy dispatching from direct Gateway startup so provider and plugin network requests honor `HTTPS_PROXY`/`HTTP_PROXY` before the first embedded agent attempt runs. (#71833) Thanks @mjamiv.
- Plugins/runtime deps: verify clean npm installs actually place requested bundled runtime packages in the managed install root, reporting exact missing specs instead of a false successful repair. (#71883) Thanks @Solvely-Colin.
- Models/LM Studio: preserve `@iq*` quant suffixes in model refs and provider matching so `/model lmstudio/...@iq3_xxs` keeps the exact LM Studio variant. Fixes #71474. (#71486) Thanks @Bartok9, @XinwuC, and @Sanjays2402.
- Matrix/cron: preserve the live Matrix delivery target when creating implicit announce reminder jobs so mixed-case room IDs are not reconstructed from lowercased session keys. Fixes #71798.
- Feishu: accept Schema 2.0 card action callbacks that report `context.open_chat_id` instead of legacy `context.chat_id`, so button callbacks no longer drop as malformed. Fixes #71670. Thanks @eddy1068.

View File

@@ -34,6 +34,12 @@ vi.mock("./config-state.js", async (importOriginal) => ({
}) => ({
activated: params.config?.entries?.[params.id]?.enabled !== false,
}),
resolveEffectiveEnableState: (params: {
config?: { entries?: Record<string, { enabled?: boolean }> };
id: string;
}) => ({
enabled: params.config?.entries?.[params.id]?.enabled !== false,
}),
}));
const { loadEnabledClaudeBundleCommands } = await import("./bundle-commands.js");

View File

@@ -31,6 +31,16 @@ function makeTempDir(): string {
return dir;
}
function writeInstalledPackage(rootDir: string, packageName: string, version: string): void {
const packageDir = path.join(rootDir, "node_modules", ...packageName.split("/"));
fs.mkdirSync(packageDir, { recursive: true });
fs.writeFileSync(
path.join(packageDir, "package.json"),
JSON.stringify({ name: packageName, version }),
"utf8",
);
}
afterEach(() => {
vi.restoreAllMocks();
spawnSyncMock.mockReset();
@@ -182,13 +192,16 @@ describe("installBundledRuntimeDeps", () => {
vi.spyOn(fs, "existsSync").mockImplementation(
(candidate) => candidate === "C:\\node\\node_modules\\npm\\bin\\npm-cli.js",
);
spawnSyncMock.mockReturnValue({
pid: 123,
output: [],
stdout: "",
stderr: "",
signal: null,
status: 0,
spawnSyncMock.mockImplementation((_command, _args, options) => {
writeInstalledPackage(String(options?.cwd ?? ""), "acpx", "0.5.3");
return {
pid: 123,
output: [],
stdout: "",
stderr: "",
signal: null,
status: 0,
};
});
installBundledRuntimeDeps({
@@ -235,6 +248,7 @@ describe("installBundledRuntimeDeps", () => {
name: "openclaw-runtime-deps-install",
private: true,
});
writeInstalledPackage(cwd, "@grammyjs/runner", "2.0.3");
return {
pid: 123,
output: [],
@@ -317,13 +331,16 @@ describe("installBundledRuntimeDeps", () => {
it("uses an OpenClaw-owned npm cache for runtime dependency installs", () => {
const installRoot = makeTempDir();
spawnSyncMock.mockReturnValue({
pid: 123,
output: [],
stdout: "",
stderr: "",
signal: null,
status: 0,
spawnSyncMock.mockImplementation((_command, _args, options) => {
writeInstalledPackage(String(options?.cwd ?? ""), "tokenjuice", "0.6.1");
return {
pid: 123,
output: [],
stdout: "",
stderr: "",
signal: null,
status: 0,
};
});
installBundledRuntimeDeps({
@@ -374,6 +391,26 @@ describe("installBundledRuntimeDeps", () => {
);
});
it("fails when npm exits cleanly without installing requested packages", () => {
const installRoot = makeTempDir();
spawnSyncMock.mockReturnValue({
pid: 123,
output: [],
stdout: "",
stderr: "",
signal: null,
status: 0,
});
expect(() =>
installBundledRuntimeDeps({
installRoot,
missingSpecs: ["tokenjuice@0.6.1"],
env: {},
}),
).toThrow(`npm install did not place bundled runtime deps in ${installRoot}: tokenjuice@0.6.1`);
});
it("cleans an owned isolated execution root after copying node_modules back", () => {
const installRoot = makeTempDir();
const installExecutionRoot = path.join(installRoot, ".openclaw-install-stage");

View File

@@ -684,6 +684,19 @@ function hasDependencySentinel(
});
}
function assertBundledRuntimeDepsInstalled(rootDir: string, specs: readonly string[]): void {
const missingSpecs = specs.filter((spec) => {
const dep = parseInstallableRuntimeDepSpec(spec);
return !hasDependencySentinel([rootDir], dep);
});
if (missingSpecs.length === 0) {
return;
}
throw new Error(
`npm install did not place bundled runtime deps in ${rootDir}: ${missingSpecs.join(", ")}`,
);
}
function replaceNodeModulesDir(targetDir: string, sourceDir: string): void {
const parentDir = path.dirname(targetDir);
const tempDir = fs.mkdtempSync(path.join(parentDir, ".openclaw-runtime-deps-copy-"));
@@ -1223,6 +1236,7 @@ export function installBundledRuntimeDeps(params: {
.trim();
throw new Error(output || "npm install failed");
}
assertBundledRuntimeDepsInstalled(installExecutionRoot, params.missingSpecs);
if (isolatedExecutionRoot) {
const stagedNodeModulesDir = path.join(installExecutionRoot, "node_modules");
if (!fs.existsSync(stagedNodeModulesDir)) {
@@ -1234,6 +1248,7 @@ export function installBundledRuntimeDeps(params: {
} else {
replaceNodeModulesDir(targetNodeModulesDir, stagedNodeModulesDir);
}
assertBundledRuntimeDepsInstalled(params.installRoot, params.missingSpecs);
}
} finally {
if (cleanInstallExecutionRoot) {