Skip to content

Commit 81eaec1

Browse files
fix: warn on windows cloud synced state dirs
1 parent fc5ba0e commit 81eaec1

5 files changed

Lines changed: 248 additions & 2 deletions

File tree

docs/gateway/doctor.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -397,7 +397,7 @@ That stages grounded durable candidates into the short-term dreaming store while
397397

398398
- **State dir missing**: warns about catastrophic state loss, prompts to recreate the directory, and reminds you that it cannot recover missing data.
399399
- **State dir permissions**: verifies writability; offers to repair permissions (and emits a `chown` hint when owner/group mismatch is detected).
400-
- **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.
400+
- **Cloud-synced state dir**: warns when macOS state resolves under iCloud Drive (`~/Library/Mobile Documents/com~apple~CloudDocs/...`) or `~/Library/CloudStorage/...`, or when Windows state resolves under OneDrive/Dropbox/Google Drive/iCloud Drive, because sync-backed paths can cause slower I/O and lock/sync races.
401401
- **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.
402402
- **Linux volatile state dir**: warns when state resolves to `tmpfs` or `ramfs`, because sessions, credentials, config, and SQLite state with its WAL/journal sidecars will disappear on reboot. Docker `overlay` mounts are intentionally not flagged because their writable layers persist across host reboots while the container remains.
403403
- **Session dirs missing**: `sessions/` and the session store directory are required to persist history and avoid `ENOENT` crashes.

docs/platforms/windows.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,12 @@ a generated `gateway.vbs` WScript wrapper so the background Gateway does not ope
130130
a visible console window. If task creation is denied, OpenClaw falls back to a
131131
per-user Startup-folder login item.
132132

133+
Keep `OPENCLAW_STATE_DIR` on a local non-synced path such as
134+
`%USERPROFILE%\.openclaw`. Avoid OneDrive, Dropbox, Google Drive, and iCloud
135+
Drive for Gateway state because Files On-Demand, sync locks, and hydration delays
136+
can slow startup or block SQLite/session writes. `openclaw doctor` warns when it
137+
detects common Windows cloud-synced state paths.
138+
133139
To install the Gateway service:
134140

