From b979f2964c9609a4d2ce9e96d643ed020e3fc00e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 07:03:15 +0100 Subject: [PATCH] fix: warn on low disk before runtime dependency staging --- CHANGELOG.md | 1 + docs/install/updating.md | 6 ++ src/cli/update-cli.test.ts | 37 ++++++++ src/cli/update-cli/update-command.ts | 15 ++++ .../doctor-bundled-plugin-runtime-deps.ts | 1 + src/infra/disk-space.test.ts | 79 +++++++++++++++++ src/infra/disk-space.ts | 85 +++++++++++++++++++ src/plugins/bundled-runtime-deps.test.ts | 51 +++++++++++ src/plugins/bundled-runtime-deps.ts | 12 +++ src/plugins/loader.ts | 1 + 10 files changed, 288 insertions(+) create mode 100644 src/infra/disk-space.test.ts create mode 100644 src/infra/disk-space.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fdd0b00c0a..c9e22ffb260 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -116,6 +116,7 @@ Docs: https://docs.openclaw.ai `openclaw node start` command, and show an actionable browser-control error when the local control service is missing. Fixes #66637. - Gateway/update: fail package updates when the restarted managed gateway reports the wrong version, including fallback restarts and JSON mode, avoiding false-success mixed-version restarts after macOS LaunchAgent updates. Fixes #71835. Thanks @abhinas90 and @jsompis. +- Gateway/update: warn before package updates and bundled plugin runtime-dependency repairs when the target volume appears low on disk space, without blocking installs on best-effort filesystem checks. Fixes #71835. Thanks @abhinas90 and @jsompis. - Plugins/runtime deps: surface activated plugin load failures in health and fail package-update restart verification or doctor repair when bundled runtime deps still cannot load, avoiding false-success repairs. (#71883) Thanks @Solvely-Colin. - Gateway/Linux: include fnm `aliases/default/bin` in generated service PATHs and let doctor accept either modern fnm aliases or the legacy `current/bin` symlink, avoiding false PATH repair prompts. Fixes #68169. Thanks @richard-scott. - Installer/Linux: run apt installs with noninteractive dpkg and needrestart settings so fresh Ubuntu 24.04 `curl | bash` installs do not hang while installing Node.js, Git, or build tools. Fixes #41146. Thanks @iht76, @alexcarv318, @cs3gallery, @firofame, and @cgdusek. diff --git a/docs/install/updating.md b/docs/install/updating.md index 9f2583bd48f..375d08ade8f 100644 --- a/docs/install/updating.md +++ b/docs/install/updating.md @@ -79,6 +79,12 @@ ignores user npm prefix/global settings, so global-install npm config does not redirect bundled plugin dependencies into `~/node_modules` or the global package tree. +Before package updates and bundled runtime-dependency repairs, OpenClaw tries a +best-effort disk-space check for the target volume. Low space produces a warning +with the checked path, but does not block the update because filesystem quotas, +snapshots, and network volumes can change after the check. The actual npm +install, copy, and post-install verification remain authoritative. + ### Bundled plugin runtime dependencies Packaged installs keep bundled plugin runtime dependencies out of the read-only diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 44ae6434101..fe91cb3211f 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -1,4 +1,5 @@ import { EventEmitter } from "node:events"; +import fsSync from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -288,6 +289,20 @@ describe("update-cli", () => { ); }; + const statfsFixture = (params: { + bavail: number; + bsize?: number; + blocks?: number; + }): ReturnType => ({ + type: 0, + bsize: params.bsize ?? 1024, + blocks: params.blocks ?? 2_000_000, + bfree: params.bavail, + bavail: params.bavail, + files: 0, + ffree: 0, + }); + const makeOkUpdateResult = (overrides: Partial = {}): UpdateRunResult => ({ status: "ok", @@ -911,6 +926,28 @@ describe("update-cli", () => { expect(logs.join("\n")).not.toContain("already-current"); }); + it("warns but still runs package updates when disk space looks low", async () => { + const tempDir = createCaseDir("openclaw-update"); + mockPackageInstallStatus(tempDir); + vi.spyOn(fsSync, "statfsSync").mockReturnValue( + statfsFixture({ + bavail: 256, + bsize: 1024 * 1024, + }), + ); + + await updateCommand({ yes: true }); + + expectPackageInstallSpec("openclaw@latest"); + expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1); + expect( + vi + .mocked(defaultRuntime.log) + .mock.calls.map((call) => String(call[0])) + .join("\n"), + ).toContain("Low disk space near"); + }); + it("blocks package updates when the target requires a newer Node runtime", async () => { mockPackageInstallStatus(createCaseDir("openclaw-update")); vi.mocked(fetchNpmPackageTargetStatus).mockResolvedValue({ diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 5f2e7b51a60..e9a583fb81c 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -18,6 +18,7 @@ import { asResolvedSourceConfig, asRuntimeConfig } from "../../config/materializ import { resolveGatewayInstallEntrypoint } from "../../daemon/gateway-entrypoint.js"; import { resolveGatewayRestartLogPath } from "../../daemon/restart-logs.js"; import { resolveGatewayService } from "../../daemon/service.js"; +import { createLowDiskSpaceWarning } from "../../infra/disk-space.js"; import { nodeVersionSatisfiesEngine } from "../../infra/runtime-guard.js"; import { channelToNpmTag, @@ -352,6 +353,7 @@ async function runPackageInstallUpdate(params: { timeoutMs: number; startedAt: number; progress: ReturnType["progress"]; + jsonMode: boolean; }): Promise { const manager = await resolveGlobalManager({ root: params.root, @@ -384,6 +386,18 @@ async function runPackageInstallUpdate(params: { }); } + const diskWarning = createLowDiskSpaceWarning({ + targetPath: pkgRoot ? path.dirname(pkgRoot) : params.root, + purpose: "global package update", + }); + if (diskWarning) { + if (params.jsonMode) { + defaultRuntime.error(`Warning: ${diskWarning}`); + } else { + defaultRuntime.log(theme.warn(diskWarning)); + } + } + const updateStep = await runUpdateStep({ name: "global update", argv: globalInstallArgs(installTarget, installSpec), @@ -1277,6 +1291,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { timeoutMs: timeoutMs ?? 20 * 60_000, startedAt, progress, + jsonMode: Boolean(opts.json), }) : await runGitUpdate({ root, diff --git a/src/commands/doctor-bundled-plugin-runtime-deps.ts b/src/commands/doctor-bundled-plugin-runtime-deps.ts index 6881b774227..e572eb17c8c 100644 --- a/src/commands/doctor-bundled-plugin-runtime-deps.ts +++ b/src/commands/doctor-bundled-plugin-runtime-deps.ts @@ -92,6 +92,7 @@ export async function maybeRepairBundledPluginRuntimeDeps(params: { installSpecs, env: params.env ?? process.env, installDeps: params.installDeps, + warn: (message) => params.runtime.log(message), }); note(`Installed bundled plugin deps: ${result.installSpecs.join(", ")}`, "Bundled plugins"); } catch (error) { diff --git a/src/infra/disk-space.test.ts b/src/infra/disk-space.test.ts new file mode 100644 index 00000000000..596afd5af13 --- /dev/null +++ b/src/infra/disk-space.test.ts @@ -0,0 +1,79 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createLowDiskSpaceWarning, formatDiskSpaceBytes, tryReadDiskSpace } from "./disk-space.js"; + +function statfsFixture(params: { + bavail: number; + bsize?: number; + blocks?: number; +}): ReturnType { + return { + type: 0, + bsize: params.bsize ?? 1024, + blocks: params.blocks ?? 2_000_000, + bfree: params.bavail, + bavail: params.bavail, + files: 0, + ffree: 0, + }; +} + +describe("disk-space helpers", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("reads disk space from the nearest existing ancestor", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-disk-space-")); + try { + const statfs = vi.spyOn(fs, "statfsSync").mockReturnValue( + statfsFixture({ + bavail: 512, + bsize: 1024, + blocks: 4096, + }), + ); + + const snapshot = tryReadDiskSpace(path.join(tempDir, "missing", "child")); + + expect(snapshot).toEqual({ + targetPath: path.join(tempDir, "missing", "child"), + checkedPath: tempDir, + availableBytes: 512 * 1024, + totalBytes: 4096 * 1024, + }); + expect(statfs).toHaveBeenCalledWith(tempDir); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("formats low disk warnings without making them hard errors", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-disk-space-")); + try { + vi.spyOn(fs, "statfsSync").mockReturnValue( + statfsFixture({ + bavail: 256, + bsize: 1024 * 1024, + }), + ); + + expect( + createLowDiskSpaceWarning({ + targetPath: tempDir, + purpose: "test staging", + thresholdBytes: 512 * 1024 * 1024, + }), + ).toBe(`Low disk space near ${tempDir}: 256 MiB available; test staging may fail.`); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("keeps byte formatting compact", () => { + expect(formatDiskSpaceBytes(420 * 1024 * 1024)).toBe("420 MiB"); + expect(formatDiskSpaceBytes(1536 * 1024 * 1024)).toBe("1.5 GiB"); + }); +}); diff --git a/src/infra/disk-space.ts b/src/infra/disk-space.ts new file mode 100644 index 00000000000..57391338b7f --- /dev/null +++ b/src/infra/disk-space.ts @@ -0,0 +1,85 @@ +import fs from "node:fs"; +import path from "node:path"; + +export const LOW_DISK_SPACE_WARNING_THRESHOLD_BYTES = 1024 * 1024 * 1024; + +export type DiskSpaceSnapshot = { + targetPath: string; + checkedPath: string; + availableBytes: number; + totalBytes: number | null; +}; + +function finiteNonNegativeNumber(value: unknown): number | null { + const numberValue = Number(value); + return Number.isFinite(numberValue) && numberValue >= 0 ? numberValue : null; +} + +function findExistingDiskSpacePath(targetPath: string): string | null { + let current = path.resolve(targetPath); + while (true) { + try { + const stats = fs.statSync(current); + return stats.isDirectory() ? current : path.dirname(current); + } catch { + const parent = path.dirname(current); + if (parent === current) { + return null; + } + current = parent; + } + } +} + +export function tryReadDiskSpace(targetPath: string): DiskSpaceSnapshot | null { + if (typeof fs.statfsSync !== "function") { + return null; + } + const checkedPath = findExistingDiskSpacePath(targetPath); + if (!checkedPath) { + return null; + } + try { + const stats = fs.statfsSync(checkedPath); + const blockSize = finiteNonNegativeNumber(stats.bsize); + const availableBlocks = finiteNonNegativeNumber(stats.bavail); + if (blockSize === null || availableBlocks === null) { + return null; + } + const totalBlocks = finiteNonNegativeNumber(stats.blocks); + return { + targetPath, + checkedPath, + availableBytes: blockSize * availableBlocks, + totalBytes: totalBlocks === null ? null : blockSize * totalBlocks, + }; + } catch { + return null; + } +} + +export function formatDiskSpaceBytes(bytes: number): string { + const mib = bytes / (1024 * 1024); + if (mib < 1024) { + return `${Math.max(0, Math.round(mib))} MiB`; + } + const gib = mib / 1024; + return `${gib.toFixed(gib < 10 ? 1 : 0)} GiB`; +} + +export function createLowDiskSpaceWarning(params: { + targetPath: string; + purpose: string; + thresholdBytes?: number; +}): string | null { + const thresholdBytes = params.thresholdBytes ?? LOW_DISK_SPACE_WARNING_THRESHOLD_BYTES; + const snapshot = tryReadDiskSpace(params.targetPath); + if (!snapshot || snapshot.availableBytes >= thresholdBytes) { + return null; + } + const location = + path.resolve(snapshot.targetPath) === path.resolve(snapshot.checkedPath) + ? snapshot.checkedPath + : `${snapshot.targetPath} (volume checked at ${snapshot.checkedPath})`; + return `Low disk space near ${location}: ${formatDiskSpaceBytes(snapshot.availableBytes)} available; ${params.purpose} may fail.`; +} diff --git a/src/plugins/bundled-runtime-deps.test.ts b/src/plugins/bundled-runtime-deps.test.ts index eb42c46c77d..825683242f8 100644 --- a/src/plugins/bundled-runtime-deps.test.ts +++ b/src/plugins/bundled-runtime-deps.test.ts @@ -41,6 +41,22 @@ function writeInstalledPackage(rootDir: string, packageName: string, version: st ); } +function statfsFixture(params: { + bavail: number; + bsize?: number; + blocks?: number; +}): ReturnType { + return { + type: 0, + bsize: params.bsize ?? 1024, + blocks: params.blocks ?? 2_000_000, + bfree: params.bavail, + bavail: params.bavail, + files: 0, + ffree: 0, + }; +} + afterEach(() => { vi.restoreAllMocks(); spawnSyncMock.mockReset(); @@ -276,6 +292,41 @@ describe("installBundledRuntimeDeps", () => { ); }); + it("warns but still installs bundled runtime deps when disk space looks low", () => { + const installRoot = makeTempDir(); + const warn = vi.fn(); + vi.spyOn(fs, "statfsSync").mockReturnValue( + statfsFixture({ + bavail: 256, + bsize: 1024 * 1024, + }), + ); + spawnSyncMock.mockImplementation((_command, _args, options) => { + writeInstalledPackage(String(options?.cwd ?? ""), "acpx", "0.5.3"); + return { + pid: 123, + output: [], + stdout: "", + stderr: "", + signal: null, + status: 0, + }; + }); + + installBundledRuntimeDeps({ + installRoot, + missingSpecs: ["acpx@0.5.3"], + env: {}, + warn, + }); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining("Low disk space near")); + expect(spawnSyncMock).toHaveBeenCalled(); + expect(fs.existsSync(path.join(installRoot, "node_modules", "acpx", "package.json"))).toBe( + true, + ); + }); + it("uses an isolated execution root and copies node_modules back when requested", () => { const installRoot = makeTempDir(); const installExecutionRoot = makeTempDir(); diff --git a/src/plugins/bundled-runtime-deps.ts b/src/plugins/bundled-runtime-deps.ts index e26264ff49d..f7a1dbbb6fb 100644 --- a/src/plugins/bundled-runtime-deps.ts +++ b/src/plugins/bundled-runtime-deps.ts @@ -6,6 +6,7 @@ import os from "node:os"; import path from "node:path"; import { resolveStateDir } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { createLowDiskSpaceWarning } from "../infra/disk-space.js"; import { resolveHomeRelativePath } from "../infra/home-dir.js"; import { createNpmProjectInstallEnv } from "../infra/npm-install-env.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; @@ -30,6 +31,7 @@ export type BundledRuntimeDepsInstallParams = { linkNodeModulesFromExecutionRoot?: boolean; missingSpecs: string[]; installSpecs?: string[]; + warn?: (message: string) => void; }; export type BundledRuntimeDepsEnsureResult = { @@ -1198,6 +1200,7 @@ export function installBundledRuntimeDeps(params: { linkNodeModulesFromExecutionRoot?: boolean; missingSpecs: string[]; env: NodeJS.ProcessEnv; + warn?: (message: string) => void; }): void { const installExecutionRoot = params.installExecutionRoot ?? params.installRoot; const isolatedExecutionRoot = @@ -1211,6 +1214,13 @@ export function installBundledRuntimeDeps(params: { try { fs.mkdirSync(params.installRoot, { recursive: true }); fs.mkdirSync(installExecutionRoot, { recursive: true }); + const diskWarning = createLowDiskSpaceWarning({ + targetPath: installExecutionRoot, + purpose: "bundled plugin runtime dependency staging", + }); + if (diskWarning) { + params.warn?.(diskWarning); + } // Always make npm see an OpenClaw-owned package root. The package-level // doctor repair path installs directly in the external stage dir; without a // manifest, npm can honor a user's global prefix config and write under @@ -1263,6 +1273,7 @@ export function repairBundledRuntimeDepsInstallRoot(params: { installSpecs: string[]; env: NodeJS.ProcessEnv; installDeps?: (params: BundledRuntimeDepsInstallParams) => void; + warn?: (message: string) => void; }): { installSpecs: string[] } { return withBundledRuntimeDepsInstallRootLock(params.installRoot, () => { const retainedManifestSpecs = readRetainedRuntimeDepsManifest(params.installRoot); @@ -1276,6 +1287,7 @@ export function repairBundledRuntimeDepsInstallRoot(params: { installRoot: installParams.installRoot, missingSpecs: installParams.installSpecs ?? installParams.missingSpecs, env: params.env, + warn: params.warn, })); install({ installRoot: params.installRoot, diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 36aec8c3888..31f11343d09 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -2540,6 +2540,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi installExecutionRoot: params.installExecutionRoot, missingSpecs: params.installSpecs ?? params.missingSpecs, env, + warn: (message) => logger.warn(`[plugins] ${record.id}: ${message}`), })); installer(installParams); },