mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-30 05:54:43 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
125
src/commands/doctor-state-integrity.linux-storage.test.ts
Normal file
125
src/commands/doctor-state-integrity.linux-storage.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user