135141
```powershell

src/commands/doctor-state-integrity.cloud-storage.test.ts

Lines changed: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22
import os from "node:os";
33
import path from "node:path";
44
import { describe, expect, it, vi } from "vitest";
5-
import { detectMacCloudSyncedStateDir } from "./doctor-state-integrity.js";
5+
import {
6+
detectMacCloudSyncedStateDir,
7+
detectWindowsCloudSyncedStateDir,
8+
stateIntegrityIssueToHealthFinding,
9+
stateIntegrityIssueToRepairEffect,
10+
} from "./doctor-state-integrity.js";
611

712
describe("detectMacCloudSyncedStateDir", () => {
813
const home = "/Users/tester";
@@ -127,3 +132,119 @@ describe("detectMacCloudSyncedStateDir", () => {
127132
expect(result).toBeNull();
128133
});
129134
});
135+
136+
describe("detectWindowsCloudSyncedStateDir", () => {
137+
const winPath = path.win32;
138+
const home = "C:\\Users\\tester";
139+
140+
it("detects state dir under OneDrive env root", () => {
141+
const oneDriveRoot = winPath.join(home, "OneDrive - Example");
142+
const stateDir = winPath.join(oneDriveRoot, "Desktop", "OpenClaw", ".openclaw");
143+
144+
const result = detectWindowsCloudSyncedStateDir(stateDir, {
145+
platform: "win32",
146+
homedir: home,
147+
env: { OneDriveCommercial: oneDriveRoot },
148+
});
149+
150+
expect(result).toEqual({
151+
path: winPath.resolve(stateDir),
152+
storage: "OneDrive",
153+
});
154+
});
155+
156+
it("detects common Windows cloud storage folders under the user profile", () => {
157+
const stateDir = winPath.join(home, "Dropbox", "OpenClaw", ".openclaw");
158+
159+
const result = detectWindowsCloudSyncedStateDir(stateDir, {
160+
platform: "win32",
161+
homedir: home,
162+
env: {},
163+
});
164+
165+
expect(result).toEqual({
166+
path: winPath.resolve(stateDir),
167+
storage: "Dropbox",
168+
});
169+
});
170+
171+
it("detects personal and organization OneDrive folders under the user profile", () => {
172+
for (const folderName of ["OneDrive", "OneDrive - Contoso"]) {
173+
const stateDir = winPath.join(home, folderName, "OpenClaw", ".openclaw");
174+
175+
const result = detectWindowsCloudSyncedStateDir(stateDir, {
176+
platform: "win32",
177+
homedir: home,
178+
env: {},
179+
});
180+
181+
expect(result).toEqual({
182+
path: winPath.resolve(stateDir),
183+
storage: "OneDrive",
184+
});
185+
}
186+
});
187+
188+
it("detects cloud-synced target when state dir resolves via reparse point", () => {
189+
const junctionPath = winPath.join(home, ".openclaw");
190+
const resolvedCloudPath = winPath.join(home, "OneDrive", "OpenClaw", ".openclaw");
191+
192+
const result = detectWindowsCloudSyncedStateDir(junctionPath, {
193+
platform: "win32",
194+
homedir: home,
195+
env: {},
196+
resolveRealPath: () => resolvedCloudPath,
197+
});
198+
199+
expect(result).toEqual({
200+
path: winPath.resolve(resolvedCloudPath),
201+
storage: "OneDrive",
202+
});
203+
});
204+
205+
it("ignores cloud-synced prefix when resolved target is local", () => {
206+
const junctionPath = winPath.join(home, "OneDrive", "OpenClaw", ".openclaw");
207+
const resolvedLocalPath = winPath.join(home, ".openclaw");
208+
209+
const result = detectWindowsCloudSyncedStateDir(junctionPath, {
210+
platform: "win32",
211+
homedir: home,
212+
env: {},
213+
resolveRealPath: () => resolvedLocalPath,
214+
});
215+
216+
expect(result).toBeNull();
217+
});
218+
219+
it("returns null outside Windows", () => {
220+
const stateDir = winPath.join(home, "OneDrive", "OpenClaw", ".openclaw");
221+
222+
const result = detectWindowsCloudSyncedStateDir(stateDir, {
223+
platform: "linux",
224+
homedir: home,
225+
env: {},
226+
});
227+
228+
expect(result).toBeNull();
229+
});
230+
231+
it("maps Windows cloud state dir findings to warning and dry-run effect", () => {
232+
const issue = {
233+
kind: "windows-cloud-state-dir" as const,
234+
path: winPath.join(home, "OneDrive", "OpenClaw", ".openclaw"),
235+
storage: "OneDrive",
236+
};
237+
238+
expect(stateIntegrityIssueToHealthFinding(issue)).toMatchObject({
239+
checkId: "core/doctor/state-integrity",
240+
severity: "warning",
241+
path: issue.path,
242+
});
243+
expect(stateIntegrityIssueToRepairEffect(issue)).toEqual({
244+
kind: "state",
245+
action: "would-recommend-moving-state-dir",
246+
target: issue.path,
247+
dryRunSafe: true,
248+
});
249+
});
250+
});

src/commands/doctor-state-integrity.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,11 @@ export type StateIntegrityHealthIssue =
9191
path: string;
9292
storage: string;
9393
}
94+
| {
95+
kind: "windows-cloud-state-dir";
96+
path: string;
97+
storage: string;
98+
}
9499
| {
95100
kind: "linux-sd-state-dir";
96101
path: string;
@@ -459,6 +464,20 @@ function isPathUnderRootWithPathOps(
459464
);
460465
}
461466

467+
function isWindowsPathUnderRoot(targetPath: string, rootPath: string): boolean {
468+
const winPath = path.win32;
469+
const normalizedTarget = winPath.resolve(targetPath).toLowerCase();
470+
const normalizedRoot = winPath.resolve(rootPath).toLowerCase();
471+
const rootToken = winPath.parse(normalizedRoot).root;
472+
if (normalizedRoot === rootToken) {
473+
return normalizedTarget.startsWith(rootToken);
474+
}
475+
return (
476+
normalizedTarget === normalizedRoot ||
477+
normalizedTarget.startsWith(`${normalizedRoot}${winPath.sep}`)
478+
);
479+
}
480+
462481
function findLinuxMountInfoEntryForPath(
463482
targetPath: string,
464483
entries: LinuxMountInfoEntry[],
@@ -633,6 +652,75 @@ export function formatLinuxVolatileStateDirWarning(
633652
].join("\n");
634653
}
635654

655+
type WindowsCloudSyncedStorage = "OneDrive" | "Dropbox" | "Google Drive" | "iCloud Drive";
656+
657+
function windowsCloudStorageFromHomeChild(name: string): WindowsCloudSyncedStorage | null {
658+
const normalized = name.trim().toLowerCase();
659+
if (normalized === "onedrive" || normalized.startsWith("onedrive - ")) {
660+
return "OneDrive";
661+
}
662+
if (normalized === "dropbox" || normalized.startsWith("dropbox ")) {
663+
return "Dropbox";
664+
}
665+
if (normalized === "google drive" || normalized === "my drive") {
666+
return "Google Drive";
667+
}
668+
if (normalized === "iclouddrive" || normalized === "icloud drive") {
669+
return "iCloud Drive";
670+
}
671+
return null;
672+
}
673+
674+
/** Detects Windows state directories under common cloud-synced storage roots. */
675+
export function detectWindowsCloudSyncedStateDir(
676+
stateDir: string,
677+
deps?: {
678+
platform?: NodeJS.Platform;
679+
homedir?: string;
680+
env?: NodeJS.ProcessEnv;
681+
resolveRealPath?: (targetPath: string) => string | null;
682+
},
683+
): {
684+
path: string;
685+
storage: WindowsCloudSyncedStorage;
686+
} | null {
687+
const platform = deps?.platform ?? process.platform;
688+
if (platform !== "win32") {
689+
return null;
690+
}
691+
692+
const winPath = path.win32;
693+
const realPath = (deps?.resolveRealPath ?? tryResolveRealPath)(stateDir);
694+
// Prefer the resolved target when available so a synced symlink/reparse-point
695+
// prefix does not misclassify local state dirs as cloud-synced.
696+
const candidate = winPath.resolve(realPath ?? stateDir);
697+
const env = deps?.env ?? process.env;
698+
const oneDriveRoots = [
699+
env.OneDrive,
700+
env.OneDriveConsumer,
701+
env.OneDriveCommercial,
702+
env.ONEDRIVE,
703+
].flatMap((value) => {
704+
const trimmed = value?.trim();
705+
return trimmed ? [trimmed] : [];
706+
});
707+
for (const root of oneDriveRoots) {
708+
if (isWindowsPathUnderRoot(candidate, root)) {
709+
return { path: candidate, storage: "OneDrive" };
710+
}
711+
}
712+
713+
const homedir = deps?.homedir ?? os.homedir();
714+
const homeRoot = winPath.resolve(homedir);
715+
const relativeToHome = winPath.relative(homeRoot, candidate);
716+
if (!relativeToHome || relativeToHome.startsWith("..") || winPath.isAbsolute(relativeToHome)) {
717+
return null;
718+
}
719+
const [firstSegment] = relativeToHome.split(/[\\/]+/);
720+
const storage = firstSegment ? windowsCloudStorageFromHomeChild(firstSegment) : null;
721+
return storage ? { path: candidate, storage } : null;
722+
}
723+
636724
/** Detects macOS state directories under iCloud Drive or CloudStorage providers. */
637725
export function detectMacCloudSyncedStateDir(
638726
stateDir: string,
@@ -785,6 +873,15 @@ export function detectStateIntegrityHealthIssues(
785873
});
786874
}
787875

876+
const windowsCloudSyncedStateDir = detectWindowsCloudSyncedStateDir(stateDir);
877+
if (windowsCloudSyncedStateDir) {
878+
issues.push({
879+
kind: "windows-cloud-state-dir",
880+
path: windowsCloudSyncedStateDir.path,
881+
storage: windowsCloudSyncedStateDir.storage,
882+
});
883+
}
884+
788885
const linuxSdBackedStateDir = detectLinuxSdBackedStateDir(stateDir);
789886
if (linuxSdBackedStateDir) {
790887
issues.push({
@@ -888,6 +985,15 @@ export function stateIntegrityIssueToHealthFinding(
888985
path: issue.path,
889986
fixHint: "Move OPENCLAW_STATE_DIR to local non-synced storage such as ~/.openclaw.",
890987
};
988+
case "windows-cloud-state-dir":
989+
return {
990+
checkId: STATE_INTEGRITY_CHECK_ID,
991+
severity: "warning",
992+
message: `State directory is under Windows cloud-synced storage (${issue.storage}), which can cause slow I/O and sync races.`,
993+
path: issue.path,
994+
fixHint:
995+
"Move OPENCLAW_STATE_DIR to a local non-synced path such as %USERPROFILE%\\.openclaw.",
996+
};
891997
case "linux-sd-state-dir":
892998
return {
893999
checkId: STATE_INTEGRITY_CHECK_ID,
@@ -968,6 +1074,7 @@ export function stateIntegrityIssueToRepairEffect(
9681074
): HealthRepairEffect {
9691075
switch (issue.kind) {
9701076
case "mac-cloud-state-dir":
1077+
case "windows-cloud-state-dir":
9711078
case "linux-sd-state-dir":
9721079
case "linux-volatile-state-dir":
9731080
return {
@@ -1048,6 +1155,7 @@ export async function noteStateIntegrity(
10481155
const displayConfigPath = configPath ? shortenHomePath(configPath) : undefined;
10491156
const requireOAuthDir = shouldRequireOAuthDir(cfg, env);
10501157
const cloudSyncedStateDir = detectMacCloudSyncedStateDir(stateDir);
1158+
const windowsCloudSyncedStateDir = detectWindowsCloudSyncedStateDir(stateDir);
10511159
const linuxSdBackedStateDir = detectLinuxSdBackedStateDir(stateDir);
10521160
const linuxVolatileStateDir = detectLinuxVolatileStateDir(stateDir);
10531161
const suppressOrphanTranscriptWarning = shouldSuppressOrphanTranscriptWarning(cfg, agentId);
@@ -1062,6 +1170,16 @@ export async function noteStateIntegrity(
10621170
].join("\n"),
10631171
);
10641172
}
1173+
if (windowsCloudSyncedStateDir) {
1174+
warnings.push(
1175+
[
1176+
`- State directory is under Windows cloud-synced storage (${displayStateDir}; ${windowsCloudSyncedStateDir.storage}).`,
1177+
"- This can cause slow I/O and sync/lock races for sessions and credentials.",
1178+
"- Prefer a local non-synced state dir (for example: %USERPROFILE%\\.openclaw).",
1179+
` Set locally: $env:OPENCLAW_STATE_DIR="$env:USERPROFILE\\.openclaw"; ${formatCliCommand("openclaw doctor")}`,
1180+
].join("\n"),
1181+
);
1182+
}
10651183
if (linuxSdBackedStateDir) {
10661184
warnings.push(formatLinuxSdBackedStateDirWarning(displayStateDir, linuxSdBackedStateDir));
10671185
}

test/vitest/vitest.commands-light-paths.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const commandsLightEntries = [
1414
source: "src/commands/doctor-gateway-auth-token.ts",
1515
test: "src/commands/doctor-gateway-auth-token.test.ts",
1616
},
17+
{ test: "src/commands/doctor-state-integrity.cloud-storage.test.ts" },
1718
{
1819
source: "src/commands/doctor/shared/channel-plugin-blockers.ts",
1920
test: "src/commands/doctor/shared/channel-plugin-blockers.test.ts",

0 commit comments

Comments
 (0)