diff --git a/CHANGELOG.md b/CHANGELOG.md index ee0617b6ad8..d1b76fe6560 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -113,6 +113,7 @@ Docs: https://docs.openclaw.ai - Channels/Command parsing parity: align command-body parsing fields with channel command-gating text for Slack, Signal, Microsoft Teams, Mattermost, and BlueBubbles to avoid mention-strip mismatches and inconsistent command detection. - CLI/Startup (Raspberry Pi + small hosts): speed up startup by avoiding unnecessary plugin preload on fast routes, adding root `--version` fast-path bootstrap bypass, parallelizing status JSON/non-JSON scans where safe, and enabling Node compile cache at startup with env override compatibility (`NODE_COMPILE_CACHE`, `NODE_DISABLE_COMPILE_CACHE`). (#5871) Thanks @BookCatKid and @vincentkoc for raising startup reports, and @lupuletic for related startup work in #27973. - Doctor/macOS state-dir safety: warn when OpenClaw state resolves inside iCloud Drive (`~/Library/Mobile Documents/com~apple~CloudDocs/...`) or `~/Library/CloudStorage/...`, because sync-backed paths can cause slower I/O and lock/sync races. (#31004) Thanks @vincentkoc. +- Doctor/Linux state-dir safety: warn when OpenClaw state resolves to an `mmcblk*` mount source (SD or eMMC), because random I/O can be slower and media wear can increase under session and credential writes. (#31033) Thanks @vincentkoc. - CLI/Startup follow-up: add root `--help` fast-path bootstrap bypass with strict root-only matching, lazily resolve CLI channel options only when commands need them, merge build-time startup metadata (`dist/cli-startup-metadata.json`) with runtime catalog discovery so dynamic catalogs are preserved, and add low-power Linux doctor hints for compile-cache placement and respawn tuning. (#30975) Thanks @vincentkoc. - Telegram/Outbound API proxy env: keep the Node 22 `autoSelectFamily` global-dispatcher workaround while restoring env-proxy support by using `EnvHttpProxyAgent` so `HTTP_PROXY`/`HTTPS_PROXY` continue to apply to outbound requests. (#26207) Thanks @qsysbio-cjw for reporting and @rylena and @vincentkoc for work. - Browser/Security: fail closed on browser-control auth bootstrap errors; if auto-auth setup fails and no explicit token/password exists, browser control server startup now aborts instead of starting unauthenticated. This ships in the next npm release. Thanks @ijxpwastaken. diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index 98d568a26ac..87f2ff760cb 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -168,6 +168,9 @@ Doctor checks: (`~/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. - **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 diff --git a/src/commands/doctor-state-integrity.linux-storage.test.ts b/src/commands/doctor-state-integrity.linux-storage.test.ts new file mode 100644 index 00000000000..9d1ea696ce8 --- /dev/null +++ b/src/commands/doctor-state-integrity.linux-storage.test.ts @@ -0,0 +1,125 @@ +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + detectLinuxSdBackedStateDir, + formatLinuxSdBackedStateDirWarning, +} from "./doctor-state-integrity.js"; + +function encodeMountInfoPath(value: string): string { + return value + .replace(/\\/g, "\\134") + .replace(/\n/g, "\\012") + .replace(/\t/g, "\\011") + .replace(/ /g, "\\040"); +} + +describe("detectLinuxSdBackedStateDir", () => { + it("detects state dir on mmc-backed mount", () => { + const mountInfo = [ + "24 19 179:2 / / rw,relatime - ext4 /dev/mmcblk0p2 rw", + "25 24 0:22 / /proc rw,nosuid,nodev,noexec,relatime - proc proc rw", + ].join("\n"); + + const result = detectLinuxSdBackedStateDir("/home/pi/.openclaw", { + platform: "linux", + mountInfo, + }); + + expect(result).toEqual({ + path: "/home/pi/.openclaw", + mountPoint: "/", + fsType: "ext4", + source: "/dev/mmcblk0p2", + }); + }); + + it("returns null for non-mmc devices", () => { + const mountInfo = "24 19 259:2 / / rw,relatime - ext4 /dev/nvme0n1p2 rw"; + + const result = detectLinuxSdBackedStateDir("/home/user/.openclaw", { + platform: "linux", + mountInfo, + }); + + expect(result).toBeNull(); + }); + + it("resolves /dev/disk aliases to mmc devices", () => { + const mountInfo = "24 19 179:2 / / rw,relatime - ext4 /dev/disk/by-uuid/abcd-1234 rw"; + + const result = detectLinuxSdBackedStateDir("/home/user/.openclaw", { + platform: "linux", + mountInfo, + resolveDeviceRealPath: (devicePath) => { + if (devicePath === "/dev/disk/by-uuid/abcd-1234") { + return "/dev/mmcblk0p2"; + } + return null; + }, + }); + + expect(result).toEqual({ + path: "/home/user/.openclaw", + mountPoint: "/", + fsType: "ext4", + source: "/dev/disk/by-uuid/abcd-1234", + }); + }); + + it("uses resolved state path to select mount", () => { + const mountInfo = [ + "24 19 259:2 / / rw,relatime - ext4 /dev/nvme0n1p2 rw", + "30 24 179:5 / /mnt/slow rw,relatime - ext4 /dev/mmcblk1p1 rw", + ].join("\n"); + + const result = detectLinuxSdBackedStateDir("/tmp/openclaw-state", { + platform: "linux", + mountInfo, + resolveRealPath: () => "/mnt/slow/openclaw/.openclaw", + }); + + expect(result).toEqual({ + path: "/mnt/slow/openclaw/.openclaw", + mountPoint: "/mnt/slow", + fsType: "ext4", + source: "/dev/mmcblk1p1", + }); + }); + + it("returns null outside linux", () => { + const mountInfo = "24 19 179:2 / / rw,relatime - ext4 /dev/mmcblk0p2 rw"; + + const result = detectLinuxSdBackedStateDir(path.join("/Users", "tester", ".openclaw"), { + platform: "darwin", + mountInfo, + }); + + expect(result).toBeNull(); + }); + + it("escapes decoded mountinfo control characters in warning output", () => { + const mountRoot = "/home/pi/mnt\nspoofed"; + const stateDir = `${mountRoot}/.openclaw`; + const encodedSource = "/dev/disk/by-uuid/mmc\\012source"; + const mountInfo = `30 24 179:2 / ${encodeMountInfoPath(mountRoot)} rw,relatime - ext4 ${encodedSource} rw`; + + const result = detectLinuxSdBackedStateDir(stateDir, { + platform: "linux", + mountInfo, + resolveRealPath: () => stateDir, + resolveDeviceRealPath: (devicePath) => { + if (devicePath === "/dev/disk/by-uuid/mmc\nsource") { + return "/dev/mmcblk0p2"; + } + return null; + }, + }); + + expect(result).not.toBeNull(); + const warning = formatLinuxSdBackedStateDirWarning(stateDir, result!); + expect(warning).toContain("device /dev/disk/by-uuid/mmc\\nsource"); + expect(warning).toContain("mount /home/pi/mnt\\nspoofed"); + expect(warning).not.toContain("device /dev/disk/by-uuid/mmc\nsource"); + expect(warning).not.toContain("mount /home/pi/mnt\nspoofed"); + }); +}); diff --git a/src/commands/doctor-state-integrity.ts b/src/commands/doctor-state-integrity.ts index 937f6a099c6..d065fe12704 100644 --- a/src/commands/doctor-state-integrity.ts +++ b/src/commands/doctor-state-integrity.ts @@ -140,6 +140,9 @@ function findOtherStateDirs(stateDir: string): string[] { function isPathUnderRoot(targetPath: string, rootPath: string): boolean { const normalizedTarget = path.resolve(targetPath); const normalizedRoot = path.resolve(rootPath); + if (normalizedRoot === path.sep) { + return normalizedTarget.startsWith(path.sep); + } return ( normalizedTarget === normalizedRoot || normalizedTarget.startsWith(`${normalizedRoot}${path.sep}`) @@ -154,6 +157,188 @@ function tryResolveRealPath(targetPath: string): string | null { } } +function decodeMountInfoPath(value: string): string { + return value.replace(/\\([0-7]{3})/g, (_, octal: string) => + String.fromCharCode(Number.parseInt(octal, 8)), + ); +} + +function escapeControlCharsForTerminal(value: string): string { + let escaped = ""; + for (const char of value) { + if (char === "\u001b") { + escaped += "\\x1b"; + continue; + } + if (char === "\r") { + escaped += "\\r"; + continue; + } + if (char === "\n") { + escaped += "\\n"; + continue; + } + if (char === "\t") { + escaped += "\\t"; + continue; + } + const code = char.charCodeAt(0); + if ((code >= 0 && code <= 8) || code === 11 || code === 12 || (code >= 14 && code <= 31)) { + escaped += `\\x${code.toString(16).padStart(2, "0")}`; + continue; + } + if (code === 127) { + escaped += "\\x7f"; + continue; + } + escaped += char; + } + return escaped; +} + +type LinuxMountInfoEntry = { + mountPoint: string; + fsType: string; + source: string; +}; + +export type LinuxSdBackedStateDir = { + path: string; + mountPoint: string; + fsType: string; + source: string; +}; + +function parseLinuxMountInfo(rawMountInfo: string): LinuxMountInfoEntry[] { + const entries: LinuxMountInfoEntry[] = []; + for (const line of rawMountInfo.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + const separatorIndex = trimmed.indexOf(" - "); + if (separatorIndex === -1) { + continue; + } + + const left = trimmed.slice(0, separatorIndex); + const right = trimmed.slice(separatorIndex + 3); + const leftFields = left.split(" "); + const rightFields = right.split(" "); + if (leftFields.length < 5 || rightFields.length < 2) { + continue; + } + + entries.push({ + mountPoint: decodeMountInfoPath(leftFields[4]), + fsType: rightFields[0], + source: decodeMountInfoPath(rightFields[1]), + }); + } + return entries; +} + +function findLinuxMountInfoEntryForPath( + targetPath: string, + entries: LinuxMountInfoEntry[], +): LinuxMountInfoEntry | null { + const normalizedTarget = path.resolve(targetPath); + let bestMatch: LinuxMountInfoEntry | null = null; + for (const entry of entries) { + if (!isPathUnderRoot(normalizedTarget, entry.mountPoint)) { + continue; + } + if ( + !bestMatch || + path.resolve(entry.mountPoint).length > path.resolve(bestMatch.mountPoint).length + ) { + bestMatch = entry; + } + } + return bestMatch; +} + +function isMmcDevicePath(devicePath: string): boolean { + const name = path.basename(devicePath); + return /^mmcblk\d+(?:p\d+)?$/.test(name); +} + +function tryReadLinuxMountInfo(): string | null { + try { + return fs.readFileSync("/proc/self/mountinfo", "utf8"); + } catch { + return null; + } +} + +export function detectLinuxSdBackedStateDir( + stateDir: string, + deps?: { + platform?: NodeJS.Platform; + mountInfo?: string; + resolveRealPath?: (targetPath: string) => string | null; + resolveDeviceRealPath?: (targetPath: string) => string | null; + }, +): LinuxSdBackedStateDir | null { + const platform = deps?.platform ?? process.platform; + if (platform !== "linux") { + return null; + } + + const resolveRealPath = deps?.resolveRealPath ?? tryResolveRealPath; + const resolvedStatePath = resolveRealPath(stateDir) ?? path.resolve(stateDir); + const mountInfo = deps?.mountInfo ?? tryReadLinuxMountInfo(); + if (!mountInfo) { + return null; + } + + const mountEntry = findLinuxMountInfoEntryForPath( + resolvedStatePath, + parseLinuxMountInfo(mountInfo), + ); + if (!mountEntry) { + return null; + } + + const sourceCandidates = [mountEntry.source]; + if (mountEntry.source.startsWith("/dev/")) { + const resolvedDevicePath = (deps?.resolveDeviceRealPath ?? tryResolveRealPath)( + mountEntry.source, + ); + if (resolvedDevicePath) { + sourceCandidates.push(path.resolve(resolvedDevicePath)); + } + } + if (!sourceCandidates.some(isMmcDevicePath)) { + return null; + } + + return { + path: path.resolve(resolvedStatePath), + mountPoint: path.resolve(mountEntry.mountPoint), + fsType: mountEntry.fsType, + source: mountEntry.source, + }; +} + +export function formatLinuxSdBackedStateDirWarning( + displayStateDir: string, + linuxSdBackedStateDir: LinuxSdBackedStateDir, +): string { + const displayMountPoint = + linuxSdBackedStateDir.mountPoint === "/" + ? "/" + : shortenHomePath(linuxSdBackedStateDir.mountPoint); + const safeSource = escapeControlCharsForTerminal(linuxSdBackedStateDir.source); + const safeFsType = escapeControlCharsForTerminal(linuxSdBackedStateDir.fsType); + const safeMountPoint = escapeControlCharsForTerminal(displayMountPoint); + return [ + `- State directory appears to be on SD/eMMC storage (${displayStateDir}; device ${safeSource}, fs ${safeFsType}, mount ${safeMountPoint}).`, + "- SD/eMMC media can be slower for random I/O and wear faster under session/log churn.", + "- For better startup and state durability, prefer SSD/NVMe (or USB SSD on Raspberry Pi) for OPENCLAW_STATE_DIR.", + ].join("\n"); +} + export function detectMacCloudSyncedStateDir( stateDir: string, deps?: { @@ -285,6 +470,7 @@ export async function noteStateIntegrity( const displayConfigPath = configPath ? shortenHomePath(configPath) : undefined; const requireOAuthDir = shouldRequireOAuthDir(cfg, env); const cloudSyncedStateDir = detectMacCloudSyncedStateDir(stateDir); + const linuxSdBackedStateDir = detectLinuxSdBackedStateDir(stateDir); if (cloudSyncedStateDir) { warnings.push( @@ -296,6 +482,9 @@ export async function noteStateIntegrity( ].join("\n"), ); } + if (linuxSdBackedStateDir) { + warnings.push(formatLinuxSdBackedStateDirWarning(displayStateDir, linuxSdBackedStateDir)); + } let stateDirExists = existsDir(stateDir); if (!stateDirExists) {