fix(update): harden global updates

This commit is contained in:
Peter Steinberger
2026-02-02 04:44:35 -08:00
parent 6b0d6e2540
commit 57d008a33d
7 changed files with 122 additions and 3 deletions

View File

@@ -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.

View File

@@ -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 });
}
});
});

View File

@@ -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 });
}

View File

@@ -29,6 +29,7 @@ import {
import {
detectGlobalInstallManagerByPresence,
detectGlobalInstallManagerForRoot,
cleanupGlobalRenameDirs,
globalInstallArgs,
resolveGlobalPackageRoot,
type GlobalInstallManager,
@@ -736,6 +737,12 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
(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}`),

View File

@@ -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<boolean> {
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 };
}

View File

@@ -21,6 +21,15 @@ function createRunner(responses: Record<string, CommandResult>) {
return { runner, calls };
}
async function pathExists(targetPath: string): Promise<boolean> {
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");

View File

@@ -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,