fix(update): harden global updates
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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}`),
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user