From eee870576df411cb4b27e9d16bd57125c01f809f Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 1 Mar 2026 14:35:46 -0800 Subject: [PATCH] doctor: warn on macOS cloud-synced state directories (#31004) * Doctor: detect macOS cloud-synced state directories * Doctor tests: cover cloud-synced macOS state detection * Docs: note cloud-synced state warning in doctor guide * Docs: recommend local macOS state dir placement * Changelog: add macOS cloud-synced state dir warning * Changelog: credit macOS cloud state warning PR * Doctor state: anchor cloud-sync roots to macOS home * Doctor tests: cover OPENCLAW_HOME cloud-sync override * Doctor state: prefer resolved target for cloud detection * Doctor tests: cover local-target cloud symlink case --- CHANGELOG.md | 1 + docs/gateway/doctor.md | 4 + docs/platforms/macos.md | 19 +++ ...ctor-state-integrity.cloud-storage.test.ts | 128 ++++++++++++++++++ src/commands/doctor-state-integrity.ts | 74 ++++++++++ 5 files changed, 226 insertions(+) create mode 100644 src/commands/doctor-state-integrity.cloud-storage.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 78866eb2365..a279505e665 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,6 +91,7 @@ Docs: https://docs.openclaw.ai - ACP/Harness thread spawn routing: force ACP harness thread creation through `sessions_spawn` (`runtime: "acp"`, `thread: true`) and explicitly forbid `message action=thread-create` for ACP harness requests, avoiding misrouted `Unknown channel` errors. (#30957) Thanks @dutifulbob. - 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. - 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 4ecc10b4c66..98d568a26ac 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -164,6 +164,10 @@ Doctor checks: the directory, and reminds you that it cannot recover missing data. - **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. - **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/docs/platforms/macos.md b/docs/platforms/macos.md index 04c61df266a..4b0ae58ca08 100644 --- a/docs/platforms/macos.md +++ b/docs/platforms/macos.md @@ -143,6 +143,25 @@ Safety: 3. Ensure **Local** mode is active and the Gateway is running. 4. Install the CLI if you want terminal access. +## State dir placement (macOS) + +Avoid putting your OpenClaw state dir in iCloud or other cloud-synced folders. +Sync-backed paths can add latency and occasionally cause file-lock/sync races for +sessions and credentials. + +Prefer a local non-synced state path such as: + +```bash +OPENCLAW_STATE_DIR=~/.openclaw +``` + +If `openclaw doctor` detects state under: + +- `~/Library/Mobile Documents/com~apple~CloudDocs/...` +- `~/Library/CloudStorage/...` + +it will warn and recommend moving back to a local path. + ## Build & dev workflow (native) - `cd apps/macos && swift build` diff --git a/src/commands/doctor-state-integrity.cloud-storage.test.ts b/src/commands/doctor-state-integrity.cloud-storage.test.ts new file mode 100644 index 00000000000..be7830b1a3e --- /dev/null +++ b/src/commands/doctor-state-integrity.cloud-storage.test.ts @@ -0,0 +1,128 @@ +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { detectMacCloudSyncedStateDir } from "./doctor-state-integrity.js"; + +describe("detectMacCloudSyncedStateDir", () => { + const home = "/Users/tester"; + + it("detects state dir under iCloud Drive", () => { + const stateDir = path.join( + home, + "Library", + "Mobile Documents", + "com~apple~CloudDocs", + "OpenClaw", + ".openclaw", + ); + + const result = detectMacCloudSyncedStateDir(stateDir, { + platform: "darwin", + homedir: home, + }); + + expect(result).toEqual({ + path: path.resolve(stateDir), + storage: "iCloud Drive", + }); + }); + + it("detects state dir under Library/CloudStorage", () => { + const stateDir = path.join(home, "Library", "CloudStorage", "Dropbox", "OpenClaw", ".openclaw"); + + const result = detectMacCloudSyncedStateDir(stateDir, { + platform: "darwin", + homedir: home, + }); + + expect(result).toEqual({ + path: path.resolve(stateDir), + storage: "CloudStorage provider", + }); + }); + + it("detects cloud-synced target when state dir resolves via symlink", () => { + const symlinkPath = "/tmp/openclaw-state"; + const resolvedCloudPath = path.join( + home, + "Library", + "CloudStorage", + "OneDrive-Personal", + "OpenClaw", + ".openclaw", + ); + + const result = detectMacCloudSyncedStateDir(symlinkPath, { + platform: "darwin", + homedir: home, + resolveRealPath: () => resolvedCloudPath, + }); + + expect(result).toEqual({ + path: path.resolve(resolvedCloudPath), + storage: "CloudStorage provider", + }); + }); + + it("ignores cloud-synced symlink prefix when resolved target is local", () => { + const symlinkPath = path.join( + home, + "Library", + "CloudStorage", + "OneDrive-Personal", + "OpenClaw", + ".openclaw", + ); + const resolvedLocalPath = path.join(home, ".openclaw"); + + const result = detectMacCloudSyncedStateDir(symlinkPath, { + platform: "darwin", + homedir: home, + resolveRealPath: () => resolvedLocalPath, + }); + + expect(result).toBeNull(); + }); + + it("anchors cloud detection to OS homedir when OPENCLAW_HOME is overridden", () => { + const stateDir = path.join(home, "Library", "CloudStorage", "iCloud Drive", ".openclaw"); + const originalOpenClawHome = process.env.OPENCLAW_HOME; + process.env.OPENCLAW_HOME = "/tmp/openclaw-home-override"; + const homedirSpy = vi.spyOn(os, "homedir").mockReturnValue(home); + try { + const result = detectMacCloudSyncedStateDir(stateDir, { + platform: "darwin", + }); + + expect(result).toEqual({ + path: path.resolve(stateDir), + storage: "CloudStorage provider", + }); + } finally { + homedirSpy.mockRestore(); + if (originalOpenClawHome === undefined) { + delete process.env.OPENCLAW_HOME; + } else { + process.env.OPENCLAW_HOME = originalOpenClawHome; + } + } + }); + + it("returns null outside darwin", () => { + const stateDir = path.join( + home, + "Library", + "Mobile Documents", + "com~apple~CloudDocs", + "OpenClaw", + ".openclaw", + ); + + const result = detectMacCloudSyncedStateDir(stateDir, { + platform: "linux", + homedir: home, + }); + + expect(result).toBeNull(); + }); +}); diff --git a/src/commands/doctor-state-integrity.ts b/src/commands/doctor-state-integrity.ts index 1e599f0f4af..937f6a099c6 100644 --- a/src/commands/doctor-state-integrity.ts +++ b/src/commands/doctor-state-integrity.ts @@ -137,6 +137,68 @@ function findOtherStateDirs(stateDir: string): string[] { return found; } +function isPathUnderRoot(targetPath: string, rootPath: string): boolean { + const normalizedTarget = path.resolve(targetPath); + const normalizedRoot = path.resolve(rootPath); + return ( + normalizedTarget === normalizedRoot || + normalizedTarget.startsWith(`${normalizedRoot}${path.sep}`) + ); +} + +function tryResolveRealPath(targetPath: string): string | null { + try { + return fs.realpathSync(targetPath); + } catch { + return null; + } +} + +export function detectMacCloudSyncedStateDir( + stateDir: string, + deps?: { + platform?: NodeJS.Platform; + homedir?: string; + resolveRealPath?: (targetPath: string) => string | null; + }, +): { + path: string; + storage: "iCloud Drive" | "CloudStorage provider"; +} | null { + const platform = deps?.platform ?? process.platform; + if (platform !== "darwin") { + return null; + } + + // Cloud-sync roots should always be anchored to the OS account home on macOS. + // OPENCLAW_HOME can relocate app data defaults, but iCloud/CloudStorage remain under the OS home. + const homedir = deps?.homedir ?? os.homedir(); + const roots = [ + { + storage: "iCloud Drive" as const, + root: path.join(homedir, "Library", "Mobile Documents", "com~apple~CloudDocs"), + }, + { + storage: "CloudStorage provider" as const, + root: path.join(homedir, "Library", "CloudStorage"), + }, + ]; + const realPath = (deps?.resolveRealPath ?? tryResolveRealPath)(stateDir); + // Prefer the resolved target path when available so symlink prefixes do not + // misclassify local state dirs as cloud-synced. + const candidates = realPath ? [path.resolve(realPath)] : [path.resolve(stateDir)]; + + for (const candidate of candidates) { + for (const { storage, root } of roots) { + if (isPathUnderRoot(candidate, root)) { + return { path: candidate, storage }; + } + } + } + + return null; +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } @@ -222,6 +284,18 @@ export async function noteStateIntegrity( const displayStoreDir = shortenHomePath(storeDir); const displayConfigPath = configPath ? shortenHomePath(configPath) : undefined; const requireOAuthDir = shouldRequireOAuthDir(cfg, env); + const cloudSyncedStateDir = detectMacCloudSyncedStateDir(stateDir); + + if (cloudSyncedStateDir) { + warnings.push( + [ + `- State directory is under macOS cloud-synced storage (${displayStateDir}; ${cloudSyncedStateDir.storage}).`, + "- This can cause slow I/O and sync/lock races for sessions and credentials.", + "- Prefer a local non-synced state dir (for example: ~/.openclaw).", + ` Set locally: OPENCLAW_STATE_DIR=~/.openclaw ${formatCliCommand("openclaw doctor")}`, + ].join("\n"), + ); + } let stateDirExists = existsDir(stateDir); if (!stateDirExists) {