From 57d008a33d4208c81183384d47f938d69b7c7044 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 2 Feb 2026 04:44:35 -0800 Subject: [PATCH] fix(update): harden global updates --- CHANGELOG.md | 1 + src/agents/openclaw-gateway-tool.test.ts | 9 +++++ src/agents/tools/gateway-tool.ts | 10 ++++- src/cli/update-cli.ts | 7 ++++ src/infra/update-global.ts | 37 +++++++++++++++++ src/infra/update-runner.test.ts | 51 ++++++++++++++++++++++++ src/infra/update-runner.ts | 10 ++++- 7 files changed, 122 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0528c735..92762286c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Security: guard remote media fetches with SSRF protections (block private/localhost, DNS pinning). +- Updates: clean stale global install rename dirs and extend gateway update timeouts to avoid npm ENOTEMPTY failures. - Plugins: validate plugin/hook install paths and reject traversal-like names. - Telegram: add download timeouts for file fetches. (#6914) Thanks @hclsys. - Telegram: enforce thread specs for DM vs forum sends. (#6833) Thanks @obviyus. diff --git a/src/agents/openclaw-gateway-tool.test.ts b/src/agents/openclaw-gateway-tool.test.ts index 2a038b5d5..716d7ee0a 100644 --- a/src/agents/openclaw-gateway-tool.test.ts +++ b/src/agents/openclaw-gateway-tool.test.ts @@ -152,5 +152,14 @@ describe("gateway tool", () => { sessionKey: "agent:main:whatsapp:dm:+15555550123", }), ); + const updateCall = vi + .mocked(callGatewayTool) + .mock.calls.find((call) => call[0] === "update.run"); + expect(updateCall).toBeDefined(); + if (updateCall) { + const [, opts, params] = updateCall; + expect(opts).toMatchObject({ timeoutMs: 20 * 60_000 }); + expect(params).toMatchObject({ timeoutMs: 20 * 60_000 }); + } }); }); diff --git a/src/agents/tools/gateway-tool.ts b/src/agents/tools/gateway-tool.ts index ea83faf00..9560b323c 100644 --- a/src/agents/tools/gateway-tool.ts +++ b/src/agents/tools/gateway-tool.ts @@ -12,6 +12,8 @@ import { stringEnum } from "../schema/typebox.js"; import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js"; import { callGatewayTool } from "./gateway.js"; +const DEFAULT_UPDATE_TIMEOUT_MS = 20 * 60_000; + function resolveBaseHashFromSnapshot(snapshot: unknown): string | undefined { if (!snapshot || typeof snapshot !== "object") { return undefined; @@ -233,11 +235,15 @@ export function createGatewayTool(opts?: { typeof params.restartDelayMs === "number" && Number.isFinite(params.restartDelayMs) ? Math.floor(params.restartDelayMs) : undefined; - const result = await callGatewayTool("update.run", gatewayOpts, { + const updateGatewayOpts = { + ...gatewayOpts, + timeoutMs: timeoutMs ?? DEFAULT_UPDATE_TIMEOUT_MS, + }; + const result = await callGatewayTool("update.run", updateGatewayOpts, { sessionKey, note, restartDelayMs, - timeoutMs, + timeoutMs: timeoutMs ?? DEFAULT_UPDATE_TIMEOUT_MS, }); return jsonResult({ ok: true, result }); } diff --git a/src/cli/update-cli.ts b/src/cli/update-cli.ts index 56a8daafb..d7cd94375 100644 --- a/src/cli/update-cli.ts +++ b/src/cli/update-cli.ts @@ -29,6 +29,7 @@ import { import { detectGlobalInstallManagerByPresence, detectGlobalInstallManagerForRoot, + cleanupGlobalRenameDirs, globalInstallArgs, resolveGlobalPackageRoot, type GlobalInstallManager, @@ -736,6 +737,12 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { (pkgRoot ? await readPackageName(pkgRoot) : await readPackageName(root)) ?? DEFAULT_PACKAGE_NAME; const beforeVersion = pkgRoot ? await readPackageVersion(pkgRoot) : null; + if (pkgRoot) { + await cleanupGlobalRenameDirs({ + globalRoot: path.dirname(pkgRoot), + packageName, + }); + } const updateStep = await runUpdateStep({ name: "global update", argv: globalInstallArgs(manager, `${packageName}@${tag}`), diff --git a/src/infra/update-global.ts b/src/infra/update-global.ts index 940e44445..d7934be57 100644 --- a/src/infra/update-global.ts +++ b/src/infra/update-global.ts @@ -11,6 +11,7 @@ export type CommandRunner = ( const PRIMARY_PACKAGE_NAME = "openclaw"; const ALL_PACKAGE_NAMES = [PRIMARY_PACKAGE_NAME] as const; +const GLOBAL_RENAME_PREFIX = "."; async function pathExists(targetPath: string): Promise { try { @@ -142,3 +143,39 @@ export function globalInstallArgs(manager: GlobalInstallManager, spec: string): } return ["npm", "i", "-g", spec]; } + +export async function cleanupGlobalRenameDirs(params: { + globalRoot: string; + packageName: string; +}): Promise<{ removed: string[] }> { + const removed: string[] = []; + const root = params.globalRoot.trim(); + const name = params.packageName.trim(); + if (!root || !name) { + return { removed }; + } + const prefix = `${GLOBAL_RENAME_PREFIX}${name}-`; + let entries: string[] = []; + try { + entries = await fs.readdir(root); + } catch { + return { removed }; + } + for (const entry of entries) { + if (!entry.startsWith(prefix)) { + continue; + } + const target = path.join(root, entry); + try { + const stat = await fs.lstat(target); + if (!stat.isDirectory()) { + continue; + } + await fs.rm(target, { recursive: true, force: true }); + removed.push(entry); + } catch { + // ignore cleanup failures + } + } + return { removed }; +} diff --git a/src/infra/update-runner.test.ts b/src/infra/update-runner.test.ts index fea01b88f..853d876dd 100644 --- a/src/infra/update-runner.test.ts +++ b/src/infra/update-runner.test.ts @@ -21,6 +21,15 @@ function createRunner(responses: Record) { return { runner, calls }; } +async function pathExists(targetPath: string): Promise { + try { + await fs.stat(targetPath); + return true; + } catch { + return false; + } +} + describe("runGatewayUpdate", () => { let tempDir: string; @@ -199,6 +208,48 @@ describe("runGatewayUpdate", () => { expect(calls.some((call) => call === "npm i -g openclaw@latest")).toBe(true); }); + it("cleans stale npm rename dirs before global update", async () => { + const nodeModules = path.join(tempDir, "node_modules"); + const pkgRoot = path.join(nodeModules, "openclaw"); + const staleDir = path.join(nodeModules, ".openclaw-stale"); + await fs.mkdir(staleDir, { recursive: true }); + await fs.mkdir(pkgRoot, { recursive: true }); + await fs.writeFile( + path.join(pkgRoot, "package.json"), + JSON.stringify({ name: "openclaw", version: "1.0.0" }), + "utf-8", + ); + + let stalePresentAtInstall = true; + const runCommand = async (argv: string[]) => { + const key = argv.join(" "); + if (key === `git -C ${pkgRoot} rev-parse --show-toplevel`) { + return { stdout: "", stderr: "not a git repository", code: 128 }; + } + if (key === "npm root -g") { + return { stdout: nodeModules, stderr: "", code: 0 }; + } + if (key === "pnpm root -g") { + return { stdout: "", stderr: "", code: 1 }; + } + if (key === "npm i -g openclaw@latest") { + stalePresentAtInstall = await pathExists(staleDir); + return { stdout: "ok", stderr: "", code: 0 }; + } + return { stdout: "", stderr: "", code: 0 }; + }; + + const result = await runGatewayUpdate({ + cwd: pkgRoot, + runCommand: async (argv, _options) => runCommand(argv), + timeoutMs: 5000, + }); + + expect(result.status).toBe("ok"); + expect(stalePresentAtInstall).toBe(false); + expect(await pathExists(staleDir)).toBe(false); + }); + it("updates global npm installs with tag override", async () => { const nodeModules = path.join(tempDir, "node_modules"); const pkgRoot = path.join(nodeModules, "openclaw"); diff --git a/src/infra/update-runner.ts b/src/infra/update-runner.ts index 2ca0fcbad..e0d1c2428 100644 --- a/src/infra/update-runner.ts +++ b/src/infra/update-runner.ts @@ -5,7 +5,11 @@ import { type CommandOptions, runCommandWithTimeout } from "../process/exec.js"; import { trimLogTail } from "./restart-sentinel.js"; import { DEV_BRANCH, isBetaTag, isStableTag, type UpdateChannel } from "./update-channels.js"; import { compareSemverStrings } from "./update-check.js"; -import { detectGlobalInstallManagerForRoot, globalInstallArgs } from "./update-global.js"; +import { + cleanupGlobalRenameDirs, + detectGlobalInstallManagerForRoot, + globalInstallArgs, +} from "./update-global.js"; export type UpdateStepResult = { name: string; @@ -792,6 +796,10 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise< const globalManager = await detectGlobalInstallManagerForRoot(runCommand, pkgRoot, timeoutMs); if (globalManager) { const packageName = (await readPackageName(pkgRoot)) ?? DEFAULT_PACKAGE_NAME; + await cleanupGlobalRenameDirs({ + globalRoot: path.dirname(pkgRoot), + packageName, + }); const spec = `${packageName}@${normalizeTag(opts.tag)}`; const updateStep = await runStep({ runCommand,