fix(doctor): suppress repeated legacy state migration warnings (#11709)

* fix(doctor): suppress repeated state migration warning

* fix: harden state-dir mirror detection + warnings (#11709) (thanks @gumadeiras)

* test: cover mirror hardening edge cases (#11709) (thanks @gumadeiras)
This commit is contained in:
Gustavo Madeira Santana
2026-02-08 02:27:49 -05:00
committed by GitHub
parent e02d144af9
commit b75d618080
3 changed files with 219 additions and 0 deletions

View File

@@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai
- Memory/QMD: run boot refresh in background by default, add configurable QMD maintenance timeouts, and retry QMD after fallback failures. (#9690, #9705)
- Media understanding: recognize `.caf` audio attachments for transcription. (#10982) Thanks @succ985.
- State dir: honor `OPENCLAW_STATE_DIR` for default device identity and canvas storage paths. (#4824) Thanks @kossoy.
- Doctor/State dir: suppress repeated legacy migration warnings only for valid symlink mirrors, while keeping warnings for empty or invalid legacy trees. (#11709) Thanks @gumadeiras.
- Tests: harden flaky hotspots by removing timer sleeps, consolidating onboarding provider-auth coverage, and improving memory test realism. (#11598) Thanks @gumadeiras.
## 2026.2.6

View File

@@ -352,4 +352,157 @@ describe("doctor legacy state migrations", () => {
expect(result.skipped).toBe(true);
expect(result.migrated).toBe(false);
});
it("does not warn when legacy state dir is an already-migrated symlink mirror", async () => {
const root = await makeTempRoot();
const targetDir = path.join(root, ".openclaw");
const legacyDir = path.join(root, ".moltbot");
fs.mkdirSync(path.join(targetDir, "sessions"), { recursive: true });
fs.mkdirSync(path.join(targetDir, "agent"), { recursive: true });
fs.mkdirSync(legacyDir, { recursive: true });
const dirLinkType = process.platform === "win32" ? "junction" : "dir";
fs.symlinkSync(path.join(targetDir, "sessions"), path.join(legacyDir, "sessions"), dirLinkType);
fs.symlinkSync(path.join(targetDir, "agent"), path.join(legacyDir, "agent"), dirLinkType);
const result = await autoMigrateLegacyStateDir({
env: {} as NodeJS.ProcessEnv,
homedir: () => root,
});
expect(result.migrated).toBe(false);
expect(result.warnings).toEqual([]);
});
it("warns when legacy state dir is empty and target already exists", async () => {
const root = await makeTempRoot();
const targetDir = path.join(root, ".openclaw");
const legacyDir = path.join(root, ".moltbot");
fs.mkdirSync(targetDir, { recursive: true });
fs.mkdirSync(legacyDir, { recursive: true });
const result = await autoMigrateLegacyStateDir({
env: {} as NodeJS.ProcessEnv,
homedir: () => root,
});
expect(result.migrated).toBe(false);
expect(result.warnings).toEqual([
`State dir migration skipped: target already exists (${targetDir}). Remove or merge manually.`,
]);
});
it("warns when legacy state dir contains non-symlink entries and target already exists", async () => {
const root = await makeTempRoot();
const targetDir = path.join(root, ".openclaw");
const legacyDir = path.join(root, ".moltbot");
fs.mkdirSync(targetDir, { recursive: true });
fs.mkdirSync(legacyDir, { recursive: true });
fs.writeFileSync(path.join(legacyDir, "sessions.json"), "{}", "utf-8");
const result = await autoMigrateLegacyStateDir({
env: {} as NodeJS.ProcessEnv,
homedir: () => root,
});
expect(result.migrated).toBe(false);
expect(result.warnings).toEqual([
`State dir migration skipped: target already exists (${targetDir}). Remove or merge manually.`,
]);
});
it("does not warn when legacy state dir contains nested symlink mirrors", async () => {
const root = await makeTempRoot();
const targetDir = path.join(root, ".openclaw");
const legacyDir = path.join(root, ".moltbot");
fs.mkdirSync(path.join(targetDir, "agents", "main"), { recursive: true });
fs.mkdirSync(legacyDir, { recursive: true });
fs.mkdirSync(path.join(legacyDir, "agents"), { recursive: true });
const dirLinkType = process.platform === "win32" ? "junction" : "dir";
fs.symlinkSync(
path.join(targetDir, "agents", "main"),
path.join(legacyDir, "agents", "main"),
dirLinkType,
);
const result = await autoMigrateLegacyStateDir({
env: {} as NodeJS.ProcessEnv,
homedir: () => root,
});
expect(result.migrated).toBe(false);
expect(result.warnings).toEqual([]);
});
it("warns when legacy state dir symlink points outside the target tree", async () => {
const root = await makeTempRoot();
const targetDir = path.join(root, ".openclaw");
const legacyDir = path.join(root, ".moltbot");
const outsideDir = path.join(root, ".outside-state");
fs.mkdirSync(path.join(targetDir, "sessions"), { recursive: true });
fs.mkdirSync(legacyDir, { recursive: true });
fs.mkdirSync(outsideDir, { recursive: true });
const dirLinkType = process.platform === "win32" ? "junction" : "dir";
fs.symlinkSync(path.join(outsideDir), path.join(legacyDir, "sessions"), dirLinkType);
const result = await autoMigrateLegacyStateDir({
env: {} as NodeJS.ProcessEnv,
homedir: () => root,
});
expect(result.migrated).toBe(false);
expect(result.warnings).toEqual([
`State dir migration skipped: target already exists (${targetDir}). Remove or merge manually.`,
]);
});
it("warns when legacy state dir contains a broken symlink target", async () => {
const root = await makeTempRoot();
const targetDir = path.join(root, ".openclaw");
const legacyDir = path.join(root, ".moltbot");
fs.mkdirSync(path.join(targetDir, "sessions"), { recursive: true });
fs.mkdirSync(legacyDir, { recursive: true });
const dirLinkType = process.platform === "win32" ? "junction" : "dir";
const targetSessionDir = path.join(targetDir, "sessions");
fs.symlinkSync(targetSessionDir, path.join(legacyDir, "sessions"), dirLinkType);
fs.rmSync(targetSessionDir, { recursive: true, force: true });
const result = await autoMigrateLegacyStateDir({
env: {} as NodeJS.ProcessEnv,
homedir: () => root,
});
expect(result.migrated).toBe(false);
expect(result.warnings).toEqual([
`State dir migration skipped: target already exists (${targetDir}). Remove or merge manually.`,
]);
});
it("warns when legacy symlink escapes target tree through second-hop symlink", async () => {
const root = await makeTempRoot();
const targetDir = path.join(root, ".openclaw");
const legacyDir = path.join(root, ".moltbot");
const outsideDir = path.join(root, ".outside-state");
fs.mkdirSync(targetDir, { recursive: true });
fs.mkdirSync(legacyDir, { recursive: true });
fs.mkdirSync(outsideDir, { recursive: true });
const dirLinkType = process.platform === "win32" ? "junction" : "dir";
const targetHop = path.join(targetDir, "hop");
fs.symlinkSync(outsideDir, targetHop, dirLinkType);
fs.symlinkSync(targetHop, path.join(legacyDir, "sessions"), dirLinkType);
const result = await autoMigrateLegacyStateDir({
env: {} as NodeJS.ProcessEnv,
homedir: () => root,
});
expect(result.migrated).toBe(false);
expect(result.warnings).toEqual([
`State dir migration skipped: target already exists (${targetDir}). Remove or merge manually.`,
]);
});
});

View File

@@ -360,6 +360,68 @@ function isDirPath(filePath: string): boolean {
}
}
function isWithinDir(targetPath: string, rootDir: string): boolean {
const relative = path.relative(path.resolve(rootDir), path.resolve(targetPath));
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
function isLegacyTreeSymlinkMirror(currentDir: string, realTargetDir: string): boolean {
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(currentDir, { withFileTypes: true });
} catch {
return false;
}
if (entries.length === 0) {
return false;
}
for (const entry of entries) {
const entryPath = path.join(currentDir, entry.name);
let stat: fs.Stats;
try {
stat = fs.lstatSync(entryPath);
} catch {
return false;
}
if (stat.isSymbolicLink()) {
const resolvedTarget = resolveSymlinkTarget(entryPath);
if (!resolvedTarget) {
return false;
}
let resolvedRealTarget: string;
try {
resolvedRealTarget = fs.realpathSync(resolvedTarget);
} catch {
return false;
}
if (!isWithinDir(resolvedRealTarget, realTargetDir)) {
return false;
}
continue;
}
if (stat.isDirectory()) {
if (!isLegacyTreeSymlinkMirror(entryPath, realTargetDir)) {
return false;
}
continue;
}
return false;
}
return true;
}
function isLegacyDirSymlinkMirror(legacyDir: string, targetDir: string): boolean {
let realTargetDir: string;
try {
realTargetDir = fs.realpathSync(targetDir);
} catch {
return false;
}
return isLegacyTreeSymlinkMirror(legacyDir, realTargetDir);
}
export async function autoMigrateLegacyStateDir(params: {
env?: NodeJS.ProcessEnv;
homedir?: () => string;
@@ -443,6 +505,9 @@ export async function autoMigrateLegacyStateDir(params: {
}
if (isDirPath(targetDir)) {
if (legacyDir && isLegacyDirSymlinkMirror(legacyDir, targetDir)) {
return { migrated: false, skipped: false, changes, warnings };
}
warnings.push(
`State dir migration skipped: target already exists (${targetDir}). Remove or merge manually.`,
);