From c9605779ef4afd87b8a6a837559353f2ffdd754d Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 19 Jun 2026 08:56:37 +0800 Subject: [PATCH] fix(doctor): warn on volatile SQLite state (#94725) * fix(doctor): warn on volatile SQLite state * fix(doctor): resolve symlinked state paths --- docs/gateway/doctor.md | 1 + ...ctor-state-integrity.linux-storage.test.ts | 2 + src/commands/doctor-state-integrity.ts | 95 +++++++++++- src/commands/doctor-volatile-fs.test.ts | 137 ++++++++++++++++++ 4 files changed, 234 insertions(+), 1 deletion(-) create mode 100644 src/commands/doctor-volatile-fs.test.ts diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index 8ab644d6e1de..04cf5ff44363 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -397,6 +397,7 @@ That stages grounded durable candidates into the short-term dreaming store while - **State dir permissions**: verifies writability; offers to repair permissions (and emits a `chown` hint when owner/group mismatch is detected). - **macOS cloud-synced state dir**: warns when state resolves under iCloud Drive (`~/Library/Mobile Documents/com~apple~CloudDocs/...`) or `~/Library/CloudStorage/...` because sync-backed paths can cause slower I/O and lock/sync races. - **Linux SD or eMMC state dir**: warns when state resolves to an `mmcblk*` mount source, because SD or eMMC-backed random I/O can be slower and wear faster under session and credential writes. + - **Linux volatile state dir**: warns when state resolves to `tmpfs` or `ramfs`, because sessions, credentials, config, and SQLite state with its WAL/journal sidecars will disappear on reboot. Docker `overlay` mounts are intentionally not flagged because their writable layers persist across host reboots while the container remains. - **Session dirs missing**: `sessions/` and the session store directory are required to persist history and avoid `ENOENT` crashes. - **Transcript mismatch**: warns when recent session entries have missing transcript files. - **Main session "1-line JSONL"**: flags when the main transcript has only one line (history is not accumulating). diff --git a/src/commands/doctor-state-integrity.linux-storage.test.ts b/src/commands/doctor-state-integrity.linux-storage.test.ts index ea1aa5688a97..68c2a7d6da1b 100644 --- a/src/commands/doctor-state-integrity.linux-storage.test.ts +++ b/src/commands/doctor-state-integrity.linux-storage.test.ts @@ -24,6 +24,7 @@ describe("detectLinuxSdBackedStateDir", () => { const result = detectLinuxSdBackedStateDir("/home/pi/.openclaw", { platform: "linux", mountInfo, + resolveRealPath: (statePath) => statePath, }); expect(result).toEqual({ @@ -51,6 +52,7 @@ describe("detectLinuxSdBackedStateDir", () => { const result = detectLinuxSdBackedStateDir("/home/user/.openclaw", { platform: "linux", mountInfo, + resolveRealPath: (statePath) => statePath, resolveDeviceRealPath: (devicePath) => { if (devicePath === "/dev/disk/by-uuid/abcd-1234") { return "/dev/mmcblk0p2"; diff --git a/src/commands/doctor-state-integrity.ts b/src/commands/doctor-state-integrity.ts index cddde2ae447b..20dd66ddb881 100644 --- a/src/commands/doctor-state-integrity.ts +++ b/src/commands/doctor-state-integrity.ts @@ -287,6 +287,27 @@ function tryResolveRealPath(targetPath: string): string | null { } } +function resolvePathThroughExistingAncestor( + targetPath: string, + resolveRealPath: (targetPath: string) => string | null, + pathOps: Pick, +): string | null { + const missingSegments: string[] = []; + let candidate = pathOps.resolve(targetPath); + while (true) { + const resolved = resolveRealPath(candidate); + if (resolved) { + return pathOps.resolve(resolved, ...missingSegments); + } + const parent = pathOps.dirname(candidate); + if (parent === candidate) { + return null; + } + missingSegments.unshift(pathOps.basename(candidate)); + candidate = parent; + } +} + function decodeMountInfoPath(value: string): string { return value.replace(/\\([0-7]{3})/g, (_, octal: string) => String.fromCharCode(Number.parseInt(octal, 8)), @@ -436,7 +457,9 @@ export function detectLinuxSdBackedStateDir( const linuxPath = path.posix; const resolveRealPath = deps?.resolveRealPath ?? tryResolveRealPath; - const resolvedStatePath = resolveRealPath(stateDir) ?? linuxPath.resolve(stateDir); + const resolvedStatePath = + resolvePathThroughExistingAncestor(stateDir, resolveRealPath, linuxPath) ?? + linuxPath.resolve(stateDir); const mountInfo = deps?.mountInfo ?? tryReadLinuxMountInfo(); if (!mountInfo) { return null; @@ -491,6 +514,72 @@ export function formatLinuxSdBackedStateDirWarning( ].join("\n"); } +type LinuxVolatileStateDir = { + path: string; + mountPoint: string; + fsType: string; +}; + +/** Filesystems whose state disappears on reboot. Docker overlayfs is intentionally excluded. */ +const VOLATILE_FS_TYPES = new Set(["tmpfs", "ramfs"]); + +/** Detects Linux state directories mounted on filesystems that do not survive a reboot. */ +export function detectLinuxVolatileStateDir( + stateDir: string, + deps?: { + platform?: NodeJS.Platform; + mountInfo?: string; + resolveRealPath?: (targetPath: string) => string | null; + }, +): LinuxVolatileStateDir | null { + const platform = deps?.platform ?? process.platform; + if (platform !== "linux") { + return null; + } + const linuxPath = path.posix; + + const resolveRealPath = deps?.resolveRealPath ?? tryResolveRealPath; + const resolvedStatePath = + resolvePathThroughExistingAncestor(stateDir, resolveRealPath, linuxPath) ?? + linuxPath.resolve(stateDir); + const mountInfo = deps?.mountInfo ?? tryReadLinuxMountInfo(); + if (!mountInfo) { + return null; + } + + const mountEntry = findLinuxMountInfoEntryForPath( + resolvedStatePath, + parseLinuxMountInfo(mountInfo), + linuxPath, + ); + if (!mountEntry || !VOLATILE_FS_TYPES.has(mountEntry.fsType)) { + return null; + } + + return { + path: linuxPath.resolve(resolvedStatePath), + mountPoint: linuxPath.resolve(mountEntry.mountPoint), + fsType: mountEntry.fsType, + }; +} + +/** Formats the warning for state stored on a volatile Linux filesystem. */ +export function formatLinuxVolatileStateDirWarning( + displayStateDir: string, + volatileDir: LinuxVolatileStateDir, +): string { + const safeFsType = escapeControlCharsForTerminal(volatileDir.fsType); + const safeMountPoint = + volatileDir.mountPoint === "/" + ? "/" + : escapeControlCharsForTerminal(shortenHomePath(volatileDir.mountPoint)); + return [ + `- State directory is on a volatile filesystem (${displayStateDir}; fs ${safeFsType}, mount ${safeMountPoint}).`, + "- Sessions, credentials, config, and SQLite state (including WAL/journal sidecars) will be lost on reboot.", + "- Move OPENCLAW_STATE_DIR to a persistent filesystem to avoid data loss.", + ].join("\n"); +} + /** Detects macOS state directories under iCloud Drive or CloudStorage providers. */ export function detectMacCloudSyncedStateDir( stateDir: string, @@ -642,6 +731,7 @@ export async function noteStateIntegrity( const requireOAuthDir = shouldRequireOAuthDir(cfg, env); const cloudSyncedStateDir = detectMacCloudSyncedStateDir(stateDir); const linuxSdBackedStateDir = detectLinuxSdBackedStateDir(stateDir); + const linuxVolatileStateDir = detectLinuxVolatileStateDir(stateDir); const suppressOrphanTranscriptWarning = shouldSuppressOrphanTranscriptWarning(cfg, agentId); if (cloudSyncedStateDir) { @@ -657,6 +747,9 @@ export async function noteStateIntegrity( if (linuxSdBackedStateDir) { warnings.push(formatLinuxSdBackedStateDirWarning(displayStateDir, linuxSdBackedStateDir)); } + if (linuxVolatileStateDir) { + warnings.push(formatLinuxVolatileStateDirWarning(displayStateDir, linuxVolatileStateDir)); + } let stateDirExists = existsDir(stateDir); if (!stateDirExists) { diff --git a/src/commands/doctor-volatile-fs.test.ts b/src/commands/doctor-volatile-fs.test.ts new file mode 100644 index 000000000000..e75fb0a8cf85 --- /dev/null +++ b/src/commands/doctor-volatile-fs.test.ts @@ -0,0 +1,137 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + detectLinuxVolatileStateDir, + formatLinuxVolatileStateDirWarning, +} from "./doctor-state-integrity.js"; + +describe("detectLinuxVolatileStateDir", () => { + const TMPFS_MOUNT_INFO = [ + "22 1 0:21 / / rw,relatime - ext4 /dev/sda1 rw", + "30 22 0:30 / /tmp rw,nosuid,nodev - tmpfs tmpfs rw", + "35 22 0:35 / /home/user/.openclaw rw - tmpfs tmpfs rw,size=1048576k", + ].join("\n"); + const RAMFS_MOUNT_INFO = [ + "22 1 0:21 / / rw,relatime - ext4 /dev/sda1 rw", + "35 22 0:35 / /home/user/.openclaw rw - ramfs ramfs rw", + ].join("\n"); + const OVERLAY_MOUNT_INFO = [ + "22 1 0:21 / / rw,relatime - overlay overlay rw,lowerdir=/lower,upperdir=/upper", + ].join("\n"); + const EXT4_MOUNT_INFO = "22 1 0:21 / / rw,relatime - ext4 /dev/sda1 rw"; + + it.each([ + ["tmpfs", TMPFS_MOUNT_INFO], + ["ramfs", RAMFS_MOUNT_INFO], + ])("detects %s state directories", (fsType, mountInfo) => { + const result = detectLinuxVolatileStateDir("/home/user/.openclaw", { + platform: "linux", + mountInfo, + resolveRealPath: (targetPath) => targetPath, + }); + + expect(result).toMatchObject({ + path: "/home/user/.openclaw", + mountPoint: "/home/user/.openclaw", + fsType, + }); + }); + + it("uses the most specific matching mount", () => { + const mountInfo = [ + "22 1 0:21 / / rw - ext4 /dev/sda1 rw", + "30 22 0:30 / /home rw - ext4 /dev/sda2 rw", + "35 30 0:35 / /home/user/.openclaw rw - tmpfs tmpfs rw", + ].join("\n"); + + expect( + detectLinuxVolatileStateDir("/home/user/.openclaw", { + platform: "linux", + mountInfo, + resolveRealPath: (targetPath) => targetPath, + }), + ).toMatchObject({ + mountPoint: "/home/user/.openclaw", + fsType: "tmpfs", + }); + }); + + it("detects a missing state directory through an existing symlink", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-volatile-")); + try { + const volatileMount = path.join(root, "volatile"); + const stateLink = path.join(root, "state"); + fs.mkdirSync(volatileMount); + fs.symlinkSync(volatileMount, stateLink, "dir"); + const resolvedVolatileMount = fs.realpathSync(volatileMount); + + const result = detectLinuxVolatileStateDir(path.join(stateLink, "openclaw"), { + platform: "linux", + mountInfo: [ + "22 1 0:21 / / rw,relatime - ext4 /dev/sda1 rw", + `35 22 0:35 / ${resolvedVolatileMount} rw - tmpfs tmpfs rw`, + ].join("\n"), + }); + + expect(result).toMatchObject({ + path: path.join(resolvedVolatileMount, "openclaw"), + mountPoint: resolvedVolatileMount, + fsType: "tmpfs", + }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it.each([ + ["overlay", OVERLAY_MOUNT_INFO], + ["ext4", EXT4_MOUNT_INFO], + ])("does not flag %s filesystems", (_name, mountInfo) => { + expect( + detectLinuxVolatileStateDir("/home/user/.openclaw", { + platform: "linux", + mountInfo, + resolveRealPath: (targetPath) => targetPath, + }), + ).toBeNull(); + }); + + it("does not inspect mount information on non-Linux platforms", () => { + expect( + detectLinuxVolatileStateDir("/home/user/.openclaw", { + platform: "darwin", + mountInfo: TMPFS_MOUNT_INFO, + resolveRealPath: (targetPath) => targetPath, + }), + ).toBeNull(); + }); + + it("does not warn when mount information is unavailable", () => { + expect( + detectLinuxVolatileStateDir("/home/user/.openclaw", { + platform: "linux", + mountInfo: "", + resolveRealPath: (targetPath) => targetPath, + }), + ).toBeNull(); + }); +}); + +describe("formatLinuxVolatileStateDirWarning", () => { + it("covers all SQLite state and sidecar files under the volatile state directory", () => { + const warning = formatLinuxVolatileStateDirWarning("~/.openclaw", { + path: "/home/user/.openclaw", + mountPoint: "/home/user/.openclaw", + fsType: "tmpfs", + }); + + expect(warning).toContain("volatile filesystem"); + expect(warning).toContain("tmpfs"); + expect(warning).toContain("SQLite state"); + expect(warning).toContain("WAL/journal sidecars"); + expect(warning).toContain("lost on reboot"); + expect(warning).toContain("OPENCLAW_STATE_DIR"); + }); +});