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

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