diff --git a/CHANGELOG.md b/CHANGELOG.md index a494c5f7bca..0e9e85a7431 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/commands/doctor-state-migrations.test.ts b/src/commands/doctor-state-migrations.test.ts index ed89975c9c7..6ecec28a1b6 100644 --- a/src/commands/doctor-state-migrations.test.ts +++ b/src/commands/doctor-state-migrations.test.ts @@ -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.`, + ]); + }); }); diff --git a/src/infra/state-migrations.ts b/src/infra/state-migrations.ts index 39e601fd317..36ebe54b3f2 100644 --- a/src/infra/state-migrations.ts +++ b/src/infra/state-migrations.ts @@ -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.`, );