diff --git a/CHANGELOG.md b/CHANGELOG.md index af678994d88..7fb5d9526f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Plugins/runtime deps: isolate the internal npm cache used for bundled plugin runtime-dependency repair and let package updates refresh/verify already-current installs, so failed update or sudo doctor runs can be repaired by rerunning `openclaw update`. Thanks @steipete. - Plugins/runtime deps: stage bundled plugin runtime dependencies for packaged/global installs in an external runtime root and retain already staged deps across repairs, avoiding package-tree update races and npm pruning after upgrades. Thanks @steipete. - Plugins/runtime deps: log bundled plugin runtime-dependency staging before synchronous npm installs start and include elapsed timing afterward, so first boot after upgrades no longer looks hung while dependencies are being repaired. Thanks @steipete. - Agents/failover: forward embedded run abort signals into provider-owned model streams, cap implicit LLM idle watchdogs below long run timeouts, and mark 429 responses without usable retry timing as non-retryable so GitHub Copilot rate limits fail over or surface promptly instead of hanging until run timeout. Fixes #71120. Thanks @steipete. diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 432350b5026..2349e72dc59 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -837,7 +837,7 @@ describe("update-cli", () => { ); }); - it("skips package-manager updates when the installed version already matches the target", async () => { + it("refreshes package-manager updates when the installed version already matches the target", async () => { const tempDir = createCaseDir("openclaw-update"); mockPackageInstallStatus(tempDir); readPackageVersion.mockResolvedValue("2026.4.22"); @@ -853,15 +853,11 @@ describe("update-cli", () => { .mock.calls.filter( ([argv]) => Array.isArray(argv) && argv[0] === "npm" && argv[1] === "i" && argv[2] === "-g", ); - expect(installCalls).toEqual([]); - expect(syncPluginsForUpdateChannel).not.toHaveBeenCalled(); - expect(updateNpmInstalledPlugins).not.toHaveBeenCalled(); + expect(installCalls).toHaveLength(1); + expect(updateNpmInstalledPlugins).toHaveBeenCalled(); expect(replaceConfigFile).not.toHaveBeenCalled(); - expect(runRestartScript).not.toHaveBeenCalled(); - expect(runDaemonRestart).not.toHaveBeenCalled(); - expect(defaultRuntime.exit).toHaveBeenCalledWith(0); const logs = vi.mocked(defaultRuntime.log).mock.calls.map((call) => String(call[0])); - expect(logs.join("\n")).toContain("already-current"); + expect(logs.join("\n")).not.toContain("already-current"); }); it("blocks package updates when the target requires a newer Node runtime", async () => { @@ -1042,6 +1038,78 @@ describe("update-cli", () => { ); }); + it("refreshes package installs even when the current version already matches the target", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-current-")); + const nodeModules = path.join(tempDir, "node_modules"); + const pkgRoot = path.join(nodeModules, "openclaw"); + const entryPath = path.join(pkgRoot, "dist", "index.js"); + mockPackageInstallStatus(pkgRoot); + readPackageVersion.mockResolvedValue("2026.4.23"); + vi.mocked(resolveNpmChannelTag).mockResolvedValue({ + tag: "latest", + version: "2026.4.23", + }); + await fs.mkdir(path.dirname(entryPath), { recursive: true }); + await fs.writeFile( + path.join(pkgRoot, "package.json"), + JSON.stringify({ name: "openclaw", version: "2026.4.23" }), + "utf-8", + ); + await fs.writeFile(entryPath, "export {};\n", "utf-8"); + for (const relativePath of TEST_BUNDLED_RUNTIME_SIDECAR_PATHS) { + const absolutePath = path.join(pkgRoot, relativePath); + await fs.mkdir(path.dirname(absolutePath), { recursive: true }); + await fs.writeFile(absolutePath, "export {};\n", "utf-8"); + } + await writePackageDistInventory(pkgRoot); + pathExists.mockImplementation(async (candidate: string) => { + try { + await fs.access(candidate); + return true; + } catch { + return false; + } + }); + vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => { + if (Array.isArray(argv) && argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") { + return { + stdout: `${nodeModules}\n`, + stderr: "", + code: 0, + signal: null, + killed: false, + termination: "exit", + }; + } + return { + stdout: "", + stderr: "", + code: 0, + signal: null, + killed: false, + termination: "exit", + }; + }); + + await updateCommand({ yes: true, restart: false }); + + expect(runCommandWithTimeout).toHaveBeenCalledWith( + ["npm", "i", "-g", "openclaw@latest", "--no-fund", "--no-audit", "--loglevel=error"], + expect.any(Object), + ); + expect(runCommandWithTimeout).toHaveBeenCalledWith( + [expect.stringMatching(/node/), entryPath, "doctor", "--non-interactive"], + expect.any(Object), + ); + expect(updateNpmInstalledPlugins).toHaveBeenCalled(); + expect( + vi + .mocked(defaultRuntime.log) + .mock.calls.map((call) => String(call[0])) + .join("\n"), + ).not.toContain("already-current"); + }); + it("uses the owning npm binary for package updates when PATH npm points elsewhere", async () => { const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); const brewPrefix = createCaseDir("brew-prefix"); diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 6fbe9c927bf..44c635fd61b 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -1112,19 +1112,19 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { } else if (updateInstallKind === "git") { actions.push(`Run git update flow on channel ${channel} (fetch/rebase/build/doctor)`); } else if (packageAlreadyCurrent) { - actions.push(`Skip package update; current version already matches ${targetVersion}`); + actions.push( + `Refresh package install with spec ${packageInstallSpec ?? tag}; current version already matches ${targetVersion}`, + ); } else { actions.push(`Run global package manager update with spec ${packageInstallSpec ?? tag}`); } - if (!packageAlreadyCurrent) { - actions.push("Run plugin update sync after core update"); - actions.push("Refresh shell completion cache (if needed)"); - actions.push( - shouldRestart - ? "Restart gateway service and run doctor checks" - : "Skip restart (because --no-restart is set)", - ); - } + actions.push("Run plugin update sync after core update"); + actions.push("Refresh shell completion cache (if needed)"); + actions.push( + shouldRestart + ? "Restart gateway service and run doctor checks" + : "Skip restart (because --no-restart is set)", + ); const notes: string[] = []; if (opts.tag && updateInstallKind === "git") { @@ -1195,25 +1195,6 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { ); } - if (packageAlreadyCurrent) { - const mode = isPackageManagerUpdateMode(updateStatus.packageManager) - ? updateStatus.packageManager - : "unknown"; - const result: UpdateRunResult = { - status: "skipped", - mode, - root, - reason: "already-current", - before: { version: currentVersion }, - after: { version: currentVersion }, - steps: [], - durationMs: 0, - }; - printResult(result, opts); - defaultRuntime.exit(0); - return; - } - if (updateInstallKind === "package") { const runtimePreflightError = await resolvePackageRuntimePreflightError({ tag, diff --git a/src/infra/package-dist-inventory.test.ts b/src/infra/package-dist-inventory.test.ts index 02eae79c1e2..0196c506e43 100644 --- a/src/infra/package-dist-inventory.test.ts +++ b/src/infra/package-dist-inventory.test.ts @@ -87,6 +87,16 @@ describe("package dist inventory", () => { "left-pad", "index.js", ); + const omittedRuntimeDepsTempSymlink = path.join( + packageRoot, + "dist", + "extensions", + "amazon-bedrock", + ".openclaw-runtime-deps-copy-KZmXaz", + "node_modules", + ".bin", + "fxparser", + ); const omittedExtensionNodeModuleSymlink = path.join( packageRoot, "dist", @@ -111,6 +121,7 @@ describe("package dist inventory", () => { await fs.mkdir(path.dirname(omittedQaLabTypes), { recursive: true }); await fs.mkdir(path.dirname(omittedRuntimeDepsStamp), { recursive: true }); await fs.mkdir(path.dirname(omittedRuntimeDepsTempFile), { recursive: true }); + await fs.mkdir(path.dirname(omittedRuntimeDepsTempSymlink), { recursive: true }); await fs.mkdir(path.dirname(omittedExtensionNodeModuleSymlink), { recursive: true }); await fs.mkdir(path.dirname(omittedExtensionRootAliasSymlink), { recursive: true }); await fs.mkdir(path.join(packageRoot, "dist", "plugin-sdk"), { recursive: true }); @@ -125,6 +136,7 @@ describe("package dist inventory", () => { await fs.writeFile(omittedQaRuntimeChunk, "export {};\n", "utf8"); await fs.writeFile(omittedRuntimeDepsStamp, "{}\n", "utf8"); await fs.writeFile(omittedRuntimeDepsTempFile, "module.exports = 1;\n", "utf8"); + await fs.symlink(path.join(packageRoot, "color-support.js"), omittedRuntimeDepsTempSymlink); await fs.symlink( path.join(packageRoot, "color-support.js"), omittedExtensionNodeModuleSymlink, diff --git a/src/plugins/bundled-runtime-deps.test.ts b/src/plugins/bundled-runtime-deps.test.ts index 745461d784b..a51ccb3350c 100644 --- a/src/plugins/bundled-runtime-deps.test.ts +++ b/src/plugins/bundled-runtime-deps.test.ts @@ -60,13 +60,19 @@ describe("resolveBundledRuntimeDepsNpmRunner", () => { "acpx@0.5.3", ]); expect( - createBundledRuntimeDepsInstallEnv({ - PATH: "/usr/bin:/bin", - npm_config_global: "true", - npm_config_prefix: "/opt/homebrew", - }), + createBundledRuntimeDepsInstallEnv( + { + PATH: "/usr/bin:/bin", + NPM_CONFIG_CACHE: "/Users/alice/.npm-uppercase", + npm_config_cache: "/Users/alice/.npm", + npm_config_global: "true", + npm_config_prefix: "/opt/homebrew", + }, + { cacheDir: "/opt/openclaw/runtime-cache" }, + ), ).toEqual({ PATH: "/usr/bin:/bin", + npm_config_cache: "/opt/openclaw/runtime-cache", npm_config_legacy_peer_deps: "true", npm_config_package_lock: "false", npm_config_save: "false", @@ -262,6 +268,49 @@ 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, + }); + + installBundledRuntimeDeps({ + installRoot, + missingSpecs: ["tokenjuice@0.6.1"], + env: { + HOME: "/Users/alice", + NPM_CONFIG_CACHE: "/Users/alice/.npm-uppercase", + npm_config_cache: "/Users/alice/.npm", + }, + }); + + expect(spawnSyncMock).toHaveBeenCalledWith( + expect.any(String), + expect.any(Array), + expect.objectContaining({ + cwd: installRoot, + env: expect.objectContaining({ + HOME: "/Users/alice", + npm_config_cache: path.join(installRoot, ".openclaw-npm-cache"), + }), + }), + ); + expect(spawnSyncMock).toHaveBeenCalledWith( + expect.any(String), + expect.any(Array), + expect.objectContaining({ + env: expect.not.objectContaining({ + NPM_CONFIG_CACHE: expect.any(String), + }), + }), + ); + }); + 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 72f367a4dc6..facef964ab9 100644 --- a/src/plugins/bundled-runtime-deps.ts +++ b/src/plugins/bundled-runtime-deps.ts @@ -566,18 +566,24 @@ function storeSourceCheckoutRuntimeDepsCache(params: { function createNestedNpmInstallEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const nextEnv = { ...env }; + delete nextEnv.NPM_CONFIG_CACHE; + delete nextEnv.npm_config_cache; delete nextEnv.npm_config_global; delete nextEnv.npm_config_location; delete nextEnv.npm_config_prefix; return nextEnv; } -export function createBundledRuntimeDepsInstallEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { +export function createBundledRuntimeDepsInstallEnv( + env: NodeJS.ProcessEnv, + options: { cacheDir?: string } = {}, +): NodeJS.ProcessEnv { return { ...createNestedNpmInstallEnv(env), npm_config_legacy_peer_deps: "true", npm_config_package_lock: "false", npm_config_save: "false", + ...(options.cacheDir ? { npm_config_cache: options.cacheDir } : {}), }; } @@ -989,7 +995,9 @@ export function installBundledRuntimeDeps(params: { "utf8", ); } - const installEnv = createBundledRuntimeDepsInstallEnv(params.env); + const installEnv = createBundledRuntimeDepsInstallEnv(params.env, { + cacheDir: path.join(installExecutionRoot, ".openclaw-npm-cache"), + }); const npmRunner = resolveBundledRuntimeDepsNpmRunner({ env: installEnv, npmArgs: createBundledRuntimeDepsInstallArgs(params.missingSpecs),