fix(update): harden global updates
This commit is contained in:
parent
6b0d6e2540
commit
57d008a33d
7 changed files with 122 additions and 3 deletions
|
|
@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|
||||||
- Security: guard remote media fetches with SSRF protections (block private/localhost, DNS pinning).
|
- 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.
|
- Plugins: validate plugin/hook install paths and reject traversal-like names.
|
||||||
- Telegram: add download timeouts for file fetches. (#6914) Thanks @hclsys.
|
- Telegram: add download timeouts for file fetches. (#6914) Thanks @hclsys.
|
||||||
- Telegram: enforce thread specs for DM vs forum sends. (#6833) Thanks @obviyus.
|
- 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",
|
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 { type AnyAgentTool, jsonResult, readStringParam } from "./common.js";
|
||||||
import { callGatewayTool } from "./gateway.js";
|
import { callGatewayTool } from "./gateway.js";
|
||||||
|
|
||||||
|
const DEFAULT_UPDATE_TIMEOUT_MS = 20 * 60_000;
|
||||||
|
|
||||||
function resolveBaseHashFromSnapshot(snapshot: unknown): string | undefined {
|
function resolveBaseHashFromSnapshot(snapshot: unknown): string | undefined {
|
||||||
if (!snapshot || typeof snapshot !== "object") {
|
if (!snapshot || typeof snapshot !== "object") {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|
@ -233,11 +235,15 @@ export function createGatewayTool(opts?: {
|
||||||
typeof params.restartDelayMs === "number" && Number.isFinite(params.restartDelayMs)
|
typeof params.restartDelayMs === "number" && Number.isFinite(params.restartDelayMs)
|
||||||
? Math.floor(params.restartDelayMs)
|
? Math.floor(params.restartDelayMs)
|
||||||
: undefined;
|
: 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,
|
sessionKey,
|
||||||
note,
|
note,
|
||||||
restartDelayMs,
|
restartDelayMs,
|
||||||
timeoutMs,
|
timeoutMs: timeoutMs ?? DEFAULT_UPDATE_TIMEOUT_MS,
|
||||||
});
|
});
|
||||||
return jsonResult({ ok: true, result });
|
return jsonResult({ ok: true, result });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ import {
|
||||||
import {
|
import {
|
||||||
detectGlobalInstallManagerByPresence,
|
detectGlobalInstallManagerByPresence,
|
||||||
detectGlobalInstallManagerForRoot,
|
detectGlobalInstallManagerForRoot,
|
||||||
|
cleanupGlobalRenameDirs,
|
||||||
globalInstallArgs,
|
globalInstallArgs,
|
||||||
resolveGlobalPackageRoot,
|
resolveGlobalPackageRoot,
|
||||||
type GlobalInstallManager,
|
type GlobalInstallManager,
|
||||||
|
|
@ -736,6 +737,12 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
||||||
(pkgRoot ? await readPackageName(pkgRoot) : await readPackageName(root)) ??
|
(pkgRoot ? await readPackageName(pkgRoot) : await readPackageName(root)) ??
|
||||||
DEFAULT_PACKAGE_NAME;
|
DEFAULT_PACKAGE_NAME;
|
||||||
const beforeVersion = pkgRoot ? await readPackageVersion(pkgRoot) : null;
|
const beforeVersion = pkgRoot ? await readPackageVersion(pkgRoot) : null;
|
||||||
|
if (pkgRoot) {
|
||||||
|
await cleanupGlobalRenameDirs({
|
||||||
|
globalRoot: path.dirname(pkgRoot),
|
||||||
|
packageName,
|
||||||
|
});
|
||||||
|
}
|
||||||
const updateStep = await runUpdateStep({
|
const updateStep = await runUpdateStep({
|
||||||
name: "global update",
|
name: "global update",
|
||||||
argv: globalInstallArgs(manager, `${packageName}@${tag}`),
|
argv: globalInstallArgs(manager, `${packageName}@${tag}`),
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ export type CommandRunner = (
|
||||||
|
|
||||||
const PRIMARY_PACKAGE_NAME = "openclaw";
|
const PRIMARY_PACKAGE_NAME = "openclaw";
|
||||||
const ALL_PACKAGE_NAMES = [PRIMARY_PACKAGE_NAME] as const;
|
const ALL_PACKAGE_NAMES = [PRIMARY_PACKAGE_NAME] as const;
|
||||||
|
const GLOBAL_RENAME_PREFIX = ".";
|
||||||
|
|
||||||
async function pathExists(targetPath: string): Promise<boolean> {
|
async function pathExists(targetPath: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
|
|
@ -142,3 +143,39 @@ export function globalInstallArgs(manager: GlobalInstallManager, spec: string):
|
||||||
}
|
}
|
||||||
return ["npm", "i", "-g", spec];
|
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 };
|
return { runner, calls };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function pathExists(targetPath: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await fs.stat(targetPath);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe("runGatewayUpdate", () => {
|
describe("runGatewayUpdate", () => {
|
||||||
let tempDir: string;
|
let tempDir: string;
|
||||||
|
|
||||||
|
|
@ -199,6 +208,48 @@ describe("runGatewayUpdate", () => {
|
||||||
expect(calls.some((call) => call === "npm i -g openclaw@latest")).toBe(true);
|
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 () => {
|
it("updates global npm installs with tag override", async () => {
|
||||||
const nodeModules = path.join(tempDir, "node_modules");
|
const nodeModules = path.join(tempDir, "node_modules");
|
||||||
const pkgRoot = path.join(nodeModules, "openclaw");
|
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 { trimLogTail } from "./restart-sentinel.js";
|
||||||
import { DEV_BRANCH, isBetaTag, isStableTag, type UpdateChannel } from "./update-channels.js";
|
import { DEV_BRANCH, isBetaTag, isStableTag, type UpdateChannel } from "./update-channels.js";
|
||||||
import { compareSemverStrings } from "./update-check.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 = {
|
export type UpdateStepResult = {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -792,6 +796,10 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
|
||||||
const globalManager = await detectGlobalInstallManagerForRoot(runCommand, pkgRoot, timeoutMs);
|
const globalManager = await detectGlobalInstallManagerForRoot(runCommand, pkgRoot, timeoutMs);
|
||||||
if (globalManager) {
|
if (globalManager) {
|
||||||
const packageName = (await readPackageName(pkgRoot)) ?? DEFAULT_PACKAGE_NAME;
|
const packageName = (await readPackageName(pkgRoot)) ?? DEFAULT_PACKAGE_NAME;
|
||||||
|
await cleanupGlobalRenameDirs({
|
||||||
|
globalRoot: path.dirname(pkgRoot),
|
||||||
|
packageName,
|
||||||
|
});
|
||||||
const spec = `${packageName}@${normalizeTag(opts.tag)}`;
|
const spec = `${packageName}@${normalizeTag(opts.tag)}`;
|
||||||
const updateStep = await runStep({
|
const updateStep = await runStep({
|
||||||
runCommand,
|
runCommand,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue