diff --git a/CHANGELOG.md b/CHANGELOG.md index d5c97f5dd14..46225440d1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/plugins/bundle-commands.test.ts b/src/plugins/bundle-commands.test.ts index e1357a3e420..3615ab72d3e 100644 --- a/src/plugins/bundle-commands.test.ts +++ b/src/plugins/bundle-commands.test.ts @@ -34,6 +34,12 @@ vi.mock("./config-state.js", async (importOriginal) => ({ }) => ({ activated: params.config?.entries?.[params.id]?.enabled !== false, }), + resolveEffectiveEnableState: (params: { + config?: { entries?: Record }; + id: string; + }) => ({ + enabled: params.config?.entries?.[params.id]?.enabled !== false, + }), })); const { loadEnabledClaudeBundleCommands } = await import("./bundle-commands.js"); diff --git a/src/plugins/bundled-runtime-deps.test.ts b/src/plugins/bundled-runtime-deps.test.ts index 812a3269da6..eb42c46c77d 100644 --- a/src/plugins/bundled-runtime-deps.test.ts +++ b/src/plugins/bundled-runtime-deps.test.ts @@ -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"); diff --git a/src/plugins/bundled-runtime-deps.ts b/src/plugins/bundled-runtime-deps.ts index 9600b9dced0..e26264ff49d 100644 --- a/src/plugins/bundled-runtime-deps.ts +++ b/src/plugins/bundled-runtime-deps.ts @@ -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) {