Doctor: warn when Linux state dir is on SD/eMMC mounts (#31033)

* Doctor state: warn on Linux SD or eMMC state mounts

* Doctor tests: cover Linux SD or eMMC state mount detection

* Docs doctor: document Linux SD or eMMC state warning

* Changelog: add Linux SD or eMMC doctor warning

* Update CHANGELOG.md

* Doctor: escape mountinfo control chars in SD warning

* Doctor tests: cover escaped mountinfo control chars
This commit is contained in:
Vincent Koc
2026-03-01 16:36:01 -08:00
committed by GitHub
parent 412eabc42b
commit f696b64b51
4 changed files with 318 additions and 0 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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");
});
});

View File

@@ -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) {